Skip to content

Commit af6afda

Browse files
committed
feat(spec)!: hooks and datasources reject unknown keys (#4001 data step)
Closes the last two ledger entries that still carried a provisional (p) classification. The ledger's own rule for these was "verify before tightening", and verification changed the answer for one of them. CLASSIFICATION. Both types are authorable on the same evidence: they sit in BUILTIN_METADATA_TYPE_SCHEMAS, so one shape backs defineStack() parsing, /api/v1/meta/types/:type, and the Studio form. But the blanket `authorable (p)` on hook.zod.ts was too wide — HookContextSchema in the same file is the RUNTIME shape the engine hands a handler. It stays tolerant, and must: strictness there would turn an engine-internal enrichment (as `provenance` was in #3712) into a breaking change for anyone parsing a context they were given. Strict now: HookSchema + retryPolicy; both hook-body branches; DatasourceSchema + pool/healthCheck/ssl/retryPolicy; ExternalDatasourceSettingsSchema + its validation block; DatasourceCapabilities; DriverDefinitionSchema. Still open, deliberately: HookContextSchema and its session/provenance/user blocks; datasource `config` and `readReplicas` (per-driver by construction — the driver's own configSchema validates them). That openness is exactly why the top level had to close: a connection key written one level too high was stripped and the datasource then connected on driver defaults rather than failing. Those keys are prescribed into `config`; a top-level `password` is pointed at `external.credentialsRef`, because relocating an inlined secret is not the fix. CORRECTS AN ASSUMPTION THE EARLIER STEPS WERE WRITTEN UNDER. Strictness does not change the published JSON Schema. build-schemas.ts converts with the default io:'output', and in output mode zod emits additionalProperties:false for a .strip() object too — the post-parse shape genuinely has no extra keys. Verified by regenerating with and without these flips: Datasource.json is byte-identical. So the JSON Schema had been advertising additionalProperties:false while the zod parse quietly accepted and discarded unknown keys. These flips align the parse with the contract already published rather than widening it. The approval note in the ledger reads as though its flip carried strictness INTO the JSON schema; it did not, and the ledger now says so. SCOPE LIMIT, since it bounds who this reaches. There is no defineHook() factory — it is referenced twice in object.zod.ts describe strings but does not exist, unlike definePosition/defineTool/defineAgent/defineApp/defineView/ defineDatasource. Authors write `const H: Hook = {…}`, a bare type annotation that never parses, so hook strictness bites at artifact/registry LOAD time, not as they type. Datasource has its factory and so rejects at authoring time. Adding defineHook() is a new API surface and belongs in its own change. The metadata-authoring-lint coverage test failed on cue: lintables fell 16 → 14 because both types graduated out of lint coverage into parse coverage. That test exists to force a human to confirm a shrink is a graduation and not a bug — so `hook` moved out of the pinned-coverage list and both moved into the strict list. Verified: full spec suite (7136) green; gen:schema clean (covers the #3746 toJSONSchema-on-strict-lazySchema hazard); authorable-surface, liveness, empty-state, variant-docs, doc-authoring and changeset gates green; both first-party example datasources parse under strict (no finding this time — unlike the app step, these were already clean). objectql has 26 pre-existing failures in protocol-data / protocol-unknown-query- param (#4164/#4134 territory, untouched here). Confirmed pre-existing by stashing these changes and re-running: identical 26 failed | 59 passed. Refs #4001 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0147tNF4Snk7Ry1KGt4a5PY4
1 parent 8c2db68 commit af6afda

7 files changed

Lines changed: 524 additions & 22 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
Hooks and datasources reject unknown keys (#4001 data step).
6+
7+
Closes the last two entries in the strictness ledger that still carried a
8+
provisional classification. Both were confirmed authorable the same way: they sit
9+
in `BUILTIN_METADATA_TYPE_SCHEMAS`, so one shape backs `defineStack()` parsing,
10+
`/api/v1/meta/types/:type`, and the Studio form.
11+
12+
Now strict:
13+
14+
- `HookSchema` + its `retryPolicy`, and both hook-body branches
15+
(`ExpressionBodySchema`, `ScriptBodySchema`). A misspelt `capabilities`
16+
stripped to the empty default and the sandbox threw at invocation time instead
17+
of at parse; a misspelt `timeoutMs`/`memoryMb` silently downgraded the body to
18+
the enclosing hook's limits.
19+
- `DatasourceSchema` + `pool` / `healthCheck` / `ssl` / `retryPolicy`,
20+
`ExternalDatasourceSettingsSchema` + its `validation` block,
21+
`DatasourceCapabilities`, and `DriverDefinitionSchema`.
22+
23+
Deliberately still tolerant:
24+
25+
- `HookContextSchema` and its `session` / `provenance` / `user` blocks — the
26+
runtime shape the engine hands a handler. Strictness there would turn an
27+
engine-internal enrichment (as `provenance` was in #3712) into a breaking
28+
change for anyone parsing a context they were given.
29+
- `datasource.config` and `readReplicas` — per-driver by construction; the
30+
driver's own `configSchema` validates them.
31+
32+
Errors are self-fixing: connection keys written one level too high (`host`,
33+
`port`, `filename`, `url`, …) are prescribed into `config`; a top-level
34+
`password` is pointed at `external.credentialsRef` rather than merely relocated;
35+
and the two near-miss spellings that cross between shapes carry aliases
36+
(hook-level `timeout` vs body-level `timeoutMs`; hook `retryPolicy.backoffMs` vs
37+
datasource `retryPolicy.baseDelayMs`).
38+
39+
Note for anyone reading the earlier steps: strictness does not change the
40+
published JSON Schema. `build-schemas.ts` converts with `io: 'output'`, where zod
41+
emits `additionalProperties: false` for `.strip()` objects too — verified by
42+
regenerating both ways (`Datasource.json` is byte-identical). The JSON Schema was
43+
already advertising `additionalProperties: false` while the parse silently
44+
dropped keys; this aligns the parse with the published contract.

content/docs/releases/v17.mdx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,38 @@ schema to the two highest-risk authorable surfaces, per the triage in
358358
schema carries `additionalProperties: false` into the Studio form and
359359
`registerFlow()` config validation, so a mis-keyed approval `config` is
360360
rejected at registration too.
361+
- **Hooks**`HookSchema`, its `retryPolicy`, and both hook-body branches
362+
(`expression` and `js`). A body was the worst place to lose a key quietly: a
363+
misspelt `capabilities` stripped to the empty default and the sandbox then
364+
threw at *invocation* time, on whichever code path first touched `ctx.api`
365+
far from the typo. A misspelt `timeoutMs`/`memoryMb` silently downgraded the
366+
body to the enclosing hook's looser limits. Two near-miss spellings now carry
367+
aliases because they are genuinely easy to cross: hook-level `timeout` vs
368+
body-level `timeoutMs`, and hook `retryPolicy.backoffMs` vs datasource
369+
`retryPolicy.baseDelayMs`. `HookContextSchema` — the runtime shape the engine
370+
hands your handler — stays tolerant and always will.
371+
- **Datasources**`DatasourceSchema` with its `pool` / `healthCheck` / `ssl` /
372+
`retryPolicy` blocks, the ADR-0015 `external` federation settings and their
373+
`validation` policy, `DatasourceCapabilities`, and `DriverDefinitionSchema`.
374+
`config` and `readReplicas` stay **open** records: their shape is per-driver
375+
and the driver's own `configSchema` validates them. That openness is why the
376+
top level had to close — a connection key written one level too high (`host`
377+
next to `driver` instead of inside `config`) was stripped, and the datasource
378+
then connected on driver defaults rather than failing. Those keys now
379+
prescribe the move into `config`; a top-level `password` is instead pointed at
380+
`external.credentialsRef`, because relocating an inlined secret is not the fix.
381+
A dropped key in `capabilities` was quieter still: an unregistered capability
382+
reads as `false`, so the engine stopped pushing that work down to the driver
383+
and recomputed it in memory.
384+
385+
One clarification, since these flips are easy to over-read: making a schema
386+
strict does **not** change its published JSON Schema. `build-schemas.ts`
387+
converts with `io: 'output'`, and in output mode zod emits
388+
`additionalProperties: false` for a `.strip()` object too — the post-parse shape
389+
genuinely has no extra keys. So the JSON Schema was already advertising
390+
`additionalProperties: false` while the zod parse quietly accepted and discarded
391+
unknown keys. These flips align the parse with the contract that was already
392+
published; they do not widen it.
361393

362394
Every rejection is written to be self-fixing: it names the offending key and,
363395
where recognisable, the canonical spelling (`steps``nodes`, edge

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

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -157,10 +157,10 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
157157
| `field.zod.ts` | 11 | authorable | partially strict |
158158
| `filter.zod.ts` / `query.zod.ts` | 11+10 | open | query dialect — user data flows through; validated semantically elsewhere |
159159
| `driver-nosql.zod.ts` / `driver.zod.ts` / `driver-sql.zod.ts` | 10+9+2 | wire | driver capability contracts |
160-
| `datasource.zod.ts` | 9 | authorable (p) | stack-authored config**candidate** |
160+
| `datasource.zod.ts` | 9 | authorable | **strict as of #4001 data step** — all 9: `DatasourceSchema` (+ `pool` / `healthCheck` / `ssl` / `retryPolicy`), `ExternalDatasourceSettingsSchema` (+ `validation`), `DatasourceCapabilities`, `DriverDefinitionSchema`. `config` + `readReplicas` stay `z.record` by construction (per-driver shapes; the driver's own `configSchema` validates them) — which is precisely why the top level had to close: a connection key written one level too high was stripped, and the datasource then connected on driver defaults instead of failing |
161161
| `analytics.zod.ts` | 8 | mixed (p) | |
162162
| `document.zod.ts` | 8 | wire (p) | |
163-
| `hook.zod.ts` / `hook-body.zod.ts` | 6+2 | authorable (p) | `defineHook`**candidate** |
163+
| `hook.zod.ts` / `hook-body.zod.ts` | 6+2 | mixed | **strict as of #4001 data step** for the AUTHORING shapes: `HookSchema` (+ `retryPolicy`) and both body branches (`ExpressionBodySchema` / `ScriptBodySchema`). `HookContextSchema` and its `session` / `provenance` / `user` blocks are the RUNTIME shape the engine hands a handler — they stay tolerant, and must: strictness there would make an engine-internal enrichment (as `provenance` was in #3712) a breaking change for anyone parsing a context they were given. The file's old blanket `authorable (p)` was too wide — verification split it |
164164
| `mapping.zod.ts` | 3 | authorable (p) | |
165165
| `external-catalog.zod.ts` | 4 | wire (p) | |
166166
| `field-value.zod.ts` / `seed.zod.ts` / `validation.zod.ts` | 1 ea | mixed (p) | |
@@ -213,13 +213,11 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
213213

214214
## Next steps (verify-then-enforce, one shape at a time)
215215

216-
1. `data/hook.zod.ts`, `data/datasource.zod.ts``defineHook` / stack config
217-
(both still provisional (p) classifications — verify before tightening).
218-
2. The `@objectstack/lint` unknown-key WARNING layer: non-breaking, shippable
216+
1. The `@objectstack/lint` unknown-key WARNING layer: non-breaking, shippable
219217
in a minor, and it extends AI-detectable coverage to every remaining
220218
authorable site at once while accumulating evidence (which keys real
221219
tenant projects actually carry) for a v18 strict close-out.
222-
3. Promote this ledger to a machine-checked gate (pattern of
220+
2. Promote this ledger to a machine-checked gate (pattern of
223221
`packages/spec/liveness/` + `check:liveness`) once enough of the surface is
224222
classified that the table above is enforceable rather than descriptive.
225223

@@ -241,5 +239,28 @@ Done in the app step, PR A: the seven audit-dead AppSchema keys tombstoned
241239
migration entry), clearing the enforce-or-remove precondition for the app
242240
strict step (PR B).
243241

242+
Done in the data step: `data/hook.zod.ts` + `data/hook-body.zod.ts` +
243+
`data/datasource.zod.ts` — the last two entries that carried a provisional
244+
`(p)` classification. Verification changed the answer for one of them: the
245+
blanket `authorable (p)` on `hook.zod.ts` was too wide, because
246+
`HookContextSchema` in the same file is a runtime shape and stays tolerant.
247+
Both types were confirmed authorable the same way — they sit in
248+
`BUILTIN_METADATA_TYPE_SCHEMAS`, so one shape backs `defineStack()` parsing,
249+
`/api/v1/meta/types/:type`, and the Studio form.
250+
251+
Measured while doing it, and worth recording because it contradicts the
252+
assumption the earlier steps were written under: **strictness does not change
253+
the published JSON Schema.** `build-schemas.ts` converts with the default
254+
`io: 'output'`, and in output mode zod emits `additionalProperties: false` for
255+
a `.strip()` object too — the post-parse shape genuinely has no extra keys.
256+
Verified by regenerating both ways: `Datasource.json` is byte-identical before
257+
and after. So the JSON Schema had been advertising `additionalProperties: false`
258+
while the zod parse quietly accepted and dropped unknown keys. These flips align
259+
the parse with the contract that was already published, rather than widening it.
260+
(Note this cuts against the `approval.zod.ts` note above, which reads as though
261+
the flip carried strictness INTO the JSON schema. It did not; approval's schema
262+
would have said `false` regardless. The registration-time rejection it describes
263+
is real, but it came from the published schema, not from the flip.)
264+
244265
Long tail stays gated on a verification pass per shape — never a one-shot
245266
"make all ~453 sites strict" (ADR-0054 ratchet; #4001's own recommendation).

0 commit comments

Comments
 (0)