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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .changeset/strict-automation-control-flow-state-machine.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 38 additions & 0 deletions content/docs/references/automation/control-flow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

`<where>: invalid region — <the strictObject message>`. 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.

<Callout type="info">
**Source:** `packages/spec/src/automation/control-flow.zod.ts`
</Callout>
Expand Down
92 changes: 89 additions & 3 deletions content/docs/references/automation/state-machine.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Callout type="info">
**Source:** `packages/spec/src/automation/state-machine.zod.ts`
Expand Down
27 changes: 18 additions & 9 deletions docs/audits/2026-07-unknown-key-strictness-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -531,8 +531,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 `<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 |
| `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 |
Expand Down Expand Up @@ -606,16 +606,14 @@ 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/` — 53 strip of 75
#### `automation/` — 42 strip of 75

| File | Strip | Sites | Class | Batch |
|---|---|---|---|---|
| `execution.zod.ts` | 13 | 13 | wire | **out of scope** — engine-emitted run state; the ledger row already says "never strict" |
| `etl.zod.ts` | 10 | 10 | mixed | 7 authorable (`ETLSource` + `.incremental`, `ETLDestination`, `ETLTransformation`, `ETLPipeline` + `.retry` + `.notifications`), 3 wire (`ETLPipelineRun` + `.stats` + `.error` — run state) |
| `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 |
| `flow-function.zod.ts` | 1 | 1 | authorable | `FlowFunctionDeclarationSchema`; binds at authoring only (the boot reader is `normalizeFlowFunctionEntry`, not a `.parse()`) |
| `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 |
Expand All @@ -627,10 +625,21 @@ wave — `builtin-node-config.zod.ts` (8), `schemaless-node-config.zod.ts` (4) a
all three before the rows were removed, which is the only evidence that a
deletion here is bookkeeping rather than a guess.

**Authorable strip in `automation/`: 27 of 53** (was 41 of 67). What remains of
the ruling's "known main body" is `etl` 7, `flow` 6, `state-machine` 6,
`control-flow` 5, and one each from `flow-function` / `time-relative-trigger` /
`webhook`.
Two more left it at **批 10** — `control-flow.zod.ts` (5) and
`state-machine.zod.ts` (6), same reverse-pin evidence. Worth recording how the
two waves met, because it is the failure mode this table is most exposed to:
both PRs deleted their own rows and decremented this header by their own count,
so git merged the ROWS cleanly (they do not overlap) and left the header
conflicted — and the subtotal line below it, which conflicts with nothing,
merged clean while being **wrong on both branches**. Neither number was a
mistake in isolation; each was computed against one wave's deletions. The
header is therefore recomputed from the surviving rows rather than resolved in
favour of either side, and `check:strictness-ledger`'s header arithmetic is
what settles it. A clean-looking merge here is not evidence of anything.

**Authorable strip in `automation/`: 16 of 42** (was 41 of 67 before the two
waves). What remains of the ruling's "known main body" is `etl` 7, `flow` 6,
and one each from `flow-function` / `time-relative-trigger` / `webhook`.

#### `ui/` — 123 strip of 198

Expand Down
Loading
Loading