From c5413770e6b34a6d0d381e9e4cddb5048ddfaf5c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 23:48:24 +0000 Subject: [PATCH 1/4] feat(verify): front the automation engine's condition evaluator on the handle (WIP) stack.automation.evaluateCondition(condition, variables) calls the booted kernel's own automation service's evaluateCondition with the condition as given and the variables as the Map the engine takes, and answers its boolean. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- .../verify/src/handle.automation-door.test.ts | 198 ++++++++++++++++++ packages/verify/src/handle.ts | 60 ++++++ packages/verify/src/harness.ts | 3 +- 3 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 packages/verify/src/handle.automation-door.test.ts diff --git a/packages/verify/src/handle.automation-door.test.ts b/packages/verify/src/handle.automation-door.test.ts new file mode 100644 index 00000000000..1e39b307b47 --- /dev/null +++ b/packages/verify/src/handle.automation-door.test.ts @@ -0,0 +1,198 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22301 item 8: the handle's door to the automation engine's predicate + * evaluator, `stack.automation.evaluateCondition(condition, variables)`. + * + * On 17.7.0 the handle had no such door, so an app's suite that tested a + * flow condition's truth table over variable shapes no write produces reached + * `kernel.getService('automation').evaluateCondition(…)` by hand (hotcrm's + * `conditionHolds`). The door is that same call, made by the handle: the + * booted kernel's own `automation` service, the condition as given, the + * variables as the `Map` the engine takes, and the engine's answer back. + * + * So every claim below is read off the engine itself: a spy on the booted + * service instance (it was asked, with exactly these arguments, and its + * answer is the door's), the engine called directly beside the door (same + * verdict, same fault), and the engine's own dialect decision, which a door + * that rewrote the condition would change. The no-automation boot answers + * with the kernel's own rejection, compared against the kernel asked + * directly. + */ + +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; + +import { isServiceNotRegisteredError, SERVICE_NOT_REGISTERED_CODE } from '@objectstack/core'; +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +import { bootStack, type VerifyStack } from './harness.js'; +import { isVerifyRefusal } from './handle.js'; + +// Booting the full in-process stack runs well past vitest's 5s default. +const BOOT_TIMEOUT = 120_000; + +/** The engine's evaluator, as the booted `automation` service exposes it. */ +interface Evaluator { + evaluateCondition(condition: unknown, variables: Map): boolean; +} + +/** + * The fixture, built anew per boot: one neutral object, and the automation + * capability declared (`requires`) or not. Two configurations, so the two + * boots are two stacks rather than a refused second boot of one. + */ +function fixture(withAutomation: boolean) { + return defineStack({ + manifest: { + id: `com.objectstack.verify.automation-door${withAutomation ? '' : '-none'}`, + namespace: 'acd', + version: '0.0.0', + type: 'app', + name: 'Verify Automation Door Fixture', + description: 'One neutral object; the automation capability declared or not.', + }, + ...(withAutomation ? { requires: ['automation'] } : {}), + objects: [ + ObjectSchema.create({ + name: 'acd_deal', + label: 'Deal', + pluralLabel: 'Deals', + fields: { + name: Field.text({ label: 'Name', required: true }), + stage: Field.text({ label: 'Stage' }), + amount: Field.number({ label: 'Amount' }), + }, + }), + ], + } as never); +} + +/** A record-change predicate over `record` and `previous`, as a flow's start gate holds it. */ +const MOVED_AND_LARGE = 'record.amount >= 1000 && record.stage != previous.stage'; +const movedLarge = { record: { id: 'd1', amount: 5000, stage: 'won' }, previous: { id: 'd1', amount: 5000, stage: 'open' } }; +const unmoved = { record: { id: 'd1', amount: 5000, stage: 'won' }, previous: { id: 'd1', amount: 5000, stage: 'won' } }; +const small = { record: { id: 'd1', amount: 10, stage: 'won' }, previous: { id: 'd1', amount: 10, stage: 'open' } }; + +/** What the door, or the engine asked directly, answered: a verdict or a fault. */ +async function answer(run: () => T | Promise): Promise<{ value?: T; fault?: unknown }> { + try { + return { value: await run() }; + } catch (fault) { + return { fault }; + } +} + +describe('stack.automation.evaluateCondition (#22301 item 8)', () => { + let stack: VerifyStack; + let bare: VerifyStack; + let engine: Evaluator; + + beforeAll(async () => { + stack = await bootStack(fixture(true)); + bare = await bootStack(fixture(false)); + engine = await stack.kernel.getServiceAsync('automation'); + }, BOOT_TIMEOUT * 2); + + afterAll(async () => { + await stack?.stop(); + await bare?.stop(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + /** The door and the engine, asked the same question; the engine's Map built here, independently. */ + async function both(condition: unknown, variables: Record) { + const door = await answer(() => + stack.automation.evaluateCondition(condition as Parameters[0], variables), + ); + const direct = await answer(() => engine.evaluateCondition(condition, new Map(Object.entries(variables)))); + return { door, direct }; + } + + it('asks the booted automation service, with the condition as given and the variables as its Map, and answers its boolean', async () => { + const spy = vi.spyOn(engine, 'evaluateCondition'); + const condition = { dialect: 'cel', source: MOVED_AND_LARGE }; + + const verdict = await stack.automation.evaluateCondition(condition, movedLarge); + + expect(spy).toHaveBeenCalledTimes(1); + const [given, variables] = spy.mock.calls[0]; + // The same envelope object, not a rebuilt one. + expect(given).toBe(condition); + expect(variables).toBeInstanceOf(Map); + expect([...variables.entries()]).toEqual(Object.entries(movedLarge)); + expect(spy.mock.results[0]).toEqual({ type: 'return', value: true }); + expect(verdict).toBe(true); + }); + + it('answers the truth table over record-shaped variables: true and false, as the engine does', async () => { + const condition = { dialect: 'cel', source: MOVED_AND_LARGE }; + const rows: Array<[Record, boolean]> = [ + [movedLarge, true], + // A pre-image the engine would have read as unchanged, and an amount under the bar. + [unmoved, false], + [small, false], + ]; + for (const [variables, expected] of rows) { + const { door, direct } = await both(condition, variables); + expect(door).toEqual({ value: expected }); + expect(direct).toEqual({ value: expected }); + } + }); + + it('takes an envelope and a bare CEL string alike', async () => { + for (const [variables, expected] of [[movedLarge, true], [unmoved, false]] as const) { + expect(await stack.automation.evaluateCondition({ dialect: 'cel', source: MOVED_AND_LARGE }, variables)).toBe(expected); + expect(await stack.automation.evaluateCondition(MOVED_AND_LARGE, variables)).toBe(expected); + } + }); + + it('never rewrites the condition: a bare `{var}` string keeps the template dialect the engine gives it', async () => { + const holed = '{amount} > 100'; + const spy = vi.spyOn(engine, 'evaluateCondition'); + + // The engine reads a bare string with a `{…}` hole as the template dialect. + expect(await stack.automation.evaluateCondition(holed, { amount: 5000 })).toBe(true); + expect(spy.mock.calls[0][0]).toBe(holed); + + // The same text in a CEL envelope is the brace trap, and the engine refuses it. + const { door, direct } = await both({ dialect: 'cel', source: holed }, { amount: 5000 }); + expect(direct.fault).toBeInstanceOf(Error); + expect(door.fault).toBeInstanceOf(Error); + expect((door.fault as Error).message).toBe((direct.fault as Error).message); + }); + + it("rejects with the engine's own error when the condition faults, never `false`", async () => { + // A CEL parse error, and a reference to a variable the set does not bind. + const faulting: Array<[unknown, Record]> = [ + [{ dialect: 'cel', source: 'record.amount >=' }, movedLarge], + [{ dialect: 'cel', source: 'record.amount >= 1000' }, {}], + ]; + for (const [condition, variables] of faulting) { + const { door, direct } = await both(condition, variables); + expect(direct.fault).toBeInstanceOf(Error); + expect(door.value).toBeUndefined(); + expect(door.fault).toBeInstanceOf(Error); + // Handed back as the engine threw it: its message, and no refusal envelope added. + expect((door.fault as Error).message).toBe((direct.fault as Error).message); + expect(isVerifyRefusal(door.fault)).toBe(false); + expect((door.fault as { code?: unknown }).code).toBeUndefined(); + } + }); + + it("rejects with the kernel's own SERVICE_NOT_REGISTERED error, naming 'automation', on a stack without the service", async () => { + const kernelSaid = await answer(() => bare.kernel.getServiceAsync('automation')); + expect(isServiceNotRegisteredError(kernelSaid.fault)).toBe(true); + + const door = await answer(() => bare.automation.evaluateCondition({ dialect: 'cel', source: 'true' }, {})); + expect(door.value).toBeUndefined(); + expect(isServiceNotRegisteredError(door.fault)).toBe(true); + const fault = door.fault as Error & { code?: unknown; serviceName?: unknown }; + expect(fault.code).toBe(SERVICE_NOT_REGISTERED_CODE); + expect(fault.serviceName).toBe('automation'); + expect(fault.message).toBe((kernelSaid.fault as Error).message); + }); +}); diff --git a/packages/verify/src/handle.ts b/packages/verify/src/handle.ts index 094c23b555f..4b419e07847 100644 --- a/packages/verify/src/handle.ts +++ b/packages/verify/src/handle.ts @@ -43,6 +43,13 @@ // not serve it, and a method here that handed the engine a hand-built // `{ publicFormGrant, … }` context would be the stand-in the design rule // below forbids. `handle.public-form-door.test.ts` pins the door. +// automation.evaluateCondition → the booted `automation` service's +// own predicate evaluator, `AutomationEngine.evaluateCondition`: the one +// method a flow's start gate, its edges and its `decision` branches are +// decided by. [#22301] No route carries it, so the door is the service +// itself, resolved off the kernel at call time, with the condition handed +// over as given and the engine's boolean (or its throw) handed back. +// `handle.automation-door.test.ts` pins it. // contextFor(token) → the dispatcher's own identity // resolution (`resolveRequestScope` → `resolveExecutionContext` → // `@objectstack/core`'s `resolveAuthzContext`), the exact resolver every @@ -65,11 +72,19 @@ import type { AutomationResult } from '@objectstack/spec/contracts'; import type { ServiceObject } from '@objectstack/spec/data'; import { resolveEngineUpdateDispatch, type ObjectQL } from '@objectstack/objectql'; import type { TenancyService } from '@objectstack/plugin-auth'; +import type { AutomationEngine } from '@objectstack/service-automation'; /** Any row the engine hands back. Untyped on purpose: the engine's, not the handle's. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export type EngineRow = Record; +/** + * [#22301] The condition shapes the automation engine's evaluator takes: the + * method's own parameter type, read off it rather than re-declared here, so + * the door accepts exactly what the engine accepts. + */ +type AutomationCondition = Parameters[0]; + /** * The refusal a dispatcher-door method (`flows.*`, `actions.run`) throws when * the route answered a 4xx/5xx — the route's own ADR-0112 envelope, carried @@ -262,6 +277,39 @@ export interface VerifyHandle { ): Promise; }; + automation: { + /** + * [#22301] Evaluate a flow condition with the booted stack's own + * evaluator: the `automation` service's `evaluateCondition(condition, + * variables)`, the method a flow's start gate, its edges and its + * `decision` branches are decided by. For a truth table over variable + * shapes no write produces. Resolves with the engine's boolean, + * unchanged; no caller is resolved, because the evaluator takes none. + * + * `condition` is handed to the engine as given, in any shape it takes: an + * envelope (`{ dialect: 'cel', source }`) or a string, which the engine + * reads as CEL unless it carries a `{var}` hole (the legacy template + * dialect). The handle never rewrites it. The engine's own flow sites + * hand it an envelope: a start node's `condition` and a `decision` + * branch's `expression` are wrapped as `{ dialect: 'cel', source }`, and + * an edge's condition already is one. So pass the envelope to model any + * of them; a bare string gets the reading a screen field's `visibleWhen` + * gets. + * + * `variables` is a plain object, and each own key becomes one entry of + * the `Map` the engine takes: `{ record, previous }` binds `record` and + * `previous`. + * + * A refusal is the engine's own error, rethrown unchanged: a condition + * the engine cannot evaluate rejects, never answers `false`. On a stack + * with no `automation` service (boot with `automation: true`, or have the + * app declare `requires: ['automation']`) it rejects with the kernel's + * own `SERVICE_NOT_REGISTERED` error, whose `serviceName` is + * `'automation'` (`isServiceNotRegisteredError` from `@objectstack/core`). + */ + evaluateCondition(condition: AutomationCondition, variables: Record): Promise; + }; + /** * Write fixture rows through the real engine as the platform's own seed * replay does — `insert(object, rows, { context: { isSystem, seedReplay, @@ -520,6 +568,18 @@ export async function createHandle(kernel: ObjectKernel, origin: string): Promis }, }, + automation: { + async evaluateCondition(condition, variables) { + // Typed as the engine class, as `engine` above is: `evaluateCondition` + // is the engine's method, not a member of the slot's + // `IAutomationService` contract. The async accessor is the kernel's + // typed answer for an empty slot (`SERVICE_NOT_REGISTERED`), and it + // is handed back as the kernel threw it. + const automation = await kernel.getServiceAsync('automation'); + return automation.evaluateCondition(condition, new Map(Object.entries(variables))); + }, + }, + async seed(object, rows) { if (!Array.isArray(rows)) throw new Error('verify: seed(object, rows) takes an array of rows'); const ql = await engine(); diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index 9cf64d28f5b..98776d5fb75 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -146,7 +146,8 @@ function harnessCryptoProvider(): LocalCryptoProvider { /** * A booted stack: the HTTP surface (`api` / `raw` / `signIn` / `signUp` / * `apiAs`) plus the in-process handle (`hooks` / `validate` / `flows` / - * `actions` / `seed` / `rows` / `metadata` / `tenancy` / `contextFor`) on the + * `actions` / `automation` / `seed` / `rows` / `metadata` / `tenancy` / + * `contextFor`) on the * same kernel — see `./handle.ts` for what each method is a facade over. */ export interface VerifyStack extends VerifyHandle { From 0aac167d63ce61a58b76f3fdbec1682ab8e5ea19 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 23:48:57 +0000 Subject: [PATCH 2/4] docs(verify): name the evaluateCondition door in the README; changeset Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- .../22301-verify-evaluate-condition-door.md | 14 +++++++++++ packages/verify/README.md | 24 +++++++++++++++---- 2 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 .changeset/22301-verify-evaluate-condition-door.md diff --git a/.changeset/22301-verify-evaluate-condition-door.md b/.changeset/22301-verify-evaluate-condition-door.md new file mode 100644 index 00000000000..db0f1df6505 --- /dev/null +++ b/.changeset/22301-verify-evaluate-condition-door.md @@ -0,0 +1,14 @@ +--- +'@objectstack/verify': minor +--- + +feat(verify): the handle gains a door to the automation engine's condition evaluator + +Clause-②: yes (widening) + +An app's tests can evaluate a flow condition on the booted stack's own evaluator through the in-process handle, so a suite no longer calls the booted kernel's `automation` service by hand for a condition's truth table. + +- **`stack.automation.evaluateCondition(condition, variables)`** calls the booted `automation` service's `evaluateCondition`, the method a flow's start gate, its edges and its `decision` branches are decided by. It resolves with the engine's boolean, unchanged. It is for a truth table over variable shapes no write produces. +- **`condition`** takes the shapes the engine takes, and is handed over as given: an envelope (`{ dialect: 'cel', source }`) or a string, which the engine reads as CEL unless it carries a `{var}` hole (the legacy template dialect). The engine's own flow sites hand the evaluator an envelope (a start node's string `condition` and a `decision` branch's `expression` are wrapped as `{ dialect: 'cel', source }`, and an edge's condition already is one), so pass the envelope to model any of them. +- **`variables`** is a plain object. Each own key becomes one entry of the `Map` the engine takes: `{ record, previous }` binds `record` and `previous`. +- **Refusals are not the handle's.** A condition the engine cannot evaluate rejects with the engine's own error, never `false`. On a stack with no automation service it rejects with the kernel's own `SERVICE_NOT_REGISTERED` error, whose `serviceName` is `'automation'` (`isServiceNotRegisteredError` from `@objectstack/core` answers `true`). Boot with `automation: true`, or have the app declare `requires: ['automation']`. No error code is added. diff --git a/packages/verify/README.md b/packages/verify/README.md index b2ea50d67e0..c47bc5d3c73 100644 --- a/packages/verify/README.md +++ b/packages/verify/README.md @@ -77,7 +77,8 @@ await stack.stop(); Every `VerifyStack` carries it; nothing extra to boot. Each method is a thin facade over a door the kernel wired at boot — the ObjectQL engine's own write, dry-run and read calls, the runtime's `/automation` and `/actions` routes -driven in-process, the `SchemaRegistry`, the `tenancy` service — with **zero +driven in-process, the automation engine's condition evaluator, the +`SchemaRegistry`, the `tenancy` service — with **zero re-implemented semantics**: the handle assembles no execution context, orders no hooks, evaluates no permission. What the engine does is what you assert on. @@ -120,6 +121,13 @@ await stack.flows.resume(run, { quoteName: 'Q-1', discount: 10 }, { as: rep }); const out = await stack.actions.run('crm_opportunity', 'apply_discount', { as: rep, recordId: deal.id, params: { discount: 10 } }); +// A flow condition's truth table, on the engine's own evaluator, over +// variable shapes no write produces. A start gate's, an edge's and a +// decision branch's condition reach it as a CEL envelope, so pass one. +expect(await stack.automation.evaluateCondition( + { dialect: 'cel', source: 'record.stage != previous.stage' }, + { record: { stage: 'closed_won' }, previous: { stage: 'proposal' } })).toBe(true); + // Fixtures and reads through the real engine. const [acc] = await stack.seed('crm_account', [{ name: 'Globex' }]); const mine = await stack.rows('crm_opportunity', { crm_account: acc.id }, { as: rep }); @@ -140,8 +148,11 @@ await stack.stop(); `hooks.updateWhere`) run as the system principal, deliberately and by name. - A refusal from `flows.*` / `actions.run` is the route's ADR-0112 envelope (`VerifyRefusal`: `code`, `status`, `details`; `isVerifyRefusal(e)`); a - refusal from `hooks.run` / `hooks.updateWhere` / `validate` / `rows` is the - engine's own error. The two update doors refuse a malformed call themselves, + refusal from `hooks.run` / `hooks.updateWhere` / `validate` / `rows` / + `automation.evaluateCondition` is the engine's own error (a condition the + engine cannot evaluate rejects, never answers `false`; on a stack with no + automation service, `automation.evaluateCondition` rejects with the kernel's + own `SERVICE_NOT_REGISTERED` error naming `automation`). The two update doors refuse a malformed call themselves, before the engine is touched, with `INVALID_REQUEST` / `400` (`{ system: true }` on an insert or delete, a caller named twice, or a `hooks.updateWhere` the engine would write by id). Assert on `code` (and `status` / `statusCode`), @@ -202,7 +213,8 @@ run" must never read like "nothing to find". ## API - `bootStack(config, opts?)` → `VerifyStack` (`api` / `raw` / `signIn` / `signUp` / `apiAs` / `stop`, plus the handle: - `hooks.run` / `hooks.updateWhere` / `validate` / `flows.run` / `flows.resume` / `actions.run` / `seed` / `rows` / `metadata` / `tenancy` / `contextFor`). + `hooks.run` / `hooks.updateWhere` / `validate` / `flows.run` / `flows.resume` / `actions.run` / + `automation.evaluateCondition` / `seed` / `rows` / `metadata` / `tenancy` / `contextFor`). - `bootStackOnce(config, opts?)` → the same, memoised per `(config, opts)` identity for the process. - `deriveCrudCases(config)` → the auto-derived round-trip cases (write one, read one, assert) for every object. - `runCrudVerification(stack, token, config)` → `VerifyReport`; `formatReport(report)` for a log summary. @@ -211,7 +223,9 @@ run" must never read like "nothing to find". `bootStack` options: `admin`, `authSecret`, `security` (a custom `SecurityPlugin` for owner-scoped fixtures), `multiTenant` (also what decides the posture `tenancy()` reports), `automation` (register the automation service so -`flows.*` has something to drive), `orgContext`, `databaseFile`, `extraPlugins`. +`flows.*` and `automation.evaluateCondition` have something to drive; an app +that declares `requires: ['automation']` gets it without this), `orgContext`, +`databaseFile`, `extraPlugins`. ## Known limitations From 79e9053e50c93a5e818868d6b2a6d4553e1d73d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 00:01:41 +0000 Subject: [PATCH 3/4] docs(verify): re-wrap the VerifyStack member list after naming automation Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- packages/verify/src/harness.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index 98776d5fb75..ea115f37ddc 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -147,8 +147,8 @@ function harnessCryptoProvider(): LocalCryptoProvider { * A booted stack: the HTTP surface (`api` / `raw` / `signIn` / `signUp` / * `apiAs`) plus the in-process handle (`hooks` / `validate` / `flows` / * `actions` / `automation` / `seed` / `rows` / `metadata` / `tenancy` / - * `contextFor`) on the - * same kernel — see `./handle.ts` for what each method is a facade over. + * `contextFor`) on the same kernel — see `./handle.ts` for what each method + * is a facade over. */ export interface VerifyStack extends VerifyHandle { /** The booted kernel — for direct service calls when bypassing HTTP is intentional. */ From 15827331397c73330476967e876ed58e31a6f9eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 00:39:16 +0000 Subject: [PATCH 4/4] test(dogfood): the rls-runner fake stack stubs the handle's new automation member The hand-built VerifyStack literal in rls-runner.test.ts types every handle member it does not model as `never`; the handle's new `automation` member joins them, so the dogfood typecheck compiles again. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- packages/qa/dogfood/test/rls-runner.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/qa/dogfood/test/rls-runner.test.ts b/packages/qa/dogfood/test/rls-runner.test.ts index 7b5006afa71..1f2aff3774e 100644 --- a/packages/qa/dogfood/test/rls-runner.test.ts +++ b/packages/qa/dogfood/test/rls-runner.test.ts @@ -133,6 +133,7 @@ function fakeStack(opts: FakeOpts): VerifyStack { validate: undefined as never, flows: undefined as never, actions: undefined as never, + automation: undefined as never, seed: undefined as never, rows: undefined as never, metadata: undefined as never,