You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(spec)!: reject unknown keys on the ETL authoring contracts (#4001 批 12) (#4979)
* feat(spec)!: reject unknown keys on the ETL authoring contracts (#4001 批 12)
Seven strip sites in `automation/etl.zod.ts` close — `ETLSource` (+ its
`.incremental` block), `ETLDestination`, `ETLTransformation`, and
`ETLPipeline` (+ its `.retry` and `.notifications` blocks). `automation/`
remaining-strip: 53 → 46 (authorable 27 → 20).
The file's other three sites — `ETLPipelineRun` + `.stats` + `.error` — are
deliberately left OPEN. Every key on them is a fact the engine produces about
a run that already happened; nobody authors a run result, so strictness buys
no author protection there and would turn a future counter into a parse crash
for every existing reader (the #3712 shape). Same disposition, same reason, as
`FlowVersionHistorySchema` and all of `execution.zod.ts`. The exemption is
recorded on the schema itself and pinned in `etl.test.ts`, not only in the
ledger — a note only the ledger carries is a note the next sweep misses.
Verified before tightening, and the verification has a stated limit: this file
has NO parse site in objectstack / objectui / cloud, so neither half could be
settled by pointing at a live call. The seven are authorable because the
exported schema and type ARE the authoring door (SYNC_ARCHITECTURE.md and the
module's own `@example` both hand-write `const p: ETLPipeline = { … }`) — the
same posture the ledger already records for `webhook.zod.ts`. The three are
wire on the shapes' semantics plus settled precedent, not on an emit site.
Curation is anchored, not invented. With no stored payloads to scan, every
alias and guidance entry names a sibling contract in this repo that spells the
same intent differently: `timestampField` → `cursorField` (connector
`DataSyncConfig`), `onError` → `onFailure` (three in-repo surfaces),
`maxRetries` → `maxAttempts` and the retired `retryDelayMs` → `backoffMs`
(the converged `shared/RetryPolicySchema`, #4661). The connector's one
`strategy` enum splits across two keys here, so it resolves to `writeMode` on
a destination and `syncMode` on a pipeline — a single global alias would
misdirect one of them with full confidence. `direction` gets a documented
absence rather than a rename: an ETL pipeline states direction structurally.
The dominant failure on this file is misplacement, not typing — `table`,
`endpoint`, `path`, `condition` are real settings one level down, inside the
open `config` bag. That pointer lives in `history` (appended to every message)
rather than a per-key table, because the open bag's vocabulary is unbounded
and enumerating it would be guesswork.
One published-artifact consequence, stated rather than glossed: the campaign's
"strictness does not move the JSON Schema" claim is per-direction, and
`ETLPipelineSchema` is the case where the usual direction does not apply — it
cannot convert in output mode (`schedule` is a transform), so build-schemas
falls back to input mode, where strip emits nothing and strict emits
`additionalProperties: false`. The publication now matches the parse; it is a
real narrowing, not a no-op, and it is pinned in both directions.
Two out-of-scope findings filed rather than fixed: #4962 (`retry` is a third
retry-policy vocabulary #4661's convergence never reached) and #4963 (all nine
type aliases export the parsed shape under the bare name, which is why the
SYNC_ARCHITECTURE.md pipeline examples do not compile).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9
* docs(spec): changeset for the ETL strictness batch (#4001 批 12)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9
---------
Co-authored-by: Claude <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: docs/audits/2026-07-unknown-key-strictness-ledger.md
+31-18Lines changed: 31 additions & 18 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -529,7 +529,7 @@ not verdicts).
529
529
| File | Sites | Class | Note |
530
530
|---|---|---|---|
531
531
| `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) |
532
-
|`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 |
532
+
| `etl.zod.ts` | 10 | mixed | **7 strict as of #4001 批 12** — the authoring half (`ETLSource` + `.incremental`, `ETLDestination`, `ETLTransformation`, `ETLPipeline` + `.retry` + `.notifications`). The other 3 — `ETLPipelineRun` + `.stats` + `.error` — are **deliberately left open**: engine-emitted run state (an id it minted, a status it reached, counters it accumulated), same disposition and same reason as `FlowVersionHistorySchema` above and all of `execution.zod.ts`. The exemption is recorded on the schema itself, not only here, because a note only this file carries is a note the next sweep does not read. The old blanket `authorable (p)` was too wide; verification split it. ⚠️ **Read the classification caveat before reusing this verdict**: `etl.zod.ts` has NO parse site in objectstack / objectui / cloud, so neither half could be settled by pointing at a live call. The 7 are authorable because the exported schema and type ARE the door (`SYNC_ARCHITECTURE.md` and the module's `@example` both hand-write `const p: ETLPipeline = { … }`) — the `webhook.zod.ts` posture. The 3 are wire on the shape's semantics plus settled precedent, NOT on an emit site anyone can point at today; if an ETL engine ever lands and a run result turns out to be operator-authored, that verdict is the one to revisit. Two out-of-scope findings were filed rather than fixed here: the `retry` block is a third retry-policy vocabulary #4661's convergence never reached (#4962), and all nine type aliases export the parsed shape under the bare name, which is why the SYNC_ARCHITECTURE.md pipeline examples do not compile (#4963). **−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 |
533
533
|`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 |
534
534
| `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 |
535
535
| `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 |
@@ -606,12 +606,12 @@ classes; where it does, the split is stated. **Only the authorable half is in th
606
606
2026-08-03 ruling's forced scope** — wire/open rows are listed so the arithmetic
607
607
is complete and so nobody re-triages them from scratch next batch.
608
608
609
-
#### `automation/` — 33 strip of 75
609
+
#### `automation/` — 26 strip of 75
610
610
611
611
| File | Strip | Sites | Class | Batch |
612
612
|---|---|---|---|---|
613
613
|`execution.zod.ts`| 13 | 13 | wire |**out of scope** — engine-emitted run state; the ledger row already says "never strict" |
|`etl.zod.ts`|3| 10 |wire|**Authorable half closed at 批 12** (7 sites: `ETLSource` + `.incremental`, `ETLDestination`, `ETLTransformation`, `ETLPipeline` + `.retry` + `.notifications`). What is left is `ETLPipelineRun` + `.stats` + `.error` — engine-emitted run state, exempt for the `FlowVersionHistorySchema` reason and pinned as such in `etl.test.ts`, so closing it means deleting a test that says not to. **This row shrinks without disappearing** — the second in `automation/` to do so, after `flow.zod.ts` reached its own wire floor of 1 at 批 11 (the two batches were in flight together and arrived at the same shape independently, which is the better evidence that it is the right one). Worth naming because the reverse pin cannot see it: the pin fires on zero, so a row that stops at its wire floor looks exactly like a row nobody finished. The Class column is the only thing separating them — read it before treating this as unfinished work|
615
615
|`flow.zod.ts`| 1 | 11 | wire |**batch 11 closed the 6 authorable** (`FlowNode.connectorConfig` / `.position` / `.inputSchema` / `.waitEventConfig` / `.boundaryConfig`, `Flow.errorHandling`). The 1 left is `FlowVersionHistorySchema`, which this table has exempted since it was written — **do not close it**: it is emitted on publish, not authored, so closing it makes a future emitter-side field a parse failure for whoever reads history. The exemption now also lives beside the schema and in `flow.test.ts`, because a row in a table is not where the next person to open that file will look |
616
616
|`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 |
0 commit comments