Skip to content

Commit 32a0874

Browse files
feat(spec)!: reject unknown keys on the flow-node config contracts (#4001 批 9) (#4925)
Fourteen strip sites across the three automation node-config files close — the first `automation/` wave of the 2026-08-03 "necessary-and-complete" ruling. `automation/` remaining-strip: 67 → 53 (authorable 41 → 27). - io-node-config: NotifyConfig, HttpConfig - builtin-node-config: the CRUD quartet, Screen (+ field item + its options), Map - schemaless-node-config: Script, Subflow, Decision, DecisionCondition The deliberately-open `FlowNodeSchema.config` SLOT is untouched (ADR-0018 keeps `node.type` open for plugin executors); what closes is the per-node-type contract inside it. For `script` / `subflow` / `decision` this is the FIRST unknown-key gate, not a second one: `registerFlow()`'s #4277 rejection derives its declared set from a descriptor `configSchema`, and the schemaless class publishes none — so that walk skips them by construction. Curation is measured, not assumed. Every guidance/alias entry was checked against a repo-wide AST scan of 630 real flow-node payloads before it was written, and the campaign's finding 7 decided the one hard case: on a `decision`, `config.condition` is NOT renamed to `conditions` (one edit away, and taking that advice produces the double-declaration #4414 was filed for) — the mechanism is named instead. On a decision BRANCH the same word does rename, to `expression`. ADR-0087: no conversion needed. 160 flow nodes across the three shipped example apps were parsed against the new contracts — 52 carry one — with 0 unknown-key rejections, and all three `objectstack validate` runs pass. Co-authored-by: Claude <noreply@anthropic.com>
1 parent bf973ae commit 32a0874

11 files changed

Lines changed: 1047 additions & 32 deletions
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
feat(spec)!: reject unknown keys on the flow-node config contracts (#4001 批 9)
6+
7+
The first `automation/` wave of the 2026-08-03 "necessary-and-complete"
8+
ruling. Fourteen strip sites across three files close, and `automation/`'s
9+
remaining-strip count drops 67 → 53 (authorable 41 → 27).
10+
11+
- **`automation/io-node-config.zod.ts`**`NotifyConfigSchema`,
12+
`HttpConfigSchema`.
13+
- **`automation/builtin-node-config.zod.ts`** — the CRUD quartet
14+
(`get_record` / `create_record` / `update_record` / `delete_record`),
15+
`ScreenConfigSchema`, `ScreenFieldConfigSchema` and its `options` item,
16+
`MapConfigSchema`.
17+
- **`automation/schemaless-node-config.zod.ts`**`ScriptConfigSchema`,
18+
`SubflowConfigSchema`, `DecisionConfigSchema`, `DecisionConditionSchema`.
19+
20+
The deliberately-open `FlowNodeSchema.config` SLOT is unchanged — ADR-0018
21+
keeps `node.type` open so plugins contribute their own executors, and closing
22+
the slot would close that extension point. What is closed is the per-node-type
23+
contract *inside* it.
24+
25+
**Why the third file is different.** `registerFlow()` already hard-rejects
26+
undeclared config keys against a node's descriptor `configSchema` (#4277), and
27+
`script` / `subflow` / `decision` publish no descriptor `configSchema` — so
28+
that walk skips them by construction. Until now those three had **no**
29+
unknown-key enforcement at any layer. For them this is the first gate, not a
30+
second one.
31+
32+
**Migration.** Every key now rejected was previously stripped and had no
33+
runtime effect, so removing or renaming one never changes behaviour. All three
34+
shipped example apps were re-validated after the change and no stored shape
35+
needed an ADR-0087 conversion (160 flow nodes walked, 52 carrying one of these
36+
contracts, 0 rejections). The rejections carry their own prescriptions:
37+
38+
- `notify`: `to``recipients`, `subject``title`, `body``message`,
39+
`url``actionUrl`, `source: { object, id }``sourceObject` + `sourceId`.
40+
- CRUD: `object``objectName`, `filters``filter`,
41+
`fieldValues``fields`, `recordId` → a filter VALUE
42+
(`filter: { id: '{record.id}' }` — no CRUD executor has ever read a
43+
`recordId` key), and on `update_record` / `delete_record` `outputVariable`
44+
is a documented absence, not a typo — read the row back with a following
45+
`get_record`.
46+
- `screen`: `object``objectName`, and on a field item
47+
`visibleIf``visibleWhen`.
48+
- `map` / `subflow`: `flow``flowName`. `subflow`'s `timeoutMs` belongs on
49+
the NODE (`FlowNodeSchema.timeoutMs`), not in its config.
50+
- `script`: `functionName``function`, `input``inputs` (the singular
51+
stays canonical on `connector_action`'s `connectorConfig.input` — do not
52+
"fix" that one). The five `actionType`-branch keys keep their existing
53+
`retiredKey()` tombstones.
54+
- `decision`: `config.condition` (singular) is **not** renamed to
55+
`conditions`. Nothing reads it on a decision — it is the trigger gate on a
56+
`start` node and inert everywhere else (#4414) — and declaring branches here
57+
*and* on the out-edges is the double-declaration #4414 was filed for.
58+
Branching lives on the out-edges. On a decision BRANCH the predicate slot is
59+
`expression`, so `condition``expression` there.
60+
- decision branch `target`: a VIRTUAL designer column projected from the
61+
node's out-edges, never stored — route by matching the branch `label` to an
62+
out-edge `label`.
63+
64+
For a key rewritten at load by an ADR-0087 D2 conversion, reaching this
65+
rejection means the config carries BOTH spellings: `renameConfigKey` leaves a
66+
shadowed alias in place rather than clobbering the canonical winner, so the
67+
retired twin is dead weight and should be deleted.

content/docs/references/automation/builtin-node-config.mdx

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,45 @@ parse the RAW stored config — their typed slots are strings (or `unknown`
5555

5656
where values interpolate), so `\{token\}` templates pass and resolve at the
5757

58-
executor's existing interpolation points. Unknown keys are rejected earlier,
58+
executor's existing interpolation points.
5959

60-
at `registerFlow()` (the tightened #4059 check); the parse here strips them.
60+
## Unknown keys — closed here too, as of #4001 批 9
61+
62+
These contracts used to say "unknown keys are rejected earlier, at
63+
64+
`registerFlow()` (the tightened #4059 check); the parse here strips them."
65+
66+
The registration walk is still the first and more informative door — it
67+
68+
descends NESTED config against the descriptor's JSON Schema, which is how it
69+
70+
catches `fields[0].visibleIf` (#3528) and not just top-level typos — but
71+
72+
"some other door is closed" is the exact reasoning #4001 exists to retire:
73+
74+
the sibling of every guard in this campaign turned out to leave the other
75+
76+
doors open, because its author was fixing one bug rather than auditing a
77+
78+
surface. A config reaching `parse()` without passing registration (tooling
79+
80+
that parses a contract directly, a host composing the engine itself) is no
81+
82+
longer silently trimmed.
83+
84+
The two doors are kept in agreement by `builtin-node-form-zod-ledger.test.ts`,
85+
86+
which reconciles these key sets against the descriptors' in both directions.
87+
88+
The per-key prescriptions below are the same curation the registration
89+
90+
rejection carries in `FLOW_NODE_UNKNOWN_KEY_GUIDANCE` — the campaign's
91+
92+
finding is that a bespoke guard's detection generalizes for free the moment
93+
94+
a default flips, while its PROSE does not, so the prose is copied to the new
95+
96+
door rather than left behind at the old one.
6197

6298
Deliberately absent:
6399

content/docs/references/automation/io-node-config.mdx

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,39 @@ the INTERPOLATED config, because that is the shape its executor reads —
4545

4646
a `\{token\}` in a typed slot (`timeoutMs`, `durable`) resolves to its real
4747

48-
type first. Unknown keys are the registration layer's job: `registerFlow()`
48+
type first.
4949

50-
rejects keys the descriptor `configSchema` does not declare (the tightened
50+
## Unknown keys — closed here too, as of #4001 批 9
5151

52-
#4059 check), while the parse here strips them.
52+
These contracts used to say "unknown keys are the registration layer's job":
53+
54+
`registerFlow()` rejects keys the descriptor `configSchema` does not declare
55+
56+
(the tightened #4059 check), and this parse merely stripped them. That is one
57+
58+
door, and the #4001 campaign's second recurring finding is that a schema
59+
60+
which strips by default leaves every OTHER door open — whoever writes the
61+
62+
guard is fixing the bug in front of them, not auditing the surface.
63+
64+
The registration check remains the first door a stored flow meets and the
65+
66+
more informative one (it walks NESTED config against the descriptor's JSON
67+
68+
Schema and prints the declared set per path, which a flat key list cannot).
69+
70+
What changes is that a config reaching `parse()` by any OTHER route — a
71+
72+
direct `NotifyConfigSchema.parse()` in tooling, a host that composes the
73+
74+
engine without `registerFlow`, a future executor seam — no longer has its
75+
76+
undeclared keys silently deleted. The two doors are kept in agreement by
77+
78+
`io-node-form-zod-ledger.test.ts`, which reconciles this key set against the
79+
80+
descriptor's in both directions.
5381

5482
`connector_action` has no schema here on purpose: its config contract is
5583

content/docs/references/automation/schemaless-node-config.mdx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,44 @@ Undeclared aliases are NOT part of these contracts: `subflow`'s historical
125125

126126
ever sees `flowName`.
127127

128+
## Unknown keys — closed as of #4001 批 9, and this class had NO other door
129+
130+
The descriptor-schema'd builtins have a registration-time key gate:
131+
132+
`registerFlow()` walks each node's `config` against the descriptor's
133+
134+
`configSchema` and hard-rejects what it does not declare (#4277). **These
135+
136+
three node types are exempt from that walk** — by construction, since it
137+
138+
derives the declared set from a `configSchema` they publish none of
139+
140+
(`validateNodeConfigKeys`' schemaless exemption). So until now the entire
141+
142+
`script` / `subflow` / `decision` config surface had exactly zero unknown-key
143+
144+
enforcement at any layer: the execute-time parse #4343 added checks types and
145+
146+
requiredness, and Zod's default `.strip` deleted everything else in silence.
147+
148+
That is the #4001 asymmetry in its purest form — a guard was written for the
149+
150+
door in front of its author, and the class it structurally could not cover is
151+
152+
precisely the class with no second door. Closing these shapes is therefore
153+
154+
not a duplicate check for `script` and `subflow`; it is their first one.
155+
156+
`decision` is still export-only, so its strictness binds at authoring
157+
158+
(`tsc`), in the published JSON Schema, and in objectui's reconciliation —
159+
160+
not at run time. It is closed anyway, because the campaign's whole finding
161+
162+
is that a shape left open accretes a test, a form and a fixture that assert
163+
164+
the openness, and then closing it is a migration instead of an edit.
165+
128166
<Callout type="info">
129167
**Source:** `packages/spec/src/automation/schemaless-node-config.zod.ts`
130168
</Callout>

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

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -535,9 +535,9 @@ not verdicts).
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 |
538-
| `io-node-config.zod.ts` | 2 | authorable | `NotifyConfigSchema` / `HttpConfigSchema` (#4045) — the sibling contracts that validate the **open** `config` slot on flow `notify` / `http` nodes. Authored per-node, so the open-slot exemption above does not extend to them; candidate once the executors' own drift is verified |
539-
| `builtin-node-config.zod.ts` | 8 | authorable | Same family (#4045): the CRUD quartet, `screen`, `map`. Written from what the executors read rather than from the descriptors' `configSchema` literals, and reconciled bidirectionally by `builtin-node-form-zod-ledger.test.ts` — so unlike most rows here, this one already has a drift check of its own. Same candidacy note as `io-node-config` |
540-
| `schemaless-node-config.zod.ts` | 4 | authorable | Same family, third panel (#4278): `script` / `subflow` / `decision` (+ the decision branch item) — the descriptor-schemaless nodes whose form lives in objectui's hand-written table. Written from the executors; the drift check is objectui's `flow-node-config.spec-reconciliation` test (cross-repo, via the published exports). Since #4343 `script` and `subflow` ARE parsed at execute time (`parse-config.ts`)`script` once retiring its `actionType` branches left it flat — so strictness candidacy now follows `io-node-config` on the same terms rather than being moot; `decision` stays export-only |
538+
| `io-node-config.zod.ts` | 2 | authorable | `NotifyConfigSchema` / `HttpConfigSchema` (#4045) — the sibling contracts that validate the **open** `config` slot on flow `notify` / `http` nodes. Authored per-node, so the open-slot exemption above does not extend to them. **Strict as of #4001 批 9**; the node `config` SLOT itself stays open (ADR-0018 keeps `node.type` open, so the slot cannot be closed without closing the plugin extension point). Five `guidance` entries carry the ADR-0087 notify aliases (`to`/`subject`/`body`/`url`/`source`) |
539+
| `builtin-node-config.zod.ts` | 8 | authorable | Same family (#4045): the CRUD quartet, `screen`, `map`. Written from what the executors read rather than from the descriptors' `configSchema` literals, and reconciled bidirectionally by `builtin-node-form-zod-ledger.test.ts` — so unlike most rows here, this one already has a drift check of its own. **Strict as of #4001 批 9.** The curated tables are the `FLOW_NODE_UNKNOWN_KEY_GUIDANCE` prose from `service-automation`'s registration door, plus two entries that door never had: `recordId` (measured on CRUD nodes across the repo's own flow fixtures, read by no executor — on `delete_record` that is #3810 wearing a key that looks like a constraint) and `outputVariable` on `update_record` / `delete_record` (a documented ABSENCE, and the likeliest wrong key precisely because five sibling contracts declare it) |
540+
| `schemaless-node-config.zod.ts` | 4 | authorable | Same family, third panel (#4278): `script` / `subflow` / `decision` (+ the decision branch item) — the descriptor-schemaless nodes whose form lives in objectui's hand-written table. Written from the executors; the drift check is objectui's `flow-node-config.spec-reconciliation` test (cross-repo, via the published exports — it compares `.shape` key sets, so strictness does not move it). Since #4343 `script` and `subflow` ARE parsed at execute time (`parse-config.ts`). **Strict as of #4001 批 9 — and this is the one row in the table where strictness is the FIRST unknown-key gate, not a second one**: `registerFlow()`'s #4277 rejection derives its declared set from a descriptor `configSchema`, so it structurally skips the schemaless class. `decision` stays export-only, closed anyway; its `condition` guidance suppresses a one-edit rename to `conditions` that #4414 proves is the worse outcome |
541541
| `webhook.zod.ts` | 1 | authorable (p) | spec-only (#3461) |
542542
| `time-relative-trigger.zod.ts` | 1 | authorable | **Undeclared until the #4001 re-measurement, and invisible for the worst possible reason**: `TimeRelativeTriggerSchema` is written `z\n .object({`, the old textual counter matched zero sites, and a zero-site file is SKIPPED by the coverage walk as "nothing to classify". So the gate whose whole promise is "no undeclared surface" reported green over an authorable schema — the same shape as `data/driver/`, one layer subtler, because this time the file was not hidden by the walk but by the counter feeding it. Classification is not a guess: the file's own `@example` blocks author it by hand into a flow start node (`config: { timeRelative: { object, dateField, offsetDays, filter } }`), which is the authoring door. A stripped key here means the sweep silently never matches — `offsetDay` for `offsetDays` returns a trigger that never fires, reported as configured |
543543
| `flow-function.zod.ts` | 1 | authorable | `FlowFunctionDeclarationSchema` (#4396) — the `{ handler, effect }` form of a `defineStack({ functions })` entry. Authored, but note what an undeclared key here would be: a sibling of a **live function**, not data. `defineStack`'s union already rejects a record whose `handler` is not callable, and the boot-path reader is the hand-written `normalizeFlowFunctionEntry` rather than a `.parse()` (re-validating a live handler every boot buys nothing), so strictness would bind at authoring only. Candidate on the same verify-first rule as its `*-node-config` neighbours |
@@ -605,25 +605,31 @@ 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/`53 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) |
614-
| `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`) |
615614
| `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) |
616615
| `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 |
618617
| `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 |
619618
| `node-executor.zod.ts` | 4 | 4 | wire | **out of scope** — executor registration contract, code-to-code |
620-
| `schemaless-node-config.zod.ts` | 4 | 4 | authorable | `Script` / `Subflow` / `DecisionCondition` / `Decision`; `script` + `subflow` ARE parsed at execute time since #4343 |
621-
| `io-node-config.zod.ts` | 2 | 2 | authorable | `NotifyConfig` / `HttpConfig` — the sibling contracts for the deliberately-open flow node `config` slot |
622619
| `flow-function.zod.ts` | 1 | 1 | authorable | `FlowFunctionDeclarationSchema`; binds at authoring only (the boot reader is `normalizeFlowFunctionEntry`, not a `.parse()`) |
623620
| `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 |
624621
| `webhook.zod.ts` | 1 | 1 | authorable (p) | `WebhookSchema`, spec-only (#3461) |
625622

626-
**Authorable strip in `automation/`: 41 of 67.** This is the ruling's "known main body".
623+
Three rows left this table at **批 9** (#4001), the ruling's first `automation/`
624+
wave — `builtin-node-config.zod.ts` (8), `schemaless-node-config.zod.ts` (4) and
625+
`io-node-config.zod.ts` (2), all reaching zero strip. The reverse pin fired on
626+
all three before the rows were removed, which is the only evidence that a
627+
deletion here is bookkeeping rather than a guess.
628+
629+
**Authorable strip in `automation/`: 27 of 53** (was 41 of 67). What remains of
630+
the ruling's "known main body" is `etl` 7, `flow` 6, `state-machine` 6,
631+
`control-flow` 5, and one each from `flow-function` / `time-relative-trigger` /
632+
`webhook`.
627633

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

0 commit comments

Comments
 (0)