Skip to content

Commit aedb707

Browse files
committed
feat(spec)!: the six validation variants each close against their own key set (#4001)
`validation` was the registered metadata type this campaign kept deferring: a `z.lazy()` discriminated union whose six variants each `.extend()` a shared base, so the one-call `strictObject` conversion the other types took does not apply to it. WHY NOT JUST CLOSE THE BASE. `.extend()` inherits strictness, so closing `BaseValidationSchema` alone would have rejected unknown keys correctly. But an error map closes over the key list it was BUILT with, and for the base that is only the shared keys — so a typo of a variant's own key (`transtions` for `transitions`, `jsonschema` for `schema`) would have been rejected with no rename offered. That is the difference between a fixable error and a confusing one, and it is the whole reason the campaign insists rejections carry the fix. The union discriminates on `type`, so an author is always on exactly one variant and that variant's FULL key set is the right candidate list. The base is now a named shape spread into six `strictObject` calls, each with the aliases that fit its own vocabulary. No `BaseValidationSchema` object remains — nothing ever parsed the base alone. THE ENVELOPE. `validation` is registered, so the loader stamps `_packageId` / `_provenance` on it and `getMetaItemLayered` → `saveMetaItem` round-trips a body carrying them — and the schema could not represent them, so they were dropped on every parse. Declared once in the shared shape, inherited by all six. The type comes off the debt list in `kernel/metadata-type-schemas.test.ts`, which carries a reverse pin, so removing the entry was forced rather than optional. Five remain: action, field, mapping, page, translation. AND THE GATE LEARNED THE IDIOM AGAIN. Its strictness-claim check matched only a literal `.strict()`, so a file converted to `strictObject` read as NOT strict and the gate called a true claim a lie. Same blind spot the site count had one batch ago, for the same reason: a measuring tool has to learn the idiom whenever the idiom changes. Both now accept either spelling. Authoring impact: a key none of the variants declares is rejected instead of silently discarded — it was already being ignored, so no working behavior changes. The rejection names which variant it landed on ("this state-machine validation rule"), echoes the key, and suggests from that variant's own set. Verified: spec 283 files / 7159 tests, `tsc --noEmit` clean, all 8 generated artifacts current (the only regeneration is the six variants' envelope tables), all 15 `check:*` gates green. Probed directly: valid rule parses, unknown key rejected, a variant-specific typo gets its rename, envelope accepted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnqGjQFQMqd5k81LYV8SCY
1 parent 7bba90b commit aedb707

7 files changed

Lines changed: 223 additions & 14 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
The six `validation` rule variants reject unknown keys, each against its own key set, and the type can finally represent its ADR-0010 protection envelope.
6+
7+
`validation` was the registered metadata type this campaign kept deferring: a `z.lazy()` discriminated union whose six variants each `.extend()` a shared base, so the one-call `strictObject` conversion the other types took does not apply.
8+
9+
**Why not just close the base.** `.extend()` inherits strictness, so closing `BaseValidationSchema` alone would have rejected unknown keys correctly — but the error map closes over the key list it was *built* with, which for the base is only the shared keys. A typo of a variant's own key (`transtions` for `transitions`, `formuIa` for `formula`) would have been rejected with no rename offered, which is the difference between a fixable error and a confusing one. The union discriminates on `type`, so an author is always on exactly one variant and that variant's full key set is the right candidate list. The base is now a named shape spread into six `strictObject` calls, each with the aliases that fit its own vocabulary.
10+
11+
**The envelope.** `validation` is a registered metadata type, so `MetadataPlugin`'s loader stamps `_packageId` / `_provenance` on it and `getMetaItemLayered``saveMetaItem` round-trips a body carrying them — and the schema could not represent them, so they were dropped on every parse. Declared once in the shared shape, so all six variants inherit it, and the type comes off the debt list in `kernel/metadata-type-schemas.test.ts` (that list carries a reverse pin, so removing an entry is forced rather than optional).
12+
13+
Authoring impact: a key none of the variants declares is now rejected instead of silently discarded — it was already being ignored, so no working behavior changes. The rejection names which variant it landed on ("this state-machine validation rule"), echoes the key, and suggests the closest declared one from that variant's full set.

