@@ -8,6 +8,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
88// (#5286).
99import { ObjectSchema , ObjectCapabilities , IndexSchema , ObjectFieldGroupSchema , ObjectExternalBindingSchema , ObjectAccessConfigSchema , LifecycleSchema , TenancyConfigSchema , isTenancyDisabled , resolveCrudAffordances , type ServiceObject } from './object.zod' ;
1010import { resolveInjectedSystemColumns } from './injected-system-columns' ;
11+ import { Field } from './field.zod' ;
1112import type { StateMachineValidation } from './validation.zod' ;
1213
1314describe ( 'ObjectCapabilities' , ( ) => {
@@ -1216,6 +1217,134 @@ describe('ObjectSchema.create()', () => {
12161217 } ) ;
12171218} ) ;
12181219
1220+ // ============================================================================
1221+ // controlled_by_parent × master_detail — the builder forces `required: true`
1222+ // (#9138 — #8772 maintainer ruling, Direction 2 / ADR-0055)
1223+ // ============================================================================
1224+
1225+ describe ( 'ObjectSchema.create() forces a required master_detail under controlled_by_parent (#9138)' , ( ) => {
1226+ it ( 'forces required: true when `required` is omitted on the master reference' , ( ) => {
1227+ const obj = ObjectSchema . create ( {
1228+ name : 'cbp_line' ,
1229+ sharingModel : 'controlled_by_parent' ,
1230+ fields : {
1231+ parent : { type : 'master_detail' , reference : 'cbp_header' } ,
1232+ note : { type : 'text' } ,
1233+ } ,
1234+ } ) ;
1235+ const fields = obj . fields as Record < string , { required ?: boolean } > ;
1236+ expect ( fields . parent . required ) . toBe ( true ) ;
1237+ // Scope: only the master reference is forced — sibling fields keep the
1238+ // ordinary default (required: false).
1239+ expect ( fields . note . required ) . toBe ( false ) ;
1240+ } ) ;
1241+
1242+ it ( 'preserves an explicit required: true and the rest of the field config' , ( ) => {
1243+ const obj = ObjectSchema . create ( {
1244+ name : 'cbp_line_explicit' ,
1245+ sharingModel : 'controlled_by_parent' ,
1246+ fields : {
1247+ parent : Field . masterDetail ( 'cbp_header' , {
1248+ label : 'Header' ,
1249+ required : true ,
1250+ deleteBehavior : 'cascade' ,
1251+ } ) ,
1252+ } ,
1253+ } ) ;
1254+ const parent = ( obj . fields as Record < string , Record < string , unknown > > ) . parent ;
1255+ expect ( parent . required ) . toBe ( true ) ;
1256+ expect ( parent . deleteBehavior ) . toBe ( 'cascade' ) ;
1257+ expect ( parent . label ) . toBe ( 'Header' ) ;
1258+ } ) ;
1259+
1260+ it ( 'forces the Field.masterDetail helper shape too (the helper omits required)' , ( ) => {
1261+ const obj = ObjectSchema . create ( {
1262+ name : 'cbp_line_helper' ,
1263+ sharingModel : 'controlled_by_parent' ,
1264+ fields : { parent : Field . masterDetail ( 'cbp_header' , { label : 'Header' } ) } ,
1265+ } ) ;
1266+ expect ( ( obj . fields as Record < string , { required ?: boolean } > ) . parent . required ) . toBe ( true ) ;
1267+ } ) ;
1268+
1269+ it ( 'REFUSES an explicit required: false, loudly, naming object + field + the fix' , ( ) => {
1270+ let message = '' ;
1271+ try {
1272+ ObjectSchema . create ( {
1273+ name : 'cbp_bad' ,
1274+ sharingModel : 'controlled_by_parent' ,
1275+ fields : {
1276+ parent : { type : 'master_detail' , reference : 'cbp_header' , required : false } ,
1277+ } ,
1278+ } ) ;
1279+ throw new Error ( 'expected ObjectSchema.create to refuse required: false under controlled_by_parent' ) ;
1280+ } catch ( e ) {
1281+ message = ( e as Error ) . message ;
1282+ }
1283+ expect ( message ) . not . toContain ( 'expected ObjectSchema.create to refuse' ) ;
1284+ expect ( message ) . toContain ( "ObjectSchema.create('cbp_bad')" ) ;
1285+ expect ( message ) . toContain ( '`parent`' ) ;
1286+ expect ( message ) . toContain ( 'required: false' ) ;
1287+ expect ( message ) . toContain ( 'controlled_by_parent' ) ;
1288+ // The message carries the prescription, not just the verdict.
1289+ expect ( message ) . toContain ( 'Remove `required: false`' ) ;
1290+ expect ( message ) . toContain ( 'change its `sharingModel`' ) ;
1291+ } ) ;
1292+
1293+ it ( 'forces EVERY master_detail reference under the object, not just the first' , ( ) => {
1294+ // The prose contract is "exactly one required master_detail", but nothing
1295+ // enforces the count today (#7474 owns the zero-reference case at publish);
1296+ // forcing each declared reference keeps every candidate safe rather than
1297+ // silently blessing only the first.
1298+ const obj = ObjectSchema . create ( {
1299+ name : 'cbp_multi' ,
1300+ sharingModel : 'controlled_by_parent' ,
1301+ fields : {
1302+ a : { type : 'master_detail' , reference : 'master_a' } ,
1303+ b : { type : 'master_detail' , reference : 'master_b' } ,
1304+ } ,
1305+ } ) ;
1306+ const fields = obj . fields as Record < string , { required ?: boolean } > ;
1307+ expect ( fields . a . required ) . toBe ( true ) ;
1308+ expect ( fields . b . required ) . toBe ( true ) ;
1309+ } ) ;
1310+
1311+ it ( 'leaves master_detail on a NON-controlled_by_parent object alone (scope pin)' , ( ) => {
1312+ const omitted = ObjectSchema . create ( {
1313+ name : 'plain_line' ,
1314+ fields : { parent : { type : 'master_detail' , reference : 'plain_header' } } ,
1315+ } ) ;
1316+ expect ( ( omitted . fields as Record < string , { required ?: boolean } > ) . parent . required ) . toBe ( false ) ;
1317+
1318+ const explicit = ObjectSchema . create ( {
1319+ name : 'plain_line_explicit' ,
1320+ sharingModel : 'private' ,
1321+ fields : { parent : { type : 'master_detail' , reference : 'plain_header' , required : false } } ,
1322+ } ) ;
1323+ expect ( ( explicit . fields as Record < string , { required ?: boolean } > ) . parent . required ) . toBe ( false ) ;
1324+ } ) ;
1325+
1326+ it ( 'raw .parse()/.safeParse() stay TOLERANT of the old shape — metadata at rest keeps loading' , ( ) => {
1327+ // The other half of the #8772 ruling: the narrowing is authoring-time
1328+ // only. Stored metadata rehydrated through the schema (never through the
1329+ // builder) must keep loading, UNREWRITTEN — runtime tolerance for existing
1330+ // installs stays with the security gate, and the lint rule stays `warning`
1331+ // until the v18 card (#9139) promotes it.
1332+ const atRest = {
1333+ name : 'cbp_stored' ,
1334+ sharingModel : 'controlled_by_parent' ,
1335+ fields : {
1336+ parent : { type : 'master_detail' , reference : 'cbp_header' , required : false } ,
1337+ } ,
1338+ } ;
1339+ const result = ObjectSchema . safeParse ( atRest ) ;
1340+ expect ( result . success ) . toBe ( true ) ;
1341+ if ( result . success ) {
1342+ const fields = ( result . data as { fields : Record < string , { required ?: boolean } > } ) . fields ;
1343+ expect ( fields . parent . required ) . toBe ( false ) ;
1344+ }
1345+ } ) ;
1346+ } ) ;
1347+
12191348// ============================================================================
12201349// Namespace removal (D4) — Object identity is single-sourced on `name`.
12211350// ============================================================================
0 commit comments