From b9d4537980624cb6723a46a238bdad0606f949b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 23:47:59 +0000 Subject: [PATCH] fix(spec): deleted authorable-surface baseline lines must prove themselves (#4650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check (a) reads authorable-surface.json from the commit under check, so hand-deleting a baseline line deleted the evidence it runs on (#4638, #4643 landed exactly that way; #4662 proved the file was hand-edited). gen:schema / check:authorable-surface now add check (c): every key present at the merge base with origin/main but absent from this build must carry one of three in-gate proofs — 1. aged-out tombstone: base entry [RETIRED] + an ADR-0087 conversion/migration registered >= 2 majors ago; 2. def not reachable from the metadata-type roots (2026-08-02 ruling): BFS over the build's in-memory Zod graph from BUILTIN_METADATA_TYPE_SCHEMAS + EXTRA_METADATA_TYPE_SCHEMAS, with derived-clone bridging so .refine()/.extend() copies keep their originals protected; waives ONLY this file's tombstone requirement; 3. whole def no longer emitted (manifest ratchet / api-surface jurisdiction). Anchoring on the merge base (not HEAD) keeps the check alive in CI, where HEAD is the PR's own commit and a HEAD-relative diff is always empty. --check further rejects any byte of the file that is not the generator's output (#4662 description drift class); write mode regenerates it. Checks (a0)/(a)/(b) unchanged and pinned by tests. Fixes #4650 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0176qgxgCXTJCUv4YFLtusP9 --- .changeset/authorable-surface-delete-proof.md | 36 ++ .../scripts/build-schemas-check-mode.test.ts | 305 ++++++++++++ packages/spec/scripts/build-schemas.ts | 465 +++++++++++++++++- 3 files changed, 781 insertions(+), 25 deletions(-) create mode 100644 .changeset/authorable-surface-delete-proof.md diff --git a/.changeset/authorable-surface-delete-proof.md b/.changeset/authorable-surface-delete-proof.md new file mode 100644 index 0000000000..8f3a635a41 --- /dev/null +++ b/.changeset/authorable-surface-delete-proof.md @@ -0,0 +1,36 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): deleted authorable-surface baseline lines must prove themselves (#4650) + +The authorable-surface ratchet's check (a) reads `authorable-surface.json` from +the same commit it is checking, so hand-deleting a baseline line deleted the +very evidence the check runs on — #4638 and #4643 both removed authorable keys +with zero registered conversions and a green gate, and #4662 proved the file +had been hand-edited. `gen:schema` (and `check:authorable-surface`) now anchor +deletions on the baseline at the **merge base with `origin/main`** — the one +version of the file a PR cannot rewrite (comparing against `HEAD:` would be +vacuous in CI, where HEAD is the PR's own commit) — and every deleted line must +carry one of three proofs, all computed inside the gate: + +1. **Aged-out tombstone** — the base entry was `[RETIRED]` and its surface is + registered in `CONVERSIONS_BY_MAJOR` / `MIGRATIONS_BY_MAJOR` at a major ≥ 2 + behind the current one (the "~two majors" the file's description has always + promised, now enforced). +2. **Not reachable from the metadata-type roots** (2026-08-02 ruling on #4650) + — BFS over the build's in-memory Zod graph from + `BUILTIN_METADATA_TYPE_SCHEMAS` + the `EXTRA_METADATA_TYPE_SCHEMAS` overlay, + with derived-clone bridging so `.refine()`/`.extend()` copies (e.g. + `ViewSchema` inside `ViewMetadataSchema`) keep their originals protected. + Over-collected entries (REST envelopes and other never-parsed defs) may be + deleted without a tombstone; the exception waives only this file's + requirement and is not a license to change the schema. +3. **The whole def left the build** — adjudicated by the + `json-schema.manifest.json` ratchet (#2978) and `check:api-surface`. + +`--check` additionally rejects any byte of `authorable-surface.json` that is +not the generator's own output (description/formatting hand-edits included, +per #4662); write mode regenerates such drift. Checks (a0)/(a)/(b) are +unchanged. Build-time gate only — no runtime export, schema shape, or +generated artifact changes. diff --git a/packages/spec/scripts/build-schemas-check-mode.test.ts b/packages/spec/scripts/build-schemas-check-mode.test.ts index 3748da0c39..35310acdf6 100644 --- a/packages/spec/scripts/build-schemas-check-mode.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.test.ts @@ -33,6 +33,13 @@ // read-only inputs — `src/`, `node_modules/`, `package.json`. That keeps the // production code path byte-for-byte: no test-only seam is added to the gate, // because a seam is itself a place where the gate can differ from what CI runs. +// +// The sandbox is also a REAL git repository with a fabricated +// `refs/remotes/origin/main`, because the authorable-surface deletion check +// (#4650) anchors on the baseline at the merge base with origin/main — the one +// version of the file a PR cannot rewrite. Fabricating the ref (rather than +// injecting a base through some test-only env var) keeps that discipline: the +// gate runs exactly the git resolution CI runs. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { spawnSync } from 'node:child_process'; @@ -42,6 +49,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { RENAMED_DEFS } from './lib/renamed-defs'; +import { CONVERSIONS_BY_MAJOR } from '../src/conversions/registry'; +import { MIGRATIONS_BY_MAJOR } from '../src/migrations/registry'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const PKG = path.resolve(HERE, '..'); @@ -64,10 +73,26 @@ const PHANTOM_KEY = 'ui/ZzzNeverEmittedByAnyBuild'; let sandbox: string; let script: string; let manifestPath: string; +let surfacePath: string; let pristine: string; +let pristineSurface: string; + +/** Run git in the sandbox repo; throws on failure so a broken fixture is loud. */ +function git(...args: string[]): string { + const r = spawnSync( + 'git', + ['-c', 'user.name=build-schemas-test', '-c', 'user.email=test@example.invalid', ...args], + { cwd: sandbox, encoding: 'utf8' }, + ); + if (r.status !== 0) { + throw new Error(`git ${args.join(' ')} failed (${r.status}): ${r.stderr}`); + } + return (r.stdout ?? '').trim(); +} beforeAll(() => { pristine = fs.readFileSync(REAL_MANIFEST, 'utf8'); + pristineSurface = fs.readFileSync(path.join(PKG, 'authorable-surface.json'), 'utf8'); sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'build-schemas-check-')); fs.cpSync(path.join(PKG, 'scripts'), path.join(sandbox, 'scripts'), { recursive: true }); for (const entry of ['src', 'node_modules', 'package.json']) { @@ -81,6 +106,14 @@ beforeAll(() => { ); script = path.join(sandbox, 'scripts', 'build-schemas.ts'); manifestPath = path.join(sandbox, 'json-schema.manifest.json'); + surfacePath = path.join(sandbox, 'authorable-surface.json'); + // Anchor for the #4650 deletion check: a git repo whose origin/main holds the + // committed baseline. Only the baseline is tracked — src/node_modules stay + // symlinked, untracked reads the same as any dirty worktree. + git('init', '-q', '-b', 'main', '.'); + git('add', 'authorable-surface.json'); + git('commit', '-q', '-m', 'baseline: committed authorable-surface.json'); + git('update-ref', 'refs/remotes/origin/main', 'HEAD'); }); afterAll(() => { @@ -208,3 +241,275 @@ describe('build-schemas.ts --check — a check reports, it does not write (#4711 }, ); }); + +// ───────────────────────────────────────────────────────────────────────────── +// #4650 — a deleted authorable-surface line must prove itself. +// +// Checks (a)/(b) read authorable-surface.json from the SAME commit, so deleting +// a baseline line deleted the evidence they run on: #4638 and #4643 removed +// authorable keys with zero registered conversions and a green gate. Check (c) +// re-anchors deletions on the baseline at the merge base with origin/main — a +// version the PR cannot rewrite — and demands one of three proofs: an aged-out +// registered tombstone, unreachability from the metadata-type roots (2026-08-02 +// ruling), or the whole def leaving the build (the manifest ratchet's domain). +// +// Fixtures sabotage the BASE (extra lines committed under origin/main) while +// the worktree file stays canonical: set-wise identical to the real attack +// (line present at base, absent from the PR) without editing symlinked src/. +// The live end-to-end shape — delete a prop from src AND its baseline line — +// is exercised in the PR's recorded sabotage evidence instead. + +const readSurface = () => fs.readFileSync(surfacePath, 'utf8'); + +/** Write a mutated baseline to the sandbox worktree; returns the exact bytes. */ +function seedSurface(mutate: (keys: string[]) => string[]): string { + const doc = JSON.parse(pristineSurface) as { description: string; keys: string[] }; + doc.keys = mutate(doc.keys); + const text = JSON.stringify(doc, null, 2) + '\n'; + fs.writeFileSync(surfacePath, text); + return text; +} + +/** Commit a BASE variant of the baseline and point origin/main at it. */ +function seedBase(mutate: (keys: string[]) => string[]): string { + seedSurface(mutate); + git('add', 'authorable-surface.json'); + git('commit', '-q', '--allow-empty', '-m', 'base variant'); + const sha = git('rev-parse', 'HEAD'); + git('update-ref', 'refs/remotes/origin/main', sha); + return sha; +} + +/** Earliest ADR-0087 registration matching a surface leaf — the gate's clause + * vocabulary (see check (b)/(c) in build-schemas.ts), reproduced here ONLY to + * validate fixture choices loudly, never to assert gate behaviour. */ +function minRegisteredMajorForLeaf(leaf: string): number | null { + let min: number | null = null; + const consider = (surface: string, major: number): void => { + for (const clause of surface.split(' / ')) { + if (clause.endsWith('.' + leaf)) min = min === null ? major : Math.min(min, major); + } + }; + for (const [major, list] of Object.entries(CONVERSIONS_BY_MAJOR)) { + for (const c of list) consider(c.surface, Number(major)); + } + for (const [major, step] of Object.entries(MIGRATIONS_BY_MAJOR)) { + for (const sem of step.semantic ?? []) consider(sem.surface, Number(major)); + } + return min; +} + +const CURRENT_MAJOR = Number.parseInt( + (JSON.parse(fs.readFileSync(path.join(PKG, 'package.json'), 'utf8')) as { version: string }) + .version, + 10, +); + +/** Reachable def (BUILTIN root `object`), never tombstoned — the #4643 shape. */ +const DELETED_LIVE = 'data/Object:zzNeverRetired4650'; +/** Reachable def, tombstoned at base but never registered in the ADR-0087 registries. */ +const DELETED_UNREGISTERED = 'data/Object:zzRetiredButUnregistered4650 [RETIRED]'; +/** Tombstoned AND registered, but too recently: `skill.triggerPhrases` was + * registered at protocol 17. The guard below fails loudly once that ages out — + * re-pick a clause registered within the last TOMBSTONE_AGE_MAJORS majors. */ +const DELETED_UNAGED_LEAF = 'triggerPhrases'; +const DELETED_UNAGED = `ai/Agent:${DELETED_UNAGED_LEAF} [RETIRED]`; +/** Emitted but not reachable from any metadata-type root: a REST response + * envelope no metadata document is ever parsed against (the issue's own + * over-collection example). */ +const DELETED_UNREACHABLE = 'api/SessionResponse:zzOverCollected4650'; +/** Def the build no longer emits at all — the literal #4643 cluster. */ +const DELETED_GONE_DEF = ['identity/Session:userId', 'identity/Session:token']; +/** Aged-out tombstone: `object.compactLayout` registered at protocol 11 — + * ≥ 2 majors behind any current major, so this fixture never goes stale. */ +const DELETED_AGED_LEAF = 'compactLayout'; +const DELETED_AGED = `data/Object:${DELETED_AGED_LEAF} [RETIRED]`; +/** Base key under a def RENAMED_DEFS moved: carried, so never a deletion. */ +const DELETED_BY_RENAME = 'integration/RateLimitConfig:maxRequests'; + +describe('build-schemas.ts — deleted baseline lines must prove themselves (#4650)', () => { + beforeAll(() => { + // Fixture validity, asserted loudly instead of silently going stale. + const keys = (JSON.parse(pristineSurface) as { keys: string[] }).keys; + for (const injected of [ + DELETED_LIVE, + DELETED_UNREGISTERED, + DELETED_UNAGED, + DELETED_UNREACHABLE, + ...DELETED_GONE_DEF, + DELETED_AGED, + DELETED_BY_RENAME, + ]) { + expect( + keys.includes(injected) || keys.includes(injected.replace(' [RETIRED]', '')), + `fixture ${injected} already exists in the committed baseline — pick another`, + ).toBe(false); + } + expect( + keys.some((k) => k.startsWith('identity/Session:')), + 'identity/Session is emitted again — the vanished-def fixture needs a new def', + ).toBe(false); + const unagedMajor = minRegisteredMajorForLeaf(DELETED_UNAGED_LEAF); + expect( + unagedMajor !== null && CURRENT_MAJOR - unagedMajor < 2, + `'.${DELETED_UNAGED_LEAF}' (registered at major ${unagedMajor}) has aged out at major ` + + `${CURRENT_MAJOR} — re-pick a clause registered within the last 2 majors`, + ).toBe(true); + const agedMajor = minRegisteredMajorForLeaf(DELETED_AGED_LEAF); + expect( + agedMajor !== null && CURRENT_MAJOR - agedMajor >= 2, + `'.${DELETED_AGED_LEAF}' is no longer an aged-out registration`, + ).toBe(true); + // The manifest ratchet runs first; keep it current so every run reaches (c). + seedManifest((s) => s); + }); + + it( + 'fails on deletions of reachable keys — live, unregistered, and not-yet-aged tombstones each say why', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + seedBase((s) => [...s, DELETED_LIVE, DELETED_UNREGISTERED, DELETED_UNAGED].sort()); + const canonical = seedSurface((s) => s); + + const { status, output } = run(['--check']); + + expect(status).toBe(1); + expect(output).toContain('authorable baseline line(s) were deleted without proof (#4650)'); + expect(output).toMatch(/data\/Object:zzNeverRetired4650 — def reachable .* LIVE \(never tombstoned\)/); + expect(output).toMatch(/data\/Object:zzRetiredButUnregistered4650 — .*tombstoned, but no conversion\/migration clause/); + expect(output).toMatch(new RegExp(`ai/Agent:${DELETED_UNAGED_LEAF} — .*registered at major \\d+`)); + expect(output).toMatch(/must age ≥ 2 majors/); + // The remedy names the retirement route, not a hand-edit. + expect(output).toContain('gen:schema'); + expect(readSurface()).toBe(canonical); + }, + ); + + it( + 'still fails after the deletion is COMMITTED (the CI shape): the anchor is the merge base with origin/main, not HEAD', + { timeout: SPAWN_TIMEOUT_MS * 2 }, + () => { + seedBase((s) => [...s, DELETED_LIVE].sort()); + const canonical = seedSurface((s) => s); + // Commit the sabotaged (canonical-minus-line, relative to base) file so + // the worktree is CLEAN — the state CI checks out. A `git show HEAD:` + // anchor would now compare the commit to itself and never fire. + git('add', 'authorable-surface.json'); + git('commit', '-q', '-m', 'PR commit deleting a baseline line'); + + const check = run(['--check']); + expect(check.status).toBe(1); + expect(check.output).toContain('deleted without proof (#4650)'); + expect(check.output).toContain(DELETED_LIVE); + + // Write mode (gen:schema) must refuse identically — regeneration cannot + // bless a deletion either. + const write = run([]); + expect(write.status).toBe(1); + expect(write.output).toContain('deleted without proof (#4650)'); + expect(readSurface()).toBe(canonical); + }, + ); + + it( + 'allows deletions that carry their own proof: unreachable def, vanished def, aged-out tombstone — each with its reason printed', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + seedBase((s) => [...s, DELETED_UNREACHABLE, ...DELETED_GONE_DEF, DELETED_AGED].sort()); + const canonical = seedSurface((s) => s); + + const { status, output } = run(['--check']); + + expect(output).toContain('baseline deletion(s) since'); + expect(output).toContain('carry their own proof (#4650)'); + // Narrow exception (2026-08-02 ruling): computed in-gate from the real + // Zod graph, waiving ONLY the tombstone requirement. + expect(output).toMatch(/api\/SessionResponse:zzOverCollected4650 — def not reachable from the \d+ metadata-type roots/); + expect(output).toContain('BUILTIN_METADATA_TYPE_SCHEMAS + EXTRA_METADATA_TYPE_SCHEMAS'); + expect(output).toContain('not a license to change the schema'); + // Whole-def removal is the manifest ratchet's jurisdiction. + expect(output).toContain('identity/Session:* (2 line(s))'); + expect(output).toContain('json-schema.manifest.json (#2978)'); + // Aged-out tombstone names its registration major. + expect(output).toMatch(/data\/Object:compactLayout — \[RETIRED\] at [0-9a-f]+ and registered at major 11/); + expect(output).toContain('tombstone aged out'); + expect(readSurface()).toBe(canonical); + expect(status).toBe(0); + }, + ); + + it( + 'check (a) is intact: a key the BUILD stops emitting while still recorded is fatal before (c) ever runs', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + const phantom = 'data/Object:zzPhantom4650'; + seedBase((s) => [...s, phantom].sort()); + const withPhantom = seedSurface((s) => [...s, phantom].sort()); + + const { status, output } = run(['--check']); + + expect(status).toBe(1); + expect(output).toMatch(/1 authorable key\(s\) disappeared from the contract/); + expect(output).toContain(phantom); + expect(output).not.toContain('deleted without proof'); + expect(readSurface()).toBe(withPhantom); + }, + ); + + 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 }, + () => { + seedBase((s) => s); + const handEdited = seedSurface((s) => s).replace( + 'Ratchet of every AUTHORABLE key', + 'Ratchet of every AUTHORABLE key', + ); + fs.writeFileSync(surfacePath, handEdited); + + const check = run(['--check']); + expect(check.status).toBe(1); + expect(check.output).toContain('does not match its generated form'); + expect(readSurface()).toBe(handEdited); + + const write = run([]); + expect(write.status).toBe(0); + expect(write.output).toContain('🔑 authorable-surface.json updated'); + expect(readSurface()).toBe(pristineSurface); + }, + ); + + it( + 'fails LOUDLY when origin/main cannot be resolved — a deletion check that silently skips is the bypass again', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + seedBase((s) => s); + seedSurface((s) => s); + git('update-ref', '-d', 'refs/remotes/origin/main'); + try { + const { status, output } = run(['--check']); + expect(status).toBe(1); + expect(output).toContain('Cannot resolve origin/main'); + expect(output).toContain('#4650'); + } finally { + git('update-ref', 'refs/remotes/origin/main', 'HEAD'); + } + }, + ); + + it( + 'a declared def rename is not a deletion: base keys are carried through RENAMED_DEFS before comparing', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + expect(Object.keys(RENAMED_DEFS)).toContain('integration/RateLimitConfig'); + seedBase((s) => [...s, DELETED_BY_RENAME].sort()); + seedSurface((s) => s); + + const { status, output } = run(['--check']); + + expect(output).not.toContain('deleted without proof'); + expect(output).not.toContain('carry their own proof'); + expect(status).toBe(0); + }, + ); +}); diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index 889512a15c..1f82563ab6 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -7,11 +7,16 @@ process.env.OS_EAGER_SCHEMAS = '1'; import fs from 'fs'; import path from 'path'; +import { spawnSync } from 'child_process'; 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 } from '../src/migrations/registry'; +import { + getMetadataTypeSchema, + listMetadataTypeSchemaTypes, +} from '../src/kernel/metadata-type-schemas'; import * as AI from '../src/ai'; import * as API from '../src/api'; import * as Automation from '../src/automation'; @@ -170,6 +175,11 @@ let errorCount = 0; // surface stale/ENOENT entries between write and immediate read). const generatedSchemas = new Map>(); +// The live Zod instance behind each emitted def, so the authorable-surface +// deletion check (#4650) can BFS the REAL schema graph from the metadata-type +// roots instead of approximating reachability from names or imports. +const zodByDefKey = new Map(); + // Error messages for schema types that inherently cannot be represented in JSON Schema. // These are expected warnings, not build-breaking errors. const KNOWN_UNSUPPORTED_PATTERNS = [ @@ -246,6 +256,7 @@ for (const [namespaceName, namespaceExports] of Object.entries(Protocol)) { writeFileWithRetry(filePath, JSON.stringify(jsonSchema, null, 2)); generatedSchemas.set(`${categorySlug}/${schemaName}`, jsonSchema); + zodByDefKey.set(`${categorySlug}/${schemaName}`, value); console.log(` ✓ ${namespaceName.toLowerCase()}/${fileName}${io === 'input' ? ' (input shape)' : ''}`); count++; if (io === 'input') inputModeCount++; @@ -448,9 +459,11 @@ const currentEntries = [...currentKeys.entries()] .map(([k, retired]) => (retired ? k + RETIRED_MARK : k)) .sort(); +let surfaceRaw: string | null = null; let surfaceDoc: AuthorableSurface | null = null; if (fs.existsSync(AUTHORABLE_SURFACE_PATH)) { - surfaceDoc = JSON.parse(fs.readFileSync(AUTHORABLE_SURFACE_PATH, 'utf-8')) as AuthorableSurface; + surfaceRaw = fs.readFileSync(AUTHORABLE_SURFACE_PATH, 'utf-8'); + surfaceDoc = JSON.parse(surfaceRaw) as AuthorableSurface; } if (surfaceDoc) { @@ -547,37 +560,439 @@ if (surfaceDoc) { } } -const surfaceChanged = - !surfaceDoc || JSON.stringify(surfaceDoc.keys) !== JSON.stringify(currentEntries); +// ─── (c) A deleted baseline line must prove itself (#4650) ───────────── +// +// Checks (a0)/(a)/(b) read authorable-surface.json from THIS commit — a file +// the same commit can freely edit. Deleting a baseline line therefore deleted +// the very evidence check (a) runs on: #4638 and #4643 both removed authorable +// keys with zero registered conversions and a green gate, and #4662 later +// proved the file had been hand-edited. So deletions are ratcheted here +// against a baseline the PR cannot rewrite: the file at the merge base with +// origin/main. (Comparing against `HEAD:` would be vacuous exactly where it +// 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 +// inside this gate — never argued in a PR description: +// +// 1. aged-out tombstone — the base entry carried `[RETIRED]` AND its surface +// is registered in CONVERSIONS_BY_MAJOR / MIGRATIONS_BY_MAJOR at a major +// ≥ TOMBSTONE_AGE_MAJORS behind the current one (the "~two majors" this +// file's description has always promised, now enforced); +// 2. the def is not reachable from the metadata-type roots (2026-08-02 +// ruling on #4650): no metadata document is ever parsed by it, so its +// entry was over-collection and there is no author to tombstone for. +// This waives ONLY this file's tombstone requirement — it is NOT a +// 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. + +/** A tombstone may be deleted once its registration is this many majors old. */ +const TOMBSTONE_AGE_MAJORS = 2; +const CURRENT_MAJOR = Number.parseInt(SPEC_VERSION, 10); + +/** + * Every ' / '-separated surface clause registered across the ADR-0087 + * registries, mapped to the EARLIEST major that registered it — the moment the + * retirement became visible to consumers, which is when its aging clock + * started. Same clause vocabulary as check (b) above (#4659 tracks the shared + * leaf-name matching). + */ +function registeredClauseMajors(): Map { + const out = new Map(); + const add = (clause: string, major: number): void => { + const prev = out.get(clause); + if (prev === undefined || major < prev) out.set(clause, major); + }; + for (const [major, list] of Object.entries(CONVERSIONS_BY_MAJOR)) { + for (const c of list) for (const clause of c.surface.split(' / ')) add(clause, Number(major)); + } + for (const [major, step] of Object.entries(MIGRATIONS_BY_MAJOR)) { + for (const sem of step.semantic ?? []) { + for (const clause of sem.surface.split(' / ')) add(clause, Number(major)); + } + } + return out; +} + +function zodDefOf(schema: z.ZodType): Record | null { + const def = (schema as unknown as { _zod?: { def?: unknown } })._zod?.def; + return def && typeof def === 'object' ? (def as Record) : null; +} + +/** + * Every Zod schema instance a node's def references directly: shape values, + * union options, pipe in/out, record key/value, array element, wrapper inner + * types — found by walking the def's plain objects/arrays generically instead + * of enumerating node kinds (which would silently miss the next kind Zod + * adds). Two edges a generic def walk cannot see are added explicitly: + * `z.lazy` hides its target behind `getter()`, and check-clones (`.refine()`, + * `.describe()`, …) point back at the schema they cloned via `_zod.parent` — + * the clone is what a parent schema embeds (`ViewSchema.refine(…)` inside + * ViewMetadataSchema), while the BASELINE def is the original. + */ +function zodChildSchemas(schema: z.ZodType): z.ZodType[] { + const out: z.ZodType[] = []; + const def = zodDefOf(schema); + if (!def) return out; + const seen = new Set(); + const walk = (v: unknown): void => { + if (v == null) return; + if (v instanceof z.ZodType) { + out.push(v); + return; + } + if (typeof v !== 'object') return; + if (seen.has(v)) return; + seen.add(v); + if (Array.isArray(v)) { + for (const x of v) walk(x); + return; + } + if (v instanceof Map) { + for (const x of v.values()) walk(x); + return; + } + const proto = Object.getPrototypeOf(v); + if (proto === Object.prototype || proto === null) { + for (const x of Object.values(v)) walk(x); + } + }; + walk(def); + if (def.type === 'lazy' && typeof def.getter === 'function') { + try { + const inner = (def.getter as () => unknown)(); + if (inner instanceof z.ZodType) out.push(inner); + } catch { + // An unresolvable lazy getter has no graph to traverse; the schema it + // would have produced cannot be parsed against either. + } + } + const parent = (schema as unknown as { _zod?: { parent?: unknown } })._zod?.parent; + if (parent instanceof z.ZodType) out.push(parent); + return out; +} + +/** Unwrap pipes/wrappers/lazies down to a plain object def's shape, if any. */ +function zodShapeOf(schema: z.ZodType, depth = 0): Record | null { + if (depth > 12) return null; + const def = zodDefOf(schema); + if (!def) return null; + if (def.type === 'object') { + const shape = def.shape; + return shape && typeof shape === 'object' ? (shape as Record) : null; + } + if (def.type === 'pipe' && def.in instanceof z.ZodType) return zodShapeOf(def.in, depth + 1); + if (def.type === 'lazy' && typeof def.getter === 'function') { + try { + const inner = (def.getter as () => unknown)(); + if (inner instanceof z.ZodType) return zodShapeOf(inner, depth + 1); + } catch { + return null; + } + } + const wrappers = new Set(['optional', 'nullable', 'default', 'catch', 'readonly', 'nonoptional']); + if (typeof def.type === 'string' && wrappers.has(def.type) && def.innerType instanceof z.ZodType) { + return zodShapeOf(def.innerType, depth + 1); + } + return null; +} + +interface SurfaceReachability { + /** The metadata-type roots the BFS started from. */ + rootTypes: string[]; + /** How a def is reachable, or null when it is not. */ + reachableVia(defKey: string): 'root-graph' | 'derived-clone' | null; +} + +/** + * Reachability of every emitted def from the metadata-type roots — + * BUILTIN_METADATA_TYPE_SCHEMAS plus the EXTRA_METADATA_TYPE_SCHEMAS overlay + * (both behind listMetadataTypeSchemaTypes / getMetadataTypeSchema), i.e. the + * schemas a metadata document is actually parsed against. Computed by BFS over + * THIS build's in-memory Zod graph, per the 2026-08-02 ruling on #4650 — a + * static import/regex approximation misses alias imports, runtime + * registration and casts, so it is deliberately not used here. + * + * 'root-graph': the def's own instance is in the BFS closure. + * 'derived-clone': the closure holds a derived copy of it — `.extend()` / + * `.strip()` clones share no identity with (and no `_zod.parent` link to) the + * original, but they DO share its per-property schema instances, so a def + * whose shape entry (same name, same instance) appears in any visited object + * node is authorable through that clone. Judged conservatively: one shared + * entry marks the whole def reachable — a false "reachable" demands a + * tombstone too many, a false "unreachable" would waive one silently. + */ +function computeSurfaceReachability(): SurfaceReachability { + const rootTypes: string[] = []; + const roots: z.ZodType[] = []; + for (const type of listMetadataTypeSchemaTypes()) { + const schema = getMetadataTypeSchema(type); + if (schema) { + rootTypes.push(type); + roots.push(schema); + } + } + const visited = new Set(); + const queue = [...roots]; + while (queue.length > 0) { + const node = queue.pop()!; + if (visited.has(node)) continue; + visited.add(node); + for (const child of zodChildSchemas(node)) queue.push(child); + } + // (propName → propSchemaInstance) pairs of every visited object node — the + // bridge that recognises derived clones of a def the closure never names. + const bridged = new Map>(); + for (const node of visited) { + const shape = zodShapeOf(node); + if (!shape) continue; + for (const [name, prop] of Object.entries(shape)) { + if (!(prop instanceof z.ZodType)) continue; + let names = bridged.get(prop); + if (!names) { + names = new Set(); + bridged.set(prop, names); + } + names.add(name); + } + } + return { + rootTypes, + reachableVia(defKey: string): 'root-graph' | 'derived-clone' | null { + const schema = zodByDefKey.get(defKey); + if (!schema) return null; + if (visited.has(schema)) return 'root-graph'; + const shape = zodShapeOf(schema); + if (!shape) { + // Emitted with authorable keys but no derivable object shape: nothing + // to bridge on, so fail closed — demand the tombstone route rather + // than silently widening the exception. + return 'root-graph'; + } + for (const [name, prop] of Object.entries(shape)) { + if (prop instanceof z.ZodType && bridged.get(prop)?.has(name)) return 'derived-clone'; + } + return null; + }, + }; +} + +/** + * The committed authorable-surface.json this PR started from: its content at + * the merge base of HEAD and origin/main. Returns null (with a note) only + * when no baseline existed there at all; failure to ANCHOR the base is fatal — + * a deletion check that silently skips is the #4650 bypass with extra steps. + */ +function resolveSurfaceBase(): { rev: string; doc: AuthorableSurface } | null { + const cwd = path.dirname(AUTHORABLE_SURFACE_PATH); + const git = (...args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf-8' as const }); + + // CI's typecheck job checks out shallow with no branch refs, so fetch the + // one ref this check needs (depth 1 — a single snapshot) before giving up. + let tipProbe = git('rev-parse', '--verify', '--quiet', 'origin/main^{commit}'); + if (tipProbe.status !== 0) { + git('fetch', '--quiet', '--depth=1', 'origin', '+refs/heads/main:refs/remotes/origin/main'); + tipProbe = git('rev-parse', '--verify', '--quiet', 'origin/main^{commit}'); + } + if (tipProbe.status !== 0) { + console.error( + `\n❌ Cannot resolve origin/main to anchor the authorable-surface deletion check (#4650).\n\n` + + ` Deleted baseline lines are validated against authorable-surface.json at the\n` + + ` merge base with origin/main — a baseline this commit cannot rewrite. Without\n` + + ` that anchor the tombstone gate can be bypassed by hand-editing the file, so\n` + + ` this build fails instead of silently skipping the check.\n\n` + + ` Fix: \`git fetch origin main\` (or point refs/remotes/origin/main at your\n` + + ` upstream main) and re-run.`, + ); + process.exit(1); + } + const tip = tipProbe.stdout.trim(); + // Merge base, so a branch behind origin/main is compared against what it + // FORKED from (keys added on main since then are not "deleted" here). In a + // shallow clone there is no walkable ancestry — fall back to the tip, which + // on a PR's synthetic merge commit is the merge base anyway. + const mergeBase = git('merge-base', 'HEAD', tip); + const rev = mergeBase.status === 0 ? mergeBase.stdout.trim() : tip; + if (mergeBase.status !== 0) { + console.log(` (shallow history — using origin/main tip ${tip.slice(0, 12)} as the baseline anchor)`); + } + const fileName = path.basename(AUTHORABLE_SURFACE_PATH); + const show = git('show', `${rev}:./${fileName}`); + if (show.status !== 0) { + if (/does not exist in|exists on disk, but not in/.test(show.stderr)) { + console.log( + `ℹ️ authorable-surface deletion check: no ${fileName} at base ${rev.slice(0, 12)} — nothing to compare.`, + ); + return null; + } + console.error( + `\n❌ Failed to read ${fileName} at base ${rev.slice(0, 12)} (#4650):\n${show.stderr}`, + ); + process.exit(1); + } + try { + return { rev, doc: JSON.parse(show.stdout) as AuthorableSurface }; + } catch (error) { + console.error(`\n❌ ${fileName} at base ${rev.slice(0, 12)} is not valid JSON (#4650): ${error}`); + process.exit(1); + } +} + +{ + const base = resolveSurfaceBase(); + 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. + const baseSnapshot = new Map(); + for (const entry of base.doc.keys ?? []) { + const key = entry.replace(RETIRED_MARK, ''); + baseSnapshot.set(carryAuthorableKey(key), entry.endsWith(RETIRED_MARK)); + } + const deletedKeys = [...baseSnapshot.keys()].filter((k) => !currentKeys.has(k)); + if (deletedKeys.length > 0) { + const baseRev = base.rev.slice(0, 12); + const clauseMajors = registeredClauseMajors(); + const reachability = computeSurfaceReachability(); + const allowed: string[] = []; + const violations: string[] = []; + const goneDefs = new Map(); // def no longer emitted -> deleted key count + for (const key of deletedKeys) { + const sep = key.indexOf(':'); + const defKey = key.slice(0, sep); + const prop = key.slice(sep + 1); + if (!generatedSchemas.has(defKey)) { + goneDefs.set(defKey, (goneDefs.get(defKey) ?? 0) + 1); + continue; + } + const via = reachability.reachableVia(defKey); + if (via === null) { + allowed.push( + `${key} — def not reachable from the ${reachability.rootTypes.length} metadata-type roots\n` + + ` (BUILTIN_METADATA_TYPE_SCHEMAS + EXTRA_METADATA_TYPE_SCHEMAS overlay; BFS over this\n` + + ` build's in-memory Zod graph): an over-collected entry, never parsed against a\n` + + ` metadata document. This waives ONLY the tombstone requirement of this file — it is\n` + + ` not a license to change the schema (#4650).`, + ); + 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'; + if (!wasRetired) { + violations.push(`${key} — def ${how}; the entry at ${baseRev} was LIVE (never tombstoned).`); + continue; + } + const matches = [...clauseMajors.entries()].filter(([clause]) => clause.endsWith('.' + prop)); + if (matches.length === 0) { + violations.push( + `${key} — def ${how}; tombstoned, but no conversion/migration clause matching '.${prop}'\n` + + ` is registered in the ADR-0087 registries, so the retirement never reached\n` + + ` spec-changes.json or \`os migrate meta\`.`, + ); + continue; + } + const registeredAt = Math.min(...matches.map(([, major]) => major)); + if (CURRENT_MAJOR - registeredAt < TOMBSTONE_AGE_MAJORS) { + violations.push( + `${key} — def ${how}; tombstone registered at major ${registeredAt}, current major is\n` + + ` ${CURRENT_MAJOR} — a tombstone must age ≥ ${TOMBSTONE_AGE_MAJORS} majors before its line is deleted.`, + ); + continue; + } + allowed.push( + `${key} — [RETIRED] at ${baseRev} and registered at major ${registeredAt} ` + + `(current ${CURRENT_MAJOR}): tombstone aged out.`, + ); + } + 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.`, + ); + } + if (allowed.length > 0) { + console.log(`\nℹ️ ${allowed.length} baseline deletion(s) since ${baseRev} carry their own proof (#4650):`); + for (const line of allowed) console.log(` - ${line}`); + } + if (violations.length > 0) { + console.error(`\n❌ ${violations.length} authorable baseline line(s) were deleted without proof (#4650):`); + for (const line of violations) console.error(` - ${line}`); + console.error( + `\n authorable-surface.json is generated evidence, not an editable list: deleting a\n` + + ` line deletes exactly what check (a) above needs to see — #4638 and #4643 both\n` + + ` removed authorable keys that way with a green gate. Deletions are therefore\n` + + ` compared against the baseline at merge base ${baseRev} with origin/main,\n` + + ` which this commit cannot rewrite.\n\n` + + ` A line may only leave this file when:\n` + + ` 1. its key was tombstoned (\`retiredKey()\` → "[RETIRED]") with a D2 conversion\n` + + ` (src/conversions/registry.ts) or migration step registered for its surface,\n` + + ` 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` + + ` 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/).`, + ); + process.exit(1); + } + } + } +} + +// The whole FILE is compared against its canonical serialization, not just the +// key array. #4662 caught a hand-normalized description dash the keys-only +// comparison could never see — proof the file's content was not this script's +// output. Any non-generated byte is a hand-edit (or a stale writer), and +// hand-edits are exactly how deletions hid from check (a) (#4650, comment 2). +const canonicalSurface: AuthorableSurface = { + description: + 'Ratchet of every AUTHORABLE key in the spec — what a metadata author may write, which ' + + 'for this platform IS the third-party API. Auto-updated on additions (commit the change). ' + + 'A key that disappears without a tombstone fails gen:schema, because these schemas are ' + + 'not .strict() and Zod would silently strip it. "[RETIRED]" marks a tombstoned key that ' + + 'still rejects with an upgrade prescription. See #3855, ADR-0059 §5.', + keys: currentEntries, +}; +const canonicalSurfaceText = JSON.stringify(canonicalSurface, null, 2) + '\n'; +const surfaceChanged = surfaceRaw !== canonicalSurfaceText; if (surfaceChanged && CHECK) { // Removals already exited above; reaching here in check mode means the snapshot - // is behind on ADDITIONS. Left unnoticed that is not cosmetic: an unrecorded key - // is one this ratchet can never miss later, because it was never in the baseline. + // is behind on ADDITIONS, or differs without any key change at all — a hand-edit. const before = new Set(surfaceDoc?.keys ?? []); const addedKeys = currentEntries.filter((k) => !before.has(k)); - console.error( - `\n❌ authorable-surface.json is out of date (${addedKeys.length} key(s) not recorded).`, - ); - for (const k of addedKeys.slice(0, 20)) console.error(` + ${k}`); - if (addedKeys.length > 20) console.error(` … and ${addedKeys.length - 20} more`); - console.error( - `\n Run \`pnpm --filter @objectstack/spec gen:schema\` and commit the result. An\n` + - ` unrecorded key is invisible to this ratchet forever after — it can only detect\n` + - ` the disappearance of something it once saw.`, - ); + if (addedKeys.length > 0) { + console.error( + `\n❌ authorable-surface.json is out of date (${addedKeys.length} key(s) not recorded).`, + ); + for (const k of addedKeys.slice(0, 20)) console.error(` + ${k}`); + if (addedKeys.length > 20) console.error(` … and ${addedKeys.length - 20} more`); + console.error( + `\n Run \`pnpm --filter @objectstack/spec gen:schema\` and commit the result. An\n` + + ` unrecorded key is invisible to this ratchet forever after — it can only detect\n` + + ` the disappearance of something it once saw.`, + ); + } else { + console.error( + `\n❌ authorable-surface.json does not match its generated form (key set unchanged).`, + ); + console.error( + `\n The recorded keys are current, but the file's bytes are not what gen:schema\n` + + ` writes — a hand-edit or stale formatting (#4662 found a manually normalized\n` + + ` description dash exactly this way; see #4650). This file is generated evidence:\n` + + ` every difference must come from the generator.\n\n` + + ` Run \`pnpm --filter @objectstack/spec gen:schema\` and commit the result.`, + ); + } process.exit(1); } if (surfaceChanged && !CHECK) { - const updated: AuthorableSurface = { - description: - 'Ratchet of every AUTHORABLE key in the spec — what a metadata author may write, which ' + - 'for this platform IS the third-party API. Auto-updated on additions (commit the change). ' + - 'A key that disappears without a tombstone fails gen:schema, because these schemas are ' + - 'not .strict() and Zod would silently strip it. "[RETIRED]" marks a tombstoned key that ' + - 'still rejects with an upgrade prescription. See #3855, ADR-0059 §5.', - keys: currentEntries, - }; - fs.writeFileSync(AUTHORABLE_SURFACE_PATH, JSON.stringify(updated, null, 2) + '\n'); + fs.writeFileSync(AUTHORABLE_SURFACE_PATH, canonicalSurfaceText); console.log( `\n🔑 authorable-surface.json ${surfaceDoc ? 'updated' : 'created'} (${currentEntries.length} keys) — commit it.`, );