Skip to content

Commit 1c1b1d0

Browse files
committed
feat(verify,spec): bind flow-node high-risk class with a runtime proof (ADR-0054 Phase 2)
Phase 2 extends the prove-it-runs ratchet to the flow-node class. - verify: bootStack gains an opt-in `automation` flag that registers @objectstack/service-automation, so authored flows are pulled from the registry and POST /automation/:name/trigger runs their nodes. Without it, flow execution was unreachable through the harness (the dispatcher's automation routes resolved no service). Mirrors the existing `multiTenant`/`security` opt-ins; default off. - dogfood: a self-contained flow fixture (one object + one autolaunched flow whose update_record node stamps a record) + the flow-node proof. It authors the flow, triggers it over HTTP, and asserts both directions — the targeted record is stamped (node executed) AND a bystander is untouched (the input variable wired into the node filter, not a blanket update). Runs green end-to-end. - spec: flow-node class is now `bound` in proof-registry.mts; flow.nodes.type carries the proof; the liveness gate enforces it. Three classes now bound: field types, RLS, flow nodes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx
1 parent b8378a0 commit 1c1b1d0

10 files changed

Lines changed: 197 additions & 8 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@objectstack/verify": minor
3+
---
4+
5+
`bootStack` gains an opt-in `automation` boot option. When set, it registers `@objectstack/service-automation` so the app's authored flows are pulled from the registry and `POST /api/v1/automation/:name/trigger` actually executes their nodes against the real in-process stack. This makes flow-node execution + variable wiring verifiable end-to-end (ADR-0054 Phase 2), mirroring the existing `multiTenant` opt-in. Default is `false`, so the standard boot stays lean for apps that don't exercise flows.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Flow-node execution fixture — the deterministic ADR-0054 Phase-2 flow proof.
4+
//
5+
// A flow is `live` in the ledger because the automation engine *reads* its
6+
// nodes — but "reads" is not "runs correctly end-to-end". A node's value
7+
// crosses flow-trigger → variable context → CEL/template interpolation → the
8+
// data engine, and the break can live in any seam (e.g. an input variable that
9+
// never reaches a node's config, or an `update_record` that ignores its
10+
// filter). This fixture proves the integrated path with ZERO dependence on an
11+
// example app: one object `flow_note` and one `autolaunched` flow whose single
12+
// `update_record` node stamps `status: 'processed'` on the record whose id was
13+
// passed in as the `noteId` input variable.
14+
//
15+
// The proof asserts BOTH directions, mirroring the RLS fixture's rigor:
16+
// • the targeted record IS mutated → the node executed,
17+
// • a bystander record is NOT → the input variable actually flowed into
18+
// the node's filter (not a blanket update). A flow that didn't wire the
19+
// variable would either touch nothing or touch everything; only correct
20+
// execution + wiring flips exactly the target.
21+
22+
import { defineStack } from '@objectstack/spec';
23+
import { ObjectSchema, Field } from '@objectstack/spec/data';
24+
25+
/** The one object under test: a note the flow stamps as processed. */
26+
export const FlowNote = ObjectSchema.create({
27+
name: 'flow_note',
28+
label: 'Flow Note',
29+
pluralLabel: 'Flow Notes',
30+
fields: {
31+
name: Field.text({ label: 'Name', required: true }),
32+
status: Field.text({ label: 'Status' }),
33+
},
34+
});
35+
36+
/**
37+
* `flow_touch` — start → update_record → end. The `noteId` input variable is
38+
* interpolated into the update filter (`{noteId}` template), and the node sets
39+
* `status` to `processed`. Triggered via `POST /automation/flow_touch/trigger`
40+
* with `{ params: { noteId } }`.
41+
*/
42+
export const flowTouch = {
43+
name: 'flow_touch',
44+
label: 'Flow Touch',
45+
type: 'autolaunched',
46+
variables: [{ name: 'noteId', type: 'text', isInput: true }],
47+
nodes: [
48+
{ id: 'start', type: 'start', label: 'Start' },
49+
{
50+
id: 'mark_processed',
51+
type: 'update_record',
52+
label: 'Mark processed',
53+
config: {
54+
objectName: 'flow_note',
55+
filter: { id: '{noteId}' },
56+
fields: { status: 'processed' },
57+
},
58+
},
59+
{ id: 'end', type: 'end', label: 'End' },
60+
],
61+
edges: [
62+
{ id: 'e1', source: 'start', target: 'mark_processed' },
63+
{ id: 'e2', source: 'mark_processed', target: 'end' },
64+
],
65+
};
66+
67+
/** A minimal, self-contained app config the dogfood harness can boot. */
68+
export const flowFixtureStack = defineStack({
69+
manifest: {
70+
id: 'com.dogfood.flow_fixture',
71+
namespace: 'flow',
72+
version: '0.0.0',
73+
type: 'app',
74+
name: 'Flow Node Fixture',
75+
description: 'Single-object app whose flow exercises node execution + variable wiring (ADR-0054 Phase 2).',
76+
},
77+
objects: [FlowNote],
78+
flows: [flowTouch],
79+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// FLOW NODE execution proof (ADR-0054 Phase 2), exercised end-to-end through the
4+
// real HTTP + automation stack.
5+
//
6+
// @proof: flow-node-execution
7+
// ADR-0054 runtime proof for the flow-node high-risk class (node execution +
8+
// variable wiring). Referenced by the liveness ledger entry `flow.nodes.type`
9+
// (packages/spec/liveness/flow.json); the spec liveness gate fails if this tag
10+
// is removed. See proof-registry.mts.
11+
//
12+
// A flow being `live` means the engine reads its nodes — necessary but not
13+
// sufficient. This authors a flow, triggers it over HTTP, and asserts the
14+
// observable runtime outcome: the `update_record` node ran AND the `noteId`
15+
// input variable wired into its filter, so EXACTLY the targeted record changed.
16+
17+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
18+
import { bootStack, type VerifyStack } from '@objectstack/verify';
19+
import { flowFixtureStack } from './fixtures/flow-touch-fixture.js';
20+
21+
describe('objectstack verify FLOW: node execution + variable wiring (#flow-node)', () => {
22+
let stack: VerifyStack;
23+
let token: string;
24+
25+
beforeAll(async () => {
26+
// `automation: true` registers @objectstack/service-automation so the app's
27+
// flows are pulled from the registry and the trigger route runs them.
28+
stack = await bootStack(flowFixtureStack, { automation: true });
29+
token = await stack.signIn();
30+
}, 60_000);
31+
32+
afterAll(async () => {
33+
await stack?.stop();
34+
});
35+
36+
async function createNote(name: string): Promise<string> {
37+
const res = await stack.apiAs(token, 'POST', '/data/flow_note', { name, status: 'new' });
38+
expect(res.status, `create ${name} failed: ${res.status} ${await res.clone().text()}`).toBeLessThan(300);
39+
const j = (await res.json()) as { id?: string; record?: { id?: string } };
40+
const id = j.id ?? j.record?.id;
41+
expect(id, 'no id returned from create').toBeTruthy();
42+
return id as string;
43+
}
44+
45+
async function statusOf(id: string): Promise<unknown> {
46+
const res = await stack.apiAs(token, 'GET', `/data/flow_note/${id}`);
47+
expect(res.status).toBe(200);
48+
const j = (await res.json()) as { record?: Record<string, unknown> } & Record<string, unknown>;
49+
return (j.record ?? j).status;
50+
}
51+
52+
it('precondition: the automation service is wired and the flow is registered', async () => {
53+
// The flow-list route is served by the same dispatcher; if automation were
54+
// unregistered this would not return the flow (the whole proof would be moot).
55+
const res = await stack.apiAs(token, 'GET', '/automation/flow_touch');
56+
expect(res.status, `automation service not wired: ${res.status}`).toBe(200);
57+
});
58+
59+
it('runs the update_record node and wires the input variable into the filter', async () => {
60+
const target = await createNote('target');
61+
const bystander = await createNote('bystander');
62+
expect(await statusOf(target)).toBe('new');
63+
expect(await statusOf(bystander)).toBe('new');
64+
65+
const res = await stack.apiAs(token, 'POST', '/automation/flow_touch/trigger', {
66+
params: { noteId: target },
67+
});
68+
expect(res.status, `trigger failed: ${res.status} ${await res.clone().text()}`).toBeLessThan(300);
69+
const body = (await res.json()) as { success?: boolean; data?: { success?: boolean; error?: string } };
70+
expect(body.success).toBe(true);
71+
expect(body.data?.success, `flow run not successful: ${JSON.stringify(body.data)}`).toBe(true);
72+
73+
// The node executed → the targeted record was stamped.
74+
expect(await statusOf(target)).toBe('processed');
75+
// Variable wiring is REAL → the filter used noteId, so the bystander is
76+
// untouched. (A flow that dropped the variable would touch nothing or, with a
77+
// filterless update, touch every row — both caught here.)
78+
expect(await statusOf(bystander)).toBe('new');
79+
});
80+
});

packages/spec/liveness/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ binding lands one class at a time (ADR-0054 §3), never as a big-bang backfill.
7777
|---|---|---|---|
7878
| Field types | ✅ enforced | `field.type` | `field-zoo-roundtrip.dogfood.test.ts#field-type-roundtrip` |
7979
| RLS / sharing | ✅ enforced | `permission.rowLevelSecurity.using` | `rls-fixture.dogfood.test.ts#rls-by-id-write` |
80+
| Flow nodes | ✅ enforced | `flow.nodes.type` | `flow-node.dogfood.test.ts#flow-node-execution` |
8081
| Analytics dims/measures | ⛔ pending || `analytics-timezone.dogfood.test.ts#analytics-tz-bucketing` (proof exists; surface `dataset`/`report` not yet governed) |
81-
| Flow nodes | ⛔ pending | `flow.nodes.type` (candidate) | none yet (Phase 2) |
8282
| Form layout/section/widget | ⛔ pending || none yet (Phase 2) |
8383

8484
To bind a pending class: add its dogfood proof + `@proof:` tag, set `bound: true` and

packages/spec/liveness/flow.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@
6464
"type": {
6565
"status": "live",
6666
"evidence": "packages/services/service-automation/src/engine.ts",
67-
"note": "open string vs live executor registry (ADR-0018); FlowNodeAction enum is out of sync."
67+
"proof": "packages/dogfood/test/flow-node.dogfood.test.ts#flow-node-execution",
68+
"note": "open string vs live executor registry (ADR-0018); FlowNodeAction enum is out of sync. ADR-0054 high-risk class (flow nodes): the proof authors a flow, triggers it over HTTP, and asserts the update_record node executed AND the input variable wired into its filter (only the targeted record changed) — guarding node execution + variable wiring, not just that the engine reads the node."
6869
},
6970
"label": {
7071
"status": "live",

packages/spec/scripts/liveness/proof-registry.mts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,11 @@ export const HIGH_RISK_CLASSES: HighRiskClass[] = [
9595
label: 'Flow nodes',
9696
summary: 'node execution + variable wiring through the automation engine.',
9797
proofId: 'flow-node-execution',
98-
proofRef: null,
99-
bound: false,
100-
// Candidate binding target once a Phase-2 flow-execution proof exists.
98+
proofRef: 'packages/dogfood/test/flow-node.dogfood.test.ts#flow-node-execution',
99+
bound: true,
100+
// `nodes.type` selects which executor runs — the property whose end-to-end
101+
// execution + variable wiring the proof guards.
101102
ledgerBindings: [{ type: 'flow', path: 'nodes.type' }],
102-
blockedReason: 'no runtime proof yet (ADR-0054 Phase 2). Add a dogfood flow-execution proof, then bind.',
103103
},
104104
{
105105
id: 'form-widget',

packages/spec/scripts/liveness/proof-registry.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ describe('registry invariants', () => {
102102

103103
it('BOUND_PROOF_PATHS maps the expected entries this phase', () => {
104104
expect([...BOUND_PROOF_PATHS.keys()].sort()).toEqual(
105-
['field/type', 'permission/rowLevelSecurity.using'].sort(),
105+
['field/type', 'flow/nodes.type', 'permission/rowLevelSecurity.using'].sort(),
106106
);
107107
});
108108

@@ -119,6 +119,7 @@ describe('real proof wiring resolves', () => {
119119
const ledgerFor: Record<string, string> = {
120120
field: 'packages/spec/liveness/field.json',
121121
permission: 'packages/spec/liveness/permission.json',
122+
flow: 'packages/spec/liveness/flow.json',
122123
};
123124

124125
function ledgerEntry(type: string, path: string): any {

packages/verify/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
"@objectstack/plugin-sharing": "workspace:*",
3232
"@objectstack/plugin-org-scoping": "workspace:*",
3333
"@objectstack/service-settings": "workspace:*",
34-
"@objectstack/service-analytics": "workspace:*"
34+
"@objectstack/service-analytics": "workspace:*",
35+
"@objectstack/service-automation": "workspace:*"
3536
},
3637
"devDependencies": {
3738
"@types/node": "^25.9.3",

packages/verify/src/harness.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,16 @@ export interface BootOptions {
8383
* stripped and a member sees every row. Default `false`.
8484
*/
8585
multiTenant?: boolean;
86+
/**
87+
* Register `@objectstack/service-automation` so authored flows execute against
88+
* the real stack. The plugin seeds the built-in node executors and, at start(),
89+
* pulls every flow in the app config from the ObjectQL registry and registers
90+
* it — so `POST /api/v1/automation/:name/trigger` actually runs the flow's
91+
* nodes. Without this the dispatcher's automation routes resolve no `automation`
92+
* service and flow execution is unreachable. Opt-in (like `multiTenant`) so the
93+
* default boot stays lean for apps that don't exercise flows. Default `false`.
94+
*/
95+
automation?: boolean;
8696
}
8797

8898
/**
@@ -126,6 +136,15 @@ export async function bootStack(
126136
await kernel.use(new OrgScopingPlugin());
127137
}
128138

139+
// Automation service — opt-in. Registered before bootstrap so its start()
140+
// phase pulls the app's flows from the ObjectQL registry (populated by
141+
// AppPlugin.init) and registers them. `memory` suspended-run store keeps the
142+
// harness free of any manifest/persistence dependency for flow execution.
143+
if (opts.automation) {
144+
const { AutomationServicePlugin } = await import('@objectstack/service-automation');
145+
await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' }));
146+
}
147+
129148
await kernel.use(opts.security ?? new SecurityPlugin());
130149
// Sharing service — apps that declare `requires: ['sharing']` rely on it for
131150
// record-share grants; without it their RLS/sharing rules are inert and the

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)