content/docs/references/data/validation.mdx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,13 @@ const result = ConditionalValidation.parse(data);
146146
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
147147
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | |
148148
| **message** | `string` || Error message to display to the user |
149+
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
150+
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
151+
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
152+
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
153+
| **_packageId** | `string` | optional | Owning package machine id. |
154+
| **_packageVersion** | `string` | optional | Owning package version. |
155+
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
149156
| **type** | `'conditional'` || |
150157
| **when** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` || Predicate (CEL). e.g. P`record.type == 'enterprise'` |
151158
| **then** | `{ name: string; label?: string; description?: string; active?: boolean; … } \| { name: string; label?: string; description?: string; active?: boolean; … } \| { name: string; label?: string; description?: string; active?: boolean; … } \| { name: string; label?: string; description?: string; active?: boolean; … } \| { name: string; label?: string; description?: string; active?: boolean; … } \| [ConditionalValidation](#conditionalvalidation)` || Validation rule to apply when condition is true |
@@ -169,6 +176,13 @@ const result = ConditionalValidation.parse(data);
169176
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
170177
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | |
171178
| **message** | `string` || Error message to display to the user |
179+
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
180+
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
181+
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
182+
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
183+
| **_packageId** | `string` | optional | Owning package machine id. |
184+
| **_packageVersion** | `string` | optional | Owning package version. |
185+
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
172186
| **type** | `'cross_field'` || |
173187
| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` || Predicate (CEL) comparing fields. e.g. P`record.end_date > record.start_date` |
174188
| **fields** | `string[]` || Fields involved. Only fields[0] is read (labels which field the violation attaches to); the rest are advisory. Shares script’s evaluation path. |
@@ -191,6 +205,13 @@ const result = ConditionalValidation.parse(data);
191205
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
192206
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` || |
193207
| **message** | `string` || Error message to display to the user |
208+
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
209+
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
210+
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
211+
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
212+
| **_packageId** | `string` | optional | Owning package machine id. |
213+
| **_packageVersion** | `string` | optional | Owning package version. |
214+
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
194215
| **type** | `'format'` || |
195216
| **field** | `string` || |
196217
| **regex** | `string` | optional | |
@@ -214,6 +235,13 @@ const result = ConditionalValidation.parse(data);
214235
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
215236
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` || |
216237
| **message** | `string` || Error message to display to the user |
238+
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
239+
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
240+
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
241+
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
242+
| **_packageId** | `string` | optional | Owning package machine id. |
243+
| **_packageVersion** | `string` | optional | Owning package version. |
244+
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
217245
| **type** | `'json_schema'` || |
218246
| **field** | `string` || JSON field to validate |
219247
| **schema** | `Record<string, any>` || JSON Schema object definition |
@@ -236,6 +264,13 @@ const result = ConditionalValidation.parse(data);
236264
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
237265
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | |
238266
| **message** | `string` || Error message to display to the user |
267+
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
268+
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
269+
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
270+
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
271+
| **_packageId** | `string` | optional | Owning package machine id. |
272+
| **_packageVersion** | `string` | optional | Owning package version. |
273+
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
239274
| **type** | `'script'` || |
240275
| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` || Predicate (CEL). If TRUE, validation fails. e.g. P`record.amount < 0` |
241276

@@ -257,6 +292,13 @@ const result = ConditionalValidation.parse(data);
257292
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
258293
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` || |
259294
| **message** | `string` || Error message to display to the user |
295+
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
296+
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
297+
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
298+
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
299+
| **_packageId** | `string` | optional | Owning package machine id. |
300+
| **_packageVersion** | `string` | optional | Owning package version. |
301+
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
260302
| **type** | `'state_machine'` || |
261303
| **field** | `string` || State field (e.g. status) |
262304
| **transitions** | `Record<string, string[]>` || Map of `{ OldState: [AllowedNewStates] }` |
@@ -288,6 +330,13 @@ This schema accepts one of the following structures:
288330
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
289331
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | |
290332
| **message** | `string` || Error message to display to the user |
333+
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
334+
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
335+
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
336+
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
337+
| **_packageId** | `string` | optional | Owning package machine id. |
338+
| **_packageVersion** | `string` | optional | Owning package version. |
339+
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
291340
| **type** | `'script'` || |
292341
| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` || Predicate (CEL). If TRUE, validation fails. e.g. P`record.amount < 0` |
293342

@@ -310,6 +359,13 @@ This schema accepts one of the following structures:
310359
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
311360
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | |
312361
| **message** | `string` || Error message to display to the user |
362+
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
363+
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
364+
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
365+
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
366+
| **_packageId** | `string` | optional | Owning package machine id. |
367+
| **_packageVersion** | `string` | optional | Owning package version. |
368+
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
313369
| **type** | `'state_machine'` || |
314370
| **field** | `string` || State field (e.g. status) |
315371
| **transitions** | `Record<string, string[]>` || Map of `{ OldState: [AllowedNewStates] }` |
@@ -334,6 +390,13 @@ This schema accepts one of the following structures:
334390
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
335391
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | |
336392
| **message** | `string` || Error message to display to the user |
393+
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
394+
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
395+
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
396+
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
397+
| **_packageId** | `string` | optional | Owning package machine id. |
398+
| **_packageVersion** | `string` | optional | Owning package version. |
399+
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
337400
| **type** | `'format'` || |
338401
| **field** | `string` || |
339402
| **regex** | `string` | optional | |
@@ -358,6 +421,13 @@ This schema accepts one of the following structures:
358421
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
359422
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | |
360423
| **message** | `string` || Error message to display to the user |
424+
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
425+
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
426+
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
427+
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
428+
| **_packageId** | `string` | optional | Owning package machine id. |
429+
| **_packageVersion** | `string` | optional | Owning package version. |
430+
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
361431
| **type** | `'cross_field'` || |
362432
| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` || Predicate (CEL) comparing fields. e.g. P`record.end_date > record.start_date` |
363433
| **fields** | `string[]` || Fields involved. Only fields[0] is read (labels which field the violation attaches to); the rest are advisory. Shares script’s evaluation path. |
@@ -381,6 +451,13 @@ This schema accepts one of the following structures:
381451
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
382452
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | |
383453
| **message** | `string` || Error message to display to the user |
454+
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
455+
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
456+
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
457+
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
458+
| **_packageId** | `string` | optional | Owning package machine id. |
459+
| **_packageVersion** | `string` | optional | Owning package version. |
460+
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
384461
| **type** | `'json_schema'` || |
385462
| **field** | `string` || JSON field to validate |
386463
| **schema** | `Record<string, any>` || JSON Schema object definition |
@@ -404,6 +481,13 @@ This schema accepts one of the following structures:
404481
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
405482
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | |
406483
| **message** | `string` || Error message to display to the user |
484+
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
485+
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
486+
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
487+
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
488+
| **_packageId** | `string` | optional | Owning package machine id. |
489+
| **_packageVersion** | `string` | optional | Owning package version. |
490+
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
407491
| **type** | `'conditional'` || |
408492
| **when** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` || Predicate (CEL). e.g. P`record.type == 'enterprise'` |
409493
| **then** | `[ValidationRule](#validationrule)` || Validation rule to apply when condition is true |

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
243243
| `notification.zod.ts` / `offline.zod.ts` / `report.zod.ts` | 3 ea | authorable (p) | |
244244
| `sharing.zod.ts` | 2 | authorable (p) | public-sharing config |
245245

246-
### `data/`160 sites
246+
### `data/`165 sites
247247

248248
| File | Sites | Class | Note |
249249
|---|---|---|---|
@@ -262,7 +262,8 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
262262
| `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 |
263263
| `mapping.zod.ts` | 3 | authorable (p) | |
264264
| `external-catalog.zod.ts` | 4 | wire (p) | |
265-
| `field-value.zod.ts` / `seed.zod.ts` / `validation.zod.ts` | 1 ea | mixed (p) | |
265+
| `validation.zod.ts` | 6 | authorable | **strict as of #4001 batch 3b** — a `z.lazy()` discriminated union, so the one-call conversion does not apply: each of the six variants builds its own `strictObject` from a shared `BASE_VALIDATION_SHAPE`. Closing the base alone would have rejected correctly but suggested from the SHARED keys only, so a typo of a variant's own key (`transtions``transitions`) would get no rename. Site count 1 → 6 because the six variants are now object sites in their own right. The ADR-0010 envelope lives in the shared shape, so all six inherit it |
266+
| `field-value.zod.ts` / `seed.zod.ts` | 1+1 | mixed (p) | `seed` is strict (registered-types batch) |
266267

267268
### `automation/` — 88 sites
268269

0 commit comments

Comments
 (0)