Skip to content

Commit b9d4537

Browse files
committed
fix(spec): deleted authorable-surface baseline lines must prove themselves (#4650)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176qgxgCXTJCUv4YFLtusP9
1 parent 23338c3 commit b9d4537

3 files changed

Lines changed: 781 additions & 25 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): deleted authorable-surface baseline lines must prove themselves (#4650)
6+
7+
The authorable-surface ratchet's check (a) reads `authorable-surface.json` from
8+
the same commit it is checking, so hand-deleting a baseline line deleted the
9+
very evidence the check runs on — #4638 and #4643 both removed authorable keys
10+
with zero registered conversions and a green gate, and #4662 proved the file
11+
had been hand-edited. `gen:schema` (and `check:authorable-surface`) now anchor
12+
deletions on the baseline at the **merge base with `origin/main`** — the one
13+
version of the file a PR cannot rewrite (comparing against `HEAD:` would be
14+
vacuous in CI, where HEAD is the PR's own commit) — and every deleted line must
15+
carry one of three proofs, all computed inside the gate:
16+
17+
1. **Aged-out tombstone** — the base entry was `[RETIRED]` and its surface is
18+
registered in `CONVERSIONS_BY_MAJOR` / `MIGRATIONS_BY_MAJOR` at a major ≥ 2
19+
behind the current one (the "~two majors" the file's description has always
20+
promised, now enforced).
21+
2. **Not reachable from the metadata-type roots** (2026-08-02 ruling on #4650)
22+
— BFS over the build's in-memory Zod graph from
23+
`BUILTIN_METADATA_TYPE_SCHEMAS` + the `EXTRA_METADATA_TYPE_SCHEMAS` overlay,
24+
with derived-clone bridging so `.refine()`/`.extend()` copies (e.g.
25+
`ViewSchema` inside `ViewMetadataSchema`) keep their originals protected.
26+
Over-collected entries (REST envelopes and other never-parsed defs) may be
27+
deleted without a tombstone; the exception waives only this file's
28+
requirement and is not a license to change the schema.
29+
3. **The whole def left the build** — adjudicated by the
30+
`json-schema.manifest.json` ratchet (#2978) and `check:api-surface`.
31+
32+
`--check` additionally rejects any byte of `authorable-surface.json` that is
33+
not the generator's own output (description/formatting hand-edits included,
34+
per #4662); write mode regenerates such drift. Checks (a0)/(a)/(b) are
35+
unchanged. Build-time gate only — no runtime export, schema shape, or
36+
generated artifact changes.

packages/spec/scripts/build-schemas-check-mode.test.ts

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@
3333
// read-only inputs — `src/`, `node_modules/`, `package.json`. That keeps the
3434
// production code path byte-for-byte: no test-only seam is added to the gate,
3535
// because a seam is itself a place where the gate can differ from what CI runs.
36+
//
37+
// The sandbox is also a REAL git repository with a fabricated
38+
// `refs/remotes/origin/main`, because the authorable-surface deletion check
39+
// (#4650) anchors on the baseline at the merge base with origin/main — the one
40+
// version of the file a PR cannot rewrite. Fabricating the ref (rather than
41+
// injecting a base through some test-only env var) keeps that discipline: the
42+
// gate runs exactly the git resolution CI runs.
3643

3744
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
3845
import { spawnSync } from 'node:child_process';
@@ -42,6 +49,8 @@ import path from 'node:path';
4249
import { fileURLToPath } from 'node:url';
4350

4451
import { RENAMED_DEFS } from './lib/renamed-defs';
52+
import { CONVERSIONS_BY_MAJOR } from '../src/conversions/registry';
53+
import { MIGRATIONS_BY_MAJOR } from '../src/migrations/registry';
4554

4655
const HERE = path.dirname(fileURLToPath(import.meta.url));
4756
const PKG = path.resolve(HERE, '..');
@@ -64,10 +73,26 @@ const PHANTOM_KEY = 'ui/ZzzNeverEmittedByAnyBuild';
6473
let sandbox: string;
6574
let script: string;
6675
let manifestPath: string;
76+
let surfacePath: string;
6777
let pristine: string;
78+
let pristineSurface: string;
79+
80+
/** Run git in the sandbox repo; throws on failure so a broken fixture is loud. */
81+
function git(...args: string[]): string {
82+
const r = spawnSync(
83+
'git',
84+
['-c', 'user.name=build-schemas-test', '-c', 'user.email=test@example.invalid', ...args],
85+
{ cwd: sandbox, encoding: 'utf8' },
86+
);
87+
if (r.status !== 0) {
88+
throw new Error(`git ${args.join(' ')} failed (${r.status}): ${r.stderr}`);
89+
}
90+
return (r.stdout ?? '').trim();
91+
}
6892

6993
beforeAll(() => {
7094
pristine = fs.readFileSync(REAL_MANIFEST, 'utf8');
95+
pristineSurface = fs.readFileSync(path.join(PKG, 'authorable-surface.json'), 'utf8');
7196
sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'build-schemas-check-'));
7297
fs.cpSync(path.join(PKG, 'scripts'), path.join(sandbox, 'scripts'), { recursive: true });
7398
for (const entry of ['src', 'node_modules', 'package.json']) {
@@ -81,6 +106,14 @@ beforeAll(() => {
81106
);
82107
script = path.join(sandbox, 'scripts', 'build-schemas.ts');
83108
manifestPath = path.join(sandbox, 'json-schema.manifest.json');
109+
surfacePath = path.join(sandbox, 'authorable-surface.json');
110+
// Anchor for the #4650 deletion check: a git repo whose origin/main holds the
111+
// committed baseline. Only the baseline is tracked — src/node_modules stay
112+
// symlinked, untracked reads the same as any dirty worktree.
113+
git('init', '-q', '-b', 'main', '.');
114+
git('add', 'authorable-surface.json');
115+
git('commit', '-q', '-m', 'baseline: committed authorable-surface.json');
116+
git('update-ref', 'refs/remotes/origin/main', 'HEAD');
84117
});
85118

86119
afterAll(() => {
@@ -208,3 +241,275 @@ describe('build-schemas.ts --check — a check reports, it does not write (#4711
208241
},
209242
);
210243
});
244+
245+
// ─────────────────────────────────────────────────────────────────────────────
246+
// #4650 — a deleted authorable-surface line must prove itself.
247+
//
248+
// Checks (a)/(b) read authorable-surface.json from the SAME commit, so deleting
249+
// a baseline line deleted the evidence they run on: #4638 and #4643 removed
250+
// authorable keys with zero registered conversions and a green gate. Check (c)
251+
// re-anchors deletions on the baseline at the merge base with origin/main — a
252+
// version the PR cannot rewrite — and demands one of three proofs: an aged-out
253+
// registered tombstone, unreachability from the metadata-type roots (2026-08-02
254+
// ruling), or the whole def leaving the build (the manifest ratchet's domain).
255+
//
256+
// Fixtures sabotage the BASE (extra lines committed under origin/main) while
257+
// the worktree file stays canonical: set-wise identical to the real attack
258+
// (line present at base, absent from the PR) without editing symlinked src/.
259+
// The live end-to-end shape — delete a prop from src AND its baseline line —
260+
// is exercised in the PR's recorded sabotage evidence instead.
261+
262+
const readSurface = () => fs.readFileSync(surfacePath, 'utf8');
263+
264+
/** Write a mutated baseline to the sandbox worktree; returns the exact bytes. */
265+
function seedSurface(mutate: (keys: string[]) => string[]): string {
266+
const doc = JSON.parse(pristineSurface) as { description: string; keys: string[] };
267+
doc.keys = mutate(doc.keys);
268+
const text = JSON.stringify(doc, null, 2) + '\n';
269+
fs.writeFileSync(surfacePath, text);
270+
return text;
271+
}
272+
273+
/** Commit a BASE variant of the baseline and point origin/main at it. */
274+
function seedBase(mutate: (keys: string[]) => string[]): string {
275+
seedSurface(mutate);
276+
git('add', 'authorable-surface.json');
277+
git('commit', '-q', '--allow-empty', '-m', 'base variant');
278+
const sha = git('rev-parse', 'HEAD');
279+
git('update-ref', 'refs/remotes/origin/main', sha);
280+
return sha;
281+
}
282+
283+
/** Earliest ADR-0087 registration matching a surface leaf — the gate's clause
284+
* vocabulary (see check (b)/(c) in build-schemas.ts), reproduced here ONLY to
285+
* validate fixture choices loudly, never to assert gate behaviour. */
286+
function minRegisteredMajorForLeaf(leaf: string): number | null {
287+
let min: number | null = null;
288+
const consider = (surface: string, major: number): void => {
289+
for (const clause of surface.split(' / ')) {
290+
if (clause.endsWith('.' + leaf)) min = min === null ? major : Math.min(min, major);
291+
}
292+
};
293+
for (const [major, list] of Object.entries(CONVERSIONS_BY_MAJOR)) {
294+
for (const c of list) consider(c.surface, Number(major));
295+
}
296+
for (const [major, step] of Object.entries(MIGRATIONS_BY_MAJOR)) {
297+
for (const sem of step.semantic ?? []) consider(sem.surface, Number(major));
298+
}
299+
return min;
300+
}
301+
302+
const CURRENT_MAJOR = Number.parseInt(
303+
(JSON.parse(fs.readFileSync(path.join(PKG, 'package.json'), 'utf8')) as { version: string })
304+
.version,
305+
10,
306+
);
307+
308+
/** Reachable def (BUILTIN root `object`), never tombstoned — the #4643 shape. */
309+
const DELETED_LIVE = 'data/Object:zzNeverRetired4650';
310+
/** Reachable def, tombstoned at base but never registered in the ADR-0087 registries. */
311+
const DELETED_UNREGISTERED = 'data/Object:zzRetiredButUnregistered4650 [RETIRED]';
312+
/** Tombstoned AND registered, but too recently: `skill.triggerPhrases` was
313+
* registered at protocol 17. The guard below fails loudly once that ages out —
314+
* re-pick a clause registered within the last TOMBSTONE_AGE_MAJORS majors. */
315+
const DELETED_UNAGED_LEAF = 'triggerPhrases';
316+
const DELETED_UNAGED = `ai/Agent:${DELETED_UNAGED_LEAF} [RETIRED]`;
317+
/** Emitted but not reachable from any metadata-type root: a REST response
318+
* envelope no metadata document is ever parsed against (the issue's own
319+
* over-collection example). */
320+
const DELETED_UNREACHABLE = 'api/SessionResponse:zzOverCollected4650';
321+
/** Def the build no longer emits at all — the literal #4643 cluster. */
322+
const DELETED_GONE_DEF = ['identity/Session:userId', 'identity/Session:token'];
323+
/** Aged-out tombstone: `object.compactLayout` registered at protocol 11 —
324+
* ≥ 2 majors behind any current major, so this fixture never goes stale. */
325+
const DELETED_AGED_LEAF = 'compactLayout';
326+
const DELETED_AGED = `data/Object:${DELETED_AGED_LEAF} [RETIRED]`;
327+
/** Base key under a def RENAMED_DEFS moved: carried, so never a deletion. */
328+
const DELETED_BY_RENAME = 'integration/RateLimitConfig:maxRequests';
329+
330+
describe('build-schemas.ts — deleted baseline lines must prove themselves (#4650)', () => {
331+
beforeAll(() => {
332+
// Fixture validity, asserted loudly instead of silently going stale.
333+
const keys = (JSON.parse(pristineSurface) as { keys: string[] }).keys;
334+
for (const injected of [
335+
DELETED_LIVE,
336+
DELETED_UNREGISTERED,
337+
DELETED_UNAGED,
338+
DELETED_UNREACHABLE,
339+
...DELETED_GONE_DEF,
340+
DELETED_AGED,
341+
DELETED_BY_RENAME,
342+
]) {
343+
expect(
344+
keys.includes(injected) || keys.includes(injected.replace(' [RETIRED]', '')),
345+
`fixture ${injected} already exists in the committed baseline — pick another`,
346+
).toBe(false);
347+
}
348+
expect(
349+
keys.some((k) => k.startsWith('identity/Session:')),
350+
'identity/Session is emitted again — the vanished-def fixture needs a new def',
351+
).toBe(false);
352+
const unagedMajor = minRegisteredMajorForLeaf(DELETED_UNAGED_LEAF);
353+
expect(
354+
unagedMajor !== null && CURRENT_MAJOR - unagedMajor < 2,
355+
`'.${DELETED_UNAGED_LEAF}' (registered at major ${unagedMajor}) has aged out at major ` +
356+
`${CURRENT_MAJOR} — re-pick a clause registered within the last 2 majors`,
357+
).toBe(true);
358+
const agedMajor = minRegisteredMajorForLeaf(DELETED_AGED_LEAF);
359+
expect(
360+
agedMajor !== null && CURRENT_MAJOR - agedMajor >= 2,
361+
`'.${DELETED_AGED_LEAF}' is no longer an aged-out registration`,
362+
).toBe(true);
363+
// The manifest ratchet runs first; keep it current so every run reaches (c).
364+
seedManifest((s) => s);
365+
});
366+
367+
it(
368+
'fails on deletions of reachable keys — live, unregistered, and not-yet-aged tombstones each say why',
369+
{ timeout: SPAWN_TIMEOUT_MS },
370+
() => {
371+
seedBase((s) => [...s, DELETED_LIVE, DELETED_UNREGISTERED, DELETED_UNAGED].sort());
372+
const canonical = seedSurface((s) => s);
373+
374+
const { status, output } = run(['--check']);
375+
376+
expect(status).toBe(1);
377+
expect(output).toContain('authorable baseline line(s) were deleted without proof (#4650)');
378+
expect(output).toMatch(/data\/Object:zzNeverRetired4650 def reachable .* LIVE \(never tombstoned\)/);
379+
expect(output).toMatch(/data\/Object:zzRetiredButUnregistered4650 .*tombstoned, but no conversion\/migration clause/);
380+
expect(output).toMatch(new RegExp(`ai/Agent:${DELETED_UNAGED_LEAF} — .*registered at major \\d+`));
381+
expect(output).toMatch(/must age 2 majors/);
382+
// The remedy names the retirement route, not a hand-edit.
383+
expect(output).toContain('gen:schema');
384+
expect(readSurface()).toBe(canonical);
385+
},
386+
);
387+
388+
it(
389+
'still fails after the deletion is COMMITTED (the CI shape): the anchor is the merge base with origin/main, not HEAD',
390+
{ timeout: SPAWN_TIMEOUT_MS * 2 },
391+
() => {
392+
seedBase((s) => [...s, DELETED_LIVE].sort());
393+
const canonical = seedSurface((s) => s);
394+
// Commit the sabotaged (canonical-minus-line, relative to base) file so
395+
// the worktree is CLEAN — the state CI checks out. A `git show HEAD:`
396+
// anchor would now compare the commit to itself and never fire.
397+
git('add', 'authorable-surface.json');
398+
git('commit', '-q', '-m', 'PR commit deleting a baseline line');
399+
400+
const check = run(['--check']);
401+
expect(check.status).toBe(1);
402+
expect(check.output).toContain('deleted without proof (#4650)');
403+
expect(check.output).toContain(DELETED_LIVE);
404+
405+
// Write mode (gen:schema) must refuse identically — regeneration cannot
406+
// bless a deletion either.
407+
const write = run([]);
408+
expect(write.status).toBe(1);
409+
expect(write.output).toContain('deleted without proof (#4650)');
410+
expect(readSurface()).toBe(canonical);
411+
},
412+
);
413+
414+
it(
415+
'allows deletions that carry their own proof: unreachable def, vanished def, aged-out tombstone — each with its reason printed',
416+
{ timeout: SPAWN_TIMEOUT_MS },
417+
() => {
418+
seedBase((s) => [...s, DELETED_UNREACHABLE, ...DELETED_GONE_DEF, DELETED_AGED].sort());
419+
const canonical = seedSurface((s) => s);
420+
421+
const { status, output } = run(['--check']);
422+
423+
expect(output).toContain('baseline deletion(s) since');
424+
expect(output).toContain('carry their own proof (#4650)');
425+
// Narrow exception (2026-08-02 ruling): computed in-gate from the real
426+
// Zod graph, waiving ONLY the tombstone requirement.
427+
expect(output).toMatch(/api\/SessionResponse:zzOverCollected4650 def not reachable from the \d+ metadata-type roots/);
428+
expect(output).toContain('BUILTIN_METADATA_TYPE_SCHEMAS + EXTRA_METADATA_TYPE_SCHEMAS');
429+
expect(output).toContain('not a license to change the schema');
430+
// Whole-def removal is the manifest ratchet's jurisdiction.
431+
expect(output).toContain('identity/Session:* (2 line(s))');
432+
expect(output).toContain('json-schema.manifest.json (#2978)');
433+
// Aged-out tombstone names its registration major.
434+
expect(output).toMatch(/data\/Object:compactLayout \[RETIRED\] at [0-9a-f]+ and registered at major 11/);
435+
expect(output).toContain('tombstone aged out');
436+
expect(readSurface()).toBe(canonical);
437+
expect(status).toBe(0);
438+
},
439+
);
440+
441+
it(
442+
'check (a) is intact: a key the BUILD stops emitting while still recorded is fatal before (c) ever runs',
443+
{ timeout: SPAWN_TIMEOUT_MS },
444+
() => {
445+
const phantom = 'data/Object:zzPhantom4650';
446+
seedBase((s) => [...s, phantom].sort());
447+
const withPhantom = seedSurface((s) => [...s, phantom].sort());
448+
449+
const { status, output } = run(['--check']);
450+
451+
expect(status).toBe(1);
452+
expect(output).toMatch(/1 authorable key\(s\) disappeared from the contract/);
453+
expect(output).toContain(phantom);
454+
expect(output).not.toContain('deleted without proof');
455+
expect(readSurface()).toBe(withPhantom);
456+
},
457+
);
458+
459+
it(
460+
'fails --check on a hand-edit that changes no key (generated-form mismatch, #4662), and write mode regenerates it',
461+
{ timeout: SPAWN_TIMEOUT_MS * 2 },
462+
() => {
463+
seedBase((s) => s);
464+
const handEdited = seedSurface((s) => s).replace(
465+
'Ratchet of every AUTHORABLE key',
466+
'Ratchet of every AUTHORABLE key',
467+
);
468+
fs.writeFileSync(surfacePath, handEdited);
469+
470+
const check = run(['--check']);
471+
expect(check.status).toBe(1);
472+
expect(check.output).toContain('does not match its generated form');
473+
expect(readSurface()).toBe(handEdited);
474+
475+
const write = run([]);
476+
expect(write.status).toBe(0);
477+
expect(write.output).toContain('🔑 authorable-surface.json updated');
478+
expect(readSurface()).toBe(pristineSurface);
479+
},
480+
);
481+
482+
it(
483+
'fails LOUDLY when origin/main cannot be resolved — a deletion check that silently skips is the bypass again',
484+
{ timeout: SPAWN_TIMEOUT_MS },
485+
() => {
486+
seedBase((s) => s);
487+
seedSurface((s) => s);
488+
git('update-ref', '-d', 'refs/remotes/origin/main');
489+
try {
490+
const { status, output } = run(['--check']);
491+
expect(status).toBe(1);
492+
expect(output).toContain('Cannot resolve origin/main');
493+
expect(output).toContain('#4650');
494+
} finally {
495+
git('update-ref', 'refs/remotes/origin/main', 'HEAD');
496+
}
497+
},
498+
);
499+
500+
it(
501+
'a declared def rename is not a deletion: base keys are carried through RENAMED_DEFS before comparing',
502+
{ timeout: SPAWN_TIMEOUT_MS },
503+
() => {
504+
expect(Object.keys(RENAMED_DEFS)).toContain('integration/RateLimitConfig');
505+
seedBase((s) => [...s, DELETED_BY_RENAME].sort());
506+
seedSurface((s) => s);
507+
508+
const { status, output } = run(['--check']);
509+
510+
expect(output).not.toContain('deleted without proof');
511+
expect(output).not.toContain('carry their own proof');
512+
expect(status).toBe(0);
513+
},
514+
);
515+
});

0 commit comments

Comments
 (0)