Skip to content

Commit 9ed7935

Browse files
committed
docs(spec): ledger + changeset + regenerated references for 批 10
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9
1 parent f0964e6 commit 9ed7935

6 files changed

Lines changed: 205 additions & 11 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
---
2+
'@objectstack/spec': major
3+
---
4+
5+
**BREAKING**`automation/control-flow` and `automation/state-machine` reject unknown keys (#4001 批 10, ADR-0078)
6+
7+
Eleven authoring shapes that silently discarded undeclared keys now refuse them with a
8+
named surface, the offending key echoed back, and a rename or prescription. Metadata that
9+
used to parse "successfully" while losing the key you wrote now returns 422.
10+
11+
**`automation/control-flow.zod.ts`**`FlowRegionSchema`, `LoopConfigSchema`,
12+
`ParallelBranchSchema`, `ParallelConfigSchema`, `TryCatchConfigSchema`.
13+
14+
**`automation/state-machine.zod.ts`**`ActionRefSchema` (object branch),
15+
`GuardRefSchema` (object branch), `TransitionSchema`, `StateNodeSchema`, its `meta` block,
16+
and `StateMachineSchema`.
17+
18+
## What was actually being lost
19+
20+
A `state_machine` on an agent's `lifecycle` with `onn` where `on` was meant parsed clean
21+
and came back with **no transitions at all** — the declaration whose entire purpose is to
22+
deny undeclared transitions, silently emptied and reported valid. A `loop` config with
23+
`maxIteration` (singular) came back uncapped. A `parallel` branch with `label` instead of
24+
`name` came back unnamed.
25+
26+
## Migration — FROM → TO
27+
28+
Renames the rejection now suggests for you:
29+
30+
| you wrote | write instead | on |
31+
|---|---|---|
32+
| `guard` | `cond` | a state transition (XState v5 renamed it the other way; this protocol kept `cond`) |
33+
| `action` | `actions` | a state transition |
34+
| `itemVariable` | `iteratorVariable` | a `loop` config |
35+
| `maxIteration` | `maxIterations` | a `loop` config |
36+
| `label` | `name` | a `parallel` branch |
37+
| `onn` / `entery` / typos | `on` / `entry` | a state node |
38+
39+
Keys with no replacement, and what to do instead:
40+
41+
- **`finally` on `try_catch`** — there is no `finally` region. The node's ordinary
42+
out-edges run whichever way the protected region went; put the always-run steps in the
43+
nodes **after** the container.
44+
- **`join` / `joinGateway` on `parallel`** — the join is implicit; the block continues once
45+
when every branch completes. `join_gateway` is a BPMN interop node type, never a
46+
`parallel` config key.
47+
- **`flowName` on `loop`** — that key belongs to the `map` node, which runs a subflow per
48+
item. A `loop` runs an inline region: move the steps into `config.body`, or change the
49+
node `type` to `map`.
50+
- **`name` / `label` on a region** — a `loop` body, a `try` region and a `catch` region are
51+
not named; only a `parallel` branch carries a `name`.
52+
- **`transitions` on a state node** — a state node declares transitions as `on`, keyed by
53+
event type. `transitions` is the key on the object-level `state_machine` **validation
54+
rule** (`validations[].transitions`), a different declaration.
55+
- **`context` on a state machine** — this protocol declares only the context SHAPE, as
56+
`contextSchema`. There is no key for seeding initial values, so the two are not a rename
57+
of each other.
58+
59+
## Two notes for upgraders
60+
61+
`ActionRef` / `GuardRef` are unions, so a rejected key on their object branch surfaces as
62+
zod's `invalid_union` (`"Invalid input"`) with the real prescription nested one level down
63+
in `issue.errors[]` rather than in the top-level message. The prescription is present in
64+
`ZodError.message` and in REST error bodies; single-line formatters drop it.
65+
66+
`StateNodeSchema.meta` is **closed**, not a passthrough bag. XState treats `meta` as open,
67+
but the hand-written `StateNodeConfig` type here declares exactly `label` / `description` /
68+
`color` / `aiInstructions`, nothing in the platform reads any other key, and the previous
69+
behaviour was not openness but strip — an authored `meta` arrived as `{}`.
70+
71+
All three example apps (`app-showcase`, `app-crm`, `app-todo`) validate unchanged, so no
72+
ADR-0087 conversion accompanies this change.

content/docs/references/automation/control-flow.mdx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,44 @@ interop node types (`parallel_gateway` / `join_gateway` / `boundary_event`),
7373

7474
which remain author-invisible interchange representations.
7575

76+
## Unknown keys are rejected (#4001 / ADR-0078)
77+
78+
Every shape below is `strictObject`. Before that they were plain `z.object`,
79+
80+
so zod's default `.strip` applied and a key this file does not declare was
81+
82+
**discarded in silence** — the container still parsed, still registered, and
83+
84+
still ran, with the author's configuration simply absent. On these five
85+
86+
shapes that silence is unusually expensive, because each one carries
87+
88+
*control* rather than data: a swallowed `maxIterations` is an uncapped loop,
89+
90+
a swallowed branch key is a branch that runs without what it was given.
91+
92+
### How this relates to `validateControlFlow`
93+
94+
`validateControlFlow` is a **sibling guard, not a key gate** — it answers
95+
96+
"is this region single-entry / single-exit / acyclic", which no amount of
97+
98+
key strictness can answer. The two do not overlap and cannot fight: the
99+
100+
schema rejects undeclared KEYS, the analysis rejects malformed STRUCTURE.
101+
102+
They do now meet at one seam, deliberately — `validateControlFlow`
103+
104+
`safeParse`s each region slot before analyzing it, so from #4001 that parse
105+
106+
is also where a region's undeclared key surfaces, reported as
107+
108+
`<where>: invalid region — <the strictObject message>`. Nothing was
109+
110+
duplicated and nothing was removed; the structural prose this guard exists
111+
112+
for is untouched, and it simply stopped silently repairing its own input.
113+
76114
<Callout type="info">
77115
**Source:** `packages/spec/src/automation/control-flow.zod.ts`
78116
</Callout>

content/docs/references/automation/state-machine.mdx

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,97 @@ description: State Machine protocol schemas
55

66
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
77

8-
XState-inspired State Machine Protocol
8+
@module automation/state-machine
99

10-
Used to define strict business logic constraints and lifecycle management.
10+
XState-inspired State Machine Protocol — hierarchical states, guarded
1111

12-
Prevent AI "hallucinations" by enforcing valid valid transitions.
12+
transitions, entry/exit actions. Used to declare strict business-logic
13+
14+
constraints and lifecycle management, so an AI author cannot "hallucinate" a
15+
16+
transition the machine never declared.
17+
18+
## Where this is authored — the question #4001 had to answer first
19+
20+
The ledger carried these shapes as `authorable (p)` — provisional, because
21+
22+
nobody had checked. Checking matters here more than usual, because
23+
24+
[ADR-0020](../../../docs/adr/0020-state-machine-converge-and-enforce.md)
25+
26+
**retired this shape as a record-lifecycle declaration**: the top-level
27+
28+
`workflow` metadata type and `object.stateMachines` are both gone, and a
29+
30+
record's legal transitions are declared as a `state_machine` **validation
31+
32+
rule** (`[data/validation.zod.ts](/docs/references/data/validation)`, a flat `\{ from: [to] \}` table — closed
33+
34+
since #4001 batch 3b). A schema whose only doors were those two would be
35+
36+
dead surface, and the campaign's own rule is that dead surface gets its
37+
38+
ledger class corrected, not tightened.
39+
40+
One door survives, and it is an authoring door: **`[ai/agent.zod.ts](/docs/references/ai/agent)`'s
41+
42+
`lifecycle`** is `StateMachineSchema`, and `agent` is a registered metadata
43+
44+
type — so `defineStack(\{ agents \})`, `POST /api/v1/meta/types/agent` and the
45+
46+
Studio agent form all reach this file through `AgentSchema.parse()`. Verified
47+
48+
by parse, not by reading: before this change,
49+
50+
```ts
51+
52+
AgentSchema.parse(\{ …, lifecycle: \{
53+
54+
id: 'probe_machine', initial: 'draft', stats: \{ runs: 3 \},
55+
56+
states: \{ draft: \{ onn: \{ APPROVE: 'done' \}, meta: \{ labell: 'Draft', owner: 'ops' \} \},
57+
58+
done: \{ type: 'final' \} \},
59+
60+
\} \})
61+
62+
```
63+
64+
**succeeded**, returning
65+
66+
`\{ id, initial, states: \{ draft: \{ type: 'atomic', meta: \{\} \}, done: … \} \}`
67+
68+
`stats` gone, `meta`'s two keys gone, and `onn` (one keystroke from `on`)
69+
70+
gone with every transition the author declared. A state machine whose whole
71+
72+
purpose is to *deny* undeclared transitions had silently become one with no
73+
74+
transitions at all, and reported success.
75+
76+
So: `authorable`, and every shape below is `strictObject`.
77+
78+
## `meta` is closed, deliberately
79+
80+
XState treats `meta` as an open bag, so leaving it open was the plausible
81+
82+
call and it was checked rather than assumed (the #4909 precedent: a slot
83+
84+
whose openness is real should say `.passthrough()`, not strip). Three facts
85+
86+
say closed here: the hand-written `StateNodeConfig` type beside this
87+
88+
schema declares exactly four `meta` keys, so `passthrough` would open the
89+
90+
Zod while `tsc` stayed shut — a new declared-≠-enforced split; nothing in
91+
92+
this repo reads any `meta` key (`aiInstructions` has no consumer outside
93+
94+
this file's own test); and the current behaviour is not openness but
95+
96+
*strip* — the probe above shows an author's `meta` arriving as `\{\}`. There
97+
98+
is no openness here to preserve, only a silence to end.
1399

14100
<Callout type="info">
15101
**Source:** `packages/spec/src/automation/state-machine.zod.ts`

docs/audits/2026-07-unknown-key-strictness-ledger.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -530,8 +530,8 @@ not verdicts).
530530
| `flow.zod.ts` | 11 | authorable | **strict as of #4001** (4 schemas; `FlowVersionHistorySchema` is runtime — stays tolerant) |
531531
| `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 |
532532
| `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 |
533-
| `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 |
534-
| `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 |
533+
| `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 |
534+
| `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 `<where>: invalid region — <the strictObject message>`, 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 |
535535
| `bpmn-interop.zod.ts` | 5 | wire (p) | interop import shapes |
536536
| `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) |
537537
| `node-executor.zod.ts` | 4 | wire | executor contract |
@@ -605,25 +605,23 @@ classes; where it does, the split is stated. **Only the authorable half is in th
605605
2026-08-03 ruling's forced scope** — wire/open rows are listed so the arithmetic
606606
is complete and so nobody re-triages them from scratch next batch.
607607

608-
#### `automation/`67 strip of 75
608+
#### `automation/`56 strip of 75
609609

610610
| File | Strip | Sites | Class | Batch |
611611
|---|---|---|---|---|
612612
| `execution.zod.ts` | 13 | 13 | wire | **out of scope** — engine-emitted run state; the ledger row already says "never strict" |
613613
| `etl.zod.ts` | 10 | 10 | mixed | 7 authorable (`ETLSource` + `.incremental`, `ETLDestination`, `ETLTransformation`, `ETLPipeline` + `.retry` + `.notifications`), 3 wire (`ETLPipelineRun` + `.stats` + `.error` — run state) |
614614
| `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`) |
615615
| `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) |
616-
| `state-machine.zod.ts` | 6 | 6 | authorable (p) | `ActionRef` / `GuardRef` / `Transition` / `StateNode` + `.meta` / `StateMachine` |
617616
| `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 |
618-
| `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 |
619617
| `node-executor.zod.ts` | 4 | 4 | wire | **out of scope** — executor registration contract, code-to-code |
620618
| `schemaless-node-config.zod.ts` | 4 | 4 | authorable | `Script` / `Subflow` / `DecisionCondition` / `Decision`; `script` + `subflow` ARE parsed at execute time since #4343 |
621619
| `io-node-config.zod.ts` | 2 | 2 | authorable | `NotifyConfig` / `HttpConfig` — the sibling contracts for the deliberately-open flow node `config` slot |
622620
| `flow-function.zod.ts` | 1 | 1 | authorable | `FlowFunctionDeclarationSchema`; binds at authoring only (the boot reader is `normalizeFlowFunctionEntry`, not a `.parse()`) |
623621
| `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 |
624622
| `webhook.zod.ts` | 1 | 1 | authorable (p) | `WebhookSchema`, spec-only (#3461) |
625623

626-
**Authorable strip in `automation/`: 41 of 67.** This is the ruling's "known main body".
624+
**Authorable strip in `automation/`: 30 of 56.** This is the ruling's "known main body".
627625

628626
#### `ui/` — 124 strip of 198
629627

skills/objectstack-ai/references/_index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ from `node_modules` — there is no local copy in the skill bundle.
2222

2323
## Transitive dependencies
2424

25-
- `node_modules/@objectstack/spec/src/automation/state-machine.zod.ts` — XState-inspired State Machine Protocol
25+
- `node_modules/@objectstack/spec/src/automation/state-machine.zod.ts` — XState-inspired State Machine Protocol — hierarchical states, guarded
2626
- `node_modules/@objectstack/spec/src/data/field.zod.ts` — Field Type Enum
2727
- `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification
2828
- `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010)

0 commit comments

Comments
 (0)