Skip to content

Commit de2979d

Browse files
committed
fix(spec): correct three history strings that named the wrong failure
Self-review against the actual behaviour, before the claim shipped. The `connectorConfig` / `boundaryConfig` / `position` / `inputSchema` prose named a slip on a REQUIRED key (`connectorID`, `attachedToRef`) as the silent case. It never was: a required key spelled wrong then reads as MISSING, which zod has always rejected loudly. Nothing was silent about it. What `.strip` actually swallowed is the OPTIONAL half — the mapped `input` map (dispatched as `input ?? {}`: a successful connector call carrying nothing), BPMN's `cancelActivity: false` (so `interrupting` fell to its `true` default and a NON-interrupting event cancelled the host anyway), `optional: false` (so `required` fell to false and `validateNodeInputSchemas` had nothing to require), a canvas hint beside x/y. I.e. exactly the keys an author adds to CONSTRAIN behaviour, replaced by a permissive default. The corrected version is the stronger argument as well as the true one, and getting it wrong is the failure mode this campaign has already paid for four times (the withdrawn guidance prescriptions): rejection prose is behaviour, not documentation. Fixed in the schemas, the module comment, the tests and the ledger row; a test now pins the required-key case as loud so the distinction cannot quietly re-blur. Part of #4001 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9
1 parent 1114d54 commit de2979d

3 files changed

Lines changed: 50 additions & 24 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,7 @@ not verdicts).
527527

