@@ -263,8 +263,13 @@ describe('#7733 runtime email_template write materializes without a restart', ()
263263 // `getMetaItem` returns a DECORATED item (`_diagnostics` from
264264 // `decorateMetadataItem`, `_packageId` / `_provenance` from the registry
265265 // and the overlay row). `EmailTemplateDefinitionSchema` is a strictObject
266- // that declares no underscore key, so an unstripped body would reject the
267- // very baseline the reset exists to restore.
266+ // that declares neither `_diagnostics` nor `_draft`, so an unstripped body
267+ // would reject the very baseline the reset exists to restore.
268+ //
269+ // [#16152] It DOES declare the ADR-0010 envelope (`_packageId` /
270+ // `_provenance` and the `_lock*` family, via `MetadataProtectionFields`) —
271+ // those parse clean and are not stripped. See the `#16152` describe block
272+ // below for the pins that hold that apart.
268273 const protocol = withEffectiveRead ( fakeProtocol ( ) , async ( ) => ( {
269274 type : 'email_template' ,
270275 name : 'auth.password_reset' ,
@@ -378,3 +383,110 @@ describe('#7733 runtime email_template write materializes without a restart', ()
378383 expect ( engine . rows ) . toHaveLength ( 0 ) ;
379384 } ) ;
380385} ) ;
386+
387+ // ── #16152 ─────────────────────────────────────────────────────────────────
388+ //
389+ // `readEffectiveTemplate` used to strip read decorations with a MODULE-LOCAL
390+ // copy of `stripReadDecorations` that dropped every key starting with `_`.
391+ // The shared list it drifted from (`spec/kernel/metadata-read-decorations.ts`)
392+ // carries exactly `['_diagnostics', '_draft']` and names the ADR-0010
393+ // protection envelope as "Deliberately NOT" a member — envelope state the
394+ // write path legitimately carries, allowlisted by the closed schemas so a
395+ // served document keeps its provenance on re-parse.
396+ //
397+ // The copy's docblock justified the blanket sweep on the claim that
398+ // `EmailTemplateDefinitionSchema` "declares no underscore key". These pin why
399+ // that claim is false and what the narrow strip must do instead.
400+
401+ describe ( '#16152 read-decoration strip uses the shared list, not a blanket `_` sweep' , ( ) => {
402+ /** The full ADR-0010 envelope, as `MetadataProtectionFields` declares it. */
403+ const ENVELOPE = {
404+ _lock : 'no-delete' ,
405+ _lockReason : 'Shipped by the auth package.' ,
406+ _lockSource : 'artifact' ,
407+ _provenance : 'package' ,
408+ _packageId : 'com.objectstack.auth' ,
409+ _packageVersion : '1.4.2' ,
410+ _lockDocsUrl : 'https://example.invalid/locks' ,
411+ } as const ;
412+
413+ it ( 'the schema declares the ADR-0010 envelope and rejects the read decorations' , async ( ) => {
414+ // The premise the deleted docblock got backwards, asserted directly
415+ // against the schema rather than inferred from it. `email-template.zod.ts`
416+ // spreads `MetadataProtectionFields` into its `strictObject`, so every
417+ // envelope key is authorable surface here; `_diagnostics` / `_draft` are
418+ // not declared anywhere in that shape, which is why they must be stripped.
419+ const { EmailTemplateDefinitionSchema } = await import ( '@objectstack/spec/system' ) ;
420+
421+ const withEnvelope = EmailTemplateDefinitionSchema . safeParse ( { ...template ( ) , ...ENVELOPE } ) ;
422+ expect ( withEnvelope . success ) . toBe ( true ) ;
423+ // Guarding a KEY's reachability: no `unrecognized_keys` on any envelope key.
424+ expect (
425+ ( withEnvelope as any ) . error ?. issues ?. filter ( ( i : any ) => i . code === 'unrecognized_keys' ) ?? [ ] ,
426+ ) . toEqual ( [ ] ) ;
427+
428+ for ( const decoration of [ '_diagnostics' , '_draft' ] ) {
429+ const served = EmailTemplateDefinitionSchema . safeParse ( {
430+ ...template ( ) ,
431+ [ decoration ] : decoration === '_draft' ? true : { valid : true } ,
432+ } ) ;
433+ expect ( served . success ) . toBe ( false ) ;
434+ const keys = ( served as any ) . error . issues
435+ . filter ( ( i : any ) => i . code === 'unrecognized_keys' )
436+ . flatMap ( ( i : any ) => i . keys ) ;
437+ expect ( keys ) . toContain ( decoration ) ;
438+ }
439+ } ) ;
440+
441+ it ( 're-materializes a baseline served with the FULL protection envelope on it' , async ( ) => {
442+ // The envelope rides along on the layered read (`_packageId` /
443+ // `_provenance` from the registry, `_lock*` from the artifact layer). It
444+ // parses clean, so the reset restores the packaged baseline with no
445+ // projector failure — and `mapTemplateToRow`'s closed column list is what
446+ // keeps it out of the row, not a strip.
447+ const protocol = withEffectiveRead ( fakeProtocol ( ) , async ( ) => ( {
448+ item : {
449+ ...template ( { subject : 'The packaged subject' } ) ,
450+ ...ENVELOPE ,
451+ _diagnostics : { valid : true } ,
452+ _draft : false ,
453+ } ,
454+ } ) ) ;
455+ const { engine } = await boot ( { protocol } ) ;
456+
457+ await protocol . save ( 'auth.password_reset' , template ( { subject : 'An operator override' } ) ) ;
458+ await protocol . remove ( 'auth.password_reset' ) ;
459+
460+ expect ( protocol . projectorFailures ) . toEqual ( [ ] ) ;
461+ const rows = rowsOf ( engine , 'auth.password_reset' ) ;
462+ expect ( rows ) . toHaveLength ( 1 ) ;
463+ expect ( rows [ 0 ] . subject ) . toBe ( 'The packaged subject' ) ;
464+ // `sys_email_template` declares no underscore column, and
465+ // `mapTemplateToRow` projects a closed list — so the envelope cannot reach
466+ // the row whatever the strip does. This is the measured reason there is no
467+ // second, envelope-stripping pass beside the shared one.
468+ for ( const k of Object . keys ( ENVELOPE ) ) expect ( rows [ 0 ] ) . not . toHaveProperty ( k ) ;
469+ } ) ;
470+
471+ it ( 'does not silently swallow an underscore key the schema never declared' , async ( ) => {
472+ // The blanket sweep dropped EVERY `_` key before the parse, so a key that
473+ // is neither a decoration nor declared — a producer's typo, a decoration
474+ // added upstream and never added to the shared list — vanished and the
475+ // reset reported success. That is precisely the silent-strip failure the
476+ // closed schemas (#4001) exist to end, and the shared list keeps loud:
477+ // the projector surfaces it on the write's own response.
478+ const protocol = withEffectiveRead ( fakeProtocol ( ) , async ( ) => ( {
479+ item : { ...template ( { subject : 'The packaged subject' } ) , _notADeclaredKey : 'x' } ,
480+ } ) ) ;
481+ const { engine } = await boot ( { protocol } ) ;
482+
483+ await protocol . save ( 'auth.password_reset' , template ( { subject : 'An operator override' } ) ) ;
484+ await protocol . remove ( 'auth.password_reset' ) ;
485+
486+ expect ( protocol . projectorFailures ) . toHaveLength ( 1 ) ;
487+ expect ( protocol . projectorFailures [ 0 ] ) . toContain ( '_notADeclaredKey' ) ;
488+ // The override row is left exactly as it was — a body the schema refuses
489+ // never becomes a write.
490+ expect ( rowsOf ( engine , 'auth.password_reset' ) [ 0 ] . subject ) . toBe ( 'An operator override' ) ;
491+ } ) ;
492+ } ) ;
0 commit comments