diff --git a/.changeset/manifest-removal-gate.md b/.changeset/manifest-removal-gate.md new file mode 100644 index 0000000000..3565e03e1b --- /dev/null +++ b/.changeset/manifest-removal-gate.md @@ -0,0 +1,54 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): manifest 删行不再是纪律 —— 整 schema 消失必须相对 merge-base 自证 (#4725) + +#4650 堵住了 `authorable-surface.json` 的手编删行捷径:被删的基线行现在必须在门禁 +内自证。但它把「整 def 消失」这条路交给了 `json-schema.manifest.json` 的 #2978 +ratchet —— 检查 (c) 的第 3 条出口至今原话写着「whole-schema removals are +adjudicated by json-schema.manifest.json」。**那个 ratchet 什么也没裁判。** 它的 +`missing` 集合是「manifest − 本次产出」,而 manifest 是**同一个 commit 里可以随手 +改的文件**:把导出、manifest 行、基线行一起删掉,`missing` 恒空,检查 (c) 又以 +「def 已不再产出」为由放行它名下的每一行,`check:api-surface` 只是新鲜度门(重新 +生成即绿)。三个门,一句话都不说 —— 和 #4650 修复前的形状同构,只是上移了一层。 + +实测(修复前,在测试沙箱里跑真源码):删掉 `src/data/index.ts` 里 **一行** +`export * from './validation.zod';` —— `ObjectSchema` 是直接从 `./validation.zod` +导入 `ValidationRuleSchema` 的,所以这 7 个 def 依旧从 `object` 元数据根**可达**、 +它们的 116 个 key 依旧被作者书写、依旧被解析 —— `gen:schema` 与 +`check:authorable-surface` **双双 exit 0**,消失的只是它们的 JSON Schema、 +manifest 行,以及 ratchet 对它们的全部记录。 + +现在按 #4650 的同一结构重新锚定: + +- **新增门禁「manifest deletion gate」**(`scripts/build-schemas.ts`):相对 + **merge-base(HEAD, origin/main) 的 manifest** —— 本次 commit 改不动的那一份 —— + 计算「离开已发布集合」的 def,每一个都必须有登记,否则红。它跑在检查 (c) **之前**, + 所以 (c) 那句「交给 manifest 裁判」从此指向一个真的会出裁决的地方。 +- **新增导出 `RETIRED_DEFS_BY_MAJOR`**(`@objectstack/spec`, + `src/migrations/registry.ts`):`RETIRED_KEYS_BY_MAJOR` 的上一层同胞,值是确切的 + `` `${category}/${SchemaName}` `` —— manifest 里怎么写就怎么写。沿用 #4659 的 + 精确集合判定:不取叶名、不按前缀、不从相邻条目辐射。门禁失败时直接打印要粘贴的 + 那几行和它们该进哪个 major。 +- **镜像检查**:表里登记了一个本次构建**仍在发布**的 def —— 一次没有任何东西消费的 + 登记 —— 直接失败,否则真正的删除可以在几个月后的别人的 PR 里落地,而门禁早已被 + 满足、当时没有任何人写下任何东西。 +- 改名不是退休:`RENAMED_DEFS`(`scripts/lib/renamed-defs.ts`)在这张表之前被查, + 一次改名不需要、也不允许写进这里 —— 那是在谎称契约收缩了。 + +**为什么是「声明」而不是「可达性」。** issue 建议复用 #4650 的可达性 BFS(「该 def +从元数据根不可达」)。这条在本层用不了,而且是**静默地**用不了:`reachableVia()` 从 +`zodByDefKey` 取实例,而那张表只装**本次构建产出**的 def —— 一个刚刚不再产出的 def +必然答 `null`,也就是「不可达 ⇒ 放行」,正对着这道门要拦的那一类删除。加宽 BFS 也救 +不了:def 已经从源码里没了,没有图可以走。所以本层唯一诚实的证据是声明,沿用 #5902 +为 key 层定下的先例。 + +**离线沿用 #5235 的既有姿态,不新造第三种**:镜像检查不需要 git,任何环境都跑; +比较本身在解析不到 origin/main 时打印说明并跳过 —— 那些环境是已合并的不可变树 +(镜像构建阶段、离网、fork、历史 tag 重放),「这个 PR 相对 main 删了什么」在那里 +不是一个存在的问题,跳过不会让任何构建失败,因此不需要为它再加一个 in-tree 锚点。 + +`json-schema.manifest.json` 自己的描述同时更新了(并纳入陈旧判定,不会再漂移): +它此前写的「remove a key ONLY for a deliberate retirement」正是 issue 引用的那句 +「唯一的要求,且没有任何机器校验」;现在它指向这道门和这张表。 diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 5fe9264c79..bf560ea6b5 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -91,6 +91,7 @@ "PredicateInput (type)", "PredicateInputSchema (const)", "PredicateSchema (const)", + "RETIRED_DEFS_BY_MAJOR (const)", "RETIRED_KEYS_BY_MAJOR (const)", "STACK_KEY_GUIDANCE (const)", "STACK_RUNTIME_MEMBERS (const)", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index df7f1f6224..8bd045393c 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -1,5 +1,5 @@ { - "description": "Ratchet manifest of every JSON Schema emitted by scripts/build-schemas.ts. Auto-appended when new schemas are added (commit the change). A listed schema that a build no longer emits fails gen:schema — remove a key ONLY for a deliberate retirement. See #2978.", + "description": "Ratchet manifest of every JSON Schema emitted by scripts/build-schemas.ts. Auto-appended when new schemas are added (commit the change). A listed schema that a build no longer emits fails gen:schema. DELETING a key is gated too (#4725): the removal is measured against this file at the merge base with origin/main — which the commit under test cannot rewrite — and every def that leaves the published set must be declared in RETIRED_DEFS_BY_MAJOR (src/migrations/registry.ts), or in RENAMED_DEFS (scripts/lib/renamed-defs.ts) when it is a rename rather than a removal. See #2978, #4725.", "schemas": [ "ai/AIModelConfig", "ai/AIUsageRecord", diff --git a/packages/spec/scripts/build-schemas-check-mode.test.ts b/packages/spec/scripts/build-schemas-check-mode.test.ts index 3e438911a0..8db92670f8 100644 --- a/packages/spec/scripts/build-schemas-check-mode.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.test.ts @@ -56,7 +56,11 @@ import { fileURLToPath } from 'node:url'; import { RENAMED_DEFS } from './lib/renamed-defs'; import { CONVERSIONS_BY_MAJOR } from '../src/conversions/registry'; -import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../src/migrations/registry'; +import { + MIGRATIONS_BY_MAJOR, + RETIRED_DEFS_BY_MAJOR, + RETIRED_KEYS_BY_MAJOR, +} from '../src/migrations/registry'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const PKG = path.resolve(HERE, '..'); @@ -1506,3 +1510,422 @@ describe('build-schemas.ts — check (b) matches the exact retired key, not its }, ); }); + +// ───────────────────────────────────────────────────────────────────────────── +// #4725 — a deleted json-schema.manifest.json key must prove itself too. +// +// #4650 closed the hand-edit shortcut for authorable KEYS and left whole-DEF +// removals to the #2978 manifest ratchet — check (c) route 3 says so verbatim: +// "whole-schema removals are adjudicated by json-schema.manifest.json". They +// were not. That ratchet's `missing` set is `manifest − emitted` with the +// manifest read from the SAME commit, so a PR deleting the export, the manifest +// line and the baseline lines together produced an empty `missing`, a check (c) +// that waived every key under the now-gone def, and an `api-surface` diff a +// regeneration turns green. Three gates, nothing said — the same shape #4650 +// closed one level down, with the two gates deferring to each other. +// +// Measured before the fix, by deleting ONE barrel line (`export * from +// './validation.zod'` in src/data/index.ts, whose defs ObjectSchema imports +// DIRECTLY and so keeps parsing `object` metadata with): 7 defs and 116 +// authorable keys left the published contract, `gen:schema` and +// `check:authorable-surface` both exiting 0. The last case in this block is that +// repro, run for real against the copied `src/`. +// +// ── Why declaration, and not reachability ───────────────────────────────── +// The issue proposed reusing the #4650 BFS ("the def is unreachable from the +// metadata roots"). It cannot be reused in this direction, and the failure is +// silent: `reachableVia()` looks the def up in `zodByDefKey`, which is populated +// only for defs the build EMITS, so a def that just stopped being emitted +// answers `null` — "unreachable", i.e. waived — for exactly the removals the +// gate exists to catch. Widening the BFS cannot fix that: the def is gone from +// the source, so there is no schema left to walk. The proof is therefore a +// DECLARATION, RETIRED_DEFS_BY_MAJOR, following #4659's exact-key precedent one +// level up. The `zzOverCollectedFamily` fixture below is deliberately a def the +// build never emitted, so it would answer "unreachable" too — a gate that +// consulted reachability would let it through, and this block would be green. +// +// ── Why a THIRD sandbox ─────────────────────────────────────────────────── +// Two reasons, both structural. The gate reads the manifest AT THE MERGE BASE, +// so `json-schema.manifest.json` has to be a TRACKED file whose committed +// content differs from the worktree's — the first sandbox commits only the +// authorable surface, and tracking the manifest there would make every +// `seedManifest()` dirty the tree that the #5358 block asserts is clean. And the +// registry table has to differ per case, which needs a copied `src/` (the first +// sandbox symlinks it). Same discipline as the #4659 box: production code path +// byte-for-byte, fixture data only. + +/** Planted in the BASE manifest, emitted by no build: a def that "left". */ +const REMOVED_DEF = 'ui/ZzzOverCollectedFamily4725'; +/** A second one, so the prescription is pinned in the plural. */ +const REMOVED_DEF_2 = 'ui/ZzzOverCollectedFamily4725Sibling'; +/** A def every build publishes — the mirror's fixture. */ +const STILL_PUBLISHED_DEF = 'ui/View'; +/** The barrel line whose removal unpublishes a REACHABLE def family. */ +const BARREL_LINE = "export * from './validation.zod';\n"; +/** The defs that line publishes; `ObjectSchema` imports them directly, so every + * one of them stays reachable from the `object` metadata root after it goes. */ +const UNPUBLISHED_FAMILY = [ + 'data/ConditionalValidation', + 'data/CrossFieldValidation', + 'data/FormatValidation', + 'data/JSONValidation', + 'data/ScriptValidation', + 'data/StateMachineValidation', + 'data/ValidationRule', +]; + +describe('build-schemas.ts — a deleted manifest key must prove itself (#4725)', () => { + let box: string; + let boxScript: string; + let boxManifest: string; + let boxSurface: string; + let boxRegistry: string; + let boxBarrel: string; + let pristineRegistry: string; + let pristineBarrel: string; + let head: string; + + const boxGit = (...args: string[]): string => { + const r = spawnSync( + 'git', + ['-c', 'user.name=build-schemas-test', '-c', 'user.email=test@example.invalid', ...args], + { cwd: box, encoding: 'utf8' }, + ); + if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed (${r.status}): ${r.stderr}`); + return (r.stdout ?? '').trim(); + }; + + const runBox = (args: string[] = []): { status: number; output: string } => { + const r = spawnSync(TSX, [boxScript, ...args], { + cwd: box, + encoding: 'utf8', + timeout: SPAWN_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { status: r.status ?? -1, output: `${r.stdout ?? ''}${r.stderr ?? ''}` }; + }; + + /** Write the box's worktree manifest from the committed one. */ + const writeManifest = (mutate: (schemas: string[]) => string[]): void => { + const doc = JSON.parse(pristine) as { description?: string; schemas: string[] }; + doc.schemas = mutate(doc.schemas); + fs.writeFileSync(boxManifest, JSON.stringify(doc, null, 2) + '\n'); + }; + + /** + * Commit a BASE variant of the manifest and point origin/main at it, then + * restore the worktree manifest to the canonical one. Set-wise identical to + * the real removal — a key present at the merge base and absent from what this + * build emits — without needing the source that emitted it. + */ + const seedBaseManifest = (mutate: (schemas: string[]) => string[]): void => { + writeManifest(mutate); + boxGit('add', 'json-schema.manifest.json'); + boxGit('commit', '-q', '--allow-empty', '-m', 'base manifest variant'); + head = boxGit('rev-parse', 'HEAD'); + boxGit('update-ref', 'refs/remotes/origin/main', head); + writeManifest((s) => s); + }; + + /** Substitute RETIRED_DEFS_BY_MAJOR in the box's own copy of the registry. */ + const seedRetiredDefs = (table: Record): void => { + const rendered = + `export const RETIRED_DEFS_BY_MAJOR: Readonly> = {\n` + + Object.keys(table) + .map(Number) + .sort((a, b) => a - b) + .map((m) => ` ${m}: [\n${table[m]!.map((k) => ` '${k}',\n`).join('')} ],\n`) + .join('') + + `};\n`; + const anchor = /export const RETIRED_DEFS_BY_MAJOR[\s\S]*?\n\};\n/; + expect( + anchor.test(pristineRegistry), + 'RETIRED_DEFS_BY_MAJOR is no longer a single object literal in src/migrations/registry.ts — ' + + 'this fixture substitutes it textually and can no longer find it', + ).toBe(true); + fs.writeFileSync(boxRegistry, pristineRegistry.replace(anchor, rendered)); + }; + + const NO_REGISTERED_REMOVAL = 'schema(s) left the published set with no registered removal (#4725)'; + + beforeAll(() => { + // Fixture validity, loud rather than silently stale. + const manifestKeys = (JSON.parse(pristine) as { schemas: string[] }).schemas; + for (const def of [REMOVED_DEF, REMOVED_DEF_2]) { + expect(manifestKeys, `${def} is a real published def — pick a phantom`).not.toContain(def); + } + expect(manifestKeys, `${STILL_PUBLISHED_DEF} is no longer published — pick another`).toContain( + STILL_PUBLISHED_DEF, + ); + for (const def of UNPUBLISHED_FAMILY) { + expect(manifestKeys, `${def} is no longer published — the end-to-end repro needs a new family`).toContain(def); + } + const declared = Object.values(RETIRED_DEFS_BY_MAJOR).flat(); + for (const def of [REMOVED_DEF, REMOVED_DEF_2, STILL_PUBLISHED_DEF, ...UNPUBLISHED_FAMILY]) { + expect(declared, `${def} is now registered for real — pick an unregistered fixture`).not.toContain(def); + } + + box = fs.mkdtempSync(path.join(os.tmpdir(), 'build-schemas-manifest-removal-')); + fs.cpSync(path.join(PKG, 'scripts'), path.join(box, 'scripts'), { recursive: true }); + fs.cpSync(path.join(PKG, 'src'), path.join(box, 'src'), { recursive: true }); + for (const entry of ['node_modules', 'package.json']) { + fs.symlinkSync(path.join(PKG, entry), path.join(box, entry)); + } + boxScript = path.join(box, 'scripts', 'build-schemas.ts'); + boxManifest = path.join(box, 'json-schema.manifest.json'); + boxSurface = path.join(box, 'authorable-surface.json'); + boxRegistry = path.join(box, 'src', 'migrations', 'registry.ts'); + boxBarrel = path.join(box, 'src', 'data', 'index.ts'); + pristineRegistry = fs.readFileSync(boxRegistry, 'utf8'); + pristineBarrel = fs.readFileSync(boxBarrel, 'utf8'); + expect( + pristineBarrel.includes(BARREL_LINE), + 'src/data/index.ts no longer re-exports ./validation.zod — the end-to-end repro needs a new family', + ).toBe(true); + + fs.writeFileSync(boxManifest, pristine); + fs.writeFileSync(boxSurface, pristineSurface); + boxGit('init', '-q', '-b', 'main', '.'); + // BOTH artifacts tracked here: the merge-base manifest is what this gate + // reads, and the surface baseline keeps the #4650 gate honest alongside it. + boxGit('add', 'json-schema.manifest.json', 'authorable-surface.json'); + boxGit('commit', '-q', '-m', 'baseline: committed manifest + authorable surface'); + fs.writeFileSync( + path.join(box, 'authorable-surface.base.json'), + JSON.stringify( + { + description: surfaceBaseDescription, + baseRev: boxGit('rev-parse', 'HEAD'), + keys: (JSON.parse(pristineSurface) as { keys: string[] }).keys, + }, + null, + 2, + ) + '\n', + ); + boxGit('add', 'authorable-surface.base.json'); + boxGit('commit', '-q', '-m', 'baseline anchor'); + head = boxGit('rev-parse', 'HEAD'); + boxGit('update-ref', 'refs/remotes/origin/main', head); + }); + + beforeEach(() => { + // A clean, current base every time — including the merge-base MANIFEST, + // which each fixture below mutates and which would otherwise leak the + // previous case's planted removal into the next one's count. + seedRetiredDefs({}); + fs.writeFileSync(boxSurface, pristineSurface); + fs.writeFileSync(boxBarrel, pristineBarrel); + seedBaseManifest((s) => s); + }); + + afterAll(() => { + if (box) fs.rmSync(box, { recursive: true, force: true }); + }); + + it( + 'is silent when nothing left the published set — the new failure is the removal, not the gate', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // Negative control. Without it, "always red" would satisfy every assertion + // below while breaking `check:authorable-surface` for everyone. + const { status, output } = runBox(['--check']); + + expect(output).not.toContain(NO_REGISTERED_REMOVAL); + expect(output).not.toContain('left the published set since'); + expect(status).toBe(0); + }, + ); + + it( + 'the bypass repro: a def gone from the build AND from the manifest is red, naming it', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // The #4725 shape, set-wise: two defs present in the manifest at the merge + // base, emitted by no build, and absent from the manifest this commit + // carries. Before the fix `missing` was empty and every gate exited 0. + seedBaseManifest((s) => [...s, REMOVED_DEF, REMOVED_DEF_2].sort()); + + const { status, output } = runBox(['--check']); + + expect(status).toBe(1); + expect(output).toContain(`2 ${NO_REGISTERED_REMOVAL}`); + expect(output).toContain(` - json-schema/${REMOVED_DEF}.json`); + expect(output).toContain(` - json-schema/${REMOVED_DEF_2}.json`); + // The prescription IS the contract: the exact lines to paste, and where. + expect(output).toContain(` '${REMOVED_DEF}',`); + expect(output).toContain(` '${REMOVED_DEF_2}',`); + expect(output).toContain('RETIRED_DEFS_BY_MAJOR'); + expect(output).toContain(`under \`${CURRENT_MAJOR}: [ … ]\``); + // …and the two other routes a reader might actually need are named. + expect(output).toContain('RENAMED_DEFS'); + expect(output).toContain('src/conversions/registry.ts'); + // Anchored on the merge base, which is the half a hand-edit cannot reach. + expect(output).toContain(`merge base ${head.slice(0, 12)}`); + }, + ); + + it( + 'write mode refuses identically — regenerating cannot bless a removal either', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + seedBaseManifest((s) => [...s, REMOVED_DEF].sort()); + const before = fs.readFileSync(boxManifest, 'utf8'); + + const { status, output } = runBox([]); + + expect(status).toBe(1); + expect(output).toContain(NO_REGISTERED_REMOVAL); + expect(output).toContain(REMOVED_DEF); + expect(fs.readFileSync(boxManifest, 'utf8')).toBe(before); + }, + ); + + it( + 'a declared removal passes, and the run says which major declared it', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + seedBaseManifest((s) => [...s, REMOVED_DEF].sort()); + seedRetiredDefs({ [CURRENT_MAJOR]: [REMOVED_DEF] }); + + const { status, output } = runBox(['--check']); + + expect(output).not.toContain(NO_REGISTERED_REMOVAL); + expect(output).toContain('1 schema(s) left the published set since'); + expect(output).toContain(`json-schema/${REMOVED_DEF}.json — RETIRED_DEFS_BY_MAJOR, major ${CURRENT_MAJOR}`); + expect(status).toBe(0); + }, + ); + + it( + 'registration is per DEF: declaring one does not cover its sibling', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // The #4659 lesson, inherited rather than re-learned: nothing radiates + // from one entry to another, and no name is matched by prefix or leaf. + seedBaseManifest((s) => [...s, REMOVED_DEF, REMOVED_DEF_2].sort()); + seedRetiredDefs({ [CURRENT_MAJOR]: [REMOVED_DEF] }); + + const { status, output } = runBox(['--check']); + + expect(status).toBe(1); + expect(output).toContain(`1 ${NO_REGISTERED_REMOVAL}`); + expect(output).toContain(` - json-schema/${REMOVED_DEF_2}.json`); + // The declared one appears only in the ℹ️ notice, never in the fatal list. + // Matched with its trailing newline, because the notice's line opens with + // the same path and then goes on to name the major. + expect(output).toContain( + `json-schema/${REMOVED_DEF}.json — RETIRED_DEFS_BY_MAJOR, major ${CURRENT_MAJOR}`, + ); + expect(output).not.toContain(` - json-schema/${REMOVED_DEF}.json\n`); + }, + ); + + it( + 'an entry naming a schema this build still publishes is red: a registration nothing consumed', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // The mirror of check (b2), one level up. Without it an author could + // pre-register the def they intend to unpublish, and the real removal + // would land later — in someone else's PR — with this gate already + // satisfied and nobody writing anything down at the time it happened. + seedRetiredDefs({ [CURRENT_MAJOR]: [STILL_PUBLISHED_DEF] }); + + const { status, output } = runBox(['--check']); + + expect(status).toBe(1); + expect(output).toContain('RETIRED_DEFS_BY_MAJOR entr(ies) name a schema this build still publishes'); + expect(output).toContain(` - ${STILL_PUBLISHED_DEF} (registered at major ${CURRENT_MAJOR})`); + expect(output).not.toContain(NO_REGISTERED_REMOVAL); + }, + ); + + it( + 'a declared def rename is not a removal — RENAMED_DEFS is consulted before the table', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + const [renamedSource] = Object.keys(RENAMED_DEFS); + expect(renamedSource, 'RENAMED_DEFS is empty — this test exercises nothing').toBeTruthy(); + // The source def is gone from the build by construction (checkRenameTable + // rejects a table whose source is still emitted), so at the merge base it + // is a manifest key this build does not emit — a removal in every respect + // except the one that matters. + seedBaseManifest((s) => [...s, renamedSource!].sort()); + + const { status, output } = runBox(['--check']); + + expect(output).not.toContain(NO_REGISTERED_REMOVAL); + expect(output).not.toContain('left the published set since'); + expect(status).toBe(0); + }, + ); + + it( + 'offline: the comparison says so and skips, while the registration mirror still runs', + { timeout: SPAWN_TIMEOUT_MS * 2 }, + () => { + // #5235's posture, not a third one. The comparison has no baseline with + // origin/main out of reach, so it reports that and skips — those trees are + // immutable, already-merged builds (image stages, air-gapped, forks, + // historical tags) where "what did this PR delete relative to main" is not + // a question that exists. The mirror needs no git and therefore keeps + // running: an entry that pre-approves a removal is wrong everywhere. + seedBaseManifest((s) => [...s, REMOVED_DEF].sort()); + boxGit('update-ref', '-d', 'refs/remotes/origin/main'); + try { + const skipped = runBox(['--check']); + expect(skipped.status).toBe(0); + expect(skipped.output).toContain('json-schema.manifest.json removal check'); + expect(skipped.output).toContain('no git-resolved baseline'); + expect(skipped.output).not.toContain(NO_REGISTERED_REMOVAL); + + seedRetiredDefs({ [CURRENT_MAJOR]: [STILL_PUBLISHED_DEF] }); + const mirrored = runBox(['--check']); + expect(mirrored.status).toBe(1); + expect(mirrored.output).toContain('name a schema this build still publishes'); + } finally { + boxGit('update-ref', 'refs/remotes/origin/main', head); + } + }, + ); + + it( + 'end to end: unpublishing a REACHABLE def family is red, where every gate used to be green', + { timeout: SPAWN_TIMEOUT_MS * 2 }, + () => { + // The measured repro, run against real source rather than modelled. The + // barrel line goes; `ObjectSchema` still imports ValidationRuleSchema from + // './validation.zod', so all 7 defs stay reachable from the `object` + // metadata root and every one of their 116 keys stays authorable — only + // their JSON Schemas, their manifest lines and the ratchet's record of + // them disappear. Then the two hand-edits the old procedure sanctioned: + // delete the manifest lines ("a deliberate retirement") and the baseline + // lines (without which check (a) fires first). + fs.writeFileSync(boxBarrel, pristineBarrel.replace(BARREL_LINE, '')); + writeManifest((s) => s.filter((k) => !UNPUBLISHED_FAMILY.includes(k))); + const surface = JSON.parse(pristineSurface) as { description: string; keys: string[] }; + const kept = surface.keys.filter( + (k) => !UNPUBLISHED_FAMILY.some((d) => k.startsWith(d + ':')), + ); + expect( + surface.keys.length - kept.length, + 'the family stopped carrying authorable keys — re-measure the repro', + ).toBeGreaterThan(0); + surface.keys = kept; + fs.writeFileSync(boxSurface, JSON.stringify(surface, null, 2) + '\n'); + + const { status, output } = runBox(['--check']); + + expect(status).toBe(1); + expect(output).toContain(`${UNPUBLISHED_FAMILY.length} ${NO_REGISTERED_REMOVAL}`); + for (const def of UNPUBLISHED_FAMILY) { + expect(output).toContain(` - json-schema/${def}.json`); + expect(output).toContain(` '${def}',`); + } + // The old verdict, in full: check (c) waived all 116 lines as "def no + // longer emitted", the manifest ratchet said nothing, and the run exited + // 0. The gate now exits before check (c) reaches that branch at all. + expect(output).not.toContain('carry their own proof'); + expect(output).not.toContain('baseline deletion(s) since'); + }, + ); +}); diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index 8f1347c72f..8bef4f8317 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -12,7 +12,11 @@ import { z } from 'zod'; import { schemaNameFromExportKey } from './lib/schema-name'; import { RENAMED_DEFS, carryAuthorableKey, checkRenameTable } from './lib/renamed-defs'; import { CONVERSIONS_BY_MAJOR } from '../src/conversions/registry'; -import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../src/migrations/registry'; +import { + MIGRATIONS_BY_MAJOR, + RETIRED_DEFS_BY_MAJOR, + RETIRED_KEYS_BY_MAJOR, +} from '../src/migrations/registry'; import { getMetadataTypeSchema, listMetadataTypeSchemaTypes, @@ -419,6 +423,21 @@ interface SchemaManifest { schemas: string[]; } +/** + * The manifest's own description — the procedure a reader who opens the file to + * delete a line follows. Until #4725 it ended "remove a key ONLY for a + * deliberate retirement", which was the entire requirement and was checked by + * nothing; it now names the gate and the table that answer for a removal. + */ +const MANIFEST_DESCRIPTION = + 'Ratchet manifest of every JSON Schema emitted by scripts/build-schemas.ts. ' + + 'Auto-appended when new schemas are added (commit the change). A listed schema that a ' + + 'build no longer emits fails gen:schema. DELETING a key is gated too (#4725): the removal ' + + 'is measured against this file at the merge base with origin/main — which the commit under ' + + 'test cannot rewrite — and every def that leaves the published set must be declared in ' + + 'RETIRED_DEFS_BY_MAJOR (src/migrations/registry.ts), or in RENAMED_DEFS ' + + '(scripts/lib/renamed-defs.ts) when it is a rename rather than a removal. See #2978, #4725.'; + let manifest: SchemaManifest | null = null; try { manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf-8')) as SchemaManifest; @@ -460,8 +479,10 @@ if (missing.length > 0) { ` Zod change made it unrepresentable (e.g. an added .transform in "output" AND "input"\n` + ` io modes) or an export was renamed/removed. Fix the schema, or — if the removal is\n` + ` deliberate — delete the key(s) from packages/spec/json-schema.manifest.json in the\n` + - ` same PR. Silently unpublishing a schema deletes its reference docs on the next\n` + - ` gen:docs run (see #2978).`, + ` same PR AND declare each one in RETIRED_DEFS_BY_MAJOR (src/migrations/registry.ts),\n` + + ` which the manifest deletion gate below requires (#4725). Deleting the line alone\n` + + ` used to be the whole procedure, and nothing checked it. Silently unpublishing a\n` + + ` schema deletes its reference docs on the next gen:docs run (see #2978).`, ); process.exit(1); } @@ -472,7 +493,15 @@ const added = [...generatedKeys].filter((key) => !(manifest?.schemas ?? []).incl // existed. Without this the stale key would sit in the manifest forever, kept // alive only by its RENAMED_DEFS entry. const renamedAway = (manifest?.schemas ?? []).filter((key) => key in RENAMED_DEFS); -const manifestChanged = !manifest || added.length > 0 || renamedAway.length > 0; +// The file's own description states the procedure for deleting a key, and #4725 +// changed that procedure from "do it deliberately" to "declare it in +// RETIRED_DEFS_BY_MAJOR". A generated file that documents a superseded procedure +// is read by whoever opens it to delete a line — precisely the reader the gate +// exists for — so the text is part of the artifact and drifting from it is +// staleness like any other. Reported separately below: "0 schema(s) not +// recorded" would be a confusing way to say the prose moved. +const descriptionStale = !!manifest && manifest.description !== MANIFEST_DESCRIPTION; +const manifestChanged = !manifest || added.length > 0 || renamedAway.length > 0 || descriptionStale; if (manifestChanged && CHECK) { // Removals already exited above; reaching here in check mode means the manifest // is behind on ADDITIONS (or still lists a def that RENAMED_DEFS moved away). @@ -483,11 +512,14 @@ if (manifestChanged && CHECK) { // generated artifact of eight that can never go red in CI — "stale ⇒ rewrite it // for you" instead of "stale ⇒ run the generator" (#4711). Same split as the // authorable-surface ratchet below. + const onlyDescription = manifest && added.length === 0 && renamedAway.length === 0; console.error( - manifest - ? `\n❌ json-schema.manifest.json is out of date (${added.length} schema(s) not recorded` + - `${renamedAway.length > 0 ? `, ${renamedAway.length} renamed-away key(s) still listed` : ''}).` - : `\n❌ json-schema.manifest.json is missing (${generatedKeys.size} schema(s) unrecorded).`, + !manifest + ? `\n❌ json-schema.manifest.json is missing (${generatedKeys.size} schema(s) unrecorded).` + : onlyDescription + ? `\n❌ json-schema.manifest.json carries a stale description (the key set is current).` + : `\n❌ json-schema.manifest.json is out of date (${added.length} schema(s) not recorded` + + `${renamedAway.length > 0 ? `, ${renamedAway.length} renamed-away key(s) still listed` : ''}).`, ); for (const key of added.slice(0, 20)) console.error(` + json-schema/${key}.json`); if (added.length > 20) console.error(` … and ${added.length - 20} more`); @@ -501,16 +533,16 @@ if (manifestChanged && CHECK) { } if (manifestChanged && !CHECK) { const updated: SchemaManifest = { - description: - 'Ratchet manifest of every JSON Schema emitted by scripts/build-schemas.ts. ' + - 'Auto-appended when new schemas are added (commit the change). A listed schema that a ' + - 'build no longer emits fails gen:schema — remove a key ONLY for a deliberate retirement. See #2978.', + description: MANIFEST_DESCRIPTION, schemas: [...generatedKeys].sort(), }; fs.writeFileSync(MANIFEST_PATH, JSON.stringify(updated, null, 2) + '\n'); - console.log( - `\n📒 json-schema.manifest.json ${manifest ? `updated (+${added.length} schema(s))` : `created (${generatedKeys.size} schemas)`} — commit it.`, - ); + const what = !manifest + ? `created (${generatedKeys.size} schemas)` + : added.length > 0 || renamedAway.length > 0 + ? `updated (+${added.length} schema(s))` + : 'description refreshed (key set unchanged)'; + console.log(`\n📒 json-schema.manifest.json ${what} — commit it.`); } // ─── Authorable-surface ratchet (#3855 follow-up) ──────────────────── @@ -767,8 +799,13 @@ if (surfaceDoc) { // license to change the schema (plugin manifests, connector configs and // other non-metadata authoring go through their own gates); // 3. the whole def is no longer emitted — whole-schema removals are -// adjudicated by the json-schema.manifest.json ratchet (#2978) and -// check:api-surface, not by this per-key ratchet. +// adjudicated by the json-schema.manifest.json ratchet (#2978), not by +// this per-key ratchet. Until #4725 that deferral was to nothing: the +// ratchet's `missing` set was computed from the same-commit manifest, so +// 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. /** A tombstone may be deleted once its registration is this many majors old. */ const TOMBSTONE_AGE_MAJORS = 2; @@ -1369,8 +1406,179 @@ function resolveSurfaceBase(): { rev: string; doc: AuthorableSurface } | null { process.exit(1); } +// ─── The manifest deletion gate (#4725) ─────────────────────────────── +// +// #4650 closed the hand-edit shortcut one level down, for authorable KEYS. It +// left the whole-def case to the #2978 manifest ratchet, and route 3 of check +// (c) still says so in as many words: "whole-schema removals are adjudicated by +// json-schema.manifest.json". They were not. That ratchet's `missing` set is +// `manifest − emitted` with the manifest read from THIS commit — the same +// same-commit-evidence defect #4650 exists for — so a PR that deleted the +// export, the manifest line and the baseline lines together produced an empty +// `missing`, a check (c) that waived every key under the now-gone def, and an +// `api-surface` diff that a regeneration turns green. Three gates, nothing said. +// +// Measured on #4725, by deleting ONE barrel re-export (`export * from +// './validation.zod'` in src/data/index.ts, whose defs ObjectSchema imports +// directly and therefore keeps parsing metadata with): 7 defs and 116 authorable +// keys left the published contract with `gen:schema`, `check:authorable-surface` +// and `check:api-surface` all exiting 0. +// +// So the removal is re-anchored the way #4650 re-anchored key deletions: against +// json-schema.manifest.json at the merge base with origin/main, which the commit +// under test cannot rewrite. The proof demanded there is a DECLARATION — +// RETIRED_DEFS_BY_MAJOR — and deliberately not reachability, which #4650 uses +// per key. Reachability is keyed by `zodByDefKey`, populated only for defs this +// build EMITS, so `reachableVia()` answers `null` ("unreachable", i.e. waived) +// for every def that just stopped being emitted — a green light aimed exactly at +// the removals this gate is for. That is not a bug to fix by widening the BFS: +// the def is gone from the source, so there is no schema left to walk. + +/** The manifest file, by name — what every message here points the reader at. */ +const MANIFEST_FILE_NAME = path.basename(MANIFEST_PATH); + +/** + * Every def the ADR-0087 registries declare as unpublished, by exact + * `${category}/${SchemaName}`, mapped to the earliest major that declared it. + * + * No rename carry (cf. `carryAuthorableKey` above): a renamed def is not + * retired, and `RENAMED_DEFS` is consulted separately by the gate. A def cannot + * be in both tables — `checkRenameTable` rejects a rename whose target this + * build no longer emits. + */ +function registeredRetiredDefs(): Map { + const out = new Map(); + for (const [major, defs] of Object.entries(RETIRED_DEFS_BY_MAJOR)) { + for (const def of defs) { + const prev = out.get(def); + if (prev === undefined || Number(major) < prev) out.set(def, Number(major)); + } + } + return out; +} + +/** + * Adjudicate whole-schema removals against the merge-base manifest. + * + * `baseRev` is the git-resolved baseline — the same rev the #4650 key gate + * anchors on — or null when this build could not resolve origin/main at all. + * + * Offline posture, following #5235 rather than inventing a third one: the + * REGISTRATION MIRROR below is git-free and always runs, while the removal + * comparison is a verification that has no baseline offline and therefore says + * so and skips — the same call `verifyCommittedSurfaceBase` makes when it cannot + * fetch the commit it would check. It is not a bypass a PR can reach for: the + * environments without a route to GitHub are immutable already-merged trees + * (image-build stages, air-gapped builds, forks, historical tags), where "what + * did this PR delete relative to main" is not a question that exists. Unlike the + * key gate, no in-tree anchor is needed to keep them building, because a skipped + * comparison fails nothing. + */ +function checkManifestRemovals(git: GitRun, baseRev: string | null): void { + const registered = registeredRetiredDefs(); + + // The (b2) mirror, one level up: an entry that pre-approves a removal nobody + // performed. Left unchecked it would let the real removal land later, in + // someone else's PR, with this gate already satisfied and nothing written down + // at the time it happened. + const stillPublished = [...registered.entries()].filter(([def]) => generatedKeys.has(def)); + if (stillPublished.length > 0) { + console.error( + `\n❌ ${stillPublished.length} RETIRED_DEFS_BY_MAJOR entr(ies) name a schema this build still publishes:`, + ); + for (const [def, major] of stillPublished) { + console.error(` - ${def} (registered at major ${major})`); + } + console.error( + `\n RETIRED_DEFS_BY_MAJOR records defs that HAVE left the published set — no\n` + + ` json-schema/.json, no line in ${MANIFEST_FILE_NAME}. These are still emitted,\n` + + ` so the entry registers a removal nobody performed, and the gate below would accept\n` + + ` the real one later without it ever being declared.\n\n` + + ` Either remove the export (and its ${MANIFEST_FILE_NAME} line) in this PR, or delete\n` + + ` the entry from packages/spec/src/migrations/registry.ts.`, + ); + process.exit(1); + } + + if (baseRev === null) { + console.log( + `ℹ️ ${MANIFEST_FILE_NAME} removal check: no git-resolved baseline in this build\n` + + ` environment, so whole-schema removals are not compared here (#4725, offline posture\n` + + ` of #5235). Every environment that can reach origin/main runs it.`, + ); + return; + } + + const short = baseRev.slice(0, 12); + const show = git('show', `${baseRev}:./${MANIFEST_FILE_NAME}`); + if (show.status !== 0) { + if (/does not exist in|exists on disk, but not in/.test(show.stderr)) { + console.log( + `ℹ️ ${MANIFEST_FILE_NAME} removal check: no ${MANIFEST_FILE_NAME} at base ${short} — nothing to compare.`, + ); + return; + } + console.error(`\n❌ Failed to read ${MANIFEST_FILE_NAME} at base ${short} (#4725):\n${show.stderr}`); + process.exit(1); + } + let baseSchemas: string[]; + try { + baseSchemas = (JSON.parse(show.stdout) as SchemaManifest).schemas ?? []; + } catch (error) { + console.error(`\n❌ ${MANIFEST_FILE_NAME} at base ${short} is not valid JSON (#4725): ${error}`); + process.exit(1); + } + + // Measured against what this build EMITS, never against the manifest file in + // the tree: the file is what the PR can rewrite, and rewriting it is the + // bypass. A base key still listed in the tree's manifest but no longer emitted + // has already exited above, in the disappearance ratchet. + const removed = baseSchemas.filter((key) => !generatedKeys.has(key) && !(key in RENAMED_DEFS)); + const unregistered = removed.filter((key) => !registered.has(key)); + const declared = removed.filter((key) => registered.has(key)); + if (declared.length > 0) { + console.log(`\nℹ️ ${declared.length} schema(s) left the published set since ${short}, each declared (#4725):`); + for (const def of declared) { + console.log(` - json-schema/${def}.json — RETIRED_DEFS_BY_MAJOR, major ${registered.get(def)}.`); + } + } + if (unregistered.length === 0) return; + + console.error( + `\n❌ ${unregistered.length} schema(s) left the published set with no registered removal (#4725):`, + ); + for (const def of unregistered) console.error(` - json-schema/${def}.json`); + console.error( + `\n ${MANIFEST_FILE_NAME} is the committed record of every schema this repo has ever\n` + + ` published — the \`$id\` URLs under schema.objectstack.io, IDE validation, gen:docs\n` + + ` input. Its disappearance ratchet reads that file from THIS commit, so deleting the\n` + + ` export and the manifest line in one PR left nothing to detect, and the #4650 key gate\n` + + ` waived every baseline line under the vanished def on the grounds that this file would\n` + + ` adjudicate it. Removals are therefore compared against ${MANIFEST_FILE_NAME} at the\n` + + ` merge base ${short} with origin/main, which this commit cannot rewrite.\n\n` + + ` 1. Declare each removal by its EXACT def key in RETIRED_DEFS_BY_MAJOR\n` + + ` (packages/spec/src/migrations/registry.ts) — copy these lines in:\n\n` + + unregistered.map((def) => ` '${def}',\n`).join('') + + `\n under \`${CURRENT_MAJOR}: [ … ]\` (create the major's array if it is the first).\n\n` + + ` 2. Add a D2 conversion in src/conversions/registry.ts naming the surface (and a D3\n` + + ` chain step referencing it) plus a \`major\` changeset, so the removal reaches\n` + + ` spec-changes.json, the upgrade guide and \`os migrate meta\` — the table is the\n` + + ` proof it was declared, the conversion is the prescription a consumer follows.\n\n` + + ` If the schema was not meant to disappear at all, this is not the fix: an added\n` + + ` \`.transform\` can make a schema unrepresentable in BOTH io modes and silently\n` + + ` unpublish it (#2967). And a def published under a NEW name is a rename — declare it\n` + + ` in RENAMED_DEFS (scripts/lib/renamed-defs.ts), which carries its authorable keys\n` + + ` across; an entry here would falsely claim the contract shrank.`, + ); + process.exit(1); +} + { const base = resolveSurfaceBase(); + // Whole defs first: check (c) below waives every baseline line under a def this + // build stopped emitting, on the grounds that this gate adjudicates it. Running + // it first is what makes that deferral true rather than circular. + checkManifestRemovals(gitInPackage, gitResolvedAnchor?.rev ?? null); if (base) { // Carry base keys through declared def renames first — same discipline as // the snapshot carry above — so a rename is never misread as a deletion. @@ -1440,7 +1648,9 @@ function resolveSurfaceBase(): { rev: string; doc: AuthorableSurface } | null { for (const [defKey, keyCount] of goneDefs) { allowed.push( `${defKey}:* (${keyCount} line(s)) — def no longer emitted by this build; whole-schema\n` + - ` removals are adjudicated by json-schema.manifest.json (#2978) and check:api-surface.`, + ` removals are adjudicated by json-schema.manifest.json (#2978) — since #4725 by the\n` + + ` manifest deletion gate that ran above, which required a declared removal for it\n` + + ` (until then this deferral pointed at a ratchet that said nothing).`, ); } if (allowed.length > 0) { @@ -1462,7 +1672,9 @@ function resolveSurfaceBase(): { rev: string; doc: AuthorableSurface } | null { ` AND that registration is ≥ ${TOMBSTONE_AGE_MAJORS} majors old (≤ v${CURRENT_MAJOR - TOMBSTONE_AGE_MAJORS}); or\n` + ` 2. its def is not reachable from the metadata-type roots — this gate computes\n` + ` that itself (it would have said so above); or\n` + - ` 3. its whole def stopped being emitted (adjudicated by the manifest ratchet).\n\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` + ` 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/).`, diff --git a/packages/spec/src/migrations/index.ts b/packages/spec/src/migrations/index.ts index 0ddf24a264..ad4ee847d1 100644 --- a/packages/spec/src/migrations/index.ts +++ b/packages/spec/src/migrations/index.ts @@ -21,6 +21,7 @@ export { MIGRATIONS_BY_MAJOR, MIGRATION_MAJORS, MIGRATION_SUPPORT_FLOOR, + RETIRED_DEFS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR, } from './registry.js'; export { diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 2484edeeba..995095e295 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -2041,3 +2041,80 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // Empty by design at protocol 17: see "Not a backfill of history" above. The // first entry arrives with the first retirement tombstoned after #4659. }; + +/** + * Every whole JSON Schema **def** UNPUBLISHED at each protocol major, named + * EXACTLY. + * + * One entry per def, spelled `${category}/${SchemaName}` — the same string + * `packages/spec/json-schema.manifest.json` records (`'identity/Session'`, + * `'data/ValidationRule'`). The sibling of {@link RETIRED_KEYS_BY_MAJOR} one + * level up: that table registers a single authorable KEY being tombstoned, this + * one registers a whole SCHEMA leaving the published set. + * + * ## What reads it + * + * The manifest deletion gate in `scripts/build-schemas.ts` + * (`check:authorable-surface`): a def listed in `json-schema.manifest.json` at + * the merge base with origin/main that this build no longer emits must appear + * here, by exact set membership, or the build fails. Nothing else consumes the + * table, and nothing infers an entry. + * + * ## Why the manifest line alone was not a declaration + * + * The #2978 ratchet asks for one, in a comment: "remove a key ONLY for a + * deliberate retirement". Nothing checked it. The ratchet's `missing` set is + * `manifest − emitted` with the manifest read from the SAME commit, so a PR that + * deleted the export and the manifest line together produced an empty `missing` + * and a silent gate — the exact shape #4650 had just closed one level down, for + * authorable keys. Worse, the two gates deferred to each other: the #4650 + * deletion gate waives every baseline key under a def the build stopped emitting + * ("adjudicated by json-schema.manifest.json"), and the manifest said nothing. + * Measured on #4725 by dropping one barrel re-export: 7 defs and 116 authorable + * keys left the contract with `gen:schema`, `check:authorable-surface` and + * `check:api-surface` all green. + * + * The fix is the #4650 structure: the removal is judged against the manifest at + * the merge base — which the commit under test cannot rewrite — and the deleted + * line has to be answered by a declaration written down HERE, under a major. + * + * ## Why not reachability + * + * #4650's per-key gate lets a deletion prove itself by showing the def is + * unreachable from the metadata-type roots, computed by BFS over the build's own + * Zod graph. That proof is unavailable one level up, and unavailable in the + * dangerous direction: reachability is keyed by `zodByDefKey`, which only holds + * defs this build EMITS, so a def that just stopped being emitted answers + * "unreachable" — a waiver for exactly the removals the gate exists to catch. + * Declaration is therefore the only honest proof at def granularity. + * + * ## What it does NOT replace + * + * A rename is not a retirement: a def published under a new name is declared in + * `scripts/lib/renamed-defs.ts` (`RENAMED_DEFS`), which the gate consults first, + * and an entry here would be a false claim that the contract shrank. And as with + * {@link RETIRED_KEYS_BY_MAJOR}, the D2 conversion (`src/conversions/registry.ts`) + * plus its D3 chain step stay the *documentation* channel that `spec-changes.json`, + * the upgrade guide and `os migrate meta` project; an entry here is the *proof + * the removal was declared*. A retirement needs both. + * + * ## Not a backfill of history + * + * The gate fires only on a def that leaves the published set relative to the + * merge base, so removals that landed before this table existed never re-trigger + * it and are deliberately absent. This reads "whole-schema removals registered + * under the manifest deletion gate", not "every schema ever unpublished". + * + * ## Lifecycle + * + * Entries are permanent, and after the removal merges they name a def no build + * emits — the expected steady state. The one state the gate rejects is an entry + * naming a def this build STILL publishes: a registration nothing consumed, + * pre-approving a removal that has not happened. + * + * @see scripts/build-schemas.ts — the manifest deletion gate, the only consumer + */ +export const RETIRED_DEFS_BY_MAJOR: Readonly> = { + // Empty by design at protocol 17: see "Not a backfill of history" above. The + // first entry arrives with the first whole-schema removal after #4725. +};