528528
| File | Sites | Class | Note |
529529
|---|---|---|---|
530-
| `flow.zod.ts` | 11 | authorable | **strict as of #4001** — the four outer authoring shapes at step 1, and **the six nested blocks at batch 11** (`FlowNode.connectorConfig` / `.position` / `.inputSchema` / `.waitEventConfig` / `.boundaryConfig`, `Flow.errorHandling`). The gap between those two dates is this campaign's own finding 17 inside its own file: closing the shells left the gate rejecting `nodee:` at node level while `connectorConfig: { connectorID }` — one capital letter — parsed clean and dispatched against an undefined connector id. Two things stay open and are now pinned in code with the reason, so a later sweep stops rather than "finishes" the file: the node `config` slot (ADR-0018 plugin namespace) and `FlowVersionHistorySchema` (the file's only WIRE shape — emitted on publish, never authored; its `definition` is `FlowSchema`, so the authored half inside a history record is gated anyway) |
530+
| `flow.zod.ts` | 11 | authorable | **strict as of #4001** — the four outer authoring shapes at step 1, and **the six nested blocks at batch 11** (`FlowNode.connectorConfig` / `.position` / `.inputSchema` / `.waitEventConfig` / `.boundaryConfig`, `Flow.errorHandling`). The gap between those two dates is this campaign's own finding 17 inside its own file: closing the shells left the gate rejecting `nodee:` at node level while `connectorConfig: { connectorId, actionId, params: {…} }` parsed clean and the executor dispatched `input ?? {}` — a successful connector call carrying nothing. Worth recording precisely, because the obvious example is the wrong one: a slip on a REQUIRED key was always loud (it then reads as missing). What `.strip` swallowed here is the OPTIONAL half — the input map, the retry budget, `interrupting: false`, `required: true` — i.e. exactly the keys an author adds to CONSTRAIN behaviour, replaced by a permissive default without a word. Two things stay open and are now pinned in code with the reason, so a later sweep stops rather than "finishes" the file: the node `config` slot (ADR-0018 plugin namespace) and `FlowVersionHistorySchema` (the file's only WIRE shape — emitted on publish, never authored; its `definition` is `FlowSchema`, so the authored half inside a history record is gated anyway) |
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 |
533533
| `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 |

packages/spec/src/automation/flow.test.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1310,10 +1310,15 @@ describe('unknown keys are rejected, not stripped (#4001)', () => {
13101310
// ── batch 11: the INNER blocks ────────────────────────────────────────────
13111311
//
13121312
// Closing the four outer shells above left six nested authoring blocks on
1313-
// zod's default `.strip`, so the gate rejected `nodee:` at the node level
1314-
// while `connectorConfig: { connectorID }` — one capital letter — parsed
1315-
// clean and dispatched against an undefined connector id. Same defect, one
1316-
// layer in: a guard put where the author who wrote it was standing.
1313+
// zod's default `.strip`. Same defect, one layer in — a guard put where the
1314+
// author who wrote it was standing.
1315+
//
1316+
// What those six were actually hiding is worth stating, because it is not the
1317+
// obvious case: a slip on a REQUIRED key was always loud (the key then reads
1318+
// as missing). `.strip` swallowed the OPTIONAL half — the mapped input map,
1319+
// the retry budget, `interrupting: false`, `required: true` — i.e. precisely
1320+
// the keys an author adds to CONSTRAIN behaviour, silently replaced by a
1321+
// permissive default.
13171322
describe('the nested authoring blocks (batch 11)', () => {
13181323
const node = (extra: Record<string, unknown>) => ({ id: 'n1', type: 'script', label: 'N', ...extra });
13191324

@@ -1323,11 +1328,18 @@ describe('unknown keys are rejected, not stripped (#4001)', () => {
13231328
}));
13241329
expect(issue!.message).toContain("connector_action node's `connectorConfig`");
13251330
expect(issue!.message).toContain('`params` → `input`');
1326-
// The one that motivates the block: a case slip on the id the executor
1327-
// reads, which used to dispatch with `connectorId: undefined`.
1328-
expect(unknownKeyIssue(FlowNodeSchema, node({
1331+
});
1332+
1333+
it('connectorConfig: the silent case was the OPTIONAL half, not the ids', () => {
1334+
// Before this change, `{ connectorId, actionId, params }` parsed clean and
1335+
// the executor dispatched `input ?? {}` — a successful call carrying
1336+
// nothing. A slip on a REQUIRED id was never silent (it reads as missing),
1337+
// which is why this block's history names the input map and not the ids.
1338+
const result = FlowNodeSchema.safeParse(node({
13291339
connectorConfig: { connectorID: 'rest', actionId: 'get' },
1330-
}))!.message).toContain('`connectorID` → `connectorId`');
1340+
}));
1341+
expect(result.success).toBe(false);
1342+
expect(JSON.stringify(result.error!.issues)).toContain('connectorId');
13311343
});
13321344

13331345
it('position: rejects a third coordinate rather than dropping it at (0, 0)', () => {

packages/spec/src/automation/flow.zod.ts

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,21 @@ export const FLOW_STRUCTURAL_NODE_TYPES: readonly string[] = ['start', 'end'];
8585
* / `.position` / `.inputSchema` / `.waitEventConfig` / `.boundaryConfig` and
8686
* `Flow.errorHandling`. That is the shape this campaign keeps re-finding: a
8787
* guard put where the author who wrote it was standing. The outer gate rejected
88-
* `nodee:` at the node level while `connectorConfig: { connectorID }` — one
89-
* capital letter — still parsed clean and dispatched the action against an
90-
* undefined connector id. They use {@link strictObject}, whose candidate list is
91-
* read from the shape itself, so these six need no drift-guard entry (and adding
92-
* one would be the second copy of the truth the helper exists to delete).
88+
* `nodee:` at the node level while `connectorConfig: { connectorId, actionId,
89+
* params: {…} }` parsed clean and dispatched the action with **no inputs at
90+
* all** — the executor reads `input ?? {}`, so the whole mapped payload became
91+
* an empty object and the call succeeded against nothing.
92+
*
93+
* Note which cases those six were, and were not, hiding: a slip on a REQUIRED
94+
* key (`connectorID` for `connectorId`, `attachedToRef` for `attachedToNodeId`)
95+
* was always loud, because the required key then reads as missing. What
96+
* `.strip` swallowed is the OPTIONAL half — the input map, the retry budget,
97+
* `interrupting: false`, `required: true` — i.e. exactly the keys an author adds
98+
* to CONSTRAIN behaviour, dropped back to a permissive default without a word.
99+
*
100+
* They use {@link strictObject}, whose candidate list is read from the shape
101+
* itself, so these six need no drift-guard entry (and adding one would be the
102+
* second copy of the truth the helper exists to delete).
93103
*
94104
* Deliberately still open, both re-confirmed here rather than left to be
95105
* rediscovered: the node `config` slot (above), and
@@ -206,9 +216,9 @@ export const FlowNodeSchema = lazySchema(() => z.object({
206216
payload: 'input',
207217
},
208218
history:
209-
'Until #4001 these were dropped silently — the block still parsed, so a ' +
210-
'mis-spelled connector/action id or a whole input map was ignored and the ' +
211-
'action dispatched with `input ?? {}` against `undefined`.',
219+
'Until #4001 these were dropped silently — the block still parsed, so a whole ' +
220+
'mapped input map written under another word vanished and the executor ' +
221+
'dispatched the action with `input ?? {}`: a successful call carrying nothing.',
212222
},
213223
{
214224
connectorId: z.string().describe('Registered connector name'),
@@ -229,8 +239,10 @@ export const FlowNodeSchema = lazySchema(() => z.object({
229239
{
230240
surface: "this node's canvas `position`",
231241
history:
232-
'Until #4001 these were dropped silently — the block still parsed, so a node ' +
233-
'carrying a size or a third coordinate landed on the canvas at (0, 0) instead.',
242+
'Until #4001 these were dropped silently — the block still parsed, so a canvas ' +
243+
'hint written beside x/y (a size, a third coordinate, a designer marker) was ' +
244+
'discarded, and the round-trip back through the designer could not tell it had ' +
245+
'ever been written.',
234246
},
235247
{ x: z.number(), y: z.number() },
236248
).optional(),
@@ -255,8 +267,9 @@ export const FlowNodeSchema = lazySchema(() => z.object({
255267
},
256268
history:
257269
'Until #4001 these were dropped silently — the declaration still parsed, so a ' +
258-
'parameter the author marked required was generated as an optional Studio form ' +
259-
'field and never validated at run time.',
270+
'parameter constrained under a word we do not declare (`optional: false`) came ' +
271+
'back UNconstrained: `required` fell to its `false` default, and the engine\'s ' +
272+
'pre-execution check (`validateNodeInputSchemas`) then had nothing to require.',
260273
},
261274
{
262275
type: z.enum(['string', 'number', 'boolean', 'object', 'array']).describe('Parameter type'),
@@ -368,9 +381,10 @@ export const FlowNodeSchema = lazySchema(() => z.object({
368381
duration: 'timerDuration',
369382
},
370383
history:
371-
'Until #4001 these were dropped silently — the block still parsed, so a boundary ' +
372-
'event whose host node the author named with the BPMN word attached to nothing and ' +
373-
'never fired.',
384+
'Until #4001 these were dropped silently — the block still parsed, so BPMN\'s ' +
385+
'`cancelActivity: false` was discarded and `interrupting` fell to its `true` ' +
386+
'default: an event the author declared NON-interrupting cancelled the host ' +
387+
'activity anyway.',
374388
}, {
375389
/** ID of the host node this boundary event is attached to */
376390
attachedToNodeId: z.string().describe('Host node ID this boundary event monitors'),

0 commit comments

Comments
 (0)