Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/22301-verify-evaluate-condition-door.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/qa/dogfood/test/rls-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 19 additions & 5 deletions packages/verify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 });
Expand All @@ -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`),
Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand Down
198 changes: 198 additions & 0 deletions packages/verify/src/handle.automation-door.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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<T>(run: () => T | Promise<T>): 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<Evaluator>('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<string, unknown>) {
const door = await answer(() =>
stack.automation.evaluateCondition(condition as Parameters<VerifyStack['automation']['evaluateCondition']>[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<string, unknown>, 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<string, unknown>]> = [
[{ 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<Evaluator>('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);
});
});
Loading
Loading