diff --git a/packages/spec/scripts/build-schemas-check-mode.test.ts b/packages/spec/scripts/build-schemas-check-mode.test.ts index 628e8beff0..f149c5e537 100644 --- a/packages/spec/scripts/build-schemas-check-mode.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.test.ts @@ -830,6 +830,50 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 }, ); + it( + "check (a)'s remedy names every route and claims exhaustiveness for none (#12574)", + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // Same trip condition as the test above — this one reads the REMEDY rather + // than the verdict. The message once said tombstoning was the only route and + // that an aged-out tombstone was "the ONE legitimate reason to delete": a + // claim of exhaustiveness that check (c) below already contradicted in its + // own logic (proofs 2 and 3), and that measurably re-routed a dev off a + // documented route. The pin is on the FORK — the precondition question, both + // of its answers, and the absence of the exhaustiveness claim. + seedBase((s) => [...s, 'data/Object:zzPhantom12574'].sort()); + seedSurface((s) => [...s, 'data/Object:zzPhantom12574'].sort()); + + const { status, output } = run(['--check']); + + expect(status).toBe(1); + + // The fork is asked as a question about the def, not decreed. + expect(output).toContain('Does anything still PARSE this def'); + + // Route A — the def survives, so there is a reader to warn. + expect(output).toContain('tombstone the key instead of deleting it'); + expect(output).toContain('retiredKey('); + + // Route B (#4650 proofs 2 and 3) — nothing parses the def, or the whole def + // is leaving: the baseline line goes with the key, and check (c) adjudicates. + expect(output).toContain('not reachable from the metadata-type roots'); + expect(output).toContain('the whole def is leaving this build'); + expect(output).toContain('gen:schema'); + expect(output).toContain('#4650 proofs'); + // The 2026-08-02 ruling's limit travels with the route it waives. + expect(output).toContain("waives THIS file's tombstone requirement only"); + + // The aged-out tombstone survives as A route, no longer as THE reason. + expect(output).toContain('aged out (~two majors)'); + expect(output).toContain('RETIRED_KEYS_BY_MAJOR'); + expect(output).not.toContain('ONE legitimate reason'); + + // Still gate (a) talking: (c) never ran. + expect(output).not.toContain('deleted without proof'); + }, + ); + it( 'fails --check on a hand-edit that changes no key (generated-form mismatch, #4662), and write mode regenerates it', { timeout: SPAWN_TIMEOUT_MS * 2 }, diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index 16f926aa06..906868c36f 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -814,15 +814,32 @@ if (surfaceDoc) { `\n These schemas are NOT .strict(), so Zod silently STRIPS an unknown key: an author\n` + ` who keeps writing one gets a clean parse and a setting that never takes effect —\n` + ` no error, nothing to grep, nothing pointing at the changelog (#3733, ADR-0104).\n\n` + - ` To retire a key, tombstone it instead of deleting it:\n` + + ` You are seeing this because the BUILD stopped emitting the key while the committed\n` + + ` baseline still records it — a bare deletion, which is the one shape none of the\n` + + ` retirement routes has. Which route you owe turns on a single question:\n\n` + + ` Does anything still PARSE this def — can an author keep writing this key?\n\n` + + ` YES — the def survives and keeps emitting; only the key is leaving. There is a\n` + + ` reader to warn, so tombstone the key instead of deleting it:\n` + ` 1. \`retiredKey(' was removed in ... — use . ...')\` in the schema\n` + ` (or an UNKNOWN_KEY_GUIDANCE entry for an object top-level key), so the\n` + ` rejection carries the fix;\n` + ` 2. a D2 conversion (and D3 chain step) so the rename reaches spec-changes.json\n` + ` and \`os migrate meta\` can rewrite consumer sources;\n` + ` 3. a \`major\` changeset carrying the FROM → TO mapping.\n\n` + - ` A tombstone that has aged out (~two majors) is the ONE legitimate reason to delete\n` + - ` a line here — do it in the same PR, deliberately.`, + ` NO — the def is not reachable from the metadata-type roots (no metadata document is\n` + + ` ever parsed by it), or the whole def is leaving this build. Then there is no author\n` + + ` to tombstone for, and a prescription nobody can receive is not worth its cost: let\n` + + ` the baseline line go WITH the key, in the same commit —\n` + + ` \`pnpm --filter @objectstack/spec gen:schema\`. You are not taken at your word on\n` + + ` that answer: check (c) below recomputes it from this build's own Zod graph and from\n` + + ` the manifest deletion gate, and refuses the deletion unless it holds (#4650 proofs\n` + + ` 2 and 3). ⚠️ It waives THIS file's tombstone requirement only — plugin manifests,\n` + + ` connector configs and other non-metadata authoring go through their own gates.\n\n` + + ` Separately, a tombstone that has aged out (~two majors) may have its line deleted\n` + + ` too — check (c) proof 1, which requires the exact key be declared in\n` + + ` RETIRED_KEYS_BY_MAJOR. Do it in the same PR, deliberately.\n\n` + + ` The routes and their preconditions are the spec-property-retirement playbook's\n` + + ` (.claude/skills/); ADR-0104 and #4650 are the decisions behind them.`, ); process.exit(1); }