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/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, 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 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..ea115f37ddc 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -146,8 +146,9 @@ 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 - * same kernel — see `./handle.ts` for what each method is a facade over. + * `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 { /** The booted kernel — for direct service calls when bypassing HTTP is intentional. */