From f210de9c3d06c3f9e18c8925de13d8f7dcc4e894 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 20:21:57 +0000 Subject: [PATCH 1/7] feat(spec): check (c) proves a guidance-route retirement on a reachable def Check (c) of the authorable-surface deletion gate admitted a deleted baseline line on three proofs. A key retired by DELETING it from the shape and leaving its prescription in the closed shape's `guidance` table never carries the `[RETIRED]` mark proof 1 starts from, so on a reachable def it had no proof shape at all -- not "has not aged yet" but "has no clock". Adds proof 4: the def's emitted JSON Schema is closed (`additionalProperties: false`) AND the one `strictObject` declaration that matches the def by shape identity names the key in `guidance` (or enumerates it in a `guidanceSets` entry). Both facts are read from this build's own tree -- the declaration registry `strict-object.ts` records at construction -- never argued in a PR description, on the same discipline as the other three proofs. Two narrowings, both fail-closed: an empty shape carries no identity and is excluded, and a `guidanceSets` RegExp claims a family whose members were never written down, so it does not NAME a key and does not count. Measured on the shipped graph: 1525 emitted defs, 1117 closed, 144 carrying a route at all, naming 772 keys between them. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- packages/spec/scripts/build-schemas.ts | 214 +++++++++++++++++++++++-- 1 file changed, 203 insertions(+), 11 deletions(-) diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index a6915166494..bbdc4eb157d 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -87,6 +87,17 @@ import { listMetadataTypeSchemaTypes, listUnregisteredKindSchemaTypes, } from '../src/kernel/metadata-type-schemas'; +// Check (c) proof 4's evidence (#18301): the `guidance` / `guidanceSets` tables +// `strictObject` records at CONSTRUCTION, which is the only place a key removed +// from a shape still has its prescription written down. Read here rather than +// re-derived, because a second reading of the same tables is a second thing to +// keep in step — and `keySetMatches` is, by its own docblock, the one place set +// membership is decided. +import { + strictObjectDeclarations, + type StrictObjectDeclaration, +} from '../src/shared/strict-object'; +import { keySetMatches } from '../src/shared/suggestions.zod'; import * as AI from '../src/ai'; import * as API from '../src/api'; import * as Automation from '../src/automation'; @@ -1071,7 +1082,7 @@ if (surfaceDoc) { // matters — in CI, HEAD IS the PR's own commit, so both sides always match // and the check never fires.) // -// A deletion is legitimate on exactly one of three proofs, each computed +// A deletion is legitimate on exactly one of FOUR proofs, each computed // inside this gate — never argued in a PR description: // // 1. aged-out tombstone — the base entry carried `[RETIRED]` AND its EXACT @@ -1096,7 +1107,22 @@ if (surfaceDoc) { // deleting the line deleted the evidence, exactly as hand-editing this // file did before #4650. The manifest deletion gate below now anchors that // comparison on the merge base and demands a declared removal, and it runs -// BEFORE this check so the deferral resolves to a real verdict. +// BEFORE this check so the deferral resolves to a real verdict; +// 4. the guidance route (#18301) — the def's shape is CLOSED and NAMES the key +// in its `strictObject` `guidance` table (or an enumerated `guidanceSets` +// entry), so an author who keeps writing it is answered with the upgrade +// prescription instead of a silent parse. This is the retirement route that +// deletes the key from the shape rather than tombstoning it in place, so it +// never earns the `[RETIRED]` mark proof 1 starts from — proof 1 cannot +// apply to it at all, which is why it needed a proof of its own rather than +// a relaxation of that clock. `computeGuidanceRoutes()` below is the +// authority on what is proved and on the two deliberate narrowings. +// +// Proofs 2 and 4 are asymmetric on purpose and must stay so: proof 2 waives +// because nobody could be authoring the key, proof 4 because everybody who does +// is told what to write instead. Widening either one into the other's territory +// — "reachable enough", "prescribed enough" — waives a deletion silently, and +// this gate's whole history (#4638, #4643, #4662) is that direction of error. /** A tombstone may be deleted once its registration is this many majors old. */ const TOMBSTONE_AGE_MAJORS = 2; @@ -1277,6 +1303,137 @@ function computeSurfaceReachability(): SurfaceReachability { }; } +/** Check (c) proof 4's lookup: which keys a def's authoring shape prescribes for. */ +interface GuidanceRoutes { + /** + * The keys `defKey`'s own authoring shape answers with a PRESCRIPTION rather + * than a silent strip — or `null` when this gate cannot establish that for the + * def at all. Never `null` to mean "none": an empty set and "no evidence" would + * then read alike, and only one of them is a safe thing to waive a deletion on. + */ + prescribedKeys(defKey: string): ReadonlySet | null; +} + +/** + * Check (c) proof 4 (#18301): the retirement route that leaves NO tombstone. + * + * Proof 1 dates a retirement by its `[RETIRED]` tombstone — `retiredKey()`, which + * keeps the key DECLARED (as `z.never()`) so it stays in the shape, stays in + * `authorable-surface/`, and carries its own `[REMOVED]` prescription. A key + * retired the other legal way — deleted from the shape outright, its prescription + * moved into the closed shape's `guidance` table (`data/object.zod.ts`'s + * `UNKNOWN_KEY_GUIDANCE` is the founding spelling; `strictObject`'s `guidance` / + * `guidanceSets` options are the general one) — is never marked `[RETIRED]`, + * because there is nothing left in the shape to mark. Proof 1 therefore cannot + * apply to it, EVER: not "has not aged yet" but "has no clock". + * + * Until #17356 that gap was invisible, because proof 2 was answering for these + * defs: the BFS root set omitted the four unregistered kinds, so whole families + * read as unreachable and every deletion under them was waived as + * over-collection. #18131 repaired the root set, and the repair is what exposes + * the class — a guidance-route retirement on a REACHABLE def had, at that point, + * no proof shape at all. + * + * ## What is proved, and from where + * + * The prescription has to be read from the TREE, not from a PR description, on + * the same discipline as the other three proofs. Two tree facts, and both are + * required: + * + * - **The door is closed.** This build's own emitted JSON Schema for the def + * carries `additionalProperties: false`. On an OPEN shape the author's write + * is stripped in silence and no error map is ever consulted, so a `guidance` + * entry on it prescribes to nobody — which is the #1535 failure this whole + * campaign exists to kill, not a retirement route. Read off `generatedSchemas` + * rather than re-walked, so the fact is the one that SHIPPED. + * - **The prescription names the key.** The def resolves to exactly one + * {@link StrictObjectDeclaration}, and that declaration's `guidance` names the + * exact key, or one of its `guidanceSets` ENUMERATES it. + * + * ## Why `strictObjectDeclarations()` and not the built schema + * + * A `guidance` table is consumed inside a closure (`strictObjectError`'s deferred + * error map) and leaves no mark on the instance — and any mark it did leave would + * not survive the `.extend()` / `.superRefine()` clones, which is the very trap + * `strict-object.ts` records for the audit registry. The registry is the one + * handle, and it is recorded at construction, which by this point in the run has + * happened for every emitted def: the JSON-Schema pass above walked all of them. + * + * ## The two narrowings, both deliberate, both fail-closed + * + * - **Exactly one matching declaration.** A declaration is matched to a def by + * shape IDENTITY — same key set, and every entry the same Zod instance — + * because `z.object(shape)` copies the shape object while sharing its entries + * (`.strict()` clones again). An EMPTY shape is excluded outright: it matches + * every other empty shape, and on the shipped graph that alone was 6 defs + * answering to 9 declarations each. Where two declarations still answer, this + * returns `null` rather than unioning them — a wrong table here waives a + * deletion silently, which is the one direction this gate must not err in. + * - **A `guidanceSets` PATTERN does not count.** `keySetMatches` decides + * membership for both forms, but only an enumerated `keys: [...]` list NAMES + * the key; a `RegExp` claims a family whose members were never written down, + * so it cannot distinguish "this key was retired with a prescription" from + * "this key happens to be spelled like a wrong-layer pointer". 55 of the 103 + * declared sets are enumerated and are honoured; the 48 pattern sets prove + * nothing here and still prescribe to authors exactly as before. + * + * Measured on the shipped graph at #18301: 1525 emitted defs, 1117 of them + * closed, 144 carrying a route at all, naming 772 keys between them — an + * enumerable set written down key by key, which is what makes this a proof and + * not a blanket waiver. `integration/DataSyncConfig` deliberately has NO route + * (its shape is not a `strictObject` and nothing prescribes for `schedule`), so + * this proof does not reach the 2026-09-10 ruling that withheld that tombstone. + */ +function computeGuidanceRoutes(): GuidanceRoutes { + const shapeSignature = (shape: Record): string => + JSON.stringify(Object.keys(shape).sort()); + const bySignature = new Map(); + for (const decl of strictObjectDeclarations()) { + // See "the two narrowings" above: an empty shape carries no identity. + if (Object.keys(decl.shape).length === 0) continue; + const signature = shapeSignature(decl.shape); + const list = bySignature.get(signature); + if (list) list.push(decl); + else bySignature.set(signature, [decl]); + } + const cache = new Map | null>(); + const compute = (defKey: string): ReadonlySet | null => { + const emitted = generatedSchemas.get(defKey); + if (!emitted || emitted.additionalProperties !== false) return null; + const schema = zodByDefKey.get(defKey); + if (!schema) return null; + const shape = zodShapeOf(schema); + if (!shape || Object.keys(shape).length === 0) return null; + const matched = (bySignature.get(shapeSignature(shape)) ?? []).filter((decl) => + Object.entries(decl.shape).every(([name, prop]) => shape[name] === prop)); + if (matched.length !== 1) return null; + const { options } = matched[0]!; + const prescribed = new Set(); + // `name in shape` is what keeps proof 4 disjoint from proof 1 rather than a + // way around it: a key the shape still DECLARES is a tombstone, it reaches + // the aging clock and never the unrecognized-key path, so its prescription + // here would be a claim about a door the author never arrives at. The + // `alias-integrity` audit says the same thing from the other side; this gate + // computes it instead of importing the guarantee. + for (const name of Object.keys(options.guidance ?? {})) { + if (!(name in shape)) prescribed.add(name); + } + for (const set of options.guidanceSets ?? []) { + if (set.keys instanceof RegExp) continue; + for (const name of set.keys) { + if (!(name in shape) && keySetMatches(set, name)) prescribed.add(name); + } + } + return prescribed; + }; + return { + prescribedKeys(defKey: string): ReadonlySet | null { + if (!cache.has(defKey)) cache.set(defKey, compute(defKey)); + return cache.get(defKey) ?? null; + }, + }; +} + /** * ⚠️ Every byte of this string is part of the anchor file's canonical form — * `readCommittedSurfaceBase` compares the committed file against @@ -2237,14 +2394,25 @@ let gitResolvedAnchor: { rev: string; keys: string[] } | null = null; const baseRev = base.rev.slice(0, 12); const declaredRetired = registeredRetiredKeys(); const reachability = computeSurfaceReachability(); + const guidanceRoutes = computeGuidanceRoutes(); + /** How a def is reachable, in the words every verdict below reuses. */ + const describeReach = (via: 'root-graph' | 'derived-clone'): string => + via === 'root-graph' + ? 'reachable from the metadata-type roots' + : 'authorable through a derived clone of a root-reachable schema'; const allowed: string[] = []; const violations: string[] = []; const goneDefs = new Map(); // def no longer emitted -> deleted key count for (const key of deletedKeys) { - // Only the def half is read now. The leaf half fed the leaf-NAME match - // #5898 removed from route 3 (see the RETIRED_KEYS_BY_MAJOR message - // below); slicing it out survived the rewrite as a dead local (#5475). - const defKey = key.slice(0, key.indexOf(':')); + // Both halves are read. The def half routes proofs 2 and 3; the leaf half + // is what proof 4 looks up in the def's `guidance` table (#18301). It is + // NOT the leaf-NAME match #5898 removed from proof 1 — that one matched a + // leaf against ADR-0087 clauses belonging to OTHER defs, which is how a + // flow node's `.type` dated an index type's tombstone. This lookup is + // scoped to the one def the key belongs to and never leaves it. + const separator = key.indexOf(':'); + const defKey = key.slice(0, separator); + const leaf = key.slice(separator + 1); if (!generatedSchemas.has(defKey)) { goneDefs.set(defKey, (goneDefs.get(defKey) ?? 0) + 1); continue; @@ -2261,11 +2429,28 @@ let gitResolvedAnchor: { rev: string; keys: string[] } | null = null; ); continue; } + // Proof 4 (#18301) runs BEFORE the tombstone chain because a + // guidance-route retirement never carried the `[RETIRED]` mark the chain + // starts from — the key left the shape rather than staying in it as a + // `retiredKey()`. Reaching the chain would print "the entry was LIVE + // (never tombstoned)", which is true and is not the question. + const prescribed = guidanceRoutes.prescribedKeys(defKey); + if (prescribed?.has(leaf)) { + allowed.push( + `${key} — def ${describeReach(via)}, and its shape is CLOSED and prescribes for +` + + ` '${leaf}' by name (\`strictObject\` \`guidance\`/\`guidanceSets\`, this build's own +` + + ` declaration registry): an author who keeps writing the key gets the upgrade +` + + ` prescription, not a silent parse, so the retirement is audible without a +` + + ` \`retiredKey()\` tombstone to age out (#18301).`, + ); + continue; + } const wasRetired = baseSnapshot.get(key) === true; - const how = - via === 'root-graph' - ? 'reachable from the metadata-type roots' - : 'authorable through a derived clone of a root-reachable schema'; + const how = describeReach(via); if (!wasRetired) { violations.push(`${key} — def ${how}; the entry at ${baseRev} was LIVE (never tombstoned).`); continue; @@ -2328,7 +2513,14 @@ let gitResolvedAnchor: { rev: string; keys: string[] } | null = null; ` that itself (it would have said so above); or\n` + ` 3. its whole def stopped being emitted — adjudicated by the manifest deletion\n` + ` gate above (#4725), which demands the removal be declared in\n` + - ` RETIRED_DEFS_BY_MAJOR (src/migrations/registry.ts).\n\n` + + ` RETIRED_DEFS_BY_MAJOR (src/migrations/registry.ts); or\n` + + ` 4. its def's shape is CLOSED and NAMES the key in its \`strictObject\`\n` + + ` \`guidance\` table — an enumerated \`guidanceSets\` entry counts, a RegExp\n` + + ` one does not — so writing the key raises the upgrade prescription\n` + + ` instead of parsing clean. That is the retirement route that removes the\n` + + ` key from the shape rather than leaving a \`retiredKey()\` tombstone in it,\n` + + ` and it is the only one of the four with no \`[RETIRED]\` mark to age\n` + + ` (#18301). This gate computes it too (it would have said so above).\n\n` + ` Restore the line(s) — \`pnpm --filter @objectstack/spec gen:schema\` regenerates\n` + ` the file — or complete the retirement route (#4650, ADR-0104, and the\n` + ` spec-property-retirement skill in .claude/skills/).`, From 204bf5ebf2d6432b145df2a6cde63e7ef9c09241 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 20:26:05 +0000 Subject: [PATCH 2/7] test(spec): pin check (c) proof 4 in both directions, on a real retirement `data/Metric:filters` is a completed guidance-route retirement in the tree, so the positive leg needs no synthetic key: the leaf really has left `MetricSchema`'s shape and its prescription really is in the closed shape's `guidance` table. Four keys in one run, because a proof that admits everything and a proof that admits nothing both pass a one-legged test: - the real retirement is admitted, by proof 4 and specifically not proof 2; - the SAME def with an unprescribed key is still refused, which is what separates a proof from a waiver for the def; - `integration/DataSyncConfig:schedule` -- the tombstone a maintainer ruling deliberately withheld -- is still refused, so this card adds a proof beside that ruling rather than reversing it; - a genuinely unreachable def is still waived by proof 2, in proof 2's words, which would move if proof 4 had been written as a widening of it. The `beforeAll` guard reads the tree fact as a lit/dark PAIR: the retired key's rejection carries a prescription bullet and an undeclared neighbour's does not. A guard that only asserted the rejection would pass on a shape that rejects everything and prescribes for nothing. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- .../scripts/build-schemas-check-mode.test.ts | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) diff --git a/packages/spec/scripts/build-schemas-check-mode.test.ts b/packages/spec/scripts/build-schemas-check-mode.test.ts index 6016897ac82..53b5a96e3c6 100644 --- a/packages/spec/scripts/build-schemas-check-mode.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.test.ts @@ -68,6 +68,10 @@ import { listMetadataTypeSchemaTypes, listUnregisteredKindSchemaTypes, } from '../src/kernel/metadata-type-schemas'; +// Read ONLY to keep the #18301 fixture honest about the TREE FACT it models — a +// key that left the shape whose door still answers an author with a prescription +// — never to assert gate behaviour, which is read off the spawned run's output. +import { MetricSchema } from '../src/data/analytics.zod'; import { AUTHORABLE_SURFACE_DIR_NAME, SCHEMA_MANIFEST_DIR_NAME, @@ -1027,6 +1031,35 @@ const DELETED_BY_RENAME = `${DELETED_BY_RENAME_SOURCE_DEF}:source`; * upstream anchor from before the rename is the mirror image, and holding both * at once is the #17383 collision. */ const CARRIED_BY_RENAME = `${RENAMED_DEFS[DELETED_BY_RENAME_SOURCE_DEF]}:source`; +/** #18301's pin — the GUIDANCE ROUTE, and the only fixture here that is a REAL + * completed retirement rather than a synthetic key. + * + * `data/Metric:filters` was removed from `MetricSchema`'s shape outright (#10414, + * ADR-0049) with its prescription left in the closed shape's `guidance` table. A + * retirement done that way never carries the `[RETIRED]` mark, because there is + * nothing left in the shape to mark — so proof 1 could not apply to it at any + * major, and while proof 2 was broken (#17356) proof 2 was answering instead. The + * key is usable verbatim here for the same reason the synthetic ones are not: + * check (c) only ever sees a key the build STOPPED emitting, and this one really + * has stopped. */ +const GUIDANCE_ROUTE_DEF = 'data/Metric'; +const GUIDANCE_ROUTE_LEAF = 'filters'; +const DELETED_GUIDANCE_ROUTE = `${GUIDANCE_ROUTE_DEF}:${GUIDANCE_ROUTE_LEAF}`; +/** The dark control, and the reason proof 4 is not a blanket waiver: the SAME def + * — same reachability, same closed door — with a key nothing prescribes for. */ +const DELETED_GUIDANCE_UNNAMED = `${GUIDANCE_ROUTE_DEF}:zzNotPrescribed18301`; +/** The key a maintainer ruling (2026-09-10, #16320) retired while DELIBERATELY + * withholding the tombstone. `DataSyncConfigSchema` is a plain `z.object`, not a + * `strictObject`, so nothing declares a prescription for it and proof 4 has no + * route to it — which is what keeps this card an ADDED proof rather than a + * reversal of that ruling. If someone later writes a `guidance` entry for + * `schedule`, this assertion flips, and it SHOULD: the retirement would have + * become audible, which is a real change and not a test to relax. */ +const WITHHELD_TOMBSTONE = 'integration/DataSyncConfig:schedule'; +/** How `strictUnknownKeyError` renders a prescription: one bullet line. The + * guard below reads it as a lit/dark PAIR, never alone — a shape that rejects + * everything and prescribes for nothing passes a one-legged rejection test. */ +const PRESCRIPTION_BULLET = '\n • '; describe('build-schemas.ts — deleted baseline lines must prove themselves (#4650)', () => { beforeAll(() => { @@ -1041,6 +1074,9 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 ...DELETED_GONE_DEF, DELETED_AGED, DELETED_BY_RENAME, + DELETED_GUIDANCE_ROUTE, + DELETED_GUIDANCE_UNNAMED, + WITHHELD_TOMBSTONE, ]) { expect( keys.includes(injected) || keys.includes(injected.replace(' [RETIRED]', '')), @@ -1091,6 +1127,36 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 `${DELETED_VIA_UNREGISTERED_KIND_DEF} is no longer emitted with authorable keys — check (c) ` + `would route this fixture to the vanished-def proof instead; re-pick the def`, ).toBe(true); + // #18301's fixture is only a pin while `data/Metric:filters` is still a + // completed guidance-route retirement in the tree. Three halves, all loud, + // because each can rot on its own and each rots the pin into a green that + // asserts nothing about proof 4. + expect( + keys.some((k) => k.startsWith(`${GUIDANCE_ROUTE_DEF}:`)), + `${GUIDANCE_ROUTE_DEF} is no longer emitted with authorable keys — check (c) would route ` + + `this fixture to the vanished-def proof instead; re-pick the def`, + ).toBe(true); + expect( + Object.keys(MetricSchema.shape), + `'${GUIDANCE_ROUTE_LEAF}' is DECLARED on the shape again — it would reach the aging clock, ` + + `not the unrecognized-key path, so this fixture no longer models the guidance route`, + ).not.toContain(GUIDANCE_ROUTE_LEAF); + // The lit leg and the dark leg of the same read: the retired key's rejection + // carries a prescription bullet, an undeclared neighbour's does not. + const metric = { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' }; + const lit = MetricSchema.safeParse({ ...metric, [GUIDANCE_ROUTE_LEAF]: [{ sql: '1 = 1' }] }); + const dark = MetricSchema.safeParse({ ...metric, zzNotPrescribed18301: 1 }); + expect(lit.success, `writing '${GUIDANCE_ROUTE_LEAF}' is accepted again — re-pick the fixture`).toBe(false); + expect( + lit.success ? '' : lit.error.issues.map((i) => i.message).join('\n'), + `'${GUIDANCE_ROUTE_LEAF}' is rejected with no prescription — the \`guidance\` entry that IS ` + + `proof 4's evidence has gone; this fixture models nothing`, + ).toContain(PRESCRIPTION_BULLET); + expect( + dark.success ? '' : dark.error.issues.map((i) => i.message).join('\n'), + 'an UNdeclared metric key now renders a prescription bullet too — the lit leg above has ' + + 'stopped discriminating, so it no longer reads the guidance table', + ).not.toContain(PRESCRIPTION_BULLET); // The manifest ratchet runs first; keep it current so every run reaches (c). seedManifest((s) => s); }); @@ -1272,6 +1338,115 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 }, ); + it( + '#18301 — a guidance-route retirement on a reachable def proves itself; the same deletion, unnamed, does not', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // FOUR keys, ONE run, because every one of them is satisfiable by a gate + // that is simply wrong somewhere else — and a proof that admits everything + // and a proof that admits nothing both pass a one-legged test: + // + // - `DELETED_GUIDANCE_ROUTE` a blanket waiver passes this alone; + // - `DELETED_GUIDANCE_UNNAMED` the SAME def, so a gate that keyed proof 4 + // off the def rather than the KEY passes the first and fails here; + // - `WITHHELD_TOMBSTONE` the 2026-09-10 ruling, which this card adds + // a proof beside and must not reverse; + // - `DELETED_UNREACHABLE` proof 2's own territory, unchanged — it + // would move if proof 4 had been written as a widening of proof 2 + // instead of a fourth proof beside it. + seedBase((s) => [ + ...s, + DELETED_GUIDANCE_ROUTE, + DELETED_GUIDANCE_UNNAMED, + WITHHELD_TOMBSTONE, + DELETED_UNREACHABLE, + ].sort()); + const canonical = seedSurface((s) => s); + + const rx = (key: string, tail: string): RegExp => + new RegExp(`${key.replace(/[/$]/g, '\\$&')} — ${tail}`); + + // Read the gate as CI runs it first: `check:authorable-surface` exports + // OS_EAGER_SCHEMAS=1, so `zodByDefKey` holds the real instances and the + // declaration registry proof 4 reads is populated by construction. + const eager = run(['--check'], EAGER_SCHEMAS_ENV); + + // Direction 1 — proof 4 fires, and the verdict carries its own evidence: + // which def, that the door is closed, and the key it prescribes for BY + // NAME. A reader judging this waiver is not taking the deleter's word. + expect(eager.output).toContain('carry their own proof (#4650)'); + expect(eager.output).toMatch( + rx(DELETED_GUIDANCE_ROUTE, 'def reachable from the metadata-type roots, and its shape is CLOSED'), + ); + expect(eager.output).toContain(`'${GUIDANCE_ROUTE_LEAF}' by name`); + // Specifically NOT proof 2. `data/Metric` hangs off the `analytics_cube` + // root, one of the four unregistered kinds #18131 put into the root union, + // so before that repair this key read unreachable and was waived for the + // WRONG reason — which is exactly how the class stayed invisible. + expect(eager.output).not.toMatch(rx(DELETED_GUIDANCE_ROUTE, 'def not reachable from the')); + + // Direction 2 — the same deletion, on the same def, with nothing naming + // the key: still a violation. This is what makes proof 4 a proof and not a + // waiver for the def. + expect(eager.status).toBe(1); + expect(eager.output).toContain('authorable baseline line(s) were deleted without proof (#4650)'); + expect(eager.output).toMatch( + rx(DELETED_GUIDANCE_UNNAMED, 'def reachable from the metadata-type roots; .*was LIVE'), + ); + expect(eager.output).not.toMatch(rx(DELETED_GUIDANCE_UNNAMED, 'shape is CLOSED')); + + // Direction 3 — the withheld tombstone stays withheld. Nothing prescribes + // for `schedule`, so proof 4 has no route to it and the deletion is still + // refused, on the same verdict it was refused on before this card. + expect(eager.output).toMatch(rx(WITHHELD_TOMBSTONE, 'def .*was LIVE \\(never tombstoned\\)')); + expect(eager.output).not.toMatch(rx(WITHHELD_TOMBSTONE, 'shape is CLOSED')); + + // Direction 4 — proof 2's repaired conservatism is untouched: a def no + // metadata document is ever parsed against still reads `null` and is still + // waived by proof 2, with proof 2's words and not proof 4's. + expect(eager.output).toMatch(rx(DELETED_UNREACHABLE, 'def not reachable from the \\d+ metadata-type roots')); + expect(eager.output).not.toMatch(rx(DELETED_UNREACHABLE, 'shape is CLOSED')); + + // The same four verdicts under the lazy-Proxy graph, where every def + // resolves through `zodShapeOf`'s lazy getter rather than by identity. The + // VERDICT is what this gate acts on, so it is the verdict pinned in both + // regimes. + const lazy = run(['--check']); + expect(lazy.status).toBe(1); + expect(lazy.output).toMatch(rx(DELETED_GUIDANCE_ROUTE, 'def .*shape is CLOSED')); + expect(lazy.output).toMatch(rx(DELETED_GUIDANCE_UNNAMED, 'def .*was LIVE \\(never tombstoned\\)')); + expect(lazy.output).toMatch(rx(WITHHELD_TOMBSTONE, 'def .*was LIVE \\(never tombstoned\\)')); + expect(lazy.output).toMatch(rx(DELETED_UNREACHABLE, 'def not reachable from the \\d+ metadata-type roots')); + + expect(readSurface()).toBe(canonical); + }, + ); + + it( + "#18301 — the remedy names four proofs, and names what the fourth one needs", + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // The remedy is the only thing an author who hits this gate reads. Until + // this card it listed three routes and a guidance-route retirement matched + // none of them, so the honest next step was invisible — the #12574 shape, + // one proof further on. + seedBase((s) => [...s, DELETED_GUIDANCE_UNNAMED].sort()); + seedSurface((s) => s); + + const { status, output } = run(['--check'], EAGER_SCHEMAS_ENV); + + expect(status).toBe(1); + expect(output).toContain('A line may only leave this file when:'); + for (const route of [' 1.', ' 2.', ' 3.', ' 4.']) { + expect(output, `the remedy stopped naming route ${route.trim()}`).toContain(route); + } + // Route 4 states both halves of its own evidence, and the narrowing that + // keeps it from being a blanket waiver. + expect(output).toContain('its def\'s shape is CLOSED and NAMES the key'); + expect(output).toContain('an enumerated `guidanceSets` entry counts, a RegExp'); + }, + ); + it( 'check (a) is intact: a key the BUILD stops emitting while still recorded is fatal before (c) ever runs', { timeout: SPAWN_TIMEOUT_MS }, From 121465ba1681b5369725d56b172406d9629594b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 21:13:26 +0000 Subject: [PATCH 3/7] fix(spec): proof 4 requires the baseline entry to be un-marked, so proof 1 keeps its clock CI reddened `pnpm run test:repo` on the previous head, in the file this card edits: `data/Object:compactLayout` is BOTH a `[RETIRED]` baseline entry (the #5898 aged-tombstone fixture) AND a real `guidance` key on the same def. With proof 4 placed ahead of the tombstone chain it admitted that deletion, which took it off proof 1's aging clock -- a tombstone could then be deleted early by writing a `guidance` line beside it. Proof 4 now lives on the `!wasRetired` branch and only there. That is not a patch over the symptom: a guidance-route retirement deletes the key from the shape instead of leaving a `retiredKey()` in it, so it never earned the mark, and "the entry at baseRev was LIVE" is the true description of every member of the class. Requiring the entry to be un-marked is therefore the class's own property, and it makes the two proofs disjoint rather than merely different. Pinned where it failed: the specimen is now a fifth key in the #18301 case, asserted to fall to the tombstone chain and specifically NOT to proof 4. The remedy's route 4 states the un-marked requirement, and says a marked key stays on route 1's clock. Also corrects an escaping slip in the first commit -- the proof-4 message carried real newlines inside its template literal where the file's convention is `\n` escapes. Same bytes on stdout, different source. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- .../scripts/build-schemas-check-mode.test.ts | 36 +++++-- packages/spec/scripts/build-schemas.ts | 96 +++++++++++-------- 2 files changed, 84 insertions(+), 48 deletions(-) diff --git a/packages/spec/scripts/build-schemas-check-mode.test.ts b/packages/spec/scripts/build-schemas-check-mode.test.ts index 53b5a96e3c6..b86fcc276c4 100644 --- a/packages/spec/scripts/build-schemas-check-mode.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.test.ts @@ -1353,13 +1353,20 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 // a proof beside and must not reverse; // - `DELETED_UNREACHABLE` proof 2's own territory, unchanged — it // would move if proof 4 had been written as a widening of proof 2 - // instead of a fourth proof beside it. + // instead of a fourth proof beside it; + // - `DELETED_AGED` the DISJOINTNESS, measured rather than + // argued: `data/Object:compactLayout` is a `[RETIRED]` baseline entry + // AND a real `guidance` key on the same def, so a proof 4 that did not + // require the entry to be un-marked takes this deletion off proof 1's + // aging clock. It did, on the way in — the two #5898 cases at the + // bottom of this file are what caught it. seedBase((s) => [ ...s, DELETED_GUIDANCE_ROUTE, DELETED_GUIDANCE_UNNAMED, WITHHELD_TOMBSTONE, DELETED_UNREACHABLE, + DELETED_AGED, ].sort()); const canonical = seedSurface((s) => s); @@ -1407,7 +1414,17 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 expect(eager.output).toMatch(rx(DELETED_UNREACHABLE, 'def not reachable from the \\d+ metadata-type roots')); expect(eager.output).not.toMatch(rx(DELETED_UNREACHABLE, 'shape is CLOSED')); - // The same four verdicts under the lazy-Proxy graph, where every def + // Direction 5 — the two proofs are DISJOINT, not merely different. This key + // satisfies every OTHER condition proof 4 tests: reachable def, closed + // shape, and `compactLayout` really is in `data/Object`'s `guidance` table. + // What disqualifies it is that its baseline entry carried `[RETIRED]`, so it + // is a tombstone and stays on proof 1's clock — here refused for want of a + // RETIRED_KEYS_BY_MAJOR declaration, exactly as before this card. + const agedKey = DELETED_AGED.replace(RETIRED_MARK, ''); + expect(eager.output).toMatch(rx(agedKey, 'def .*tombstoned, but no entry in RETIRED_KEYS_BY_MAJOR')); + expect(eager.output).not.toMatch(rx(agedKey, 'shape is CLOSED')); + + // The same five verdicts under the lazy-Proxy graph, where every def // resolves through `zodShapeOf`'s lazy getter rather than by identity. The // VERDICT is what this gate acts on, so it is the verdict pinned in both // regimes. @@ -1417,6 +1434,7 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 expect(lazy.output).toMatch(rx(DELETED_GUIDANCE_UNNAMED, 'def .*was LIVE \\(never tombstoned\\)')); expect(lazy.output).toMatch(rx(WITHHELD_TOMBSTONE, 'def .*was LIVE \\(never tombstoned\\)')); expect(lazy.output).toMatch(rx(DELETED_UNREACHABLE, 'def not reachable from the \\d+ metadata-type roots')); + expect(lazy.output).not.toMatch(rx(agedKey, 'shape is CLOSED')); expect(readSurface()).toBe(canonical); }, @@ -1440,10 +1458,16 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 for (const route of [' 1.', ' 2.', ' 3.', ' 4.']) { expect(output, `the remedy stopped naming route ${route.trim()}`).toContain(route); } - // Route 4 states both halves of its own evidence, and the narrowing that - // keeps it from being a blanket waiver. - expect(output).toContain('its def\'s shape is CLOSED and NAMES the key'); - expect(output).toContain('an enumerated `guidanceSets` entry counts, a RegExp'); + // Route 4 states all three halves of its own evidence — the un-marked + // baseline entry, the closed door, the named key — and the narrowing that + // keeps it from being a blanket waiver. The un-marked half is the one a + // reader most needs: without it route 4 reads as a way around route 1. + expect(output).toContain('its baseline entry was NOT `[RETIRED]`'); + expect(output).toContain('its def\'s shape is CLOSED and'); + expect(output).toContain('NAMES the key in its `strictObject` `guidance` table'); + expect(output).toContain('an enumerated'); + expect(output).toContain('entry counts, a RegExp one does not'); + expect(output).toContain("A key that IS marked is a tombstone"); }, ); diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index bbdc4eb157d..5e4cf44ef83 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -1108,15 +1108,18 @@ if (surfaceDoc) { // file did before #4650. The manifest deletion gate below now anchors that // comparison on the merge base and demands a declared removal, and it runs // BEFORE this check so the deferral resolves to a real verdict; -// 4. the guidance route (#18301) — the def's shape is CLOSED and NAMES the key -// in its `strictObject` `guidance` table (or an enumerated `guidanceSets` -// entry), so an author who keeps writing it is answered with the upgrade -// prescription instead of a silent parse. This is the retirement route that -// deletes the key from the shape rather than tombstoning it in place, so it -// never earns the `[RETIRED]` mark proof 1 starts from — proof 1 cannot -// apply to it at all, which is why it needed a proof of its own rather than -// a relaxation of that clock. `computeGuidanceRoutes()` below is the -// authority on what is proved and on the two deliberate narrowings. +// 4. the guidance route (#18301) — the base entry was NOT `[RETIRED]`, and the +// def's shape is CLOSED and NAMES the key in its `strictObject` `guidance` +// table (or an enumerated `guidanceSets` entry), so an author who keeps +// writing it is answered with the upgrade prescription instead of a silent +// parse. This is the retirement route that deletes the key from the shape +// rather than tombstoning it in place, so it never earns the `[RETIRED]` +// mark proof 1 starts from — proof 1 cannot apply to it at all, which is +// why it needed a proof of its own rather than a relaxation of that clock. +// The un-marked half is load-bearing: it keeps the two DISJOINT, so a key +// that IS tombstoned stays on proof 1's aging clock and a `guidance` line +// written beside it cannot shorten that. `computeGuidanceRoutes()` below is +// the authority on what is proved and on the two deliberate narrowings. // // Proofs 2 and 4 are asymmetric on purpose and must stay so: proof 2 waives // because nobody could be authoring the key, proof 4 because everybody who does @@ -1409,12 +1412,15 @@ function computeGuidanceRoutes(): GuidanceRoutes { if (matched.length !== 1) return null; const { options } = matched[0]!; const prescribed = new Set(); - // `name in shape` is what keeps proof 4 disjoint from proof 1 rather than a - // way around it: a key the shape still DECLARES is a tombstone, it reaches - // the aging clock and never the unrecognized-key path, so its prescription - // here would be a claim about a door the author never arrives at. The - // `alias-integrity` audit says the same thing from the other side; this gate - // computes it instead of importing the guarantee. + // `name in shape` drops an entry filed under a key the shape DECLARES. Such an + // entry is a false claim about its own schema — `guidance` is consulted only + // from the `unrecognized_keys` path, which a declared key never reaches — so + // it prescribes about a door no author arrives at and must prove nothing + // here. The `alias-integrity` audit says the same from the other side; this + // gate computes it rather than importing the guarantee. (What keeps proof 4 + // disjoint from proof 1 is the un-marked gate at the CALL SITE, not this + // line: a tombstone is in the shape at HEAD but its baseline entry is what + // check (c) reads, and the two can disagree.) for (const name of Object.keys(options.guidance ?? {})) { if (!(name in shape)) prescribed.add(name); } @@ -2429,29 +2435,33 @@ let gitResolvedAnchor: { rev: string; keys: string[] } | null = null; ); continue; } - // Proof 4 (#18301) runs BEFORE the tombstone chain because a - // guidance-route retirement never carried the `[RETIRED]` mark the chain - // starts from — the key left the shape rather than staying in it as a - // `retiredKey()`. Reaching the chain would print "the entry was LIVE - // (never tombstoned)", which is true and is not the question. - const prescribed = guidanceRoutes.prescribedKeys(defKey); - if (prescribed?.has(leaf)) { - allowed.push( - `${key} — def ${describeReach(via)}, and its shape is CLOSED and prescribes for -` + - ` '${leaf}' by name (\`strictObject\` \`guidance\`/\`guidanceSets\`, this build's own -` + - ` declaration registry): an author who keeps writing the key gets the upgrade -` + - ` prescription, not a silent parse, so the retirement is audible without a -` + - ` \`retiredKey()\` tombstone to age out (#18301).`, - ); - continue; - } const wasRetired = baseSnapshot.get(key) === true; const how = describeReach(via); if (!wasRetired) { + // Proof 4 (#18301) lives on THIS branch and only on it. A guidance-route + // retirement deletes the key from the shape instead of leaving a + // `retiredKey()` in it, so it never earned the `[RETIRED]` mark — "the + // entry at baseRev was LIVE" is the true description of every member of + // the class, and it is the verdict they were all getting. + // + // The un-marked gate is what makes the two proofs DISJOINT rather than + // merely different, and it is not a formality: `data/Object:compactLayout` + // is both #5898's aged-tombstone fixture and a real `guidance` key, so a + // proof 4 placed AHEAD of this branch took that deletion off proof 1's + // aging clock — measured, in the two #5898 cases that reddened. A key that + // WAS marked is a tombstone; it stays on the clock, and a `guidance` line + // written beside it may not shorten that. + const prescribed = guidanceRoutes.prescribedKeys(defKey); + if (prescribed?.has(leaf)) { + allowed.push( + `${key} — def ${how}, and its shape is CLOSED and prescribes for\n` + + ` '${leaf}' by name (\`strictObject\` \`guidance\`/\`guidanceSets\`, this build's\n` + + ` own declaration registry): an author who keeps writing the key gets the\n` + + ` upgrade prescription, not a silent parse, so the retirement is audible\n` + + ` without a \`retiredKey()\` tombstone to age out (#18301).`, + ); + continue; + } violations.push(`${key} — def ${how}; the entry at ${baseRev} was LIVE (never tombstoned).`); continue; } @@ -2514,13 +2524,15 @@ let gitResolvedAnchor: { rev: string; keys: string[] } | null = null; ` 3. its whole def stopped being emitted — adjudicated by the manifest deletion\n` + ` gate above (#4725), which demands the removal be declared in\n` + ` RETIRED_DEFS_BY_MAJOR (src/migrations/registry.ts); or\n` + - ` 4. its def's shape is CLOSED and NAMES the key in its \`strictObject\`\n` + - ` \`guidance\` table — an enumerated \`guidanceSets\` entry counts, a RegExp\n` + - ` one does not — so writing the key raises the upgrade prescription\n` + - ` instead of parsing clean. That is the retirement route that removes the\n` + - ` key from the shape rather than leaving a \`retiredKey()\` tombstone in it,\n` + - ` and it is the only one of the four with no \`[RETIRED]\` mark to age\n` + - ` (#18301). This gate computes it too (it would have said so above).\n\n` + + ` 4. its baseline entry was NOT \`[RETIRED]\`, and its def's shape is CLOSED and\n` + + ` NAMES the key in its \`strictObject\` \`guidance\` table — an enumerated\n` + + ` \`guidanceSets\` entry counts, a RegExp one does not — so writing the key\n` + + ` raises the upgrade prescription instead of parsing clean. That is the\n` + + ` retirement route that removes the key from the shape rather than leaving\n` + + ` a \`retiredKey()\` tombstone in it, and it is the only one of the four with\n` + + ` no \`[RETIRED]\` mark to age (#18301). A key that IS marked is a tombstone\n` + + ` and stays on route 1's clock. This gate computes all of that too (it\n` + + ` would have said so above).\n\n` + ` Restore the line(s) — \`pnpm --filter @objectstack/spec gen:schema\` regenerates\n` + ` the file — or complete the retirement route (#4650, ADR-0104, and the\n` + ` spec-property-retirement skill in .claude/skills/).`, From 944d773b8c428f4542a7cfc59f387ce825a64de0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 01:25:22 +0000 Subject: [PATCH 4/7] wip(spec): proof 4 proves the door by probing the def, not by reading the artifact Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude --- packages/spec/scripts/build-schemas.ts | 295 ++++++++++++++++++------- 1 file changed, 212 insertions(+), 83 deletions(-) diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index 5e4cf44ef83..322938f2478 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -1108,11 +1108,13 @@ if (surfaceDoc) { // file did before #4650. The manifest deletion gate below now anchors that // comparison on the merge base and demands a declared removal, and it runs // BEFORE this check so the deferral resolves to a real verdict; -// 4. the guidance route (#18301) — the base entry was NOT `[RETIRED]`, and the -// def's shape is CLOSED and NAMES the key in its `strictObject` `guidance` -// table (or an enumerated `guidanceSets` entry), so an author who keeps -// writing it is answered with the upgrade prescription instead of a silent -// parse. This is the retirement route that deletes the key from the shape +// 4. the guidance route (#18301) — the base entry was NOT `[RETIRED]`, and +// writing the key at this build's own instance for the def is REFUSED as an +// unrecognized key carrying the prescription its `strictObject` `guidance` +// table (or an enumerated `guidanceSets` entry) declares for it, so an +// author who keeps writing it is answered with the upgrade prescription +// instead of a silent parse. This is the retirement route that deletes the +// key from the shape // rather than tombstoning it in place, so it never earns the `[RETIRED]` // mark proof 1 starts from — proof 1 cannot apply to it at all, which is // why it needed a proof of its own rather than a relaxation of that clock. @@ -1306,15 +1308,32 @@ function computeSurfaceReachability(): SurfaceReachability { }; } -/** Check (c) proof 4's lookup: which keys a def's authoring shape prescribes for. */ +/** Check (c) proof 4's reading: what a def really does with a key it no longer declares. */ +type GuidanceVerdict = + /** The def REFUSES the key as unrecognized, and the refusal carries the exact + * prescription its `strictObject` declaration owes it. */ + | 'prescribed' + /** A declaration promises a prescription for the key and this def does not + * deliver it — an open door, or an error map that is not the one holding + * that table. The promise is the only part that was ever checkable from the + * registry alone, and it is not the part an author meets. */ + | 'declared-but-silent' + /** Nothing this gate can read prescribes for this key on this def. */ + | 'none'; + +/** Check (c) proof 4's lookup: which keys a def answers with a prescription. */ interface GuidanceRoutes { /** - * The keys `defKey`'s own authoring shape answers with a PRESCRIPTION rather - * than a silent strip — or `null` when this gate cannot establish that for the - * def at all. Never `null` to mean "none": an empty set and "no evidence" would - * then read alike, and only one of them is a safe thing to waive a deletion on. + * What an author who keeps writing `leaf` on `defKey` actually gets. + * + * THREE answers rather than a boolean, because "nothing prescribes for this + * key" and "something prescribes for it and the author never hears it" are + * different facts about the tree — the second is a declaration that is not + * being kept, and a verdict that folded it into the first would report the + * deletion in words that send its author looking for a `guidance` entry they + * have already written. */ - prescribedKeys(defKey: string): ReadonlySet | null; + verdictFor(defKey: string, leaf: string): GuidanceVerdict; } /** @@ -1339,19 +1358,55 @@ interface GuidanceRoutes { * * ## What is proved, and from where * - * The prescription has to be read from the TREE, not from a PR description, on - * the same discipline as the other three proofs. Two tree facts, and both are - * required: + * What proof 4 waives a deletion on is a claim about the AUTHOR: someone who + * keeps writing the key is answered with the upgrade prescription instead of + * having the value dropped in silence. So it is established the way the author + * meets it — by writing the key at this build's own instance for the def and + * reading what comes back. Two halves, both required: * - * - **The door is closed.** This build's own emitted JSON Schema for the def - * carries `additionalProperties: false`. On an OPEN shape the author's write - * is stripped in silence and no error map is ever consulted, so a `guidance` - * entry on it prescribes to nobody — which is the #1535 failure this whole - * campaign exists to kill, not a retirement route. Read off `generatedSchemas` - * rather than re-walked, so the fact is the one that SHIPPED. - * - **The prescription names the key.** The def resolves to exactly one - * {@link StrictObjectDeclaration}, and that declaration's `guidance` names the - * exact key, or one of its `guidanceSets` ENUMERATES it. + * - **A declaration promises the prescription.** The def resolves to exactly + * one {@link StrictObjectDeclaration}, and that declaration's `guidance` + * names the exact key, or one of its `guidanceSets` ENUMERATES it. This half + * says which text is owed, and it is read from the tree. + * - **The def keeps that promise.** `safeParse` of `{ [key]: … }` against the + * schema `zodByDefKey` holds raises an `unrecognized_keys` issue naming the + * key, and that issue's message carries the declared text verbatim. + * + * ## ⚠️ Two things that look like door tests and are not + * + * Both of these were load-bearing in the first cut of this proof, and the repo's + * own measurements say neither separates a closed door from a silent strip: + * + * - **`additionalProperties: false` on the emitted artifact.** This generator + * converts with the default `io: 'output'`, and in output mode zod emits + * `additionalProperties: false` for a `.strip()` object too — the post-parse + * shape genuinely has no extra keys, whether they were refused or dropped. + * Measured and written down in + * `docs/audits/2026-07-unknown-key-strictness-ledger.md` ("strictness does + * not change the published JSON Schema", verified there by regenerating both + * ways to a byte-identical artifact). A condition that answers the same for + * both cases cannot be the thing that excludes one of them, so the artifact + * read is GONE from this proof rather than restated more carefully. + * - **Shape identity against the declaration registry.** `strictObjectError()` + * registers a declaration WITHOUT closing the shape (`view.zod.ts`'s + * `FormFieldBaseSchema` is the shipped case), and `Strict.strip()` / + * `z.object(Strict.shape)` build a clone that shares every shape ENTRY with a + * closed declaration while leaving the door open. Either one satisfies the + * identity match while the author's write is dropped in silence — the one + * direction this gate must not err in. The match stays, because it is how the + * owed text is found; what it is no longer asked to prove is closure. + * + * ## Why the probe reads `unrecognized_keys`, and why it reads the message + * + * `unrecognized_keys` is the ONLY issue code a `guidance` table is ever consulted + * from — `strictUnknownKeyError` returns undefined for every other code — and the + * prescription is appended to that message verbatim, one bullet per key. So the + * issue's presence is exactly "the door refused this write", and the declared + * text appearing in its message is exactly "the map this def parses through is + * the one holding that table" — which shape identity alone cannot tell, since a + * clone can share a shape without sharing an error map. ⛔ No message WORDING is + * pinned by this: the needle is read out of the tree, from the very declaration + * the first half matched, so a rewritten prescription moves both sides together. * * ## Why `strictObjectDeclarations()` and not the built schema * @@ -1370,22 +1425,24 @@ interface GuidanceRoutes { * (`.strict()` clones again). An EMPTY shape is excluded outright: it matches * every other empty shape, and on the shipped graph that alone was 6 defs * answering to 9 declarations each. Where two declarations still answer, this - * returns `null` rather than unioning them — a wrong table here waives a - * deletion silently, which is the one direction this gate must not err in. + * returns "no evidence" rather than unioning them — a wrong table here waives + * a deletion silently, which is the one direction this gate must not err in. * - **A `guidanceSets` PATTERN does not count.** `keySetMatches` decides * membership for both forms, but only an enumerated `keys: [...]` list NAMES * the key; a `RegExp` claims a family whose members were never written down, * so it cannot distinguish "this key was retired with a prescription" from - * "this key happens to be spelled like a wrong-layer pointer". 55 of the 103 - * declared sets are enumerated and are honoured; the 48 pattern sets prove - * nothing here and still prescribe to authors exactly as before. + * "this key happens to be spelled like a wrong-layer pointer". The pattern + * sets prove nothing here and still prescribe to authors exactly as before. + * + * Everything else fails closed by construction: a def with no shape, an empty + * shape, an ambiguous match, a probe that throws, a parse that SUCCEEDS with the + * key in it, or a refusal whose message does not carry the declared text all read + * as "no evidence", never as "proved". * - * Measured on the shipped graph at #18301: 1525 emitted defs, 1117 of them - * closed, 144 carrying a route at all, naming 772 keys between them — an - * enumerable set written down key by key, which is what makes this a proof and - * not a blanket waiver. `integration/DataSyncConfig` deliberately has NO route - * (its shape is not a `strictObject` and nothing prescribes for `schedule`), so - * this proof does not reach the 2026-09-10 ruling that withheld that tombstone. + * Measured on the shipped graph at #18301 with THIS instrument — see the PR body + * for the census run: `integration/DataSyncConfig` has NO route (its shape is not + * a `strictObject` and nothing prescribes for `schedule`), so this proof does not + * reach the 2026-09-10 ruling that withheld that tombstone. */ function computeGuidanceRoutes(): GuidanceRoutes { const shapeSignature = (shape: Record): string => @@ -1399,43 +1456,94 @@ function computeGuidanceRoutes(): GuidanceRoutes { if (list) list.push(decl); else bySignature.set(signature, [decl]); } - const cache = new Map | null>(); - const compute = (defKey: string): ReadonlySet | null => { - const emitted = generatedSchemas.get(defKey); - if (!emitted || emitted.additionalProperties !== false) return null; - const schema = zodByDefKey.get(defKey); - if (!schema) return null; - const shape = zodShapeOf(schema); - if (!shape || Object.keys(shape).length === 0) return null; - const matched = (bySignature.get(shapeSignature(shape)) ?? []).filter((decl) => - Object.entries(decl.shape).every(([name, prop]) => shape[name] === prop)); - if (matched.length !== 1) return null; - const { options } = matched[0]!; - const prescribed = new Set(); - // `name in shape` drops an entry filed under a key the shape DECLARES. Such an - // entry is a false claim about its own schema — `guidance` is consulted only - // from the `unrecognized_keys` path, which a declared key never reaches — so - // it prescribes about a door no author arrives at and must prove nothing - // here. The `alias-integrity` audit says the same from the other side; this - // gate computes it rather than importing the guarantee. (What keeps proof 4 - // disjoint from proof 1 is the un-marked gate at the CALL SITE, not this - // line: a tombstone is in the shape at HEAD but its baseline entry is what - // check (c) reads, and the two can disagree.) - for (const name of Object.keys(options.guidance ?? {})) { - if (!(name in shape)) prescribed.add(name); - } - for (const set of options.guidanceSets ?? []) { - if (set.keys instanceof RegExp) continue; - for (const name of set.keys) { - if (!(name in shape) && keySetMatches(set, name)) prescribed.add(name); + /** defKey -> key -> the prescription its declaration OWES for that key. */ + const promised = new Map | null>(); + const verdicts = new Map(); + + const promisesFor = (defKey: string): ReadonlyMap | null => { + if (promised.has(defKey)) return promised.get(defKey) ?? null; + const computed = ((): ReadonlyMap | null => { + const schema = zodByDefKey.get(defKey); + if (!schema) return null; + const shape = zodShapeOf(schema); + if (!shape || Object.keys(shape).length === 0) return null; + const matched = (bySignature.get(shapeSignature(shape)) ?? []).filter((decl) => + Object.entries(decl.shape).every(([name, prop]) => shape[name] === prop)); + if (matched.length !== 1) return null; + const { options } = matched[0]!; + const owed = new Map(); + // `name in shape` drops an entry filed under a key the shape DECLARES. Such an + // entry is a false claim about its own schema — `guidance` is consulted only + // from the `unrecognized_keys` path, which a declared key never reaches — so + // it prescribes about a door no author arrives at and must prove nothing + // here. The `alias-integrity` audit says the same from the other side; this + // gate computes it rather than importing the guarantee. (What keeps proof 4 + // disjoint from proof 1 is the un-marked gate at the CALL SITE, not this + // line: a tombstone is in the shape at HEAD but its baseline entry is what + // check (c) reads, and the two can disagree.) + for (const [name, prescription] of Object.entries(options.guidance ?? {})) { + if (!(name in shape)) owed.set(name, prescription); + } + // Precedence, as `strictUnknownKeyError` resolves it: an exact `guidance` + // entry wins over every set, and among sets the first declared one answers. + // Read the same way here so the text this gate expects is the text that + // surface really emits. + for (const set of options.guidanceSets ?? []) { + if (set.keys instanceof RegExp) continue; + for (const name of set.keys) { + if (!(name in shape) && !owed.has(name) && keySetMatches(set, name)) { + owed.set(name, set.prescription); + } + } } + return owed; + })(); + promised.set(defKey, computed); + return computed; + }; + + /** + * Write the key at the def and read the answer. + * + * The value is irrelevant and never parsed — a key the shape does not declare + * is rejected before its value is looked at — so `null` is used rather than + * anything that could accidentally satisfy a declared key if the shape ever + * regains one. + * + * A throw is an answer too, and it is "no evidence": a def whose parse cannot + * be driven from here has not been shown to refuse anything. + */ + const delivers = (defKey: string, key: string, prescription: string): boolean => { + const schema = zodByDefKey.get(defKey); + if (!schema) return false; + try { + const result = schema.safeParse({ [key]: null }); + if (result.success) return false; + const issue = result.error.issues.find( + (candidate) => + candidate.code === 'unrecognized_keys' && + ((candidate as { keys?: readonly string[] }).keys ?? []).includes(key), + ); + return issue !== undefined && issue.message.includes(prescription); + } catch { + return false; } - return prescribed; }; + return { - prescribedKeys(defKey: string): ReadonlySet | null { - if (!cache.has(defKey)) cache.set(defKey, compute(defKey)); - return cache.get(defKey) ?? null; + verdictFor(defKey: string, leaf: string): GuidanceVerdict { + const cacheKey = `${defKey}:${leaf}`; + const cached = verdicts.get(cacheKey); + if (cached !== undefined) return cached; + const prescription = promisesFor(defKey)?.get(leaf); + const verdict: GuidanceVerdict = + prescription === undefined + ? 'none' + : delivers(defKey, leaf, prescription) + ? 'prescribed' + : 'declared-but-silent'; + verdicts.set(cacheKey, verdict); + return verdict; }, }; } @@ -2451,14 +2559,30 @@ let gitResolvedAnchor: { rev: string; keys: string[] } | null = null; // aging clock — measured, in the two #5898 cases that reddened. A key that // WAS marked is a tombstone; it stays on the clock, and a `guidance` line // written beside it may not shorten that. - const prescribed = guidanceRoutes.prescribedKeys(defKey); - if (prescribed?.has(leaf)) { + const verdict = guidanceRoutes.verdictFor(defKey, leaf); + if (verdict === 'prescribed') { allowed.push( - `${key} — def ${how}, and its shape is CLOSED and prescribes for\n` + - ` '${leaf}' by name (\`strictObject\` \`guidance\`/\`guidanceSets\`, this build's\n` + - ` own declaration registry): an author who keeps writing the key gets the\n` + - ` upgrade prescription, not a silent parse, so the retirement is audible\n` + - ` without a \`retiredKey()\` tombstone to age out (#18301).`, + `${key} — def ${how}; writing '${leaf}' on it is REFUSED as an unrecognized key\n` + + ` and the refusal carries the prescription its \`strictObject\` declaration owes\n` + + ` it (\`guidance\` / \`guidanceSets\`, probed against this build's own instance):\n` + + ` an author who keeps writing the key gets the upgrade prescription, not a\n` + + ` silent parse, so the retirement is audible without a \`retiredKey()\`\n` + + ` tombstone to age out (#18301).`, + ); + continue; + } + if (verdict === 'declared-but-silent') { + // A declaration that is not being kept. Reported apart from the plain + // "was LIVE" verdict because the two send their reader to different + // places: this one's `guidance` entry already exists, and what is + // missing is the door it was written for. + violations.push( + `${key} — def ${how}; a \`strictObject\` declaration NAMES '${leaf}', but writing it\n` + + ` on this def raises NO such prescription — the value is dropped in silence (an\n` + + ` open door, or an error map that is not the one holding that table), so the\n` + + ` entry prescribes to nobody and the retirement is inaudible. Probed against\n` + + ` this build's own instance; the entry at ${baseRev} was LIVE (never\n` + + ` tombstoned) (#18301).`, ); continue; } @@ -2524,15 +2648,20 @@ let gitResolvedAnchor: { rev: string; keys: string[] } | null = null; ` 3. its whole def stopped being emitted — adjudicated by the manifest deletion\n` + ` gate above (#4725), which demands the removal be declared in\n` + ` RETIRED_DEFS_BY_MAJOR (src/migrations/registry.ts); or\n` + - ` 4. its baseline entry was NOT \`[RETIRED]\`, and its def's shape is CLOSED and\n` + - ` NAMES the key in its \`strictObject\` \`guidance\` table — an enumerated\n` + - ` \`guidanceSets\` entry counts, a RegExp one does not — so writing the key\n` + - ` raises the upgrade prescription instead of parsing clean. That is the\n` + - ` retirement route that removes the key from the shape rather than leaving\n` + - ` a \`retiredKey()\` tombstone in it, and it is the only one of the four with\n` + - ` no \`[RETIRED]\` mark to age (#18301). A key that IS marked is a tombstone\n` + - ` and stays on route 1's clock. This gate computes all of that too (it\n` + - ` would have said so above).\n\n` + + ` 4. its baseline entry was NOT \`[RETIRED]\`, and writing the key on its def is\n` + + ` REFUSED as an unrecognized key carrying the prescription its\n` + + ` \`strictObject\` \`guidance\` table declares for it — an enumerated\n` + + ` \`guidanceSets\` entry counts, a RegExp one does not. Both halves are\n` + + ` probed against this build's own instance, because neither the published\n` + + ` \`additionalProperties: false\` nor the declaration registry can tell a\n` + + ` closed door from a shape that merely STRIPS the key: in \`io: 'output'\`\n` + + ` zod emits \`false\` for a stripping shape too, and a \`.strip()\` clone\n` + + ` shares its source's shape entries. That is the retirement route that\n` + + ` removes the key from the shape rather than leaving a \`retiredKey()\`\n` + + ` tombstone in it, and it is the only one of the four with no \`[RETIRED]\`\n` + + ` mark to age (#18301). A key that IS marked is a tombstone and stays on\n` + + ` route 1's clock. This gate computes all of that too (it would have said\n` + + ` so above).\n\n` + ` Restore the line(s) — \`pnpm --filter @objectstack/spec gen:schema\` regenerates\n` + ` the file — or complete the retirement route (#4650, ADR-0104, and the\n` + ` spec-property-retirement skill in .claude/skills/).`, From db835dfedf7fdfc15ba7799f77ef9ad3fa05719d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 01:32:20 +0000 Subject: [PATCH 5/7] =?UTF-8?q?test(spec):=20pin=20the=20door=20=E2=80=94?= =?UTF-8?q?=20one=20declaration,=20two=20defs,=20opposite=20verdicts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude --- .../scripts/build-schemas-check-mode.test.ts | 164 ++++++++++++++++-- 1 file changed, 154 insertions(+), 10 deletions(-) diff --git a/packages/spec/scripts/build-schemas-check-mode.test.ts b/packages/spec/scripts/build-schemas-check-mode.test.ts index b86fcc276c4..ad2e651c21d 100644 --- a/packages/spec/scripts/build-schemas-check-mode.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.test.ts @@ -72,6 +72,11 @@ import { // key that left the shape whose door still answers an author with a prescription // — never to assert gate behaviour, which is read off the spawned run's output. import { MetricSchema } from '../src/data/analytics.zod'; +// Read ONLY to keep the #18301 DOOR fixture honest about the tree fact it models +// — ONE `strictObject` declaration whose shape entries two emitted defs share, +// one of which never closed its door. Never to assert gate behaviour. +import { RateLimitConfigSchema } from '../src/shared/http.zod'; +import { ServerRateLimitConfigSchema } from '../src/system/stack-server.zod'; import { AUTHORABLE_SURFACE_DIR_NAME, SCHEMA_MANIFEST_DIR_NAME, @@ -1060,6 +1065,30 @@ const WITHHELD_TOMBSTONE = 'integration/DataSyncConfig:schedule'; * guard below reads it as a lit/dark PAIR, never alone — a shape that rejects * everything and prescribes for nothing passes a one-legged rejection test. */ const PRESCRIPTION_BULLET = '\n • '; +/** #18301's DOOR pin — and the reason the first cut of proof 4 was wrong. + * + * `ServerRateLimitConfigSchema` is declared `strictObject({… guidance: { keyBy, + * store } }, RateLimitConfigSchema.shape)` — it is built FROM the open schema's + * own shape object. So ONE declaration is matched, by shape identity, by TWO + * emitted defs: the closed one it built, and `shared/RateLimitConfig`, a plain + * `z.object` that drops an unknown key in silence. Both emit + * `additionalProperties: false` (in `io: 'output'` zod says `false` for a + * non-closing shape too), and both satisfy the declaration match — so NEITHER of + * the two facts the first cut read can tell them apart, and it waived the open + * one. Measured on the head this fixture landed against: 2 such keys, on this + * def, reachable from the roots. + * + * This is the review's "strip-mode clone shares a strict shape" case in the + * spelling the tree actually holds — sharing in the other direction, which is + * why a sweep for `.strip()` and `z.object(X.shape)` found nothing. */ +const OPEN_TWIN_DEF = 'shared/RateLimitConfig'; +const CLOSED_TWIN_DEF = 'system/ServerRateLimitConfig'; +/** A key BOTH twins' one declaration prescribes for, and only one of them delivers. */ +const TWIN_LEAF = 'keyBy'; +const DELETED_OPEN_TWIN = `${OPEN_TWIN_DEF}:${TWIN_LEAF}`; +const DELETED_CLOSED_TWIN = `${CLOSED_TWIN_DEF}:${TWIN_LEAF}`; +/** A budget every twin accepts, so the door is the only thing the probe below reads. */ +const TWIN_VALID = { enabled: true, windowMs: 60_000, maxRequests: 100 }; describe('build-schemas.ts — deleted baseline lines must prove themselves (#4650)', () => { beforeAll(() => { @@ -1076,6 +1105,8 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 DELETED_BY_RENAME, DELETED_GUIDANCE_ROUTE, DELETED_GUIDANCE_UNNAMED, + DELETED_OPEN_TWIN, + DELETED_CLOSED_TWIN, WITHHELD_TOMBSTONE, ]) { expect( @@ -1157,6 +1188,48 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 'an UNdeclared metric key now renders a prescription bullet too — the lit leg above has ' + 'stopped discriminating, so it no longer reads the guidance table', ).not.toContain(PRESCRIPTION_BULLET); + // #18301's DOOR fixture is only a pin while the tree still holds ONE + // declaration answering for TWO defs with OPPOSITE doors. Each half rots on + // its own, and each rots the pin into a green that asserts nothing about the + // one direction this gate must not err in. + expect( + Object.keys(RateLimitConfigSchema.shape), + `${OPEN_TWIN_DEF} and ${CLOSED_TWIN_DEF} no longer declare the same key SET — the ` + + `registry match is keyed off the sorted key set, so this fixture no longer reaches it`, + ).toEqual(Object.keys(ServerRateLimitConfigSchema.shape)); + expect( + Object.entries(RateLimitConfigSchema.shape).every( + ([name, prop]) => (ServerRateLimitConfigSchema.shape as Record)[name] === prop, + ), + `${CLOSED_TWIN_DEF} no longer shares ${OPEN_TWIN_DEF}'s shape ENTRIES — the declaration ` + + `match is by instance identity, so the open twin would stop matching and this fixture ` + + `would pass while modelling nothing`, + ).toBe(true); + // The two doors, read as a lit/dark pair. The open one is the whole point: + // it ACCEPTS the key and drops it, which is the silent strip proof 4 must + // never waive a deletion on. + const openTwin = RateLimitConfigSchema.safeParse({ ...TWIN_VALID, [TWIN_LEAF]: 'ip' }); + const closedTwin = ServerRateLimitConfigSchema.safeParse({ ...TWIN_VALID, [TWIN_LEAF]: 'ip' }); + expect( + openTwin.success, + `${OPEN_TWIN_DEF} now REFUSES '${TWIN_LEAF}' — its door closed, so this fixture no longer ` + + `models an open def sharing a closed declaration's shape; re-pick the pair`, + ).toBe(true); + expect( + openTwin.success && TWIN_LEAF in (openTwin.data as Record), + `${OPEN_TWIN_DEF} now CARRIES '${TWIN_LEAF}' through the parse — it is neither refusing ` + + `nor stripping, so the fixture no longer models a silent strip`, + ).toBe(false); + expect( + closedTwin.success, + `${CLOSED_TWIN_DEF} now ACCEPTS '${TWIN_LEAF}' — the closed twin opened, so the pair no ` + + `longer discriminates`, + ).toBe(false); + expect( + closedTwin.success ? '' : closedTwin.error.issues.map((i) => i.message).join('\n'), + `${CLOSED_TWIN_DEF} rejects '${TWIN_LEAF}' with no prescription — the \`guidance\` entry ` + + `that is the lit half of this pair has gone`, + ).toContain(PRESCRIPTION_BULLET); // The manifest ratchet runs first; keep it current so every run reaches (c). seedManifest((s) => s); }); @@ -1383,9 +1456,13 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 // NAME. A reader judging this waiver is not taking the deleter's word. expect(eager.output).toContain('carry their own proof (#4650)'); expect(eager.output).toMatch( - rx(DELETED_GUIDANCE_ROUTE, 'def reachable from the metadata-type roots, and its shape is CLOSED'), + rx( + DELETED_GUIDANCE_ROUTE, + `def reachable from the metadata-type roots; writing '${GUIDANCE_ROUTE_LEAF}' on it is ` + + `REFUSED as an unrecognized key`, + ), ); - expect(eager.output).toContain(`'${GUIDANCE_ROUTE_LEAF}' by name`); + expect(eager.output).toContain('carries the prescription its `strictObject` declaration owes'); // Specifically NOT proof 2. `data/Metric` hangs off the `analytics_cube` // root, one of the four unregistered kinds #18131 put into the root union, // so before that repair this key read unreachable and was waived for the @@ -1400,19 +1477,23 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 expect(eager.output).toMatch( rx(DELETED_GUIDANCE_UNNAMED, 'def reachable from the metadata-type roots; .*was LIVE'), ); - expect(eager.output).not.toMatch(rx(DELETED_GUIDANCE_UNNAMED, 'shape is CLOSED')); + expect(eager.output).not.toMatch(rx(DELETED_GUIDANCE_UNNAMED, 'is REFUSED as an unrecognized key')); + // …and not the "declared but not delivered" verdict either: nothing NAMES + // this key, so its reader is not sent looking for a `guidance` entry that + // was never written. + expect(eager.output).not.toMatch(rx(DELETED_GUIDANCE_UNNAMED, 'declaration NAMES')); // Direction 3 — the withheld tombstone stays withheld. Nothing prescribes // for `schedule`, so proof 4 has no route to it and the deletion is still // refused, on the same verdict it was refused on before this card. expect(eager.output).toMatch(rx(WITHHELD_TOMBSTONE, 'def .*was LIVE \\(never tombstoned\\)')); - expect(eager.output).not.toMatch(rx(WITHHELD_TOMBSTONE, 'shape is CLOSED')); + expect(eager.output).not.toMatch(rx(WITHHELD_TOMBSTONE, 'is REFUSED as an unrecognized key')); // Direction 4 — proof 2's repaired conservatism is untouched: a def no // metadata document is ever parsed against still reads `null` and is still // waived by proof 2, with proof 2's words and not proof 4's. expect(eager.output).toMatch(rx(DELETED_UNREACHABLE, 'def not reachable from the \\d+ metadata-type roots')); - expect(eager.output).not.toMatch(rx(DELETED_UNREACHABLE, 'shape is CLOSED')); + expect(eager.output).not.toMatch(rx(DELETED_UNREACHABLE, 'is REFUSED as an unrecognized key')); // Direction 5 — the two proofs are DISJOINT, not merely different. This key // satisfies every OTHER condition proof 4 tests: reachable def, closed @@ -1422,7 +1503,7 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 // RETIRED_KEYS_BY_MAJOR declaration, exactly as before this card. const agedKey = DELETED_AGED.replace(RETIRED_MARK, ''); expect(eager.output).toMatch(rx(agedKey, 'def .*tombstoned, but no entry in RETIRED_KEYS_BY_MAJOR')); - expect(eager.output).not.toMatch(rx(agedKey, 'shape is CLOSED')); + expect(eager.output).not.toMatch(rx(agedKey, 'is REFUSED as an unrecognized key')); // The same five verdicts under the lazy-Proxy graph, where every def // resolves through `zodShapeOf`'s lazy getter rather than by identity. The @@ -1430,11 +1511,69 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 // regimes. const lazy = run(['--check']); expect(lazy.status).toBe(1); - expect(lazy.output).toMatch(rx(DELETED_GUIDANCE_ROUTE, 'def .*shape is CLOSED')); + expect(lazy.output).toMatch(rx(DELETED_GUIDANCE_ROUTE, 'def .*is REFUSED as an unrecognized key')); expect(lazy.output).toMatch(rx(DELETED_GUIDANCE_UNNAMED, 'def .*was LIVE \\(never tombstoned\\)')); expect(lazy.output).toMatch(rx(WITHHELD_TOMBSTONE, 'def .*was LIVE \\(never tombstoned\\)')); expect(lazy.output).toMatch(rx(DELETED_UNREACHABLE, 'def not reachable from the \\d+ metadata-type roots')); - expect(lazy.output).not.toMatch(rx(agedKey, 'shape is CLOSED')); + expect(lazy.output).not.toMatch(rx(agedKey, 'is REFUSED as an unrecognized key')); + + expect(readSurface()).toBe(canonical); + }, + ); + + it( + '#18301 — proof 4 reads the DOOR, not the registry: one declaration, two defs, opposite verdicts', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // The case the contract review found, pinned in the spelling the tree + // really holds. `ServerRateLimitConfigSchema` is + // `strictObject({… guidance: { keyBy, store } }, RateLimitConfigSchema.shape)`, + // so ONE declaration answers for TWO emitted defs by shape identity — and + // only one of them ever closed its door. + // + // Everything the first cut of proof 4 read says the two are the same: + // + // - both emit `additionalProperties: false` (measured: in `io: 'output'` + // zod says `false` for a non-closing shape too — the ledger reading at + // docs/audits/2026-07-unknown-key-strictness-ledger.md); + // - both match that one declaration by sorted key set AND by per-entry + // instance identity, because the strict one was BUILT from the open + // one's shape object; + // - both are reachable, so proof 2 answers for neither. + // + // So a proof 4 that reads either of those facts waives the OPEN twin's + // deletion while an author who keeps writing `keyBy` has it dropped in + // silence — the one direction this gate must not err in, and the direction + // the whole #4001 campaign exists to kill. Only writing the key at the def + // and reading the answer separates them. + seedBase((s) => [...s, DELETED_OPEN_TWIN, DELETED_CLOSED_TWIN].sort()); + const canonical = seedSurface((s) => s); + + const rx = (key: string, tail: string): RegExp => + new RegExp(`${key.replace(/[/$]/g, '\\$&')} — ${tail}`); + + const eager = run(['--check'], EAGER_SCHEMAS_ENV); + + // The CLOSED twin: admitted by proof 4, on the door's own evidence. + expect(eager.output).toContain('carry their own proof (#4650)'); + expect(eager.output).toMatch( + rx(DELETED_CLOSED_TWIN, `def .*; writing '${TWIN_LEAF}' on it is REFUSED as an unrecognized key`), + ); + + // The OPEN twin: refused — and refused in words that name what is actually + // missing. Its `guidance` entry exists; what does not exist is a door for + // it to be delivered through, so the plain "was LIVE (never tombstoned)" + // verdict would send its reader to write an entry that is already there. + expect(eager.status).toBe(1); + expect(eager.output).toContain('authorable baseline line(s) were deleted without proof (#4650)'); + expect(eager.output).toMatch( + rx(DELETED_OPEN_TWIN, `a \`strictObject\` declaration NAMES '${TWIN_LEAF}', but writing it`), + ); + expect(eager.output).not.toMatch(rx(DELETED_OPEN_TWIN, 'is REFUSED as an unrecognized key')); + // …and it is not being waived by some OTHER proof either. The def is + // root-reachable, so proof 2 must not answer for it — without this leg the + // case would pass on a gate that had simply stopped emitting proof 4 at all. + expect(eager.output).not.toMatch(rx(DELETED_OPEN_TWIN, 'def not reachable from the')); expect(readSurface()).toBe(canonical); }, @@ -1463,8 +1602,13 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 // keeps it from being a blanket waiver. The un-marked half is the one a // reader most needs: without it route 4 reads as a way around route 1. expect(output).toContain('its baseline entry was NOT `[RETIRED]`'); - expect(output).toContain('its def\'s shape is CLOSED and'); - expect(output).toContain('NAMES the key in its `strictObject` `guidance` table'); + expect(output).toContain('writing the key on its def is'); + expect(output).toContain('REFUSED as an unrecognized key carrying the prescription its'); + // The remedy must not repeat the claim this round removed — that the + // published `additionalProperties: false` proves the door. It now says the + // opposite in the gate's own words, because an author reading route 4 is + // exactly who would otherwise go looking for that field. + expect(output).toContain("zod emits `false` for a stripping shape too"); expect(output).toContain('an enumerated'); expect(output).toContain('entry counts, a RegExp one does not'); expect(output).toContain("A key that IS marked is a tombstone"); From a8a7e5ba0845cdf317b3f0432d9d3cb982f82cc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 01:42:11 +0000 Subject: [PATCH 6/7] test(spec): the proof-4 negatives match the verdict line shape Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude --- .../scripts/build-schemas-check-mode.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/spec/scripts/build-schemas-check-mode.test.ts b/packages/spec/scripts/build-schemas-check-mode.test.ts index ad2e651c21d..e143c57e95d 100644 --- a/packages/spec/scripts/build-schemas-check-mode.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.test.ts @@ -1477,23 +1477,23 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 expect(eager.output).toMatch( rx(DELETED_GUIDANCE_UNNAMED, 'def reachable from the metadata-type roots; .*was LIVE'), ); - expect(eager.output).not.toMatch(rx(DELETED_GUIDANCE_UNNAMED, 'is REFUSED as an unrecognized key')); + expect(eager.output).not.toMatch(rx(DELETED_GUIDANCE_UNNAMED, 'def .*is REFUSED as an unrecognized key')); // …and not the "declared but not delivered" verdict either: nothing NAMES // this key, so its reader is not sent looking for a `guidance` entry that // was never written. - expect(eager.output).not.toMatch(rx(DELETED_GUIDANCE_UNNAMED, 'declaration NAMES')); + expect(eager.output).not.toMatch(rx(DELETED_GUIDANCE_UNNAMED, 'def .*declaration NAMES')); // Direction 3 — the withheld tombstone stays withheld. Nothing prescribes // for `schedule`, so proof 4 has no route to it and the deletion is still // refused, on the same verdict it was refused on before this card. expect(eager.output).toMatch(rx(WITHHELD_TOMBSTONE, 'def .*was LIVE \\(never tombstoned\\)')); - expect(eager.output).not.toMatch(rx(WITHHELD_TOMBSTONE, 'is REFUSED as an unrecognized key')); + expect(eager.output).not.toMatch(rx(WITHHELD_TOMBSTONE, 'def .*is REFUSED as an unrecognized key')); // Direction 4 — proof 2's repaired conservatism is untouched: a def no // metadata document is ever parsed against still reads `null` and is still // waived by proof 2, with proof 2's words and not proof 4's. expect(eager.output).toMatch(rx(DELETED_UNREACHABLE, 'def not reachable from the \\d+ metadata-type roots')); - expect(eager.output).not.toMatch(rx(DELETED_UNREACHABLE, 'is REFUSED as an unrecognized key')); + expect(eager.output).not.toMatch(rx(DELETED_UNREACHABLE, 'def .*is REFUSED as an unrecognized key')); // Direction 5 — the two proofs are DISJOINT, not merely different. This key // satisfies every OTHER condition proof 4 tests: reachable def, closed @@ -1503,7 +1503,7 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 // RETIRED_KEYS_BY_MAJOR declaration, exactly as before this card. const agedKey = DELETED_AGED.replace(RETIRED_MARK, ''); expect(eager.output).toMatch(rx(agedKey, 'def .*tombstoned, but no entry in RETIRED_KEYS_BY_MAJOR')); - expect(eager.output).not.toMatch(rx(agedKey, 'is REFUSED as an unrecognized key')); + expect(eager.output).not.toMatch(rx(agedKey, 'def .*is REFUSED as an unrecognized key')); // The same five verdicts under the lazy-Proxy graph, where every def // resolves through `zodShapeOf`'s lazy getter rather than by identity. The @@ -1515,7 +1515,7 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 expect(lazy.output).toMatch(rx(DELETED_GUIDANCE_UNNAMED, 'def .*was LIVE \\(never tombstoned\\)')); expect(lazy.output).toMatch(rx(WITHHELD_TOMBSTONE, 'def .*was LIVE \\(never tombstoned\\)')); expect(lazy.output).toMatch(rx(DELETED_UNREACHABLE, 'def not reachable from the \\d+ metadata-type roots')); - expect(lazy.output).not.toMatch(rx(agedKey, 'is REFUSED as an unrecognized key')); + expect(lazy.output).not.toMatch(rx(agedKey, 'def .*is REFUSED as an unrecognized key')); expect(readSurface()).toBe(canonical); }, @@ -1567,9 +1567,9 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 expect(eager.status).toBe(1); expect(eager.output).toContain('authorable baseline line(s) were deleted without proof (#4650)'); expect(eager.output).toMatch( - rx(DELETED_OPEN_TWIN, `a \`strictObject\` declaration NAMES '${TWIN_LEAF}', but writing it`), + rx(DELETED_OPEN_TWIN, `def .*; a \`strictObject\` declaration NAMES '${TWIN_LEAF}', but writing it`), ); - expect(eager.output).not.toMatch(rx(DELETED_OPEN_TWIN, 'is REFUSED as an unrecognized key')); + expect(eager.output).not.toMatch(rx(DELETED_OPEN_TWIN, 'def .*is REFUSED as an unrecognized key')); // …and it is not being waived by some OTHER proof either. The def is // root-reachable, so proof 2 must not answer for it — without this leg the // case would pass on a gate that had simply stopped emitting proof 4 at all. From 9e0324f8075b168f479204c428e3dd2f2a6c4f58 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 02:18:49 +0000 Subject: [PATCH 7/7] fix(spec): the unmet-prescription verdict states what was measured, not why Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude --- packages/spec/scripts/build-schemas.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index 322938f2478..b4f0ac58c00 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -1436,8 +1436,13 @@ interface GuidanceRoutes { * * Everything else fails closed by construction: a def with no shape, an empty * shape, an ambiguous match, a probe that throws, a parse that SUCCEEDS with the - * key in it, or a refusal whose message does not carry the declared text all read - * as "no evidence", never as "proved". + * key in it, a union or pipe the probe cannot drive to a single door, or a refusal + * whose message does not carry the declared text — all read as "no evidence", + * never as "proved". ⛔ And the verdict for the second half says only THAT the + * prescription did not arrive, never WHY: on the shipped graph 7 of the 8 defs in + * that state are unions, where "the door is open" would be a guess this gate has + * not measured — the mistake this proof's first cut made about + * `additionalProperties` and must not repeat one layer down. * * Measured on the shipped graph at #18301 with THIS instrument — see the PR body * for the census run: `integration/DataSyncConfig` has NO route (its shape is not @@ -2578,11 +2583,13 @@ let gitResolvedAnchor: { rev: string; keys: string[] } | null = null; // missing is the door it was written for. violations.push( `${key} — def ${how}; a \`strictObject\` declaration NAMES '${leaf}', but writing it\n` + - ` on this def raises NO such prescription — the value is dropped in silence (an\n` + - ` open door, or an error map that is not the one holding that table), so the\n` + - ` entry prescribes to nobody and the retirement is inaudible. Probed against\n` + - ` this build's own instance; the entry at ${baseRev} was LIVE (never\n` + - ` tombstoned) (#18301).`, + ` on this def does NOT raise that prescription — probed at this build's own\n` + + ` instance for the def. WHICH of the reasons applies is not established here\n` + + ` and this gate does not guess: the key may be dropped in silence (an open\n` + + ` door), answered by a different error map, or sit behind a union or pipe this\n` + + ` probe cannot drive. Either way the retirement is not audible THROUGH THIS\n` + + ` DEF, so proof 4 has nothing to waive on; the entry at ${baseRev} was LIVE\n` + + ` (never tombstoned) (#18301).`, ); continue; }