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
3744import { describe , it , expect , beforeAll , afterAll } from 'vitest' ;
3845import { spawnSync } from 'node:child_process' ;
@@ -42,6 +49,8 @@ import path from 'node:path';
4249import { fileURLToPath } from 'node:url' ;
4350
4451import { 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
4655const HERE = path . dirname ( fileURLToPath ( import . meta. url ) ) ;
4756const PKG = path . resolve ( HERE , '..' ) ;
@@ -64,10 +73,26 @@ const PHANTOM_KEY = 'ui/ZzzNeverEmittedByAnyBuild';
6473let sandbox : string ;
6574let script : string ;
6675let manifestPath : string ;
76+ let surfacePath : string ;
6777let 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
6993beforeAll ( ( ) => {
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
86119afterAll ( ( ) => {
@@ -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 ( / d a t a \/ O b j e c t : z z N e v e r R e t i r e d 4 6 5 0 — d e f r e a c h a b l e .* L I V E \( n e v e r t o m b s t o n e d \) / ) ;
379+ expect ( output ) . toMatch ( / d a t a \/ O b j e c t : z z R e t i r e d B u t U n r e g i s t e r e d 4 6 5 0 — .* t o m b s t o n e d , b u t n o c o n v e r s i o n \/ m i g r a t i o n c l a u s e / ) ;
380+ expect ( output ) . toMatch ( new RegExp ( `ai/Agent:${ DELETED_UNAGED_LEAF } — .*registered at major \\d+` ) ) ;
381+ expect ( output ) . toMatch ( / m u s t a g e ≥ 2 m a j o r s / ) ;
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 ( / a p i \/ S e s s i o n R e s p o n s e : z z O v e r C o l l e c t e d 4 6 5 0 — d e f n o t r e a c h a b l e f r o m t h e \d + m e t a d a t a - t y p e r o o t s / ) ;
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 ( / d a t a \/ O b j e c t : c o m p a c t L a y o u t — \[ R E T I R E D \] a t [ 0 - 9 a - f ] + a n d r e g i s t e r e d a t m a j o r 1 1 / ) ;
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 a u t h o r a b l e k e y \( s \) d i s a p p e a r e d f r o m t h e c o n t r a c t / ) ;
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