@@ -492,8 +492,10 @@ describe('FieldSchema', () => {
492492 // default the schema itself would refuse as authored — a bare
493493 // `master_detail` parses to output that OMITS `deleteBehavior`, so
494494 // `parse(parse(x))` holds on the mainline `create()` → `defineStack`
495- // path; every OTHER type keeps byte-identity with the `.default()` era,
496- // which is what the rest of this block pins.
495+ // path. The reference types that still materialize (`lookup`/`tree`)
496+ // keep byte-identity with the `.default()` era — that half is pinned
497+ // here; #9784 (the block below) gates materialization off every
498+ // NON-reference type.
497499 describe ( '[#9689] deleteBehavior: set_null on master_detail is a parse-time rejection' , ( ) => {
498500 const md = ( extra : Record < string , unknown > = { } ) => ( {
499501 name : 'parent_id' ,
@@ -571,19 +573,21 @@ describe('FieldSchema', () => {
571573 expect ( FieldSchema . parse ( { ...lookup , required : true } ) . deleteBehavior ) . toBe ( 'set_null' ) ;
572574 } ) ;
573575
574- it ( 'keeps non-reference types accepting and defaulting the key (installed-base artifact shape, #4447)' , ( ) => {
575- // Verbatim shape from examples/app-showcase/dist/objectstack.json — a
576- // materialized datetime carrying only FieldSchema defaults. Built
577- // artifacts ship this on EVERY field type; it must stay legal.
576+ it ( 'keeps non-reference types ACCEPTING the key (installed-base artifact shape, #4447) — materialization moved to the #9784 block below' , ( ) => {
577+ // Verbatim shape from the pre-#9784 examples/app-showcase/dist/
578+ // objectstack.json — a materialized datetime carrying only FieldSchema
579+ // defaults. Built artifacts of the materializing era ship this on
580+ // EVERY field type; it must STAY legal (accept-set unchanged), and the
581+ // authored value must round-trip verbatim, even though a bare
582+ // datetime no longer materializes it.
578583 const showcaseVerbatim = {
579584 label : 'Created At' , type : 'datetime' , required : false ,
580585 searchable : false , multiple : false , unique : false ,
581586 deleteBehavior : 'set_null' , hidden : false ,
582587 readonly : false , sortable : true , externalId : false ,
583588 } ;
584589 expect ( ( ) => FieldSchema . parse ( showcaseVerbatim ) ) . not . toThrow ( ) ;
585- // And a bare text field still gets the materialized default.
586- expect ( FieldSchema . parse ( { name : 'title' , label : 'Title' , type : 'text' } ) . deleteBehavior ) . toBe ( 'set_null' ) ;
590+ expect ( FieldSchema . parse ( showcaseVerbatim ) . deleteBehavior ) . toBe ( 'set_null' ) ;
587591 } ) ;
588592
589593 it ( 'keeps FieldSchema.shape enumerable (no pipe degradation from the relocation)' , ( ) => {
@@ -595,6 +599,73 @@ describe('FieldSchema', () => {
595599 } ) ;
596600 } ) ;
597601
602+ // [#9784] `deleteBehavior` materializes ONLY on reference types. On every
603+ // other type the key was inert by construction — the engine's
604+ // `cascadeDeleteRelations` reads it exclusively behind a
605+ // `master_detail`/`lookup` + `fdef.reference` guard — yet the materialized
606+ // default shipped in every built artifact as an apparent explicit
607+ // declaration (#4447 mechanism) and read as meaningful to AI authors
608+ // (ADR-0033 direction). The accept-set is UNTOUCHED: authored values on
609+ // any type round-trip verbatim (the installed-base test above).
610+ describe ( '[#9784] deleteBehavior materializes only on reference types' , ( ) => {
611+ const bare = ( type : string ) => ( { name : 'f1' , label : 'F1' , type } ) ;
612+
613+ it ( 'omits deleteBehavior from bare non-reference fields (text/datetime/number)' , ( ) => {
614+ for ( const type of [ 'text' , 'datetime' , 'number' ] ) {
615+ const result = FieldSchema . parse ( bare ( type ) ) ;
616+ expect ( result . deleteBehavior , `type=${ type } ` ) . toBeUndefined ( ) ;
617+ expect ( 'deleteBehavior' in result , `type=${ type } ` ) . toBe ( false ) ;
618+ }
619+ } ) ;
620+
621+ it ( 'omits deleteBehavior from bare `user` fields — outside today\'s cascade guard, same as text' , ( ) => {
622+ // `user` is stored identically to `lookup` but the engine's cascade
623+ // guard admits only `master_detail`/`lookup`, so the key is inert on
624+ // `user` exactly as on `text`. It takes the non-reference side of the
625+ // line; an authored value still round-trips (below).
626+ const result = FieldSchema . parse ( { ...bare ( 'user' ) , reference : 'sys_user' } ) ;
627+ expect ( 'deleteBehavior' in result ) . toBe ( false ) ;
628+ } ) ;
629+
630+ it ( 'still materializes set_null on bare lookup and tree, at shape position (byte-identity)' , ( ) => {
631+ // Key ORDER is part of the byte-identity contract (#4447):
632+ // `deleteBehavior` sits between `reference` and `hidden` in the shape.
633+ const lookupJson = JSON . stringify ( FieldSchema . parse ( {
634+ name : 'account_id' , label : 'Account' , type : 'lookup' , reference : 'account' ,
635+ } ) ) ;
636+ expect ( lookupJson ) . toContain ( '"reference":"account","deleteBehavior":"set_null","hidden":false' ) ;
637+ const treeJson = JSON . stringify ( FieldSchema . parse ( {
638+ name : 'parent_id' , label : 'Parent' , type : 'tree' , reference : 'category' ,
639+ } ) ) ;
640+ expect ( treeJson ) . toContain ( '"reference":"category","deleteBehavior":"set_null","hidden":false' ) ;
641+ } ) ;
642+
643+ it ( 'keeps an AUTHORED deleteBehavior on non-reference types, verbatim (accept-set unchanged)' , ( ) => {
644+ expect ( FieldSchema . parse ( { ...bare ( 'text' ) , deleteBehavior : 'cascade' } ) . deleteBehavior ) . toBe ( 'cascade' ) ;
645+ expect ( FieldSchema . parse ( { ...bare ( 'number' ) , deleteBehavior : 'restrict' } ) . deleteBehavior ) . toBe ( 'restrict' ) ;
646+ expect ( FieldSchema . parse ( { ...bare ( 'datetime' ) , deleteBehavior : 'set_null' } ) . deleteBehavior ) . toBe ( 'set_null' ) ;
647+ expect ( FieldSchema . parse ( { ...bare ( 'user' ) , deleteBehavior : 'set_null' } ) . deleteBehavior ) . toBe ( 'set_null' ) ;
648+ } ) ;
649+
650+ it ( 'parse(parse(x)) is byte-stable for bare and authored spellings across the type boundary' , ( ) => {
651+ const cases = [
652+ bare ( 'text' ) ,
653+ bare ( 'datetime' ) ,
654+ bare ( 'number' ) ,
655+ { ...bare ( 'user' ) , reference : 'sys_user' } ,
656+ { name : 'account_id' , label : 'Account' , type : 'lookup' , reference : 'account' } ,
657+ { name : 'parent_id' , label : 'Parent' , type : 'tree' , reference : 'category' } ,
658+ { ...bare ( 'text' ) , deleteBehavior : 'cascade' } ,
659+ { name : 'account_id' , label : 'Account' , type : 'lookup' , reference : 'account' , deleteBehavior : 'restrict' } ,
660+ ] ;
661+ for ( const input of cases ) {
662+ const once = FieldSchema . parse ( input ) ;
663+ const twice = FieldSchema . parse ( once ) ;
664+ expect ( JSON . stringify ( twice ) , `type=${ ( input as { type : string } ) . type } ` ) . toBe ( JSON . stringify ( once ) ) ;
665+ }
666+ } ) ;
667+ } ) ;
668+
598669 it ( 'should accept the relatedList prominence tri-state (false | true | primary)' , ( ) => {
599670 for ( const relatedList of [ false , true , 'primary' ] as const ) {
600671 const field : Field = {
0 commit comments