diff --git a/.changeset/strict-object-registered-types.md b/.changeset/strict-object-registered-types.md new file mode 100644 index 0000000000..cc6afc9e84 --- /dev/null +++ b/.changeset/strict-object-registered-types.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': minor +--- + +`strictObject` makes closing an authoring shape one call; `seed` and `doc` are the first two registered metadata types converted with it; and a new invariant test found two live protection-envelope bugs on `hook` and `datasource`. + +**The helper.** The #4001 wiring was four parts per schema plus a drift test: a hand-transcribed `const X_KEYS = [...]` array, a `strictUnknownKeyError({ knownKeys: X_KEYS, … })` call, the `{ error }` argument, `.strict()`, and an "accepts every declared key" probe to catch the array drifting from the shape it describes. That was 34 key arrays and 16 probe files with most of the authorable surface still ahead — and the array was never necessary: `knownKeys` feeds only the edit-distance suggestion, and the shape object is right there at the call site. `strictObject({ surface, history, aliases?, guidance? }, shape)` derives it, which also removes the per-schema drift probe: a key list read from the shape cannot disagree with it. `aliases` and `guidance` stay hand-written and stay **optional** — they carry judgement rather than transcription, and treating curation as a precondition is part of why the ratchet moved slowly. + +**A sharper targeting rule.** The five-directory triage answers "is this authorable?" but not "is this parsed?" — and after #4410 that second question decides whether a flip enforces anything. `BUILTIN_METADATA_TYPE_SCHEMAS` answers both: every entry is author-written and parsed on three paths (`defineStack()`, `/api/v1/meta/types/:type`, the Studio form). Ten had no strictness at all; `seed` and `doc` are the first two converted. Five of the ten live in `system/`, which the directory triage never covered — the two lenses miss different things. + +**Two live bugs, found by a check rather than by reading.** `MetadataPlugin`'s artifact loader stamps `_packageId` / `_provenance` on every registered type, so a strict schema that does not declare `MetadataProtectionFields` rejects its own loader's output — a hard 422 on the ADR-0094 overlay path. That defect had been found three times by hand (`permission`, `position`, then `seed`/`doc`). A new invariant test over the registered-type registry found it twice more on its first run: **`hook` and `datasource` had both gone strict in the #4001 data step without the envelope.** Both now declare it. The test asserts the hard case (rejects) unconditionally with no exemption list, and tracks the quieter case (silently strips, currently only `field`) separately. + +**Ledger.** `strictObject` replaces the old wiring recipe as the standard, and the gate's site-counting method now counts `strictObject(` alongside `z.object(` — counting only the latter would have made every conversion look like surface disappearing, so "solved" and "deleted" would read the same. The gate caught that itself on the first conversion. + +Authoring impact: on `seed` and `doc`, a key the schema never declared is now rejected instead of silently discarded — it was already being ignored, so no working behavior changes. The rejection names the surface, echoes the key and suggests the closest declared one (`rows` → `records`, `body` → `content`), with tombstones for `path` / `slug` on `doc`. The published JSON Schema is unchanged: `build-schemas.ts` converts with `io: 'output'`, which already emitted `additionalProperties: false` for these shapes. `validation` is the remaining registered type with a known envelope gap; it is a `z.lazy()` discriminated union whose variants `.extend()` a shared base, so it needs per-variant conversion rather than one call. diff --git a/content/docs/references/data/datasource.mdx b/content/docs/references/data/datasource.mdx index a1e82c7093..f698fdd6e2 100644 --- a/content/docs/references/data/datasource.mdx +++ b/content/docs/references/data/datasource.mdx @@ -46,6 +46,13 @@ const result = Datasource.parse(data); | **schemaMode** | `Enum<'managed' \| 'external' \| 'validate-only'>` | ✅ | Schema ownership mode | | **external** | `{ label?: string; allowedSchemas?: string[]; allowWrites: boolean; validation: object; … }` | optional | External datasource federation settings (schemaMode != "managed") | | **origin** | `Enum<'code' \| 'runtime'>` | ✅ | Datasource provenance (server-managed, read-only) | +| **_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. | --- diff --git a/content/docs/references/data/seed.mdx b/content/docs/references/data/seed.mdx index 805518cbf7..d8ef46d3e8 100644 --- a/content/docs/references/data/seed.mdx +++ b/content/docs/references/data/seed.mdx @@ -36,6 +36,13 @@ const result = Seed.parse(data); | **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | ✅ | Conflict resolution strategy | | **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | ✅ | Applicable environments | | **records** | `Record[]` | ✅ | Data records | +| **_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. | --- diff --git a/content/docs/references/system/doc.mdx b/content/docs/references/system/doc.mdx index d7800d34dc..64097347a2 100644 --- a/content/docs/references/system/doc.mdx +++ b/content/docs/references/system/doc.mdx @@ -68,6 +68,13 @@ const result = Doc.parse(data); | **order** | `number` | optional | Sort key within a book group (ADR-0046 §6) | | **group** | `string` | optional | Explicit book-group key (ADR-0046 §6); rules usually suffice | | **translations** | `Record` | optional | Per-locale `{label?,description?,content}` variants; the base doc is the fallback | +| **_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. | --- diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index d8104abe38..5161d19931 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -27,11 +27,11 @@ must explicitly `.strip()` back, because `.extend()` inherits `.strict()`. ## Standard wiring -`strictUnknownKeyError` in `shared/suggestions.zod.ts` (generalized from the -#3746 hand-rolled map) is the one factory every strict authoring schema wires: +`strictObject` in `shared/strict-object.ts` is the one call a strict authoring +schema needs: ```ts -z.object({ ... }, { error: strictUnknownKeyError({ surface, knownKeys, aliases, guidance, history }) }).strict() +lazySchema(() => strictObject({ surface, history, aliases?, guidance? }, { ...shape })) ``` - `aliases` — semantic near-misses edit distance cannot reach (`visibleWhen` → @@ -40,8 +40,24 @@ z.object({ ... }, { error: strictUnknownKeyError({ surface, knownKeys, aliases, rejection carries the upgrade — AGENTS.md Post-Task Checklist #3) and wrong-layer pointers (`apiOperations` is response-side; `objectName` belongs on the start node). -- Key lists live beside the schema and are **drift-guarded by tests** (an - "accepts every declared key" probe), because the schema body is lazy. +- **Both are optional.** A schema with neither still names the surface, echoes + the offending key, and suggests the closest declared one. Curation is an + upgrade, not a precondition — and treating it as a precondition is part of + why this ratchet moved as slowly as it did. + +**No key list, and no drift probe.** Earlier steps hand-transcribed a +`const X_KEYS = [...] as const` beside each schema and pinned it with an +"accepts every declared key" test, because the schema body is lazy. That was 34 +key arrays and 16 probe files with most of the surface still ahead — and it was +never necessary: `knownKeys` feeds only the edit-distance fallback, and the +shape object is right there at the call site. `strictObject` reads the keys from +`shape`, so the two copies become one and the probe has nothing left to catch. +`extraKeys` covers the one case the shape cannot see: a base `.extend()`ed +elsewhere. + +`strictUnknownKeyError` stays exported for the schemas that cannot use the +helper — notably `z.lazy()` discriminated unions, whose variants each need +their own key set. Every ratchet step ships only with the empirical zero-breakage pass: full `@objectstack/spec` suite + `tsc`, downstream consumer suites, and @@ -128,6 +144,34 @@ dropped at parse, and nothing failed. `password` / `authSource` / `options` were **wired**, having been declared and dropped on the floor. Enforcing a contract and honouring it are the same task from two directions. +8. **Three more registered types could not represent their own ADR-0010 + protection envelope** — `seed`, `doc` and `validation`, found by applying the + registered-type lens above. Exactly the gap that made `permission` return a + hard 422 on the ADR-0094 overlay path (entry 2) and that `position` carried + until step 2: `MetadataPlugin`'s artifact loader stamps `_packageId` / + `_provenance` on **every** registered type, and `getMetaItemLayered` → + `saveMetaItem` round-trips a body carrying them, so an undeclared envelope was + stripped on every parse. Declared on `seed` and `doc` as part of closing them; + `validation` still carries it (its union shape defers the conversion). + + Worth noting how it kept recurring: this was the **fourth** occurrence of one + defect, found four times by four different routes, because nothing checked + the invariant directly. So it is checked now — + `kernel/metadata-type-schemas.test.ts` asserts it over the whole registry. + + **It found two more on its first run.** `hook` and `datasource` had both gone + `.strict()` in the #4001 data step *without* declaring the envelope, so both + were in the worst class — rejecting their own loader's output, a live hard 422 + on the ADR-0094 overlay path, sitting on `main`. Three prior hand-searches for + exactly this defect had walked past them. That is the argument for writing the + check in one line: **finding the same defect repeatedly by hand is evidence + the check is missing, not evidence the search worked.** + + The check separates the two severities, because they are not the same bug: + *rejecting* the envelope is live breakage and is asserted unconditionally with + no exemption list; *stripping* it silently loses protection metadata on + round-trip and is tracked with a debt list (`field` only) — and each entry + there becomes a rejection the day its schema is closed. This is the empirical argument for the ratchet: the inference "no metadata in the repo carries unknown keys" was **false three times over**, and only the @@ -151,7 +195,7 @@ block) when `position` joined the ratchet. ## File-level triage — the five authorable directories -Site counts are `z.object(` occurrences per file (2026-07-30, this branch). +Site counts are object sites — `z.object(` or `strictObject(` — per file (2026-07-30, this branch). Classification is per the rule above; **(p)** marks a provisional call made from the file's exports/JSDoc rather than a full read — verify before tightening (the #4001 "sharing-rule lesson": candidates, not verdicts). @@ -277,6 +321,20 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts). downstream risk is the lowest on the board); it is simply unstarted. If the step-1 question comes back "nothing is reporting", start here instead. +Done in the registered-types batch: `strictObject` (`shared/strict-object.ts`) +replaced the four-part wiring recipe, and `seed` + `doc` became the first two +conversions built on it — chosen by the registered-type lens above rather than +by directory, so both are provably parsed as well as provably authored. Both +also had to declare the ADR-0010 envelope, and the invariant test written in the +same pass found `hook` and `datasource` rejecting it outright on `main` +(findings log, entry 8). The ledger's site-counting method grew `strictObject(` +in the same change, because the gate failed on the first conversion when it did +not. + +Deferred from that batch: `validation` — a `z.lazy()` discriminated union whose +variants `.extend()` a shared base, so each variant needs its own key set rather +than one `strictObject` call. It still carries the envelope gap. + Done in step 2: `security/rls.zod.ts` + `security/sharing.zod.ts` strict; `PositionSchema` strict with the protection envelope declared (closing the known sibling gap below). @@ -355,10 +413,15 @@ the app step's `ACCOUNT_APP.defaultOpen` came from exactly this class of check. Liveness Check workflow) holds the two claims here that are mechanically checkable, so this map cannot go stale in silence again: -- **Site counts.** The method is stated above — `z.object(` occurrences per file - — so every number in the triage tables is verifiable. A count that no longer - matches means schemas were added or removed under a `Class` verdict nobody - re-examined. Touching a file forces you back through this ledger. +- **Site counts.** The method is stated above — `z.object(` or `strictObject(` + occurrences per file — so every number in the triage tables is verifiable. A + count that no longer matches means schemas were added or removed under a + `Class` verdict nobody re-examined. Touching a file forces you back through + this ledger. `strictObject(` had to join the count the moment the helper + existed: counting only `z.object(` would have made every conversion look like + surface *disappearing*, so "this directory got solved" and "this directory got + deleted" would produce the same number. The gate caught that itself on the + first conversion. - **Coverage.** Every `*.zod.ts` in a triaged directory that HAS sites must have a row. A new one is undeclared surface. The walk is **recursive**; nested files are declared by their path relative to the section directory diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 9ce4ea628e..63762da4e2 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -1,5 +1,5 @@ { - "description": "Ratchet of every AUTHORABLE key in the spec \u2014 what a metadata author may write, which for this platform IS the third-party API. Auto-updated on additions (commit the change). A key that disappears without a tombstone fails gen:schema, because these schemas are not .strict() and Zod would silently strip it. \"[RETIRED]\" marks a tombstoned key that still rejects with an upgrade prescription. See #3855, ADR-0059 \u00a75.", + "description": "Ratchet of every AUTHORABLE key in the spec — what a metadata author may write, which for this platform IS the third-party API. Auto-updated on additions (commit the change). A key that disappears without a tombstone fails gen:schema, because these schemas are not .strict() and Zod would silently strip it. \"[RETIRED]\" marks a tombstoned key that still rejects with an upgrade prescription. See #3855, ADR-0059 §5.", "keys": [ "ai/AIModelConfig:maxTokens", "ai/AIModelConfig:model", @@ -3211,6 +3211,13 @@ "data/DataTypeMapping:number", "data/DataTypeMapping:text", "data/DataTypeMapping:uuid", + "data/Datasource:_lock", + "data/Datasource:_lockDocsUrl", + "data/Datasource:_lockReason", + "data/Datasource:_lockSource", + "data/Datasource:_packageId", + "data/Datasource:_packageVersion", + "data/Datasource:_provenance", "data/Datasource:active", "data/Datasource:autoConnect", "data/Datasource:capabilities", @@ -3794,6 +3801,13 @@ "data/ScriptValidation:severity", "data/ScriptValidation:tags", "data/ScriptValidation:type", + "data/Seed:_lock", + "data/Seed:_lockDocsUrl", + "data/Seed:_lockReason", + "data/Seed:_lockSource", + "data/Seed:_packageId", + "data/Seed:_packageVersion", + "data/Seed:_provenance", "data/Seed:env", "data/Seed:externalId", "data/Seed:mode", @@ -5891,6 +5905,13 @@ "system/DistributedCacheConfig:prefetch", "system/DistributedCacheConfig:tiers", "system/DistributedCacheConfig:warmup", + "system/Doc:_lock", + "system/Doc:_lockDocsUrl", + "system/Doc:_lockReason", + "system/Doc:_lockSource", + "system/Doc:_packageId", + "system/Doc:_packageVersion", + "system/Doc:_provenance", "system/Doc:content", "system/Doc:description", "system/Doc:group", diff --git a/packages/spec/scripts/lib/strictness-ledger.ts b/packages/spec/scripts/lib/strictness-ledger.ts index 927d25a76b..ef94bb29e3 100644 --- a/packages/spec/scripts/lib/strictness-ledger.ts +++ b/packages/spec/scripts/lib/strictness-ledger.ts @@ -10,9 +10,23 @@ import fs from 'node:fs'; import path from 'node:path'; -/** `z.object(` occurrences — the ledger's own stated counting method. */ +/** + * Object sites — `z.object(` **or** `strictObject(` — the ledger's own stated + * counting method. + * + * `strictObject(` counts because it *is* an object site; it is what a converted + * schema looks like. Counting only `z.object(` would have made every conversion + * silently shrink the ledger's measured surface, so a directory being solved and + * a directory being deleted would read identically — and a genuinely new, + * un-triaged `strictObject` schema would never register as undeclared surface. + * + * The gate caught this itself on the first conversion (`data/seed.zod.ts`, 1 → 0), + * which is the behaviour to preserve: a change in how schemas are written must + * fail this check rather than quietly rebase what it measures. + */ export function countSites(file: string): number { - return (fs.readFileSync(file, 'utf-8').match(/z\.object\(/g) ?? []).length; + const src = fs.readFileSync(file, 'utf-8'); + return (src.match(/z\.object\(|(? z.object({ */ origin: z.enum(['code', 'runtime']).default('code') .describe('Datasource provenance (server-managed, read-only)'), + + // ADR-0010 — runtime protection envelope (internal — set by the loader). + // MISSING until the registered-type invariant test was written: `datasource` + // closed strict in the #4001 data step without declaring it, so the + // `_packageId` / `_provenance` that `MetadataPlugin` stamps on every + // registered type were REJECTED here. Same live defect as `hook`, and the + // same one `permission` hit as a 422 on the ADR-0094 overlay path before + // Tier-A declared them (#4001 findings log, entries 2/8). + ...MetadataProtectionFields, }, { error: datasourceUnknownKeyError }).strict().superRefine((ds, ctx) => { // The `config` gate (#4410). `config` is parsed against the contract for the // declared driver and every issue is re-pathed under the slot it came from — diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index d1a5438884..eba3cae85a 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -9,6 +9,7 @@ import { ExpressionInputSchema } from '../shared/expression.zod'; */ import { lazySchema } from '../shared/lazy-schema'; import { strictUnknownKeyError } from '../shared/suggestions.zod'; +import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { HookBodySchema } from './hook-body.zod'; /* @@ -276,6 +277,14 @@ export const HookSchema = lazySchema(() => z.object({ * - log: Log error and continue */ onError: z.enum(['abort', 'log']).default('abort').describe('Error handling strategy'), + + // ADR-0010 — runtime protection envelope (internal — set by the loader). + // MISSING until the registered-type invariant test was written: `hook` closed + // strict in the #4001 data step without declaring it, so the `_packageId` / + // `_provenance` that `MetadataPlugin` stamps on every registered type were + // REJECTED here — the same live 422 that `permission` hit on the ADR-0094 + // overlay path before Tier-A declared them (#4001 findings log, entries 2/8). + ...MetadataProtectionFields, }, { error: hookUnknownKeyError }).strict()); /** diff --git a/packages/spec/src/data/seed.zod.ts b/packages/spec/src/data/seed.zod.ts index 79c9519544..60abb9eb62 100644 --- a/packages/spec/src/data/seed.zod.ts +++ b/packages/spec/src/data/seed.zod.ts @@ -7,6 +7,8 @@ import { z } from 'zod'; * Defines how the engine handles existing records when a seed is applied. */ import { lazySchema } from '../shared/lazy-schema'; +import { strictObject } from '../shared/strict-object'; +import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; export const SeedMode = z.enum([ 'insert', // Try to insert, fail on duplicate 'update', // Only update found records, ignore new @@ -28,7 +30,26 @@ export const SeedMode = z.enum([ * its rows load when the draft is published. Named `Seed` (not `Dataset`) so * the `dataset` name stays reserved for the ADR-0021 analytics semantic layer. */ -export const SeedSchema = lazySchema(() => z.object({ +export const SeedSchema = lazySchema(() => strictObject({ + surface: 'this seed', + history: + 'Until #4001 these were dropped silently — the seed still applied, on the defaults ' + + '(`mode: upsert`, `externalId: name`) rather than what was written.', + aliases: { + objectname: 'object', + target: 'object', + data: 'records', + rows: 'records', + values: 'records', + key: 'externalId', + externalkey: 'externalId', + naturalkey: 'externalId', + strategy: 'mode', + conflict: 'mode', + environment: 'env', + environments: 'env', + }, +}, { /** * Target Object * The machine name of the object to populate. @@ -73,6 +94,16 @@ export const SeedSchema = lazySchema(() => z.object({ * Array of raw JSON objects matching the Object Schema. */ records: z.array(z.record(z.string(), z.unknown())).describe('Data records'), + + // ADR-0010 — runtime protection envelope (internal — set by the loader). + // `seed` is a registered metadata type, so `MetadataPlugin`'s artifact loader + // stamps `_packageId` / `_provenance` on it like every sibling, and + // `getMetaItemLayered` → `saveMetaItem` round-trips a body carrying them. + // Undeclared, those were stripped on every parse — the same inverse drift + // that made `permission` 422 on the ADR-0094 overlay path when it went strict + // (#4001 findings log, entry 2). Declared here so closing the shape cannot + // repeat it. + ...MetadataProtectionFields, })); /** Parsed/output type — all defaults are applied (env, mode, externalId always present) */ diff --git a/packages/spec/src/kernel/metadata-authoring-lint.test.ts b/packages/spec/src/kernel/metadata-authoring-lint.test.ts index de53150651..9a5981b686 100644 --- a/packages/spec/src/kernel/metadata-authoring-lint.test.ts +++ b/packages/spec/src/kernel/metadata-authoring-lint.test.ts @@ -31,7 +31,13 @@ describe('coverage derivation (#3786 — no third hand-written list)', () => { // #4148 covered object+field: 2 surfaces. The point of this walker is the // rest. If the derivation regresses to a handful, the "evidence base" for // the #4001 strict tiers quietly becomes a sample again. - expect(lintables.length).toBeGreaterThanOrEqual(14); + // + // This floor RATCHETS DOWN as #4001 advances — every graduation moves a type + // from "lint warns" to "parse rejects", which is the campaign succeeding, not + // coverage rotting. Lower it only after confirming the shrink against the + // list below; that confirmation is the whole point of pinning a number here. + // 15 → 13 when `seed` + `doc` graduated (#4001 registered-types batch). + expect(lintables.length).toBeGreaterThanOrEqual(13); // `view` matters doubly: it is a UNION (container | ViewItem | overlay), so // its presence pins the union half of the posture logic — a regression that // silently dropped unions would shrink coverage without failing the count. @@ -50,7 +56,12 @@ describe('coverage derivation (#3786 — no third hand-written list)', () => { // count fell 16 → 14 and the pin above failed until both were confirmed // graduations and moved into this list. `hook` also had to leave the // pinned-coverage list above, where it had been an expected lint target. - for (const strict of ['flow', 'permission', 'position', 'tool', 'app', 'hook', 'datasource']) { + // It did it a third time for `seed` + `doc`, the first two conversions built + // on `strictObject` — which also proved the posture derivation reads a + // helper-built `.strict()` exactly like a hand-wired one. + for (const strict of [ + 'flow', 'permission', 'position', 'tool', 'app', 'hook', 'datasource', 'seed', 'doc', + ]) { expect(lintableTypes, `'${strict}' is .strict(); the lint must not double-report`).not.toContain(strict); } }); diff --git a/packages/spec/src/kernel/metadata-type-schemas.test.ts b/packages/spec/src/kernel/metadata-type-schemas.test.ts new file mode 100644 index 0000000000..8ec6b5e5f5 --- /dev/null +++ b/packages/spec/src/kernel/metadata-type-schemas.test.ts @@ -0,0 +1,122 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Invariants that must hold for EVERY registered metadata type. + * + * ## The protection-envelope invariant + * + * `MetadataPlugin`'s artifact loader calls `applyProtection` on every registered + * type, and `getMetaItemLayered` → `saveMetaItem` round-trips a body carrying + * the stamped `_packageId` / `_provenance`. A type whose schema does not declare + * {@link MetadataProtectionFields} therefore mishandles it in one of two ways, + * and the severities are different enough to assert separately: + * + * - **Rejects it** (the schema is `.strict()`): a hard 422 on the overlay path. + * Live breakage. Asserted unconditionally below — no debt list. + * - **Strips it** (the schema is strip-mode): the envelope is silently dropped + * on every parse, so protection metadata is lost on round-trip. Quieter, and + * it becomes the first case the day that schema is closed. + * + * ## Why this file exists at all + * + * The same defect was found four separate times, by four different routes, + * before anyone wrote a check for it: + * + * 1. `permission` (#4001 Tier-A) — surfaced as a hard 422 on the ADR-0094 + * overlay path, caught by the dogfood gate. + * 2. `position` (#4001 step 2) — found by reading, as the "known sibling gap". + * 3. `seed` + `doc` (#4001 registered-types batch) — found while converting. + * 4. `hook` + `datasource` — found by THIS test, on its first run. Both had + * gone `.strict()` in the #4001 data step without declaring the envelope, + * so both were in the hard-422 class on `main` at the time. + * + * Finding one defect four times by hand is the signal that the check is + * missing, not that the search worked. Case 4 is the argument in miniature: two + * live bugs that three prior hand-searches had walked past. + */ + +import { describe, expect, it } from 'vitest'; + +import { listMetadataTypeSchemaTypes, getMetadataTypeSchema } from './metadata-type-schemas'; + +/** The ADR-0010 stamp the loader puts on every registered item. */ +const STAMP = { _packageId: 'pkg_probe', _provenance: 'package' as const }; + +/** + * A body that satisfies the required fields of the registered types generously + * enough to reach the unknown-key check. Types it does not fully satisfy fail + * on other grounds, which the assertions below distinguish and ignore — this + * test is only about how the `_`-prefixed envelope is treated. + */ +const PROBE: Record = { + name: 'probe_item', + label: 'Probe', + object: 'probe_object', + records: [], + content: '# probe', + type: 'text', + ...STAMP, +}; + +/** `_`-prefixed keys the schema reported as unrecognized, if any. */ +function rejectedEnvelopeKeys(type: string): string[] { + const result = getMetadataTypeSchema(type)!.safeParse(PROBE); + if (result.success) return []; + return result.error.issues + .filter((i) => i.code === 'unrecognized_keys') + .flatMap((i) => (i as unknown as { keys?: string[] }).keys ?? []) + .filter((k) => k.startsWith('_')); +} + +/** + * Types that parse the envelope but drop it. Every entry is a bug awaiting a + * `...MetadataProtectionFields` spread, not a permanent exemption — and each + * becomes a hard 422 the day its schema is closed. Empty this list; never grow + * it. A new registered type belongs in neither list. + */ +const STRIPS_ENVELOPE = new Set([ + // Registered so a single field can be addressed as a metadata item, but + // authored inside `object.fields`, where the object's own envelope covers the + // package. Closing this one means auditing that nesting, so it is tracked + // rather than bundled into the batch that found it. + 'field', +]); + +describe('registered metadata types', () => { + const types = listMetadataTypeSchemaTypes(); + + it('is a non-empty set — guards the derivation returning nothing', () => { + expect(types.length).toBeGreaterThan(15); + }); + + it.each(types)('%s does not REJECT the protection envelope its loader stamps', (type) => { + expect( + rejectedEnvelopeKeys(type), + `'${type}' is strict and does not declare the ADR-0010 envelope, so ` + + '`applyProtection` output fails to parse — a hard 422 on the overlay path. ' + + 'Add `...MetadataProtectionFields` to its schema.', + ).toEqual([]); + }); + + it.each(types.filter((t) => !STRIPS_ENVELOPE.has(t)))( + '%s does not STRIP the protection envelope', + (type) => { + const result = getMetadataTypeSchema(type)!.safeParse(PROBE); + // A probe that fails for unrelated reasons (a required field this generic + // body does not supply) tells us nothing about stripping — and the reject + // case is already covered unconditionally above. + if (!result.success) return; + expect( + (result.data as Record)._packageId, + `'${type}' silently drops \`_packageId\` — protection metadata is lost on ` + + 'every round-trip. Add `...MetadataProtectionFields` to its schema.', + ).toBe(STAMP._packageId); + }, + ); + + it('every registered type resolves to a schema', () => { + for (const type of types) { + expect(getMetadataTypeSchema(type), `no schema registered for '${type}'`).toBeDefined(); + } + }); +}); diff --git a/packages/spec/src/shared/strict-object.test.ts b/packages/spec/src/shared/strict-object.test.ts new file mode 100644 index 0000000000..a7ab7922bb --- /dev/null +++ b/packages/spec/src/shared/strict-object.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { describe, expect, it } from 'vitest'; + +import { lazySchema } from './lazy-schema'; +import { strictObject } from './strict-object'; + +const WidgetSchema = lazySchema(() => + strictObject( + { + surface: 'this widget', + history: 'Until #4001 these were dropped silently — the widget still rendered.', + aliases: { visibleWhen: 'visible' }, + guidance: { span: '`span` was retired in vX. Use `columnSpan`.' }, + }, + { + name: z.string(), + visible: z.boolean().optional(), + columnSpan: z.number().optional(), + }, + ), +); + +/** Composition fixtures — the shapes real schema files actually build. */ +const Described = lazySchema(() => + strictObject({ surface: 'a described surface', history: 'h' }, { a: z.string() }).describe('d'), +); +const Refined = lazySchema(() => + strictObject({ surface: 'a refined surface', history: 'h' }, { + a: z.string(), + b: z.string().optional(), + }).superRefine((v, ctx) => { + if (!v.b) ctx.addIssue({ code: 'custom', path: ['b'], message: 'need b' }); + }), +); +const Extended = lazySchema(() => + strictObject({ surface: 'a base surface', history: 'h' }, { a: z.string() }) + .extend({ c: z.string().optional() }), +); + +describe('strictObject', () => { + it('rejects an unknown key, naming the surface and echoing the key', () => { + const r = WidgetSchema.safeParse({ name: 'x', nonsense: 1 }); + expect(r.success).toBe(false); + expect(r.error!.issues[0].message).toContain('this widget'); + expect(r.error!.issues[0].message).toContain('`nonsense`'); + }); + + it('suggests the closest key from the SHAPE — no transcribed key list', () => { + const r = WidgetSchema.safeParse({ name: 'x', colummSpan: 2 }); + expect(r.error!.issues[0].message).toContain('`colummSpan` → `columnSpan`'); + }); + + it('still honours a curated alias', () => { + const r = WidgetSchema.safeParse({ name: 'x', visibleWhen: true }); + expect(r.error!.issues[0].message).toContain('`visibleWhen` → `visible`'); + }); + + it('still honours a tombstone, and suppresses the rename for it', () => { + const r = WidgetSchema.safeParse({ name: 'x', span: 2 }); + const msg = r.error!.issues[0].message; + expect(msg).toContain('`span` was retired'); + expect(msg).not.toContain('→'); + }); + + /** + * The structural replacement for the "accepts every declared key" probe each + * hand-transcribed key array needed. Asserted once, here, instead of per + * schema: a key list read from the shape cannot disagree with it. + */ + it('accepts every key the shape declares', () => { + expect(WidgetSchema.safeParse({ name: 'x', visible: true, columnSpan: 1 }).success).toBe(true); + }); + + it('preserves type inference', () => { + const v: z.infer = { name: 'x' }; + expect(v.name).toBe('x'); + }); + + it('takes extraKeys as additional suggestion candidates', () => { + const Base = lazySchema(() => + strictObject({ surface: 'a base', history: 'h', extraKeys: ['inherited'] }, { a: z.string() }), + ); + // `inherited` is not declared here, so it is still rejected … + expect(Base.safeParse({ a: '1', inherited: 2 }).success).toBe(false); + // … but a near-miss of it resolves, which is what extraKeys is for. + expect(Base.safeParse({ a: '1', inherted: 2 }).error!.issues[0].message) + .toContain('`inherted` → `inherited`'); + }); + + describe('composition — the shapes real schema files use', () => { + it('survives .describe()', () => { + expect(Described.safeParse({ a: '1' }).success).toBe(true); + expect(Described.safeParse({ a: '1', z: 2 }).success).toBe(false); + }); + + it('survives .superRefine() — both the refinement and strictness fire', () => { + expect(Refined.safeParse({ a: '1' }).success).toBe(false); + expect(Refined.safeParse({ a: '1', b: '2' }).success).toBe(true); + expect(Refined.safeParse({ a: '1', b: '2', zz: 1 }).success).toBe(false); + }); + + /** + * `.extend()` INHERITS strictness. The ledger flags this as the trap to + * watch when batching: a response-side extension of an authoring schema + * must `.strip()` back, or a wire shape silently goes strict and an + * upstream field addition becomes a parse crash. + */ + it('propagates strictness through .extend()', () => { + expect(Extended.safeParse({ a: '1', c: '2' }).success).toBe(true); + expect(Extended.safeParse({ a: '1', nope: 1 }).success).toBe(false); + }); + + it('can be strip()ped back for a response-side extension', () => { + const Wire = lazySchema(() => + strictObject({ surface: 's', history: 'h' }, { a: z.string() }) + .extend({ serverOnly: z.string().optional() }) + .strip(), + ); + expect(Wire.safeParse({ a: '1', addedUpstreamLater: true }).success).toBe(true); + }); + }); + + /** + * ADR-0089 D3a hit `Cannot set properties of undefined (setting 'ref')` when + * `.strict()` pipelines met zod's `toJSONSchema` traversal over the lazySchema + * Proxy. The ledger names it as the hazard to watch while batching, so it is + * pinned here rather than rediscovered per conversion. + */ + it('converts to JSON Schema through the lazy proxy without throwing', () => { + for (const s of [WidgetSchema, Described, Refined, Extended] as unknown as z.ZodTypeAny[]) { + expect(() => z.toJSONSchema(s)).not.toThrow(); + } + }); + + /** + * Recorded in the ledger during the datasource step and load-bearing for the + * whole campaign: strictness does NOT widen or narrow the published JSON + * Schema. `build-schemas.ts` converts with `io: 'output'`, and output mode + * already emits `additionalProperties: false` for a `.strip()` object — so + * these flips align the parse with a contract that was already published, + * rather than changing it. + */ + it('emits additionalProperties: false, which strip mode already published', () => { + const strictJson = z.toJSONSchema(WidgetSchema as unknown as z.ZodTypeAny) as { + additionalProperties?: unknown; + }; + const stripJson = z.toJSONSchema(z.object({ name: z.string() })) as { + additionalProperties?: unknown; + }; + expect(strictJson.additionalProperties).toBe(false); + expect(stripJson.additionalProperties).toBe(false); + }); +}); diff --git a/packages/spec/src/shared/strict-object.ts b/packages/spec/src/shared/strict-object.ts new file mode 100644 index 0000000000..350948e893 --- /dev/null +++ b/packages/spec/src/shared/strict-object.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `strictObject` — one call to close an authoring shape against unknown keys. + * + * ## Why this exists + * + * The #4001 campaign's standard wiring was four moving parts per schema: a + * hand-transcribed `const X_KEYS = [...] as const` array, a + * `strictUnknownKeyError({ surface, knownKeys: X_KEYS, history })` call, the + * `{ error }` argument, and `.strict()`. Plus — because the transcribed array + * can drift from the shape it describes — an "accepts every declared key" probe + * test to catch the drift. + * + * That was ~34 key arrays and 16 drift-probe test files at the point this + * helper was written, for a campaign with most of its authorable surface still + * ahead of it. The cost is not the typing; it is that **the array is a second + * copy of the truth**, so every schema edit is two edits, and the probe test + * exists only to catch the case where someone made one of them. + * + * The array was never necessary. `knownKeys` feeds one thing — the + * edit-distance "did you mean" fallback — and the shape object is right there + * at the call site. Deriving the keys from the shape makes the two copies one, + * which is also why **no drift probe is needed for a schema built this way**: + * the key list cannot disagree with the shape it was read from. + * + * ## What it does not replace + * + * `aliases` and `guidance` stay hand-written, because they are the part that + * carries judgement rather than transcription: + * + * - `aliases` — semantic near-misses edit distance cannot reach. The one that + * proves the category is `visibleWhen → visible`: ADR-0089 made `visibleWhen` + * the correct spelling on view/page, so an author borrowing it on a different + * surface is not making a typo, and only a human-written entry can catch it. + * - `guidance` — tombstones for retired keys (the rejection must carry the + * upgrade) and wrong-layer pointers. + * + * Both are optional. A schema with neither still gets a named surface, the + * offending key echoed back, and a distance-based suggestion — which is the + * difference between a silent strip and a fixable error. Curation is an + * upgrade, not a precondition, and treating it as a precondition is part of why + * the ratchet moved as slowly as it did. + * + * @example + * ```ts + * export const WidgetSchema = lazySchema(() => strictObject( + * { + * surface: 'this widget', + * history: 'Until #4001 these were dropped silently — the widget still rendered.', + * aliases: { visibleWhen: 'visible' }, + * }, + * { + * name: z.string(), + * visible: z.boolean().optional(), + * }, + * )); + * ``` + */ + +import { z } from 'zod'; + +import { strictUnknownKeyError } from './suggestions.zod'; + +/** Authoring-surface metadata for {@link strictObject}. */ +export interface StrictObjectOptions { + /** Prose name of the surface the key was written on (e.g. `'this widget'`). */ + surface: string; + /** One sentence: what silently happened before this shape was closed. */ + history: string; + /** + * Semantic near-misses edit distance cannot reach — a different *word* for + * the same intent, usually correct on a neighbouring surface. + */ + aliases?: Readonly>; + /** + * Exact-key prescriptions appended as bullet lines: tombstones for retired + * keys, wrong-layer pointers. An entry here suppresses the rename suggestion. + */ + guidance?: Readonly>; + /** + * Extra candidates for the "did you mean" fallback beyond the shape's own + * keys. For a base that gets `.extend()`ed elsewhere, naming the extension's + * keys here keeps the suggestion useful on the extended surface. + */ + extraKeys?: readonly string[]; +} + +/** + * A `.strict()` object whose unknown-key error names the surface, echoes the + * offending key, and suggests the closest declared key — with the candidate + * list read from `shape` rather than transcribed alongside it. + */ +export function strictObject(options: StrictObjectOptions, shape: T) { + const { surface, history, aliases, guidance, extraKeys = [] } = options; + return z + .object(shape, { + error: strictUnknownKeyError({ + surface, + knownKeys: [...Object.keys(shape), ...extraKeys], + history, + aliases, + guidance, + }), + }) + .strict(); +} diff --git a/packages/spec/src/system/doc.zod.ts b/packages/spec/src/system/doc.zod.ts index bd48a566e6..d0ebc476e2 100644 --- a/packages/spec/src/system/doc.zod.ts +++ b/packages/spec/src/system/doc.zod.ts @@ -2,6 +2,8 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; +import { strictObject } from '../shared/strict-object'; +import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; /** * Package Documentation Metadata Protocol (ADR-0046) @@ -25,7 +27,34 @@ import { lazySchema } from '../shared/lazy-schema'; * resolve relative links between docs (`[guide](./crm_lead_guide.md)`) * by stripping `./` and `.md` to obtain the target doc name. */ -export const DocSchema = lazySchema(() => z.object({ +export const DocSchema = lazySchema(() => strictObject({ + surface: 'this doc', + history: + 'Until #4001 these were dropped silently — the doc still registered, just without ' + + 'whatever the key was meant to configure.', + aliases: { + title: 'label', + heading: 'label', + body: 'content', + markdown: 'content', + md: 'content', + text: 'content', + summary: 'description', + sort: 'order', + sortorder: 'order', + position: 'order', + category: 'group', + section: 'group', + i18n: 'translations', + locales: 'translations', + }, + guidance: { + path: + '`path` is not a doc key. A doc\'s identity is its `name` (the source filename stem) — ' + + 'ADR-0046 keeps `src/docs/` flat precisely so there is no path to record.', + slug: '`slug` is not a doc key. Use `name`; it is the filename stem and the resolution key.', + }, +}, { /** * Doc name; equals the source filename stem. Lowercase snake_case. A * namespace prefix (e.g. `crm_lead_guide`) is recommended for readable, @@ -95,6 +124,13 @@ export const DocSchema = lazySchema(() => z.object({ ) .optional() .describe('Per-locale {label?,description?,content} variants; the base doc is the fallback'), + + // ADR-0010 — runtime protection envelope (internal — set by the loader). + // See the note on `SeedSchema`: every registered metadata type gets stamped by + // `MetadataPlugin`'s artifact loader, and an undeclared envelope is stripped + // on every parse — the inverse drift that made `permission` 422 when it went + // strict (#4001 findings log, entry 2). + ...MetadataProtectionFields, })); export type Doc = z.infer; export type DocTranslation = NonNullable[string];