@@ -85,6 +85,11 @@ function makeStubEngine(artifacts: Array<{ type: string; name: string }> = []) {
8585 const artifactKeys = new Set ( artifacts . map ( ( a ) => `${ a . type } |${ a . name } ` ) ) ;
8686 const keyOf = ( w : Record < string , unknown > ) =>
8787 `${ w . type } |${ w . name } |${ w . organization_id ?? '__env__' } |${ w . state ?? 'active' } ` ;
88+ // #5264 — every write the engine is asked for, in order. `rows` alone
89+ // cannot tell the two persistence routes apart (both end at
90+ // `insert('sys_metadata', …)`); the tell is whether a
91+ // `sys_metadata_history` append came with it. See the #5264 block below.
92+ const writes : Array < { op : 'insert' | 'update' | 'delete' ; table : string } > = [ ] ;
8893 const engine : any = {
8994 async findOne ( _t : string , opts : { where : Record < string , unknown > } ) {
9095 for ( const row of rows . values ( ) ) {
@@ -96,14 +101,15 @@ function makeStubEngine(artifacts: Array<{ type: string; name: string }> = []) {
96101 } ,
97102 async find ( ) { return [ ] ; } ,
98103 async insert ( _t : string , data : Record < string , unknown > ) {
104+ writes . push ( { op : 'insert' , table : _t } ) ;
99105 if ( _t !== 'sys_metadata' ) return { id : 'side_effect_skip' } ;
100106 nextId += 1 ;
101107 const row = { id : `r_${ nextId } ` , ...( data as any ) } as Row ;
102108 rows . set ( keyOf ( data ) , row ) ;
103109 return { id : row . id } ;
104110 } ,
105- async update ( ) { return { id : null } ; } ,
106- async delete ( ) { return { deleted : 0 } ; } ,
111+ async update ( _t : string ) { writes . push ( { op : 'update' , table : _t } ) ; return { id : null } ; } ,
112+ async delete ( _t : string ) { writes . push ( { op : 'delete' , table : _t } ) ; return { deleted : 0 } ; } ,
107113 registry : {
108114 registerItem : ( ) => { } ,
109115 registerObject : ( ) => { } ,
@@ -115,7 +121,7 @@ function makeStubEngine(artifacts: Array<{ type: string; name: string }> = []) {
115121 artifactKeys . has ( `${ type } |${ name } ` ) ? { name, _packageId : 'showcase' } : undefined ,
116122 } ,
117123 } ;
118- return { engine, rows } ;
124+ return { engine, rows, writes } ;
119125}
120126
121127/**
@@ -132,13 +138,13 @@ const KERNELS: Array<{ label: string; environmentId?: string }> = [
132138] ;
133139
134140function makeProtocol ( environmentId ?: string , artifacts ?: Array < { type : string ; name : string } > ) {
135- const { engine, rows } = makeStubEngine ( artifacts ) ;
141+ const { engine, rows, writes } = makeStubEngine ( artifacts ) ;
136142 const protocol = new ObjectStackProtocolImplementation (
137143 engine ,
138144 ( ) => new Map ( ) ,
139145 environmentId ,
140146 ) as any ;
141- return { protocol, rows } ;
147+ return { protocol, rows, writes } ;
142148}
143149
144150const metaRows = ( rows : Map < string , Row > ) => Array . from ( rows . values ( ) ) ;
@@ -321,4 +327,117 @@ describe('code-only metadata types are refused on every kernel (#5086)', () => {
321327 } ) ;
322328 }
323329 } ) ;
330+
331+ // ── #5264 — one persistence route, and the proof it is the only one ───
332+ //
333+ // `saveMetaItem` used to end in a legacy raw-engine branch: `engine.insert`
334+ // / `engine.update` straight into `sys_metadata`, no `sys_metadata_history`
335+ // append, no watch event, no `seq`. It ran exactly when
336+ // `isOverlayAllowed(type) || isRuntimeCreateAllowed(type)` was false —
337+ // which is the predicate the #5086 gate above throws on, unconditionally,
338+ // over the same canonicalized type key. #5264 removed the branch.
339+ //
340+ // These pins are about the RECEIPT, not the verdict, because the receipt is
341+ // the only thing that told the two routes apart from outside: the legacy
342+ // one answered `200 {"success":true,"message":"Saved customization overlay
343+ // (env-wide) — type=…"}` with no `state=`, no `[seq=…]`, and no history
344+ // row. That is precisely the answer #5086 caught the showcase giving for a
345+ // `job`. They are green before the removal too — a branch nothing reaches
346+ // is what "dead" means — so they are not a regression test for the
347+ // deletion; they are the guard that stops a second historyless write path
348+ // from being introduced, and they fail loudly if the #5086 gate is ever
349+ // narrowed back to `environmentId !== undefined`.
350+ describe ( '#5264 — saveMetaItem persists through the repository or not at all' , ( ) => {
351+ for ( const type of CODE_ONLY_TYPES ) {
352+ it ( `asks the engine for NOTHING when refusing ${ type } on a control-plane kernel` , async ( ) => {
353+ // The exact condition the deleted branch claimed for itself:
354+ // "control-plane bootstrap (environmentId === undefined) for
355+ // non-overlay-allowed types". Driven here on purpose — the
356+ // refusal lands first, so the write never becomes a write.
357+ const probe = PROBES [ type ] ! ;
358+ const { protocol, rows, writes } = makeProtocol ( undefined ) ;
359+
360+ const err = await protocol
361+ . saveMetaItem ( { type, name : probe . name , item : probe . item } )
362+ . then ( ( ) => null , ( e : any ) => e ) ;
363+
364+ expect ( err ?. status ) . toBe ( 403 ) ;
365+ expect ( metaRows ( rows ) ) . toEqual ( [ ] ) ;
366+ // Stronger than "no row landed": no write was even attempted,
367+ // so there is nothing for a raw-engine path to have done.
368+ expect ( writes ) . toEqual ( [ ] ) ;
369+ } ) ;
370+ }
371+
372+ // One savable type per shape the two-tier model distinguishes.
373+ const ACCEPTED : Array < { type : string ; item : Record < string , unknown > } > = [
374+ {
375+ type : 'view' , // allowOrgOverride + allowRuntimeCreate
376+ item : {
377+ name : 'rc3_receipt_view' ,
378+ label : 'Receipt' ,
379+ object : 'task' ,
380+ columns : [ { field : 'name' , label : 'Name' } ] ,
381+ } ,
382+ } ,
383+ {
384+ type : 'hook' , // allowRuntimeCreate only
385+ item : { name : 'rc3_receipt_view' , object : 'task' , events : [ 'beforeUpdate' ] } ,
386+ } ,
387+ {
388+ type : 'theme' , // no static registry entry (plugin-registered)
389+ item : { name : 'rc3_receipt_view' , label : 'Receipt' , tokens : { } } ,
390+ } ,
391+ ] ;
392+
393+ for ( const { label, environmentId } of KERNELS ) {
394+ for ( const { type, item } of ACCEPTED ) {
395+ it ( `answers a ${ type } save with a repository receipt on a ${ label } ` , async ( ) => {
396+ const { protocol, writes } = makeProtocol ( environmentId ) ;
397+
398+ const result = await protocol . saveMetaItem ( {
399+ type,
400+ name : 'rc3_receipt_view' ,
401+ item,
402+ ...( environmentId ? { organizationId : 'org_alpha' } : { } ) ,
403+ } ) ;
404+
405+ expect ( result . success ) . toBe ( true ) ;
406+ // `seq` and `state` exist only on the repository receipt.
407+ expect ( typeof result . seq ) . toBe ( 'number' ) ;
408+ expect ( result . state ) . toBe ( 'active' ) ;
409+ expect ( result . message ) . toContain ( '[seq=' ) ;
410+ // And the change log really was appended — the legacy
411+ // branch's defining omission.
412+ expect ( writes . some ( ( w ) => w . table === 'sys_metadata_history' ) ) . toBe ( true ) ;
413+ } ) ;
414+ }
415+ }
416+
417+ for ( const type of CODE_ONLY_TYPES ) {
418+ it ( `routes ${ type } back through the repository once OS_METADATA_WRITABLE unlocks it` , async ( ) => {
419+ // The last leg of the unreachability argument: the escape hatch
420+ // does not open a second door. Unlocking a type makes
421+ // `isOverlayAllowed` true, which is the same predicate the
422+ // repository path is chosen by — so an unlocked save is a
423+ // repository save, receipt and history row included.
424+ const probe = PROBES [ type ] ! ;
425+ process . env . OS_METADATA_WRITABLE = type ;
426+ ObjectStackProtocolImplementation . resetEnvWritableCache ( ) ;
427+ resetEnvWritableMetadataTypes ( ) ;
428+
429+ const { protocol, writes } = makeProtocol ( undefined ) ;
430+ const result = await protocol . saveMetaItem ( {
431+ type,
432+ name : probe . name ,
433+ item : probe . item ,
434+ } ) ;
435+
436+ expect ( result . success ) . toBe ( true ) ;
437+ expect ( typeof result . seq ) . toBe ( 'number' ) ;
438+ expect ( result . message ) . toContain ( '[seq=' ) ;
439+ expect ( writes . some ( ( w ) => w . table === 'sys_metadata_history' ) ) . toBe ( true ) ;
440+ } ) ;
441+ }
442+ } ) ;
324443} ) ;
0 commit comments