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
44 changes: 44 additions & 0 deletions .changeset/unknown-key-strictness-data-step.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
"@objectstack/spec": minor
---

Hooks and datasources reject unknown keys (#4001 data step).

Closes the last two entries in the strictness ledger that still carried a
provisional classification. Both were confirmed authorable the same way: they sit
in `BUILTIN_METADATA_TYPE_SCHEMAS`, so one shape backs `defineStack()` parsing,
`/api/v1/meta/types/:type`, and the Studio form.

Now strict:

- `HookSchema` + its `retryPolicy`, and both hook-body branches
(`ExpressionBodySchema`, `ScriptBodySchema`). A misspelt `capabilities`
stripped to the empty default and the sandbox threw at invocation time instead
of at parse; a misspelt `timeoutMs`/`memoryMb` silently downgraded the body to
the enclosing hook's limits.
- `DatasourceSchema` + `pool` / `healthCheck` / `ssl` / `retryPolicy`,
`ExternalDatasourceSettingsSchema` + its `validation` block,
`DatasourceCapabilities`, and `DriverDefinitionSchema`.

Deliberately still tolerant:

- `HookContextSchema` and its `session` / `provenance` / `user` blocks — the
runtime shape the engine hands a handler. 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.
- `datasource.config` and `readReplicas` — per-driver by construction; the
driver's own `configSchema` validates them.

Errors are self-fixing: connection keys written one level too high (`host`,
`port`, `filename`, `url`, …) are prescribed into `config`; a top-level
`password` is pointed at `external.credentialsRef` rather than merely relocated;
and the two near-miss spellings that cross between shapes carry aliases
(hook-level `timeout` vs body-level `timeoutMs`; hook `retryPolicy.backoffMs` vs
datasource `retryPolicy.baseDelayMs`).

Note for anyone reading the earlier steps: strictness does not change the
published JSON Schema. `build-schemas.ts` converts with `io: 'output'`, where zod
emits `additionalProperties: false` for `.strip()` objects too — verified by
regenerating both ways (`Datasource.json` is byte-identical). The JSON Schema was
already advertising `additionalProperties: false` while the parse silently
dropped keys; this aligns the parse with the published contract.
32 changes: 32 additions & 0 deletions content/docs/releases/v17.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,38 @@ schema to the two highest-risk authorable surfaces, per the triage in
schema carries `additionalProperties: false` into the Studio form and
`registerFlow()` config validation, so a mis-keyed approval `config` is
rejected at registration too.
- **Hooks** — `HookSchema`, its `retryPolicy`, and both hook-body branches
(`expression` and `js`). A body was the worst place to lose a key quietly: a
misspelt `capabilities` stripped to the empty default and the sandbox then
threw at *invocation* time, on whichever code path first touched `ctx.api` —
far from the typo. A misspelt `timeoutMs`/`memoryMb` silently downgraded the
body to the enclosing hook's looser limits. Two near-miss spellings now carry
aliases because they are genuinely easy to cross: hook-level `timeout` vs
body-level `timeoutMs`, and hook `retryPolicy.backoffMs` vs datasource
`retryPolicy.baseDelayMs`. `HookContextSchema` — the runtime shape the engine
hands your handler — stays tolerant and always will.
- **Datasources** — `DatasourceSchema` with its `pool` / `healthCheck` / `ssl` /
`retryPolicy` blocks, the ADR-0015 `external` federation settings and their
`validation` policy, `DatasourceCapabilities`, and `DriverDefinitionSchema`.
`config` and `readReplicas` stay **open** records: their shape is per-driver
and the driver's own `configSchema` validates them. That openness is why the
top level had to close — a connection key written one level too high (`host`
next to `driver` instead of inside `config`) was stripped, and the datasource
then connected on driver defaults rather than failing. Those keys now
prescribe the move into `config`; a top-level `password` is instead pointed at
`external.credentialsRef`, because relocating an inlined secret is not the fix.
A dropped key in `capabilities` was quieter still: an unregistered capability
reads as `false`, so the engine stopped pushing that work down to the driver
and recomputed it in memory.

One clarification, since these flips are easy to over-read: making a schema
strict does **not** change its published JSON Schema. `build-schemas.ts`
converts with `io: 'output'`, and in output mode zod emits
`additionalProperties: false` for a `.strip()` object too — the post-parse shape
genuinely has no extra keys. So the JSON Schema was already advertising
`additionalProperties: false` while the zod parse quietly accepted and discarded
unknown keys. These flips align the parse with the contract that was already
published; they do not widen it.

Every rejection is written to be self-fixing: it names the offending key and,
where recognisable, the canonical spelling (`steps` → `nodes`, edge
Expand Down
33 changes: 27 additions & 6 deletions docs/audits/2026-07-unknown-key-strictness-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,10 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
| `field.zod.ts` | 11 | authorable | partially strict |
| `filter.zod.ts` / `query.zod.ts` | 11+10 | open | query dialect — user data flows through; validated semantically elsewhere |
| `driver-nosql.zod.ts` / `driver.zod.ts` / `driver-sql.zod.ts` | 10+9+2 | wire | driver capability contracts |
| `datasource.zod.ts` | 9 | authorable (p) | stack-authored config — **candidate** |
| `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 |
| `analytics.zod.ts` | 8 | mixed (p) | |
| `document.zod.ts` | 8 | wire (p) | |
| `hook.zod.ts` / `hook-body.zod.ts` | 6+2 | authorable (p) | `defineHook` — **candidate** |
| `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 |
| `mapping.zod.ts` | 3 | authorable (p) | |
| `external-catalog.zod.ts` | 4 | wire (p) | |
| `field-value.zod.ts` / `seed.zod.ts` / `validation.zod.ts` | 1 ea | mixed (p) | |
Expand Down Expand Up @@ -213,13 +213,11 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).

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

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

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

Done in the data step: `data/hook.zod.ts` + `data/hook-body.zod.ts` +
`data/datasource.zod.ts` — the last two entries that carried a provisional
`(p)` classification. Verification changed the answer for one of them: the
blanket `authorable (p)` on `hook.zod.ts` was too wide, because
`HookContextSchema` in the same file is a runtime shape and stays tolerant.
Both types were confirmed authorable the same way — they sit in
`BUILTIN_METADATA_TYPE_SCHEMAS`, so one shape backs `defineStack()` parsing,
`/api/v1/meta/types/:type`, and the Studio form.

Measured while doing it, and worth recording because it contradicts the
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 both ways: `Datasource.json` is byte-identical before
and after. So the JSON Schema had been advertising `additionalProperties: false`
while the zod parse quietly accepted and dropped unknown keys. These flips align
the parse with the contract that was already published, rather than widening it.
(Note this cuts against the `approval.zod.ts` note above, which reads as though
the flip carried strictness INTO the JSON schema. It did not; approval's schema
would have said `false` regardless. The registration-time rejection it describes
is real, but it came from the published schema, not from the flip.)

Long tail stays gated on a verification pass per shape — never a one-shot
"make all ~453 sites strict" (ADR-0054 ratchet; #4001's own recommendation).
Loading
Loading