From f0964e677396928215079cc605ed6b0e1633cf66 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 16:53:09 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(spec)!:=20strict=20unknown=20keys=20on?= =?UTF-8?q?=20automation=20control-flow=20+=20state-machine=20(#4001=20?= =?UTF-8?q?=E6=89=B9=2010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 11 strip sites closed across two files: - control-flow.zod.ts (5): FlowRegion / Loop / ParallelBranch / Parallel / TryCatch - state-machine.zod.ts (6): ActionRef / GuardRef / Transition / StateNode (+.meta) / StateMachine Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- .../src/builtin/parse-config.ts | 21 +- .../spec/src/automation/control-flow.test.ts | 189 ++++++++++++ .../spec/src/automation/control-flow.zod.ts | 236 +++++++++++---- .../automation/region-normalization.test.ts | 4 +- .../spec/src/automation/region-slots.test.ts | 68 ++++- .../spec/src/automation/state-machine.test.ts | 178 ++++++++++- .../spec/src/automation/state-machine.zod.ts | 279 ++++++++++++++---- 7 files changed, 837 insertions(+), 138 deletions(-) diff --git a/packages/services/service-automation/src/builtin/parse-config.ts b/packages/services/service-automation/src/builtin/parse-config.ts index 31cffc18b5..4a37b8d323 100644 --- a/packages/services/service-automation/src/builtin/parse-config.ts +++ b/packages/services/service-automation/src/builtin/parse-config.ts @@ -34,10 +34,23 @@ * silence the contract for a node forever, the same suppression shape #3863 * closed for the other refuse-to-execute guards. * - * Unknown keys are NOT this seam's job: Zod's default `.strip()` drops them - * silently here, and `registerFlow()` rejects them loudly at registration - * (the tightened #4059 check). Type + `required` live here; key membership - * lives there. + * Key membership is not this seam's JOB, but since #4001 批 10 it is no longer + * this seam's blind spot either. It used to be both: zod's default `.strip()` + * dropped an unknown key silently here, so the sentence "key membership lives + * at `registerFlow()`" described a division of labour AND a hole — anything + * reaching this parse by another route got no key check at all. The three + * ADR-0031 control-flow contracts (`LoopConfigSchema`, `ParallelConfigSchema`, + * `TryCatchConfigSchema`) are now `strictObject`, so an unknown key here + * refuses like any other contract violation. + * + * In engine-run flows that refusal should never fire: `validateNodeConfigKeys` + * rejects the same key at registration, earlier and with the descriptor's own + * prescriptions, and a flow that fails registration never reaches execution. + * That ordering is the point — the loud door stays the first one. What + * changed is only that the second door stopped being open. + * + * Type + `required` still live here; key membership still belongs at + * registration. */ import { refuseNode } from '../guard-refusal.js'; diff --git a/packages/spec/src/automation/control-flow.test.ts b/packages/spec/src/automation/control-flow.test.ts index 985c0eeabe..52ed4f2c27 100644 --- a/packages/spec/src/automation/control-flow.test.ts +++ b/packages/spec/src/automation/control-flow.test.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; import { LoopConfigSchema, ParallelConfigSchema, + ParallelBranchSchema, TryCatchConfigSchema, FlowRegionSchema, analyzeRegion, @@ -15,6 +16,7 @@ import { PARALLEL_NODE_TYPE, TRY_CATCH_NODE_TYPE, } from './control-flow.zod'; +import { findClosestMatches } from '../shared/suggestions.zod'; const node = (id: string, type = 'assignment') => ({ id, type, label: id }); const edge = (id: string, source: string, target: string) => ({ id, source, target }); @@ -266,3 +268,190 @@ describe('validateControlFlow', () => { ).toThrow(/try_catch 'tc' try/); }); }); + +// ─── [#4001 批 10] unknown keys are rejected, not stripped ────────────────── + +describe('[#4001] control-flow strictness — per shape', () => { + it('FlowRegion: `name` and `label` get wrong-layer prescriptions, not renames', () => { + for (const [key, expected] of [ + ['name', 'A region is not named'], + ['label', 'A region is not labelled'], + ] as const) { + const result = FlowRegionSchema.safeParse({ nodes: [node('a')], [key]: 'body' }); + expect(result.success, `region.${key} must be refused`).toBe(false); + expect(result.error!.issues[0]!.message).toContain(expected); + // Neither has a canonical spelling on a region, so neither may be + // renamed — suggesting one would point at a key this schema rejects. + expect(result.error!.issues[0]!.message).not.toContain('Did you mean'); + } + }); + + // This is the entry that had to be MEASURED rather than curated by taste: + // the bare edit-distance fallback answers `itemVariable` with + // `indexVariable`, which binds the loop INDEX where the author wanted the + // ITEM — a silently-wrong loop, prescribed by this campaign's own helper. + // The control below re-runs the raw suggester so the alias cannot be + // "cleaned up" as redundant: delete it and the wrong answer comes straight + // back. + it('Loop: `itemVariable` is aliased to `iteratorVariable`, overruling a WRONG edit-distance hit', () => { + const LOOP_KEYS = ['collection', 'iteratorVariable', 'indexVariable', 'maxIterations', 'body']; + const bare = findClosestMatches('itemVariable', LOOP_KEYS, Math.max(2, Math.floor('itemVariable'.length / 3)), 1); + expect(bare, 'the raw suggester still gets this wrong — that is why the alias exists').toEqual(['indexVariable']); + + const result = LoopConfigSchema.safeParse({ collection: '{tasks}', itemVariable: 'task' }); + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toContain('`itemVariable` → `iteratorVariable`'); + expect(result.error!.issues[0]!.message).not.toContain('indexVariable'); + }); + + it('Loop: `flowName` is pointed at the `map` node, which is where it is real', () => { + const result = LoopConfigSchema.safeParse({ collection: '{tasks}', flowName: 'per_item' }); + expect(result.success).toBe(false); + const message = result.error!.issues[0]!.message; + expect(message).toContain('`map` node'); + expect(message).toContain('config.body'); + }); + + it('Loop: a plain typo still rides the edit-distance fallback', () => { + const result = LoopConfigSchema.safeParse({ collection: '{x}', maxIteration: 5 }); + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toContain('`maxIteration` → `maxIterations`'); + }); + + it('ParallelBranch: `label` → `name`, the word every NODE beside it uses', () => { + // `FlowNodeSchema.label` is REQUIRED on every element of the `nodes[]` + // array in the same literal, so borrowing it is an author's reasonable + // guess — and 5 edits away, so only a named alias reaches it. + const result = ParallelBranchSchema.safeParse({ label: 'Left', nodes: [node('a')] }); + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toContain('`label` → `name`'); + // The declared spelling is untouched. + expect(() => ParallelBranchSchema.parse({ name: 'Left', nodes: [node('a')] })).not.toThrow(); + }); + + it('Parallel: the two join spellings get DISTINCT prescriptions, not one repeated twice', () => { + const result = ParallelConfigSchema.safeParse({ + branches: [{ nodes: [node('a')] }, { nodes: [node('b')] }], + join: 'all', + joinGateway: 'j1', + }); + expect(result.success).toBe(false); + const bullets = result.error!.issues[0]!.message.split('\n').filter((l) => l.trim().startsWith('•')); + expect(bullets).toHaveLength(2); + // `guidance` emits one bullet per key verbatim, so a shared string would + // print the same paragraph twice and read as a bug in the error itself. + expect(bullets[0]).not.toBe(bullets[1]); + expect(bullets[0]).toContain('joins IMPLICITLY'); + expect(bullets[1]).toContain('BPMN'); + }); + + it('TryCatch: `finally` is answered with WHERE the always-run steps go', () => { + const result = TryCatchConfigSchema.safeParse({ + try: { nodes: [node('t')] }, + finally: { nodes: [node('f')] }, + }); + expect(result.success).toBe(false); + const message = result.error!.issues[0]!.message; + expect(message).toContain('There is no `finally` region'); + // A prescription that only said "no such key" would leave the author + // stuck; the construct really does have a place for those steps. + expect(message).toContain('AFTER this container'); + }); + + it('every legal shape this file already documented still parses', () => { + // Anti-vacuity for the whole block: strictness that also refused the + // declared spellings would make every assertion above pass for the wrong + // reason. These are the showcase/app-todo shapes, verbatim in structure. + expect(() => LoopConfigSchema.parse({ + collection: '{tasksToRemind}', iteratorVariable: 'task', indexVariable: 'i', + maxIterations: 500, body: { nodes: [node('w', 'noop')], edges: [] }, + })).not.toThrow(); + expect(() => ParallelConfigSchema.parse({ + branches: [{ name: 'A', nodes: [node('a')] }, { name: 'B', nodes: [node('b')] }], + })).not.toThrow(); + expect(() => TryCatchConfigSchema.parse({ + try: { nodes: [node('t')] }, catch: { nodes: [node('c')] }, + errorVariable: '$error', retry: { maxRetries: 3, backoffMs: 500 }, + })).not.toThrow(); + }); +}); + +// The sibling-guard question the batch was dispatched to answer: does closing +// the key gate collide with `validateControlFlow`, which has validated these +// same regions structurally since ADR-0031? +// +// It does not, and the reason is that they answer different questions. The +// schema rejects undeclared KEYS; the analysis rejects malformed STRUCTURE +// (single-entry / single-exit / acyclic), which no key check can decide. They +// meet at exactly one seam — `validateControlFlow` `safeParse`s each region +// slot before analyzing it, so from #4001 that parse is also where an +// undeclared region key surfaces. Nothing was duplicated and nothing was +// removed: the guard's structural prose is untouched, and it simply stopped +// silently repairing its own input before judging it. +describe('[#4001] validateControlFlow and the key gate do not fight', () => { + const flowWith = (cfg: Record, type = LOOP_NODE_TYPE) => + ({ nodes: [{ ...node('c1', type), config: cfg }] } as never); + + it('STRUCTURE errors are still reported by the guard, in the guard\'s own words', () => { + // Two entries / two exits — a key gate cannot see this, and the message + // must stay the analysis\'s, not the schema\'s. + expect(() => validateControlFlow(flowWith({ + collection: '{items}', body: { nodes: [node('a'), node('b')], edges: [] }, + }))).toThrow(/single-entry/); + }); + + it('KEY errors now surface through that same guard, carrying the schema\'s prose', () => { + let message = ''; + try { + validateControlFlow(flowWith({ + collection: '{items}', body: { nodes: [node('a')], edges: [], name: 'inner' }, + })); + } catch (e) { message = (e as Error).message; } + + // The guard's own framing (which region, which container) … + expect(message).toContain("loop 'c1' body"); + expect(message).toContain('invalid region'); + // … wrapping the schema's prescription, rather than replacing it. + expect(message).toContain('A region is not named'); + }); + + it('a PARALLEL BRANCH keeps its `name` — the slot picks the branch schema, and now it must', () => { + // `regionSlotsOf` parses `branches[]` as `ParallelBranchSchema` and every + // other slot as `FlowRegionSchema`. That used to be a fidelity choice (the + // region schema would have STRIPPED `name`); with both strict it is a + // correctness one — the region schema would REJECT a legal branch. Pinned + // because the two schemas now differ by a rejection, not by a silent drop. + expect(() => validateControlFlow(flowWith({ + branches: [ + { name: 'left', nodes: [node('a')], edges: [] }, + { name: 'right', nodes: [node('b')], edges: [] }, + ], + }, PARALLEL_NODE_TYPE))).not.toThrow(); + + // …and the region schema really would have refused it — the anti-vacuity + // half, so this test cannot pass because `name` became universally legal. + expect(FlowRegionSchema.safeParse({ name: 'left', nodes: [node('a')], edges: [] }).success).toBe(false); + }); + + it('the guard still ignores legacy flat-graph loops (no region to key-check)', () => { + expect(() => validateControlFlow(flowWith({ collection: '{items}', iteratorVariable: 'x' }))).not.toThrow(); + }); + + it('nested regions are key-checked at depth, like the structural check (#4389)', () => { + let message = ''; + try { + validateControlFlow(flowWith({ + collection: '{outer}', + body: { + nodes: [{ + ...node('inner', TRY_CATCH_NODE_TYPE), + config: { try: { nodes: [node('x')], edges: [], label: 'oops' } }, + }], + edges: [], + }, + })); + } catch (e) { message = (e as Error).message; } + expect(message).toContain("loop 'c1' body → try_catch 'inner' try"); + expect(message).toContain('A region is not labelled'); + }); +}); diff --git a/packages/spec/src/automation/control-flow.zod.ts b/packages/spec/src/automation/control-flow.zod.ts index 796aa43577..ec6a669655 100644 --- a/packages/spec/src/automation/control-flow.zod.ts +++ b/packages/spec/src/automation/control-flow.zod.ts @@ -43,14 +43,62 @@ * {@link TRY_CATCH_NODE_TYPE} (`try_catch`). These are distinct from the BPMN * interop node types (`parallel_gateway` / `join_gateway` / `boundary_event`), * which remain author-invisible interchange representations. + * + * ## Unknown keys are rejected (#4001 / ADR-0078) + * + * Every shape below is `strictObject`. Before that they were plain `z.object`, + * so zod's default `.strip` applied and a key this file does not declare was + * **discarded in silence** — the container still parsed, still registered, and + * still ran, with the author's configuration simply absent. On these five + * shapes that silence is unusually expensive, because each one carries + * *control* rather than data: a swallowed `maxIterations` is an uncapped loop, + * a swallowed branch key is a branch that runs without what it was given. + * + * ### How this relates to {@link validateControlFlow} + * + * `validateControlFlow` is a **sibling guard, not a key gate** — it answers + * "is this region single-entry / single-exit / acyclic", which no amount of + * key strictness can answer. The two do not overlap and cannot fight: the + * schema rejects undeclared KEYS, the analysis rejects malformed STRUCTURE. + * They do now meet at one seam, deliberately — `validateControlFlow` + * `safeParse`s each region slot before analyzing it, so from #4001 that parse + * is also where a region's undeclared key surfaces, reported as + * `: invalid region — `. Nothing was + * duplicated and nothing was removed; the structural prose this guard exists + * for is untouched, and it simply stopped silently repairing its own input. */ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; +import { strictObject } from '../shared/strict-object'; import { FlowNodeSchema, FlowEdgeSchema } from './flow.zod'; import type { FlowNodeParsed, FlowEdgeParsed } from './flow.zod'; import { FLOW_REGION_SLOTS_BY_TYPE } from './region-slots'; +/** + * Shared history sentence for the five shapes in this file — one silence, one + * description of it, so the five rejections cannot drift apart. + */ +const CONTROL_FLOW_STRIP_HISTORY = + 'Until #4001 an undeclared key here was dropped silently — the container still parsed, registered and ran, with the author\'s configuration simply absent.'; + +/** + * ADR-0031 §Decision 2, stated once per spelling a BPMN-trained author reaches + * for on a `parallel` block. + * + * Two entries rather than one shared string, because `guidance` prescriptions + * are emitted **verbatim, one bullet per rejected key** — writing a flow with + * both spellings would otherwise print the identical paragraph twice, which + * reads as a bug in the error rather than as an answer. Each spelling gets the + * half of the decision that actually addresses it. + */ +const IMPLICIT_JOIN_PRESCRIPTIONS = { + join: + 'A `parallel` block joins IMPLICITLY: it continues once, when every branch has completed (ADR-0031 §Decision 2). There is no join to configure and no arrival count to get wrong — that is the point of the construct, so the key has no replacement.', + joinGateway: + '`join_gateway` is a BPMN **interop** node type, not a `parallel` config key. ADR-0031 §Decision 5 keeps the BPMN gateways for import/export and §Decision 2 folds an imported `parallel_gateway`/`join_gateway` pair INTO this block — so by the time you are writing `parallel`, the join has already been absorbed.', +} as const; + // ─── Canonical construct type ids ──────────────────────────────────── /** The structured iteration container (pre-existing built-in id). */ @@ -77,12 +125,25 @@ export const LOOP_MAX_ITERATIONS_CEILING = 100_000; * body mutations are visible to the surrounding flow), so a region is *not* a * separate `subflow` invocation. */ -export const FlowRegionSchema = lazySchema(() => z.object({ - /** Body nodes (must not include `start`/`end` trigger sentinels). */ - nodes: z.array(FlowNodeSchema).min(1).describe('Region body nodes (single-entry/single-exit sub-graph)'), - /** Body edges connecting the region nodes. */ - edges: z.array(FlowEdgeSchema).default([]).describe('Region body edges'), -})); +export const FlowRegionSchema = lazySchema(() => strictObject( + { + surface: 'this control-flow region', + history: CONTROL_FLOW_STRIP_HISTORY, + guidance: { + // Both entries are wrong-LAYER pointers, not renames: a region has no + // name of any spelling, so suggesting one would send the author to a key + // this schema cannot accept. + name: 'A region is not named. Only a `parallel` branch carries a `name` (`config.branches[].name`) — a `loop` body, a `try` region and a `catch` region are identified by the container that holds them.', + label: 'A region is not labelled. `label` is required on every NODE inside `nodes[]`, which is where you are seeing it; the region itself is identified by its container slot.', + }, + }, + { + /** Body nodes (must not include `start`/`end` trigger sentinels). */ + nodes: z.array(FlowNodeSchema).min(1).describe('Region body nodes (single-entry/single-exit sub-graph)'), + /** Body edges connecting the region nodes. */ + edges: z.array(FlowEdgeSchema).default([]).describe('Region body edges'), + }, +)); export type FlowRegion = z.input; export type FlowRegionParsed = z.infer; @@ -99,38 +160,62 @@ export type FlowRegionParsed = z.infer; * `body` is **optional** for back-compat: a `loop` node with no `body` keeps the * legacy flat-graph behavior (the constructs are additive). */ -export const LoopConfigSchema = lazySchema(() => z.object({ - /** - * The collection to iterate. A `{token}` template or bare variable name that - * resolves (at run time) to an array in the flow's variable scope, or an - * inline array — the same union `map.collection` declares, because the two - * executors share the resolve logic (#4277 aligned this contract with what - * the executor has always read; the string-only declaration under-declared). - */ - // `xExpression: 'template'` marks the string form as an `interpolate()` - // `{var}` template (not bare CEL), so the flow designer renders a `{var}` - // picker + mono editor and skips the CEL brace-trap (objectui #2670 Phase 3). - // Flows through `z.toJSONSchema` verbatim, same channel as `xRef` / - // `xEnumDeprecated`. The shipped `loop` descriptor carries the same marker on - // its hand-written configSchema literal (service-automation/builtin/loop-node.ts). - collection: z.union([z.string().min(1), z.array(z.unknown())]).meta({ - description: 'Template/variable resolving to the array to iterate (an inline array is accepted)', - xExpression: 'template', - }), - /** Variable name the current item is bound to inside the body. */ - iteratorVariable: z.string().min(1).default('item').describe('Loop variable holding the current item'), - /** Optional variable name the zero-based index is bound to inside the body. */ - indexVariable: z.string().optional().describe('Optional loop variable holding the current index'), - /** - * Maximum iterations to run — a guard against runaway collections. Clamped to - * {@link LOOP_MAX_ITERATIONS_CEILING}; a collection longer than this fails the - * node rather than truncating silently. - */ - maxIterations: z.number().int().min(1).max(LOOP_MAX_ITERATIONS_CEILING).optional() - .describe('Hard cap on iterations (clamped to the engine ceiling)'), - /** The body region executed once per item (single-entry/single-exit). */ - body: FlowRegionSchema.optional().describe('Loop body region (omit for legacy flat-graph loops)'), -})); +export const LoopConfigSchema = lazySchema(() => strictObject( + { + surface: 'this loop container config', + history: CONTROL_FLOW_STRIP_HISTORY, + // `itemVariable` is here to OVERRULE the edit-distance fallback, which was + // measured getting it wrong: `itemVariable` is 4 edits from + // `indexVariable` and further from `iteratorVariable`, so the bare + // suggester answers "did you mean `indexVariable`?" — pointing an author + // who wants the ITEM at the key that binds the INDEX. Following it yields + // a loop whose variable holds a number, silently, which is the failure + // this campaign exists to remove, produced by the campaign's own helper + // (the `pii` → `min` shape from batch 6b, and finding 7's "never signpost + // the way into the failure mode"). An alias entry wins over edit distance, + // so naming it is the whole fix. + aliases: { itemVariable: 'iteratorVariable' }, + guidance: { + // `map` is `loop`'s nearest neighbour — the two share `collection`, + // `iteratorVariable` and `indexVariable`, and `flowName` is the key that + // DEFINES map (required there). An author moving between them borrows it. + // A pointer, not a rename: `loop` has no subflow key to rename it to. + flowName: '`flowName` belongs to the `map` node, which runs a separate subflow per item (ADR-0037). A `loop` runs an INLINE `body` region in the enclosing variable scope — put the per-item steps in `config.body`, or change the node `type` to `map` if you meant the subflow form.', + }, + }, + { + /** + * The collection to iterate. A `{token}` template or bare variable name that + * resolves (at run time) to an array in the flow's variable scope, or an + * inline array — the same union `map.collection` declares, because the two + * executors share the resolve logic (#4277 aligned this contract with what + * the executor has always read; the string-only declaration under-declared). + */ + // `xExpression: 'template'` marks the string form as an `interpolate()` + // `{var}` template (not bare CEL), so the flow designer renders a `{var}` + // picker + mono editor and skips the CEL brace-trap (objectui #2670 Phase 3). + // Flows through `z.toJSONSchema` verbatim, same channel as `xRef` / + // `xEnumDeprecated`. The shipped `loop` descriptor carries the same marker on + // its hand-written configSchema literal (service-automation/builtin/loop-node.ts). + collection: z.union([z.string().min(1), z.array(z.unknown())]).meta({ + description: 'Template/variable resolving to the array to iterate (an inline array is accepted)', + xExpression: 'template', + }), + /** Variable name the current item is bound to inside the body. */ + iteratorVariable: z.string().min(1).default('item').describe('Loop variable holding the current item'), + /** Optional variable name the zero-based index is bound to inside the body. */ + indexVariable: z.string().optional().describe('Optional loop variable holding the current index'), + /** + * Maximum iterations to run — a guard against runaway collections. Clamped to + * {@link LOOP_MAX_ITERATIONS_CEILING}; a collection longer than this fails the + * node rather than truncating silently. + */ + maxIterations: z.number().int().min(1).max(LOOP_MAX_ITERATIONS_CEILING).optional() + .describe('Hard cap on iterations (clamped to the engine ceiling)'), + /** The body region executed once per item (single-entry/single-exit). */ + body: FlowRegionSchema.optional().describe('Loop body region (omit for legacy flat-graph loops)'), + }, +)); export type LoopConfig = z.input; export type LoopConfigParsed = z.infer; @@ -138,12 +223,26 @@ export type LoopConfigParsed = z.infer; // ─── Parallel block ────────────────────────────────────────────────── /** One named branch of a {@link ParallelConfigSchema} parallel block. */ -export const ParallelBranchSchema = lazySchema(() => z.object({ - /** Optional human label for the branch (designer + logs). */ - name: z.string().optional().describe('Branch label'), - nodes: z.array(FlowNodeSchema).min(1).describe('Branch body nodes'), - edges: z.array(FlowEdgeSchema).default([]).describe('Branch body edges'), -})); +export const ParallelBranchSchema = lazySchema(() => strictObject( + { + surface: 'this parallel branch', + history: CONTROL_FLOW_STRIP_HISTORY, + // `label` is not a typo of `name` — no edit distance connects them. It is + // the word this protocol uses for a human-readable name EVERYWHERE ELSE in + // the same object literal: `FlowNodeSchema.label` is REQUIRED on every + // element of the `nodes[]` array sitting right beside this key. A branch is + // the one shape here that spells it `name`, so borrowing `label` is a + // reasonable author's guess, and the `visibleWhen → visible` category + // `aliases` exists for. + aliases: { label: 'name' }, + }, + { + /** Optional human label for the branch (designer + logs). */ + name: z.string().optional().describe('Branch label'), + nodes: z.array(FlowNodeSchema).min(1).describe('Branch body nodes'), + edges: z.array(FlowEdgeSchema).default([]).describe('Branch body edges'), + }, +)); export type ParallelBranch = z.input; @@ -153,10 +252,17 @@ export type ParallelBranch = z.input; * complete). There is no author-visible split/join gateway to mis-wire. The * branches run in the enclosing variable scope. */ -export const ParallelConfigSchema = lazySchema(() => z.object({ - branches: z.array(ParallelBranchSchema).min(2) - .describe('Branch regions executed concurrently; implicit join at block end'), -})); +export const ParallelConfigSchema = lazySchema(() => strictObject( + { + surface: 'this parallel block config', + history: CONTROL_FLOW_STRIP_HISTORY, + guidance: IMPLICIT_JOIN_PRESCRIPTIONS, + }, + { + branches: z.array(ParallelBranchSchema).min(2) + .describe('Branch regions executed concurrently; implicit join at block end'), + }, +)); export type ParallelConfig = z.input; export type ParallelConfigParsed = z.infer; @@ -190,13 +296,26 @@ import { RetryPolicySchema } from '../shared/retry-policy.zod'; * native error model — the same `fault` + retry semantics already in the engine, * surfaced as a construct rather than BPMN boundary events (ADR-0031 §Decision 3). */ -export const TryCatchConfigSchema = lazySchema(() => z.object({ - try: FlowRegionSchema.describe('Protected region'), - catch: FlowRegionSchema.optional().describe('Handler region run when the try region fails'), - /** Variable the caught error is bound to inside the catch region. */ - errorVariable: z.string().default('$error').describe('Variable holding the caught error in the catch region'), - retry: RetryPolicySchema.optional().describe('Optional retry policy for the try region'), -})); +export const TryCatchConfigSchema = lazySchema(() => strictObject( + { + surface: 'this try/catch config', + history: CONTROL_FLOW_STRIP_HISTORY, + guidance: { + // The strongest prior any author brings to a construct named + // `try_catch`: every mainstream language pairs it with `finally`. This + // one deliberately does not, and the answer is a real place to put the + // steps — not "that key does not exist". + finally: 'There is no `finally` region. The `try_catch` node\'s ORDINARY out-edges are the continuation and run whichever way the protected region went (ADR-0031 §Decision 3 surfaces the engine\'s existing `fault` edge, not BPMN boundary events) — put the always-run steps in the nodes AFTER this container.', + }, + }, + { + try: FlowRegionSchema.describe('Protected region'), + catch: FlowRegionSchema.optional().describe('Handler region run when the try region fails'), + /** Variable the caught error is bound to inside the catch region. */ + errorVariable: z.string().default('$error').describe('Variable holding the caught error in the catch region'), + retry: RetryPolicySchema.optional().describe('Optional retry policy for the try region'), + }, +)); export type TryCatchConfig = z.input; export type TryCatchConfigParsed = z.infer; @@ -357,8 +476,11 @@ function regionSlotsOf(node: FlowNodeParsed): RegionSlot[] { key, index, label: `${node.type} '${node.id}' ${singularize(key)} ${index}`, - // A branch also carries an optional `name`, which the plain region - // schema (a non-strict `z.object`) would strip. + // A branch also carries an optional `name`. Picking the branch schema + // here used to be a fidelity choice — the region schema would have + // STRIPPED `name`, losing it. Since #4001 both schemas are strict, so + // it is a correctness choice: the region schema would REJECT a legal + // branch outright. Same line, higher stakes. schema: ParallelBranchSchema, })); continue; diff --git a/packages/spec/src/automation/region-normalization.test.ts b/packages/spec/src/automation/region-normalization.test.ts index d60260536a..19e7b6b551 100644 --- a/packages/spec/src/automation/region-normalization.test.ts +++ b/packages/spec/src/automation/region-normalization.test.ts @@ -75,7 +75,9 @@ describe('#4347 — normalizeControlFlowRegions', () => { const branches = (flow.nodes[1]!.config as any).branches; for (const branch of branches) expect(branch.edges[0].condition).toEqual(ENVELOPE); - // `FlowRegionSchema` would have stripped `name`; branches parse as branches. + // `FlowRegionSchema` would REJECT `name` (strict since #4001 批 10, where + // it used to merely strip it) — so `regionSlotsOf` picking the branch + // schema for `branches[]` went from a fidelity choice to a correctness one. expect(branches.map((b: { name: string }) => b.name)).toEqual(['left', 'right']); }); diff --git a/packages/spec/src/automation/region-slots.test.ts b/packages/spec/src/automation/region-slots.test.ts index 01679b668f..dc8e78606a 100644 --- a/packages/spec/src/automation/region-slots.test.ts +++ b/packages/spec/src/automation/region-slots.test.ts @@ -14,6 +14,24 @@ * key names off `.shape`. A slot renamed in the schema fails here; so does a * slot that stops accepting a region. (Approach carried over from the * `flow-walk.test.ts` version it replaces.) + * + * ## The probe was rebuilt in #4001 批 10 — it had been running on `.strip` + * + * The original handed EVERY candidate key to EVERY schema in one payload — + * `try`, `catch` and `branches` to `loop` included — and depended on zod's + * default `.strip` to discard the ones that schema does not declare. So the + * moment the three container configs became `strictObject`, the probe payload + * stopped parsing at all and the function returned `[]` for every construct: + * a reconciliation test reporting "no schema accepts any region". + * + * It failed loudly, which is the only reason this reads as a footnote rather + * than as the campaign's third-category finding — an instrument that measures + * a surface by relying on the very leniency the campaign is removing will + * either break or, worse, quietly agree with itself. The repair keeps the + * behavioural question exactly as it was and drops the dependency: the payload + * is now built from the keys the schema itself declares, so each construct is + * asked only about slots it has. Strictly more honest than before, where a + * `loop` was asked about `catch` and the answer was thrown away unread. */ import { describe, it, expect } from 'vitest'; @@ -36,22 +54,43 @@ const REGION_BEARING_CONFIGS = { /** A minimal well-formed region. `label` is required by `FlowNodeSchema`. */ const region = () => ({ nodes: [{ id: 'probe', type: 'script', label: 'Probe' }], edges: [] }); +/** + * Candidate values, one per key a container construct might carry. `collection` + * is here as a REQUIRED SIBLING with a deliberately non-region value — the + * parse has to reach the region keys, and the arity check below must not + * mistake an inline-array collection for a list of regions. + */ +const PROBE_VALUES: Readonly> = { + collection: '{items}', + body: region(), + try: region(), + catch: region(), + branches: [region(), region()], +}; + /** * Ask a config schema which of its keys accept a region — by handing it every - * candidate at once and seeing which survive the parse in a region shape. + * candidate it DECLARES and seeing which survive the parse in a region shape. * Returns each surviving key with the arity it parsed at. + * + * Restricting the payload to the schema's own keys is what makes this work + * against a strict shape (see the module note): the question asked is + * unchanged, but the probe no longer needs the schema to silently swallow + * candidates that belong to a different construct. */ function regionSlotsAccepted( - schema: { safeParse: (v: unknown) => { success: boolean; data?: unknown } }, + schema: { + shape?: Record; + safeParse: (v: unknown) => { success: boolean; data?: unknown }; + }, ): Array<{ key: string; arity: 'one' | 'many' }> { - const probes: Record = { - // Required siblings, so the parse reaches the region keys at all. - collection: '{items}', - body: region(), - try: region(), - catch: region(), - branches: [region(), region()], - }; + const declaredKeys = Object.keys(schema.shape ?? {}); + // A schema with no readable shape is not a container config; probing it with + // the full candidate set would be the old strip-dependent behaviour again. + if (declaredKeys.length === 0) return []; + const probes = Object.fromEntries( + declaredKeys.filter((k) => k in PROBE_VALUES).map((k) => [k, PROBE_VALUES[k]]), + ); const parsed = schema.safeParse(probes); if (!parsed.success) return []; const data = parsed.data as Record; @@ -102,6 +141,15 @@ describe('#4401 — FLOW_REGION_SLOTS reconciles with the ADR-0031 construct sch } // Guard against the probe going vacuous if the naming convention changes. expect(probed.length + accounted.size).toBeGreaterThanOrEqual(3); + // …and against it going vacuous the OTHER way: the probe now reads + // `.shape` and returns `[]` for anything it cannot read, so an empty + // result must mean "declares no region", never "could not be asked". + for (const [name, schema] of Object.entries(REGION_BEARING_CONFIGS)) { + expect( + Object.keys((schema as unknown as { shape: Record }).shape ?? {}).length, + `${name} must expose a readable .shape or every probe above is vacuous`, + ).toBeGreaterThan(0); + } }); it('marks exactly the array-valued slot as `many`', () => { diff --git a/packages/spec/src/automation/state-machine.test.ts b/packages/spec/src/automation/state-machine.test.ts index a6838c7fe4..b58de7d5d1 100644 --- a/packages/spec/src/automation/state-machine.test.ts +++ b/packages/spec/src/automation/state-machine.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect } from 'vitest'; -import { StateMachineSchema } from './state-machine.zod'; +import { + StateMachineSchema, + StateNodeSchema, + TransitionSchema, + ActionRefSchema, + GuardRefSchema, +} from './state-machine.zod'; +import { AgentSchema } from '../ai/agent.zod'; +import { formatZodError } from '../shared/error-map.zod'; describe('StateMachineSchema', () => { it('should validate a simple state machine', () => { @@ -123,6 +131,174 @@ describe('StateMachineSchema', () => { }); }); +// ─── [#4001 批 10] unknown keys are rejected, not stripped ────────────────── +// +// The ledger carried these six shapes as `authorable (p)` — provisional. The +// `(p)` had to be resolved before tightening (verify-before-tightening), and +// resolving it was not a formality: ADR-0020 RETIRED this XState shape as a +// record-lifecycle declaration, so the top-level `workflow` metadata type and +// `object.stateMachines` are both gone and a record's transitions live on the +// `state_machine` VALIDATION RULE instead. Had those been the only doors, this +// file would be dead surface and the correct action would have been to fix its +// ledger class, not to close it. +// +// The surviving door is `ai/agent.zod.ts`'s `lifecycle` — and `agent` is a +// registered metadata type, so `defineStack({ agents })`, the meta REST write +// and the Studio agent form all `.parse()` through here. The first test below +// IS that verification, kept executable rather than written down. +describe('[#4001] the authoring door — agent.lifecycle', () => { + const agent = (lifecycle: unknown) => ({ + name: 'probe_agent', label: 'Probe', role: 'assistant', instructions: 'do things', lifecycle, + }); + + it('a well-formed lifecycle still parses through AgentSchema', () => { + const parsed = AgentSchema.parse(agent({ + id: 'probe_machine', + initial: 'draft', + states: { + draft: { on: { APPROVE: 'done' }, meta: { aiInstructions: 'Review carefully' } }, + done: { type: 'final' }, + }, + })); + expect((parsed.lifecycle as { states: Record }).states).toHaveProperty('draft'); + }); + + // The measurement that resolved `(p)` to `authorable`. Before this batch the + // parse below SUCCEEDED, returning + // { id, initial, states: { draft: { type: 'atomic', meta: {} }, done: … } } + // — `stats` gone, both `meta` keys gone, and `onn` (one keystroke from `on`) + // gone with every transition the author declared. A machine whose whole job + // is to deny undeclared transitions had become one with NO transitions, and + // reported success. All three depths must now refuse. + it('refuses undeclared keys at all three depths, through the agent door', () => { + const result = AgentSchema.safeParse(agent({ + id: 'probe_machine', + initial: 'draft', + stats: { runs: 3 }, + states: { + draft: { onn: { APPROVE: 'done' }, meta: { labell: 'Draft', owner: 'ops' } }, + done: { type: 'final' }, + }, + })); + expect(result.success).toBe(false); + + const messages = result.error!.issues.map((i) => i.message).join('\n'); + // machine level, state-node level, meta level — and each names its own + // surface, so the author is told WHICH of the three nested shapes refused. + expect(messages).toContain('this state machine'); + expect(messages).toContain('this state node'); + expect(messages).toContain('this state node meta block'); + // Every one of the three carries a usable rename. + expect(messages).toContain('`stats` → `states`'); + expect(messages).toContain('`onn` → `on`'); + expect(messages).toContain('`labell` → `label`'); + }); +}); + +describe('[#4001] state-machine strictness — per shape', () => { + it('StateMachine: `context` gets a wrong-layer prescription, NOT a rename', () => { + const result = StateMachineSchema.safeParse({ + id: 'mm', initial: 's', states: { s: {} }, context: { amount: 0 }, + }); + expect(result.success).toBe(false); + const message = result.error!.issues[0]!.message; + // XState's `context` holds initial VALUES; `contextSchema` declares a + // SHAPE. A rename here would tell the author to write their values where a + // schema goes — so the entry states both halves and offers no rename. + expect(message).toContain('INITIAL VALUES'); + expect(message).toContain('contextSchema'); + expect(message).not.toContain('Did you mean'); + }); + + it('StateNode: `transitions` is pointed at `on`, and at the OTHER declaration', () => { + const result = StateNodeSchema.safeParse({ transitions: { draft: ['done'] } }); + expect(result.success).toBe(false); + const message = result.error!.issues[0]!.message; + expect(message).toContain('`on`'); + // The word `transitions` is not invented — it is the key on the object-level + // `state_machine` validation rule, which is the neighbouring declaration an + // author most plausibly arrives from. Saying so is the whole value. + expect(message).toContain('validations[].transitions'); + }); + + it('Transition: `guard` → `cond` needs the alias — edit distance cannot reach it', () => { + const result = TransitionSchema.safeParse({ target: 'approved', guard: 'isManager' }); + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toContain('`guard` → `cond`'); + }); + + it('Transition: a plain typo still rides the edit-distance fallback', () => { + const result = TransitionSchema.safeParse({ target: 'approved', action: ['notify'] }); + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toContain('`action` → `actions`'); + }); + + // `meta` was the one shape in this file that had to be argued rather than + // measured-and-closed: XState treats `meta` as an open bag, and the #4909 + // precedent says a genuinely-open slot should SAY `.passthrough()`. It is + // closed here because the hand-written `StateNodeConfig` type declares + // exactly these four keys (passthrough would open the Zod while `tsc` stayed + // shut), because nothing in the repo reads any `meta` key, and because the + // pre-existing behaviour was not openness but strip — an author's `meta` + // arrived as `{}`. There was no openness to preserve. + it('StateNode.meta is CLOSED — the four declared keys and no bag', () => { + expect(() => StateNodeSchema.parse({ + meta: { label: 'L', description: 'D', color: '#fff', aiInstructions: 'A' }, + })).not.toThrow(); + + const result = StateNodeSchema.safeParse({ meta: { label: 'L', tooltip: 'T' } }); + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).toContain('this state node meta block'); + }); +}); + +// The union branches behave measurably differently from the plain shapes, and +// the difference is zod's, not this file's. Pinned in BOTH directions so the +// next reader does not "fix" the quietness by reopening the branch, and so a +// future improvement to the flattening consumers is noticed here first. +describe('[#4001] ActionRef / GuardRef — strict inside a union', () => { + it.each([ + ['ActionRef', ActionRefSchema, 'this action reference'], + ['GuardRef', GuardRefSchema, 'this guard reference'], + ] as const)('%s: the object branch rejects an unknown key', (_label, schema, surface) => { + // The string branch is untouched — it has no keys to be strict about. + expect(() => schema.parse('isManager')).not.toThrow(); + expect(() => schema.parse({ type: 'log', params: { a: 1 } })).not.toThrow(); + + const result = schema.safeParse({ type: 'log', args: { a: 1 } }); + expect(result.success).toBe(false); + + // The union raises ONE issue, and its own message is the bare zod string. + const issue = result.error!.issues[0] as { code: string; message: string; errors?: unknown[][] }; + expect(issue.code).toBe('invalid_union'); + expect(issue.message).toBe('Invalid input'); + + // …with the real prescription intact one level down. This is the assertion + // that keeps "quieter" from decaying into "silent". + const nested = (issue.errors ?? []).flat() as Array<{ message: string }>; + const prose = nested.map((i) => i.message).join('\n'); + expect(prose).toContain(surface); + expect(prose).toContain('`args`'); + }); + + // Anti-vacuity for the claim above: a PLAIN strictObject in this same file + // does surface its prose through the same formatter, so the union's + // `Invalid input` is a property of the union and not of the curation. If + // this control ever goes quiet too, the diagnosis changes completely. + it('CONTROL — a non-union shape renders its full prescription through formatZodError', () => { + const plain = TransitionSchema.safeParse({ target: 't', guard: 'isX' }); + expect(formatZodError(plain.error!)).toContain('`guard` → `cond`'); + + const union = ActionRefSchema.safeParse({ type: 'log', args: { a: 1 } }); + // Same formatter, same class of mistake, flattened to nothing usable. + // `formatZodError` maps `error.issues` and never descends into + // `invalid_union.errors` — filed as a finding, deliberately not fixed in a + // spec-strictness PR (it would change CLI output for every union). + expect(formatZodError(union.error!)).toContain('Invalid input'); + expect(formatZodError(union.error!)).not.toContain('this action reference'); + }); +}); + // ─── [#4658] `EventSchema` is gone from ./automation — dual-source C6 ──────── // // `./automation` and `./kernel` both exported an `EventSchema`, for two diff --git a/packages/spec/src/automation/state-machine.zod.ts b/packages/spec/src/automation/state-machine.zod.ts index 5210a0c601..12a6197e29 100644 --- a/packages/spec/src/automation/state-machine.zod.ts +++ b/packages/spec/src/automation/state-machine.zod.ts @@ -1,27 +1,117 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +/** + * @module automation/state-machine + * + * XState-inspired State Machine Protocol — hierarchical states, guarded + * transitions, entry/exit actions. Used to declare strict business-logic + * constraints and lifecycle management, so an AI author cannot "hallucinate" a + * transition the machine never declared. + * + * ## Where this is authored — the question #4001 had to answer first + * + * The ledger carried these shapes as `authorable (p)` — provisional, because + * nobody had checked. Checking matters here more than usual, because + * [ADR-0020](../../../docs/adr/0020-state-machine-converge-and-enforce.md) + * **retired this shape as a record-lifecycle declaration**: the top-level + * `workflow` metadata type and `object.stateMachines` are both gone, and a + * record's legal transitions are declared as a `state_machine` **validation + * rule** (`data/validation.zod.ts`, a flat `{ from: [to] }` table — closed + * since #4001 batch 3b). A schema whose only doors were those two would be + * dead surface, and the campaign's own rule is that dead surface gets its + * ledger class corrected, not tightened. + * + * One door survives, and it is an authoring door: **`ai/agent.zod.ts`'s + * `lifecycle`** is `StateMachineSchema`, and `agent` is a registered metadata + * type — so `defineStack({ agents })`, `POST /api/v1/meta/types/agent` and the + * Studio agent form all reach this file through `AgentSchema.parse()`. Verified + * by parse, not by reading: before this change, + * + * ```ts + * AgentSchema.parse({ …, lifecycle: { + * id: 'probe_machine', initial: 'draft', stats: { runs: 3 }, + * states: { draft: { onn: { APPROVE: 'done' }, meta: { labell: 'Draft', owner: 'ops' } }, + * done: { type: 'final' } }, + * } }) + * ``` + * + * **succeeded**, returning + * `{ id, initial, states: { draft: { type: 'atomic', meta: {} }, done: … } }` — + * `stats` gone, `meta`'s two keys gone, and `onn` (one keystroke from `on`) + * gone with every transition the author declared. A state machine whose whole + * purpose is to *deny* undeclared transitions had silently become one with no + * transitions at all, and reported success. + * + * So: `authorable`, and every shape below is `strictObject`. + * + * ## `meta` is closed, deliberately + * + * XState treats `meta` as an open bag, so leaving it open was the plausible + * call and it was checked rather than assumed (the #4909 precedent: a slot + * whose openness is real should say `.passthrough()`, not strip). Three facts + * say closed here: the hand-written {@link StateNodeConfig} type beside this + * schema declares exactly four `meta` keys, so `passthrough` would open the + * Zod while `tsc` stayed shut — a new declared-≠-enforced split; nothing in + * this repo reads any `meta` key (`aiInstructions` has no consumer outside + * this file's own test); and the current behaviour is not openness but + * *strip* — the probe above shows an author's `meta` arriving as `{}`. There + * is no openness here to preserve, only a silence to end. + */ + import { z } from 'zod'; + +import { lazySchema } from '../shared/lazy-schema'; +import { strictObject } from '../shared/strict-object'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; /** - * XState-inspired State Machine Protocol - * Used to define strict business logic constraints and lifecycle management. - * Prevent AI "hallucinations" by enforcing valid valid transitions. + * Shared history sentence for every shape in this file — one silence, one + * description of it, so the rejections cannot drift apart. */ +const STATE_MACHINE_STRIP_HISTORY = + 'Until #4001 an undeclared key here was dropped silently — the machine still parsed, so a mistyped `on`/`entry`/`cond` produced a machine missing the very transition it was written to declare, reported as valid.'; // --- Primitives --- /** * References a named action (side effect) * Can be a script, a webhook, or a field update. + * + * A union: the string form names a registered action, the object form + * parameterises one. Only the OBJECT branch has keys to be strict about. + * + * ⚠️ **The rejection is quieter here than on a plain shape, and that is a zod + * property, not a curation gap.** A failing branch does not raise its own + * issue to the top: the union raises ONE `invalid_union` issue whose + * `message` is the literal string `"Invalid input"`, with each branch's real + * issues nested one level down in `issue.errors[]`. Measured, both ways — + * `TransitionSchema` (a plain `strictObject`) renders its full prescription + * through `formatZodError`, while this schema renders `✗ (root): Invalid + * input` for the same class of mistake, with the prescription intact in the + * payload underneath. The nested message survives everywhere the issues are + * carried structurally (`ZodError.message`, the REST error body); it is the + * flatten-to-one-line consumers that drop it, and `formatZodError` is one — + * filed as a finding rather than fixed here, since teaching that shared + * formatter to descend changes CLI output for every union in the repo. + * + * Strictness still earns its place: the alternative is not a better message, + * it is `params` misspelled as `args` **accepted in silence**, with the + * action running unparameterised. Rejection beats that even at "Invalid + * input". Both facts are pinned in `state-machine.test.ts` so neither the + * quietness nor the underlying prose can regress unnoticed. */ -import { lazySchema } from '../shared/lazy-schema'; export const ActionRefSchema = lazySchema(() => z.union([ z.string().describe('Action Name'), - z.object({ - type: z.string(), // e.g., 'xstate.assign', 'log', 'email' - params: z.record(z.string(), z.unknown()).optional() - }) + strictObject( + { + surface: 'this action reference', + history: STATE_MACHINE_STRIP_HISTORY, + }, + { + type: z.string(), // e.g., 'xstate.assign', 'log', 'email' + params: z.record(z.string(), z.unknown()).optional(), + }, + ), ])); /** @@ -30,10 +120,16 @@ export const ActionRefSchema = lazySchema(() => z.union([ */ export const GuardRefSchema = lazySchema(() => z.union([ z.string().describe('Guard Name (e.g., "isManager", "amountGT1000")'), - z.object({ - type: z.string(), - params: z.record(z.string(), z.unknown()).optional() - }) + strictObject( + { + surface: 'this guard reference', + history: STATE_MACHINE_STRIP_HISTORY, + }, + { + type: z.string(), + params: z.record(z.string(), z.unknown()).optional(), + }, + ), ])); // --- Core Structure --- @@ -42,12 +138,26 @@ export const GuardRefSchema = lazySchema(() => z.union([ * State Transition Definition * "When EVENT happens, if GUARD is true, go to TARGET and run ACTIONS" */ -export const TransitionSchema = lazySchema(() => z.object({ - target: z.string().optional().describe('Target State ID'), - cond: GuardRefSchema.optional().describe('Condition (Guard) required to take this path'), - actions: z.array(ActionRefSchema).optional().describe('Actions to execute during transition'), - description: z.string().optional().describe('Human readable description of this rule'), -})); +export const TransitionSchema = lazySchema(() => strictObject( + { + surface: 'this state transition', + history: STATE_MACHINE_STRIP_HISTORY, + // `guard → cond` is the alias category's textbook case, and the evidence + // is inside this file rather than in XState release notes: the key is + // `cond`, its value is a `GuardRefSchema`, and its own `.describe()` calls + // it "Condition (Guard) required to take this path". An author who reads + // the schema — or arrives with XState v5 priors, where `cond` WAS renamed + // to `guard` — writes the word the prose uses. Edit distance never + // connects `guard` to `cond`, so only a named entry can. + aliases: { guard: 'cond' }, + }, + { + target: z.string().optional().describe('Target State ID'), + cond: GuardRefSchema.optional().describe('Condition (Guard) required to take this path'), + actions: z.array(ActionRefSchema).optional().describe('Actions to execute during transition'), + description: z.string().optional().describe('Human readable description of this rule'), + }, +)); // `EventSchema` (XState-style signal declaration `{ type, schema }`) was removed // here in #4658 (dual-source ledger #4535 C6): nothing in this file — or any @@ -90,57 +200,96 @@ export type StateNodeConfig = { * output side — which is what this schema already claimed before, so nothing * regressed. Making the output exact means re-deriving `StateNodeConfig` from * the schema rather than maintaining it beside one; that is a separate change. + * + * Note the annotation also ERASES strictness from the static type: `tsc` judges + * an author's literal against {@link StateNodeConfig}, which is a plain object + * type, so an excess key is caught by the parse rather than the compiler. That + * is exactly why the parse had to stop stripping. */ -export const StateNodeSchema: z.ZodType = z.lazy(() => z.object({ - /** Type of state */ - type: z.enum(['atomic', 'compound', 'parallel', 'final', 'history']).default('atomic'), - - /** Entry/Exit Actions */ - entry: z.array(ActionRefSchema).optional().describe('Actions to run when entering this state'), - exit: z.array(ActionRefSchema).optional().describe('Actions to run when leaving this state'), - - /** Transitions (Events) */ - on: z.record(z.string(), z.union([ - z.string(), // Shorthand target - TransitionSchema, - z.array(TransitionSchema) - ])).optional().describe('Map of Event Type -> Transition Definition'), - - /** Always Transitions (Eventless) */ - always: z.array(TransitionSchema).optional(), - - /** Nesting (Hierarchical States) */ - initial: z.string().optional().describe('Initial child state (if compound)'), - states: z.record(z.string(), StateNodeSchema).optional(), - - /** Metadata for UI/AI */ - meta: z.object({ - label: z.string().optional(), - description: z.string().optional(), - color: z.string().optional(), // For UI diagrams - // Instructions for AI Agent when in this state - aiInstructions: z.string().optional().describe('Specific instructions for AI when in this state'), - }).optional(), -})); +export const StateNodeSchema: z.ZodType = z.lazy(() => strictObject( + { + surface: 'this state node', + history: STATE_MACHINE_STRIP_HISTORY, + guidance: { + // A wrong-LAYER pointer, not a rename: `on` is a record keyed by EVENT + // TYPE, so there is no `transitions` key to send the author to. Named + // because `transitions` is the word the surviving record-lifecycle shape + // uses (`data/validation.zod.ts`'s `state_machine` rule), which is the + // neighbouring declaration an author is most likely to be coming from. + transitions: 'A state node declares its transitions as `on`, a record keyed by EVENT TYPE (`on: { APPROVE: "approved" }`). `transitions` is the key on the object-level `state_machine` VALIDATION RULE (`validations[].transitions`, a flat `{ from: [to] }` table) — a different declaration, for a record\'s lifecycle rather than an agent\'s.', + }, + }, + { + /** Type of state */ + type: z.enum(['atomic', 'compound', 'parallel', 'final', 'history']).default('atomic'), + + /** Entry/Exit Actions */ + entry: z.array(ActionRefSchema).optional().describe('Actions to run when entering this state'), + exit: z.array(ActionRefSchema).optional().describe('Actions to run when leaving this state'), + + /** Transitions (Events) */ + on: z.record(z.string(), z.union([ + z.string(), // Shorthand target + TransitionSchema, + z.array(TransitionSchema), + ])).optional().describe('Map of Event Type -> Transition Definition'), + + /** Always Transitions (Eventless) */ + always: z.array(TransitionSchema).optional(), + + /** Nesting (Hierarchical States) */ + initial: z.string().optional().describe('Initial child state (if compound)'), + states: z.record(z.string(), StateNodeSchema).optional(), + + /** Metadata for UI/AI — closed, see the module note on `meta`. */ + meta: strictObject( + { + surface: 'this state node meta block', + history: STATE_MACHINE_STRIP_HISTORY, + }, + { + label: z.string().optional(), + description: z.string().optional(), + color: z.string().optional(), // For UI diagrams + // Instructions for AI Agent when in this state + aiInstructions: z.string().optional().describe('Specific instructions for AI when in this state'), + }, + ).optional(), + }, +)); /** * Top-Level State Machine Definition */ -export const StateMachineSchema = lazySchema(() => z.object({ - id: SnakeCaseIdentifierSchema.describe('Unique Machine ID'), - description: z.string().optional(), - - /** Context (Memory) Schema */ - contextSchema: z.record(z.string(), z.unknown()).optional().describe('Zod Schema for the machine context/memory'), - - /** Initial State */ - initial: z.string().describe('Initial State ID'), - - /** State Definitions */ - states: z.record(z.string(), StateNodeSchema).describe('State Nodes'), - - /** Global Listeners */ - on: z.record(z.string(), z.union([z.string(), TransitionSchema, z.array(TransitionSchema)])).optional(), -})); +export const StateMachineSchema = lazySchema(() => strictObject( + { + surface: 'this state machine', + history: STATE_MACHINE_STRIP_HISTORY, + guidance: { + // XState's `context` holds initial VALUES; this protocol's + // `contextSchema` declares a SHAPE. Renaming one to the other would tell + // an author to write their initial values where a schema goes — a + // confidently wrong prescription of exactly the kind the campaign's + // fourth finding is about. So: a pointer that states both halves. + context: '`context` in XState holds the machine\'s INITIAL VALUES. This protocol declares only the context SHAPE, as `contextSchema` — there is no key here for seeding values, so the two are not a rename of each other.', + }, + }, + { + id: SnakeCaseIdentifierSchema.describe('Unique Machine ID'), + description: z.string().optional(), + + /** Context (Memory) Schema */ + contextSchema: z.record(z.string(), z.unknown()).optional().describe('Zod Schema for the machine context/memory'), + + /** Initial State */ + initial: z.string().describe('Initial State ID'), + + /** State Definitions */ + states: z.record(z.string(), StateNodeSchema).describe('State Nodes'), + + /** Global Listeners */ + on: z.record(z.string(), z.union([z.string(), TransitionSchema, z.array(TransitionSchema)])).optional(), + }, +)); export type StateMachineConfig = z.infer; From 9ed7935503994c2785728f2bcabb9044910c9d41 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 17:20:35 +0000 Subject: [PATCH 2/2] =?UTF-8?q?docs(spec):=20ledger=20+=20changeset=20+=20?= =?UTF-8?q?regenerated=20references=20for=20=E6=89=B9=2010?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - strictness ledger: automation/ 67->56 strip (rows for the two closed files deleted per the reverse pin), authorable 41->30; both (p) verdicts resolved with the evidence that resolved them - region-slots.test.ts probe rebuilt: it depended on .strip - parse-config.ts doc corrected: unknown keys are no longer this seam's blind spot - major changeset with the full FROM -> TO migration table - regenerated content/docs/references + skill refs (check:generated 8/8) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- ...t-automation-control-flow-state-machine.md | 72 +++++++++++++++ .../references/automation/control-flow.mdx | 38 ++++++++ .../references/automation/state-machine.mdx | 92 ++++++++++++++++++- .../2026-07-unknown-key-strictness-ledger.md | 10 +- skills/objectstack-ai/references/_index.md | 2 +- .../references/_index.md | 2 +- 6 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 .changeset/strict-automation-control-flow-state-machine.md diff --git a/.changeset/strict-automation-control-flow-state-machine.md b/.changeset/strict-automation-control-flow-state-machine.md new file mode 100644 index 0000000000..b27e14b972 --- /dev/null +++ b/.changeset/strict-automation-control-flow-state-machine.md @@ -0,0 +1,72 @@ +--- +'@objectstack/spec': major +--- + +**BREAKING** — `automation/control-flow` and `automation/state-machine` reject unknown keys (#4001 批 10, ADR-0078) + +Eleven authoring shapes that silently discarded undeclared keys now refuse them with a +named surface, the offending key echoed back, and a rename or prescription. Metadata that +used to parse "successfully" while losing the key you wrote now returns 422. + +**`automation/control-flow.zod.ts`** — `FlowRegionSchema`, `LoopConfigSchema`, +`ParallelBranchSchema`, `ParallelConfigSchema`, `TryCatchConfigSchema`. + +**`automation/state-machine.zod.ts`** — `ActionRefSchema` (object branch), +`GuardRefSchema` (object branch), `TransitionSchema`, `StateNodeSchema`, its `meta` block, +and `StateMachineSchema`. + +## What was actually being lost + +A `state_machine` on an agent's `lifecycle` with `onn` where `on` was meant parsed clean +and came back with **no transitions at all** — the declaration whose entire purpose is to +deny undeclared transitions, silently emptied and reported valid. A `loop` config with +`maxIteration` (singular) came back uncapped. A `parallel` branch with `label` instead of +`name` came back unnamed. + +## Migration — FROM → TO + +Renames the rejection now suggests for you: + +| you wrote | write instead | on | +|---|---|---| +| `guard` | `cond` | a state transition (XState v5 renamed it the other way; this protocol kept `cond`) | +| `action` | `actions` | a state transition | +| `itemVariable` | `iteratorVariable` | a `loop` config | +| `maxIteration` | `maxIterations` | a `loop` config | +| `label` | `name` | a `parallel` branch | +| `onn` / `entery` / typos | `on` / `entry` | a state node | + +Keys with no replacement, and what to do instead: + +- **`finally` on `try_catch`** — there is no `finally` region. The node's ordinary + out-edges run whichever way the protected region went; put the always-run steps in the + nodes **after** the container. +- **`join` / `joinGateway` on `parallel`** — the join is implicit; the block continues once + when every branch completes. `join_gateway` is a BPMN interop node type, never a + `parallel` config key. +- **`flowName` on `loop`** — that key belongs to the `map` node, which runs a subflow per + item. A `loop` runs an inline region: move the steps into `config.body`, or change the + node `type` to `map`. +- **`name` / `label` on a region** — a `loop` body, a `try` region and a `catch` region are + not named; only a `parallel` branch carries a `name`. +- **`transitions` on a state node** — a state node declares transitions as `on`, keyed by + event type. `transitions` is the key on the object-level `state_machine` **validation + rule** (`validations[].transitions`), a different declaration. +- **`context` on a state machine** — this protocol declares only the context SHAPE, as + `contextSchema`. There is no key for seeding initial values, so the two are not a rename + of each other. + +## Two notes for upgraders + +`ActionRef` / `GuardRef` are unions, so a rejected key on their object branch surfaces as +zod's `invalid_union` (`"Invalid input"`) with the real prescription nested one level down +in `issue.errors[]` rather than in the top-level message. The prescription is present in +`ZodError.message` and in REST error bodies; single-line formatters drop it. + +`StateNodeSchema.meta` is **closed**, not a passthrough bag. XState treats `meta` as open, +but the hand-written `StateNodeConfig` type here declares exactly `label` / `description` / +`color` / `aiInstructions`, nothing in the platform reads any other key, and the previous +behaviour was not openness but strip — an authored `meta` arrived as `{}`. + +All three example apps (`app-showcase`, `app-crm`, `app-todo`) validate unchanged, so no +ADR-0087 conversion accompanies this change. diff --git a/content/docs/references/automation/control-flow.mdx b/content/docs/references/automation/control-flow.mdx index 5c98907410..fa0e5001e2 100644 --- a/content/docs/references/automation/control-flow.mdx +++ b/content/docs/references/automation/control-flow.mdx @@ -73,6 +73,44 @@ interop node types (`parallel_gateway` / `join_gateway` / `boundary_event`), which remain author-invisible interchange representations. +## Unknown keys are rejected (#4001 / ADR-0078) + +Every shape below is `strictObject`. Before that they were plain `z.object`, + +so zod's default `.strip` applied and a key this file does not declare was + +**discarded in silence** — the container still parsed, still registered, and + +still ran, with the author's configuration simply absent. On these five + +shapes that silence is unusually expensive, because each one carries + +*control* rather than data: a swallowed `maxIterations` is an uncapped loop, + +a swallowed branch key is a branch that runs without what it was given. + +### How this relates to `validateControlFlow` + +`validateControlFlow` is a **sibling guard, not a key gate** — it answers + +"is this region single-entry / single-exit / acyclic", which no amount of + +key strictness can answer. The two do not overlap and cannot fight: the + +schema rejects undeclared KEYS, the analysis rejects malformed STRUCTURE. + +They do now meet at one seam, deliberately — `validateControlFlow` + +`safeParse`s each region slot before analyzing it, so from #4001 that parse + +is also where a region's undeclared key surfaces, reported as + +`: invalid region — `. Nothing was + +duplicated and nothing was removed; the structural prose this guard exists + +for is untouched, and it simply stopped silently repairing its own input. + **Source:** `packages/spec/src/automation/control-flow.zod.ts` diff --git a/content/docs/references/automation/state-machine.mdx b/content/docs/references/automation/state-machine.mdx index 9b73638af4..5fc9f8845c 100644 --- a/content/docs/references/automation/state-machine.mdx +++ b/content/docs/references/automation/state-machine.mdx @@ -5,11 +5,97 @@ description: State Machine protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -XState-inspired State Machine Protocol +@module automation/state-machine -Used to define strict business logic constraints and lifecycle management. +XState-inspired State Machine Protocol — hierarchical states, guarded -Prevent AI "hallucinations" by enforcing valid valid transitions. +transitions, entry/exit actions. Used to declare strict business-logic + +constraints and lifecycle management, so an AI author cannot "hallucinate" a + +transition the machine never declared. + +## Where this is authored — the question #4001 had to answer first + +The ledger carried these shapes as `authorable (p)` — provisional, because + +nobody had checked. Checking matters here more than usual, because + +[ADR-0020](../../../docs/adr/0020-state-machine-converge-and-enforce.md) + +**retired this shape as a record-lifecycle declaration**: the top-level + +`workflow` metadata type and `object.stateMachines` are both gone, and a + +record's legal transitions are declared as a `state_machine` **validation + +rule** (`[data/validation.zod.ts](/docs/references/data/validation)`, a flat `\{ from: [to] \}` table — closed + +since #4001 batch 3b). A schema whose only doors were those two would be + +dead surface, and the campaign's own rule is that dead surface gets its + +ledger class corrected, not tightened. + +One door survives, and it is an authoring door: **`[ai/agent.zod.ts](/docs/references/ai/agent)`'s + +`lifecycle`** is `StateMachineSchema`, and `agent` is a registered metadata + +type — so `defineStack(\{ agents \})`, `POST /api/v1/meta/types/agent` and the + +Studio agent form all reach this file through `AgentSchema.parse()`. Verified + +by parse, not by reading: before this change, + +```ts + +AgentSchema.parse(\{ …, lifecycle: \{ + +id: 'probe_machine', initial: 'draft', stats: \{ runs: 3 \}, + +states: \{ draft: \{ onn: \{ APPROVE: 'done' \}, meta: \{ labell: 'Draft', owner: 'ops' \} \}, + +done: \{ type: 'final' \} \}, + +\} \}) + +``` + +**succeeded**, returning + +`\{ id, initial, states: \{ draft: \{ type: 'atomic', meta: \{\} \}, done: … \} \}` — + +`stats` gone, `meta`'s two keys gone, and `onn` (one keystroke from `on`) + +gone with every transition the author declared. A state machine whose whole + +purpose is to *deny* undeclared transitions had silently become one with no + +transitions at all, and reported success. + +So: `authorable`, and every shape below is `strictObject`. + +## `meta` is closed, deliberately + +XState treats `meta` as an open bag, so leaving it open was the plausible + +call and it was checked rather than assumed (the #4909 precedent: a slot + +whose openness is real should say `.passthrough()`, not strip). Three facts + +say closed here: the hand-written `StateNodeConfig` type beside this + +schema declares exactly four `meta` keys, so `passthrough` would open the + +Zod while `tsc` stayed shut — a new declared-≠-enforced split; nothing in + +this repo reads any `meta` key (`aiInstructions` has no consumer outside + +this file's own test); and the current behaviour is not openness but + +*strip* — the probe above shows an author's `meta` arriving as `\{\}`. There + +is no openness here to preserve, only a silence to end. **Source:** `packages/spec/src/automation/state-machine.zod.ts` diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 0151f81526..3463e39629 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -530,8 +530,8 @@ not verdicts). | `flow.zod.ts` | 11 | authorable | **strict as of #4001** (4 schemas; `FlowVersionHistorySchema` is runtime — stays tolerant) | | `etl.zod.ts` | 10 | authorable (p) | authored pipelines — **candidate**. **−12 at #4738**: `sync.zod.ts` (the L1 "Simple Sync" file — `DataSyncConfig`, its `ConflictResolution` enum and satellites, formerly this row's co-candidate) was deleted whole rather than hardened: three-repo zero importers, no parse site, defs unreachable from the metadata-type roots (#4650 gate), so there was no author for strictness to protect (#4535 C13+C15). The integration-side `ConflictResolution` → `ConnectorConflictResolution` rename in the same change is name-only and moves no sites | | `execution.zod.ts` | 13 | wire | run-state envelopes — never strict. +5 at #4354 (the run-summary family: step metrics / skip reason / per-node / per-gate / the summary itself) — engine-emitted telemetry read by the Console and by operator queries, nobody authors them, so the `wire` verdict covers them unchanged | -| `state-machine.zod.ts` | 6 | authorable (p) | **−1 at #4658**: the orphan `EventSchema` (`{ type, schema }`, an XState-style signal declaration nothing referenced — `StateMachineSchema` names event types as `on:` record keys) was deleted rather than converged with `kernel/events/core.zod.ts`'s envelope `EventSchema`, whose key set it did not intersect (#4535 C6). The remaining 6 sites and their verdict are unchanged | -| `control-flow.zod.ts` | 5 | authorable (p) | validated structurally by `validateControlFlow`. **−1 at #4661**: `RetryPolicySchema` moved out to `shared/retry-policy.zod.ts` — `./automation` and `./system` published the same name for two different declarations (#4411), so the retry policy converged onto one. The site still exists and is still non-strict and authorable; it is simply no longer in a directory this ledger sections. ⚠️ That is a coverage gap worth knowing about: this audit sections `ui/` / `data/` / `automation/` / `security/` / `studio/` only, so a `shared/` shape is unaudited by construction. The tolerance is deliberate here — the `retryDelayMs` → `backoffMs` rename is tombstoned via `retiredKey()` precisely because a non-strict parent would otherwise swallow the old spelling | +| `state-machine.zod.ts` | 6 | authorable | **strict as of #4001 批 10** — all six sites (`ActionRef` / `GuardRef` / `Transition` / `StateNode` + `.meta` / `StateMachine`). **The `(p)` was NOT a formality here.** ADR-0020 retired this XState shape as a *record-lifecycle* declaration — the top-level `workflow` metadata type and `object.stateMachines` are both gone, and a record's transitions live on the `state_machine` VALIDATION RULE instead — so had those been the only doors this file would be DEAD surface, and the correct action would have been to fix its class, not close it. One authoring door survives: `ai/agent.zod.ts`'s `lifecycle` is `StateMachineSchema`, and `agent` is a registered type, so `defineStack({ agents })` / meta REST / the Studio agent form all reach here through `AgentSchema.parse()`. Verified by parse: an agent whose lifecycle carried `stats`, a state with `onn` (one keystroke from `on`) and a `meta` with two unknown keys **parsed clean**, returning a machine with NO transitions at all — the declaration whose whole job is to deny undeclared transitions, silently emptied and reported valid. `.meta` was checked for the #4909 open-slot case and is CLOSED: the hand-written `StateNodeConfig` type declares exactly its four keys (passthrough would open the Zod while `tsc` stayed shut), nothing in the repo reads any `meta` key, and the prior behaviour was strip — an author's `meta` arrived as `{}` — so there was no openness to preserve. ⚠️ `ActionRef` / `GuardRef` are UNIONS: a strict branch's message does not reach the top (zod raises one `invalid_union` whose message is the literal `"Invalid input"`, with the real prescription nested in `issue.errors[]`), which `formatZodError` then flattens away — filed, not fixed here. **−1 at #4658**: the orphan `EventSchema` (`{ type, schema }`, an XState-style signal declaration nothing referenced — `StateMachineSchema` names event types as `on:` record keys) was deleted rather than converged with `kernel/events/core.zod.ts`'s envelope `EventSchema`, whose key set it did not intersect (#4535 C6). The remaining 6 sites and their verdict are unchanged | +| `control-flow.zod.ts` | 5 | authorable | **strict as of #4001 批 10** — all five sites (`FlowRegion` / `Loop` / `ParallelBranch` / `Parallel` / `TryCatch`). The `(p)` resolves to authorable on the executors' own parse seam (`parseNodeConfig`, #4277) plus `validateControlFlow`'s region parse. **`validateControlFlow` is a sibling guard, not a key gate, and the two do not fight**: it answers single-entry / single-exit / acyclic, which no key check can decide, and the schema answers key membership, which no structural check can decide. They meet at exactly one seam — the guard `safeParse`s each region slot before analyzing it, so an undeclared region key now surfaces there as `: invalid region — `, the guard's framing wrapping the schema's prescription. Nothing was duplicated and nothing removed; the guard simply stopped silently repairing its own input before judging it. Two curation entries had to be MEASURED rather than reasoned: the bare edit-distance fallback answers `itemVariable` with **`indexVariable`** — binding the loop INDEX where the author wanted the ITEM — so the alias exists to overrule a confidently wrong suggestion from this campaign's own helper (the `pii` → `min` shape, third instance); and `join`/`joinGateway` needed two DISTINCT prescriptions because `guidance` emits one bullet per key verbatim, so a shared string printed the same paragraph twice. Its test instrument also had to be rebuilt: `region-slots.test.ts` probed every construct with every candidate key at once and depended on `.strip` to discard the mismatches, so it returned "no schema accepts any region" the moment the shapes closed — it failed loudly, which is the only reason this is a footnote and not a fourth finding-3. Structural validation by `validateControlFlow` remains. **−1 at #4661**: `RetryPolicySchema` moved out to `shared/retry-policy.zod.ts` — `./automation` and `./system` published the same name for two different declarations (#4411), so the retry policy converged onto one. The site still exists and is still non-strict and authorable; it is simply no longer in a directory this ledger sections. ⚠️ That is a coverage gap worth knowing about: this audit sections `ui/` / `data/` / `automation/` / `security/` / `studio/` only, so a `shared/` shape is unaudited by construction. The tolerance is deliberate here — the `retryDelayMs` → `backoffMs` rename is tombstoned via `retiredKey()` precisely because a non-strict parent would otherwise swallow the old spelling | | `bpmn-interop.zod.ts` | 5 | wire (p) | interop import shapes | | `approval.zod.ts` | 4 | authorable | **strict as of #4001 step 3** — all four authoring schemas (node config / approver / escalation / decision-output). The published JSON schema carries `additionalProperties: false` into the Studio form AND `registerFlow()` config validation (#4027/#4040), so an unknown key in an approval node's `config` is rejected at registration too — verified: `z.toJSONSchema` on the strict lazySchema does not throw (#3746 hazard checked) | | `node-executor.zod.ts` | 4 | wire | executor contract | @@ -605,7 +605,7 @@ classes; where it does, the split is stated. **Only the authorable half is in th 2026-08-03 ruling's forced scope** — wire/open rows are listed so the arithmetic is complete and so nobody re-triages them from scratch next batch. -#### `automation/` — 67 strip of 75 +#### `automation/` — 56 strip of 75 | File | Strip | Sites | Class | Batch | |---|---|---|---|---| @@ -613,9 +613,7 @@ is complete and so nobody re-triages them from scratch next batch. | `etl.zod.ts` | 10 | 10 | mixed | 7 authorable (`ETLSource` + `.incremental`, `ETLDestination`, `ETLTransformation`, `ETLPipeline` + `.retry` + `.notifications`), 3 wire (`ETLPipelineRun` + `.stats` + `.error` — run state) | | `builtin-node-config.zod.ts` | 8 | 8 | authorable | CRUD quartet + `Screen` (+ `.options`) + `Map`; already has a bidirectional drift check (`builtin-node-form-zod-ledger.test.ts`) | | `flow.zod.ts` | 7 | 11 | mixed | 6 authorable (`FlowNode.connectorConfig` / `.position` / `.inputSchema` / `.waitEventConfig` / `.boundaryConfig`, `Flow.errorHandling`), 1 wire (`FlowVersionHistorySchema` — the ledger row already exempts it) | -| `state-machine.zod.ts` | 6 | 6 | authorable (p) | `ActionRef` / `GuardRef` / `Transition` / `StateNode` + `.meta` / `StateMachine` | | `bpmn-interop.zod.ts` | 5 | 5 | wire (p) | **out of scope** — third-party BPMN import/export shapes; strictness turns an upstream addition into our parse crash | -| `control-flow.zod.ts` | 5 | 5 | authorable (p) | `FlowRegion` / `Loop` / `ParallelBranch` / `Parallel` / `TryCatch` — validated structurally by `validateControlFlow` today, which is a sibling guard, not a key gate | | `node-executor.zod.ts` | 4 | 4 | wire | **out of scope** — executor registration contract, code-to-code | | `schemaless-node-config.zod.ts` | 4 | 4 | authorable | `Script` / `Subflow` / `DecisionCondition` / `Decision`; `script` + `subflow` ARE parsed at execute time since #4343 | | `io-node-config.zod.ts` | 2 | 2 | authorable | `NotifyConfig` / `HttpConfig` — the sibling contracts for the deliberately-open flow node `config` slot | @@ -623,7 +621,7 @@ is complete and so nobody re-triages them from scratch next batch. | `time-relative-trigger.zod.ts` | 1 | 1 | authorable | `TimeRelativeTriggerSchema` — **newly visible** (see its triage row); a stripped `offsetDay`/`withinDay` yields a trigger that never fires, reported as configured | | `webhook.zod.ts` | 1 | 1 | authorable (p) | `WebhookSchema`, spec-only (#3461) | -**Authorable strip in `automation/`: 41 of 67.** This is the ruling's "known main body". +**Authorable strip in `automation/`: 30 of 56.** This is the ruling's "known main body". #### `ui/` — 124 strip of 198 diff --git a/skills/objectstack-ai/references/_index.md b/skills/objectstack-ai/references/_index.md index 171c79a662..799f6b47e0 100644 --- a/skills/objectstack-ai/references/_index.md +++ b/skills/objectstack-ai/references/_index.md @@ -22,7 +22,7 @@ from `node_modules` — there is no local copy in the skill bundle. ## Transitive dependencies -- `node_modules/@objectstack/spec/src/automation/state-machine.zod.ts` — XState-inspired State Machine Protocol +- `node_modules/@objectstack/spec/src/automation/state-machine.zod.ts` — XState-inspired State Machine Protocol — hierarchical states, guarded - `node_modules/@objectstack/spec/src/data/field.zod.ts` — Field Type Enum - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) diff --git a/skills/objectstack-automation/references/_index.md b/skills/objectstack-automation/references/_index.md index 51feaf5ee7..11781ec4db 100644 --- a/skills/objectstack-automation/references/_index.md +++ b/skills/objectstack-automation/references/_index.md @@ -13,7 +13,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/automation/execution.zod.ts` — Automation Execution Protocol - `node_modules/@objectstack/spec/src/automation/flow.zod.ts` — Flow Node Types — **built-in seed set** (ADR-0018). - `node_modules/@objectstack/spec/src/automation/node-executor.zod.ts` — Node Executor Plugin Protocol — Wait Node Pause/Resume -- `node_modules/@objectstack/spec/src/automation/state-machine.zod.ts` — XState-inspired State Machine Protocol +- `node_modules/@objectstack/spec/src/automation/state-machine.zod.ts` — XState-inspired State Machine Protocol — hierarchical states, guarded - `node_modules/@objectstack/spec/src/automation/time-relative-trigger.zod.ts` — Time-Relative Trigger Protocol - `node_modules/@objectstack/spec/src/automation/webhook.zod.ts` — Webhook Trigger Event - `node_modules/@objectstack/spec/src/data/validation.zod.ts` — ObjectStack Validation Protocol