diff --git a/.changeset/validation-variants-strict.md b/.changeset/validation-variants-strict.md new file mode 100644 index 0000000000..66166a5b54 --- /dev/null +++ b/.changeset/validation-variants-strict.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': minor +--- + +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. + +`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. + +**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. + +**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). + +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. diff --git a/content/docs/references/data/validation.mdx b/content/docs/references/data/validation.mdx index 608a7e4410..2f29f7301c 100644 --- a/content/docs/references/data/validation.mdx +++ b/content/docs/references/data/validation.mdx @@ -146,6 +146,13 @@ const result = ConditionalValidation.parse(data); | **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") | | **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | | | **message** | `string` | ✅ | Error message to display to the user | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | | **type** | `'conditional'` | ✅ | | | **when** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL). e.g. P`record.type == 'enterprise'` | | **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); | **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") | | **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | | | **message** | `string` | ✅ | Error message to display to the user | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | | **type** | `'cross_field'` | ✅ | | | **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` | | **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); | **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") | | **severity** | `Enum<'error' \| 'warning' \| 'info'>` | ✅ | | | **message** | `string` | ✅ | Error message to display to the user | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | | **type** | `'format'` | ✅ | | | **field** | `string` | ✅ | | | **regex** | `string` | optional | | @@ -214,6 +235,13 @@ const result = ConditionalValidation.parse(data); | **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") | | **severity** | `Enum<'error' \| 'warning' \| 'info'>` | ✅ | | | **message** | `string` | ✅ | Error message to display to the user | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | | **type** | `'json_schema'` | ✅ | | | **field** | `string` | ✅ | JSON field to validate | | **schema** | `Record` | ✅ | JSON Schema object definition | @@ -236,6 +264,13 @@ const result = ConditionalValidation.parse(data); | **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") | | **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | | | **message** | `string` | ✅ | Error message to display to the user | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | | **type** | `'script'` | ✅ | | | **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` | @@ -257,6 +292,13 @@ const result = ConditionalValidation.parse(data); | **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") | | **severity** | `Enum<'error' \| 'warning' \| 'info'>` | ✅ | | | **message** | `string` | ✅ | Error message to display to the user | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | | **type** | `'state_machine'` | ✅ | | | **field** | `string` | ✅ | State field (e.g. status) | | **transitions** | `Record` | ✅ | Map of `{ OldState: [AllowedNewStates] }` | @@ -288,6 +330,13 @@ This schema accepts one of the following structures: | **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") | | **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | | | **message** | `string` | ✅ | Error message to display to the user | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | | **type** | `'script'` | ✅ | | | **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` | @@ -310,6 +359,13 @@ This schema accepts one of the following structures: | **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") | | **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | | | **message** | `string` | ✅ | Error message to display to the user | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | | **type** | `'state_machine'` | ✅ | | | **field** | `string` | ✅ | State field (e.g. status) | | **transitions** | `Record` | ✅ | Map of `{ OldState: [AllowedNewStates] }` | @@ -334,6 +390,13 @@ This schema accepts one of the following structures: | **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") | | **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | | | **message** | `string` | ✅ | Error message to display to the user | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | | **type** | `'format'` | ✅ | | | **field** | `string` | ✅ | | | **regex** | `string` | optional | | @@ -358,6 +421,13 @@ This schema accepts one of the following structures: | **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") | | **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | | | **message** | `string` | ✅ | Error message to display to the user | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | | **type** | `'cross_field'` | ✅ | | | **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` | | **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: | **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") | | **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | | | **message** | `string` | ✅ | Error message to display to the user | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | | **type** | `'json_schema'` | ✅ | | | **field** | `string` | ✅ | JSON field to validate | | **schema** | `Record` | ✅ | JSON Schema object definition | @@ -404,6 +481,13 @@ This schema accepts one of the following structures: | **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") | | **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | | | **message** | `string` | ✅ | Error message to display to the user | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | | **type** | `'conditional'` | ✅ | | | **when** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL). e.g. P`record.type == 'enterprise'` | | **then** | `[ValidationRule](#validationrule)` | ✅ | Validation rule to apply when condition is true | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index c7db571910..0c15194b4b 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -243,7 +243,7 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts). | `notification.zod.ts` / `offline.zod.ts` / `report.zod.ts` | 3 ea | authorable (p) | | | `sharing.zod.ts` | 2 | authorable (p) | public-sharing config | -### `data/` — 160 sites +### `data/` — 165 sites | File | Sites | Class | Note | |---|---|---|---| @@ -262,7 +262,8 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts). | `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) | | +| `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 | +| `field-value.zod.ts` / `seed.zod.ts` | 1+1 | mixed (p) | `seed` is strict (registered-types batch) | ### `automation/` — 88 sites diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 73aeed3b87..5dc5b16391 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -3099,6 +3099,13 @@ "data/AutoPersistenceConfig:path", "data/AutoPersistenceConfig:type", "data/BaseEngineOptions:context", + "data/ConditionalValidation:_lock", + "data/ConditionalValidation:_lockDocsUrl", + "data/ConditionalValidation:_lockReason", + "data/ConditionalValidation:_lockSource", + "data/ConditionalValidation:_packageId", + "data/ConditionalValidation:_packageVersion", + "data/ConditionalValidation:_provenance", "data/ConditionalValidation:active", "data/ConditionalValidation:description", "data/ConditionalValidation:events", @@ -3112,6 +3119,13 @@ "data/ConditionalValidation:then", "data/ConditionalValidation:type", "data/ConditionalValidation:when", + "data/CrossFieldValidation:_lock", + "data/CrossFieldValidation:_lockDocsUrl", + "data/CrossFieldValidation:_lockReason", + "data/CrossFieldValidation:_lockSource", + "data/CrossFieldValidation:_packageId", + "data/CrossFieldValidation:_packageVersion", + "data/CrossFieldValidation:_provenance", "data/CrossFieldValidation:active", "data/CrossFieldValidation:condition", "data/CrossFieldValidation:description", @@ -3489,6 +3503,13 @@ "data/FileValue:name", "data/FileValue:size", "data/FileValue:url", + "data/FormatValidation:_lock", + "data/FormatValidation:_lockDocsUrl", + "data/FormatValidation:_lockReason", + "data/FormatValidation:_lockSource", + "data/FormatValidation:_packageId", + "data/FormatValidation:_packageVersion", + "data/FormatValidation:_provenance", "data/FormatValidation:active", "data/FormatValidation:description", "data/FormatValidation:events", @@ -3527,6 +3548,13 @@ "data/Index:partial", "data/Index:type", "data/Index:unique", + "data/JSONValidation:_lock", + "data/JSONValidation:_lockDocsUrl", + "data/JSONValidation:_lockReason", + "data/JSONValidation:_lockSource", + "data/JSONValidation:_packageId", + "data/JSONValidation:_packageVersion", + "data/JSONValidation:_provenance", "data/JSONValidation:active", "data/JSONValidation:description", "data/JSONValidation:events", @@ -3790,6 +3818,13 @@ "data/ScriptBody:memoryMb", "data/ScriptBody:source", "data/ScriptBody:timeoutMs", + "data/ScriptValidation:_lock", + "data/ScriptValidation:_lockDocsUrl", + "data/ScriptValidation:_lockReason", + "data/ScriptValidation:_lockSource", + "data/ScriptValidation:_packageId", + "data/ScriptValidation:_packageVersion", + "data/ScriptValidation:_provenance", "data/ScriptValidation:active", "data/ScriptValidation:condition", "data/ScriptValidation:description", @@ -3862,6 +3897,13 @@ "data/SqliteConfig:filename", "data/SqliteWasmConfig:filename", "data/SqliteWasmConfig:persist", + "data/StateMachineValidation:_lock", + "data/StateMachineValidation:_lockDocsUrl", + "data/StateMachineValidation:_lockReason", + "data/StateMachineValidation:_lockSource", + "data/StateMachineValidation:_packageId", + "data/StateMachineValidation:_packageVersion", + "data/StateMachineValidation:_provenance", "data/StateMachineValidation:active", "data/StateMachineValidation:description", "data/StateMachineValidation:events", diff --git a/packages/spec/scripts/check-strictness-ledger.mts b/packages/spec/scripts/check-strictness-ledger.mts index 0c256a7d41..532c2e8c93 100644 --- a/packages/spec/scripts/check-strictness-ledger.mts +++ b/packages/spec/scripts/check-strictness-ledger.mts @@ -34,7 +34,7 @@ // 3. Section totals. `### \`ui/\` — 192 sites` must equal the sum of its rows. // Cheap, and it catches a row edited without updating the header. // 4. Strictness claims. A row whose note says "strict as of" must name a file that -// actually contains `.strict()`. This is deliberately weak — it proves the claim +// actually contains `.strict()` or `strictObject(`. This is deliberately weak — it proves the claim // is not fiction, not that every site in the file is strict. Rows say things like // "partially strict"; encoding which sites those are would need a second ledger, // and the per-schema truth already lives in the code the note points at. @@ -154,7 +154,13 @@ for (const row of rows) { if (/strict as of/i.test(row.note)) { const anyStrict = row.files.some((f) => { const abs = path.join(SRC, row.dir, f); - return fs.existsSync(abs) && /\.strict\(\)/.test(fs.readFileSync(abs, 'utf-8')); + if (!fs.existsSync(abs)) return false; + // `strictObject(` counts as strictness, same as a literal `.strict()` — + // the helper applies it. Matching only `.strict()` made a converted file + // read as NOT strict, so this check called a true claim a lie. Same blind + // spot as the site count had: the measuring tool has to learn the idiom + // whenever the idiom changes. + return /\.strict\(\)|(? BaseValidationSchema.extend({ +export const ScriptValidationSchema = lazySchema(() => strictObject({ + surface: 'this script validation rule', + history: + 'Until #4001 closed this shape these were dropped silently — the rule still registered and ran, minus whatever the key was meant to configure.', + aliases: { formula: 'condition', expression: 'condition', predicate: 'condition', rule: 'condition' }, +}, { + ...BASE_VALIDATION_SHAPE, type: z.literal('script'), condition: ExpressionInputSchema.describe('Predicate (CEL). If TRUE, validation fails. e.g. P`record.amount < 0`'), })); @@ -123,7 +155,13 @@ export const ScriptValidationSchema = lazySchema(() => BaseValidationSchema.exte * 2. State Machine Validation * State transition logic. */ -export const StateMachineValidationSchema = lazySchema(() => BaseValidationSchema.extend({ +export const StateMachineValidationSchema = lazySchema(() => strictObject({ + surface: 'this state-machine validation rule', + history: + 'Until #4001 closed this shape these were dropped silently — the rule still registered and ran, minus whatever the key was meant to configure.', + aliases: { states: 'transitions', statefield: 'field', from: 'transitions', initial: 'initialStates', initialstate: 'initialStates' }, +}, { + ...BASE_VALIDATION_SHAPE, type: z.literal('state_machine'), field: z.string().describe('State field (e.g. status)'), transitions: z.record(z.string(), z.array(z.string())).describe('Map of { OldState: [AllowedNewStates] }'), @@ -134,7 +172,13 @@ export const StateMachineValidationSchema = lazySchema(() => BaseValidationSchem * 3. Value Format Validation * Regex or specialized formats. */ -export const FormatValidationSchema = lazySchema(() => BaseValidationSchema.extend({ +export const FormatValidationSchema = lazySchema(() => strictObject({ + surface: 'this format validation rule', + history: + 'Until #4001 closed this shape these were dropped silently — the rule still registered and ran, minus whatever the key was meant to configure.', + aliases: { pattern: 'regex', fieldname: 'field' }, +}, { + ...BASE_VALIDATION_SHAPE, type: z.literal('format'), field: z.string(), regex: z.string().optional(), @@ -210,7 +254,13 @@ export const FormatValidationSchema = lazySchema(() => BaseValidationSchema.exte // `fields[1..]` are documentation only. Prefer `script` unless you want the error // targeted at a specific field. Kept as a distinct variant for that field-targeting // affordance and for backward compatibility. -export const CrossFieldValidationSchema = lazySchema(() => BaseValidationSchema.extend({ +export const CrossFieldValidationSchema = lazySchema(() => strictObject({ + surface: 'this cross-field validation rule', + history: + 'Until #4001 closed this shape these were dropped silently — the rule still registered and ran, minus whatever the key was meant to configure.', + aliases: { formula: 'condition', expression: 'condition' }, +}, { + ...BASE_VALIDATION_SHAPE, type: z.literal('cross_field'), condition: ExpressionInputSchema.describe('Predicate (CEL) comparing fields. e.g. P`record.end_date > record.start_date`'), fields: z.array(z.string()).describe('Fields involved. Only fields[0] is read (labels which field the violation attaches to); the rest are advisory. Shares script’s evaluation path.'), @@ -225,7 +275,13 @@ export const CrossFieldValidationSchema = lazySchema(() => BaseValidationSchema. * - Enforcing API payload structures * - Complex nested data validation */ -export const JSONValidationSchema = lazySchema(() => BaseValidationSchema.extend({ +export const JSONValidationSchema = lazySchema(() => strictObject({ + surface: 'this JSON-schema validation rule', + history: + 'Until #4001 closed this shape these were dropped silently — the rule still registered and ran, minus whatever the key was meant to configure.', + aliases: { jsonschema: 'schema', fieldname: 'field' }, +}, { + ...BASE_VALIDATION_SHAPE, type: z.literal('json_schema'), field: z.string().describe('JSON field to validate'), schema: z.record(z.string(), z.unknown()).describe('JSON Schema object definition'), @@ -450,7 +506,13 @@ export const ValidationRuleSchema: z.ZodType BaseValidationSchema.extend({ +export const ConditionalValidationSchema = lazySchema(() => strictObject({ + surface: 'this conditional validation rule', + history: + 'Until #4001 closed this shape these were dropped silently — the rule still registered and ran, minus whatever the key was meant to configure.', + aliases: { if: 'when', condition: 'when', match: 'when', else: 'otherwise' }, +}, { + ...BASE_VALIDATION_SHAPE, type: z.literal('conditional'), when: ExpressionInputSchema.describe('Predicate (CEL). e.g. P`record.type == \'enterprise\'`'), then: ValidationRuleSchema.describe('Validation rule to apply when condition is true'), diff --git a/packages/spec/src/kernel/metadata-type-schemas.test.ts b/packages/spec/src/kernel/metadata-type-schemas.test.ts index dde8787eac..c0c8ae14b0 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.test.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.test.ts @@ -71,10 +71,11 @@ const PROBE: Record = { * it. A NEW registered type belongs in neither list. * * The structural walk found 8 of these; the probe it replaced had been hiding 7. - * `job` and `book` were closed in the same pass, leaving 6. + * `job` and `book` were closed in the same pass, leaving 6; `validation` came off + * when its six union variants were converted, leaving 5. */ const UNDECLARED_ENVELOPE = new Set([ - 'action', 'field', 'mapping', 'page', 'translation', 'validation', + 'action', 'field', 'mapping', 'page', 'translation', ]); /**