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
67 changes: 67 additions & 0 deletions .changeset/unknown-key-strictness-automation-node-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
"@objectstack/spec": major
---

feat(spec)!: reject unknown keys on the flow-node config contracts (#4001 批 9)

The first `automation/` wave of the 2026-08-03 "necessary-and-complete"
ruling. Fourteen strip sites across three files close, and `automation/`'s
remaining-strip count drops 67 → 53 (authorable 41 → 27).

- **`automation/io-node-config.zod.ts`** — `NotifyConfigSchema`,
`HttpConfigSchema`.
- **`automation/builtin-node-config.zod.ts`** — the CRUD quartet
(`get_record` / `create_record` / `update_record` / `delete_record`),
`ScreenConfigSchema`, `ScreenFieldConfigSchema` and its `options` item,
`MapConfigSchema`.
- **`automation/schemaless-node-config.zod.ts`** — `ScriptConfigSchema`,
`SubflowConfigSchema`, `DecisionConfigSchema`, `DecisionConditionSchema`.

The deliberately-open `FlowNodeSchema.config` SLOT is unchanged — ADR-0018
keeps `node.type` open so plugins contribute their own executors, and closing
the slot would close that extension point. What is closed is the per-node-type
contract *inside* it.

**Why the third file is different.** `registerFlow()` already hard-rejects
undeclared config keys against a node's descriptor `configSchema` (#4277), and
`script` / `subflow` / `decision` publish no descriptor `configSchema` — so
that walk skips them by construction. Until now those three had **no**
unknown-key enforcement at any layer. For them this is the first gate, not a
second one.

**Migration.** Every key now rejected was previously stripped and had no
runtime effect, so removing or renaming one never changes behaviour. All three
shipped example apps were re-validated after the change and no stored shape
needed an ADR-0087 conversion (160 flow nodes walked, 52 carrying one of these
contracts, 0 rejections). The rejections carry their own prescriptions:

- `notify`: `to` → `recipients`, `subject` → `title`, `body` → `message`,
`url` → `actionUrl`, `source: { object, id }` → `sourceObject` + `sourceId`.
- CRUD: `object` → `objectName`, `filters` → `filter`,
`fieldValues` → `fields`, `recordId` → a filter VALUE
(`filter: { id: '{record.id}' }` — no CRUD executor has ever read a
`recordId` key), and on `update_record` / `delete_record` `outputVariable`
is a documented absence, not a typo — read the row back with a following
`get_record`.
- `screen`: `object` → `objectName`, and on a field item
`visibleIf` → `visibleWhen`.
- `map` / `subflow`: `flow` → `flowName`. `subflow`'s `timeoutMs` belongs on
the NODE (`FlowNodeSchema.timeoutMs`), not in its config.
- `script`: `functionName` → `function`, `input` → `inputs` (the singular
stays canonical on `connector_action`'s `connectorConfig.input` — do not
"fix" that one). The five `actionType`-branch keys keep their existing
`retiredKey()` tombstones.
- `decision`: `config.condition` (singular) is **not** renamed to
`conditions`. Nothing reads it on a decision — it is the trigger gate on a
`start` node and inert everywhere else (#4414) — and declaring branches here
*and* on the out-edges is the double-declaration #4414 was filed for.
Branching lives on the out-edges. On a decision BRANCH the predicate slot is
`expression`, so `condition` → `expression` there.
- decision branch `target`: a VIRTUAL designer column projected from the
node's out-edges, never stored — route by matching the branch `label` to an
out-edge `label`.

For a key rewritten at load by an ADR-0087 D2 conversion, reaching this
rejection means the config carries BOTH spellings: `renameConfigKey` leaves a
shadowed alias in place rather than clobbering the canonical winner, so the
retired twin is dead weight and should be deleted.
40 changes: 38 additions & 2 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,45 @@ parse the RAW stored config — their typed slots are strings (or `unknown`

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

executor's existing interpolation points. Unknown keys are rejected earlier,
executor's existing interpolation points.

at `registerFlow()` (the tightened #4059 check); the parse here strips them.
## Unknown keys — closed here too, as of #4001 批 9

These contracts used to say "unknown keys are rejected earlier, at

`registerFlow()` (the tightened #4059 check); the parse here strips them."

The registration walk is still the first and more informative door — it

descends NESTED config against the descriptor's JSON Schema, which is how it

catches `fields[0].visibleIf` (#3528) and not just top-level typos — but

"some other door is closed" is the exact reasoning #4001 exists to retire:

the sibling of every guard in this campaign turned out to leave the other

doors open, because its author was fixing one bug rather than auditing a

surface. A config reaching `parse()` without passing registration (tooling

that parses a contract directly, a host composing the engine itself) is no

longer silently trimmed.

The two doors are kept in agreement by `builtin-node-form-zod-ledger.test.ts`,

which reconciles these key sets against the descriptors' in both directions.

The per-key prescriptions below are the same curation the registration

rejection carries in `FLOW_NODE_UNKNOWN_KEY_GUIDANCE` — the campaign's

finding is that a bespoke guard's detection generalizes for free the moment

a default flips, while its PROSE does not, so the prose is copied to the new

door rather than left behind at the old one.

Deliberately absent:

Expand Down
34 changes: 31 additions & 3 deletions content/docs/references/automation/io-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,39 @@ the INTERPOLATED config, because that is the shape its executor reads —

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

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

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

#4059 check), while the parse here strips them.
These contracts used to say "unknown keys are the registration layer's job":

`registerFlow()` rejects keys the descriptor `configSchema` does not declare

(the tightened #4059 check), and this parse merely stripped them. That is one

door, and the #4001 campaign's second recurring finding is that a schema

which strips by default leaves every OTHER door open — whoever writes the

guard is fixing the bug in front of them, not auditing the surface.

The registration check remains the first door a stored flow meets and the

more informative one (it walks NESTED config against the descriptor's JSON

Schema and prints the declared set per path, which a flat key list cannot).

What changes is that a config reaching `parse()` by any OTHER route — a

direct `NotifyConfigSchema.parse()` in tooling, a host that composes the

engine without `registerFlow`, a future executor seam — no longer has its

undeclared keys silently deleted. The two doors are kept in agreement by

`io-node-form-zod-ledger.test.ts`, which reconciles this key set against the

descriptor's in both directions.

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

Expand Down
38 changes: 38 additions & 0 deletions content/docs/references/automation/schemaless-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,44 @@ Undeclared aliases are NOT part of these contracts: `subflow`'s historical

ever sees `flowName`.

## Unknown keys — closed as of #4001 批 9, and this class had NO other door

The descriptor-schema'd builtins have a registration-time key gate:

`registerFlow()` walks each node's `config` against the descriptor's

`configSchema` and hard-rejects what it does not declare (#4277). **These

three node types are exempt from that walk** — by construction, since it

derives the declared set from a `configSchema` they publish none of

(`validateNodeConfigKeys`' schemaless exemption). So until now the entire

`script` / `subflow` / `decision` config surface had exactly zero unknown-key

enforcement at any layer: the execute-time parse #4343 added checks types and

requiredness, and Zod's default `.strip` deleted everything else in silence.

That is the #4001 asymmetry in its purest form — a guard was written for the

door in front of its author, and the class it structurally could not cover is

precisely the class with no second door. Closing these shapes is therefore

not a duplicate check for `script` and `subflow`; it is their first one.

`decision` is still export-only, so its strictness binds at authoring

(`tsc`), in the published JSON Schema, and in objectui's reconciliation —

not at run time. It is closed anyway, because the campaign's whole finding

is that a shape left open accretes a test, a form and a fixture that assert

the openness, and then closing it is a migration instead of an edit.

<Callout type="info">
**Source:** `packages/spec/src/automation/schemaless-node-config.zod.ts`
</Callout>
Expand Down
22 changes: 14 additions & 8 deletions docs/audits/2026-07-unknown-key-strictness-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -535,9 +535,9 @@ not verdicts).
| `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 |
| `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 |
| `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` |
| `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 |
| `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`) |
| `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) |
| `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 |
| `webhook.zod.ts` | 1 | authorable (p) | spec-only (#3461) |
| `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 |
| `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 |
Expand Down Expand Up @@ -605,25 +605,31 @@ 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/` — 53 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) |
| `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 |
| `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 |
| `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".
Three rows left this table at **批 9** (#4001), the ruling's first `automation/`
wave — `builtin-node-config.zod.ts` (8), `schemaless-node-config.zod.ts` (4) and
`io-node-config.zod.ts` (2), all reaching zero strip. The reverse pin fired on
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`.

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

Expand Down
Loading
Loading