Found while verifying #8688 (no PR there — that card stopped on a pinned-envelope fork). Not fixed here: fixing it changes the authoring/lint surface, which is a different package and a different decision from #8688's error envelope. Recorded so the dependency is visible, because it is the reason #8688's literal route was measured unsafe.
What was measured
assertControlledByParentWrite refuses an insert whose master FK is absent, unconditionally. Required-field validation does not cover the same ground. Probed against validateRecord's handling in packages/objectql/src/validation/record-validator.ts, over the shapes a controlled_by_parent object can actually declare:
| master reference declaration | validateRecord on an insert that omits it |
|---|
master_detail, required: true | rejects — VALIDATION_FAILED, fields: [{ field, code: 'required' }] |
master_detail, required: true, explicit null | rejects, same |
master_detail, no required | no error — the write proceeds |
master_detail, required: true + readonly | no error — the write proceeds |
master_detail, required: true + system | no error — the write proceeds |
The last two follow from record-validator.ts, which skips the required check for provenance-flagged fields before it is ever reached:
if(def.system||def.readonly)continue;
Why those shapes are reachable, not hypothetical
resolveCbpRelation in packages/plugins/plugin-security/src/security-plugin.ts resolves the master relation through three fallbacks, and only the first tests required:
pick((f)=>f?.type==='master_detail'&&f?.required)??pick((f)=>f?.type==='master_detail')??pick((f)=>f?.type==='lookup'&&f?.required);
The second fallback is a supported, exercised shape — validate-security-posture.test.ts pins "stays silent on step 2: ANY master_detail (not marked required)".
packages/lint/src/data-model-rules.ts emits only severity: 'warning' for relationship/master-detail-required, so nothing refuses the declaration at publish time:
severity: 'warning',rule: 'relationship/master-detail-required',
Why it matters
There is no live defect today — the guard closes all five rows of the table, which is why this is filed as an observation rather than a bug. The finding is where the closure comes from: for three authorable shapes it comes from a security gate, not from the declaration. Two consequences:
Possible directions (not a recommendation — needs triage)
- Promote
relationship/master-detail-required from warning to error, so a controlled_by_parent master reference that is not required fails lint. Loudest, and it breaks any existing app relying on the shape the posture test currently pins as supported. - Have the authoring builder force
required: true on a master_detail under controlled_by_parent, so the shape cannot be declared. - Narrow
resolveCbpRelation to the required fallback only — but that turns the other shapes into 422 INVALID_METADATA at write time rather than at authoring time, which moves the report later, not earlier. - Accept it and document that the guard is the enforcement point for non-
required master references, in which case the guard must not be moved without this being revisited.
Note that direction 3 interacts with the validate-security-posture.test.ts pin, and directions 1–2 are authoring-surface changes with migration cost — none of them is a drive-by.
Filed unassigned for triage. Related: #8688, #7474.
Generated by Claude Code
Found while verifying #8688 (no PR there — that card stopped on a pinned-envelope fork). Not fixed here: fixing it changes the authoring/lint surface, which is a different package and a different decision from #8688's error envelope. Recorded so the dependency is visible, because it is the reason #8688's literal route was measured unsafe.
What was measured
assertControlledByParentWriterefuses an insert whose master FK is absent, unconditionally. Required-field validation does not cover the same ground. Probed againstvalidateRecord's handling inpackages/objectql/src/validation/record-validator.ts, over the shapes acontrolled_by_parentobject can actually declare:validateRecordon an insert that omits itmaster_detail,required: trueVALIDATION_FAILED,fields: [{ field, code: 'required' }]master_detail,required: true, explicitnullmaster_detail, norequiredmaster_detail,required: true+readonlymaster_detail,required: true+systemThe last two follow from
record-validator.ts, which skips the required check for provenance-flagged fields before it is ever reached:Why those shapes are reachable, not hypothetical
resolveCbpRelationinpackages/plugins/plugin-security/src/security-plugin.tsresolves the master relation through three fallbacks, and only the first testsrequired:The second fallback is a supported, exercised shape —
validate-security-posture.test.tspins "stays silent on step 2: ANY master_detail (not marked required)".packages/lint/src/data-model-rules.tsemits onlyseverity: 'warning'forrelationship/master-detail-required, so nothing refuses the declaration at publish time:Why it matters
There is no live defect today — the guard closes all five rows of the table, which is why this is filed as an observation rather than a bug. The finding is where the closure comes from: for three authorable shapes it comes from a security gate, not from the declaration. Two consequences:
fields[]and a[Security]message — while the same field, present-but-unresolvable, answers 400 VALIDATION_FAILED withfields[](#7474 residual, 17.0.0 GA) #8688 proposes) is safe only for the first two rows. For the other three it would create a detail row whose master FK is null — a row thecontrolled_by_parentread filter (fk IN (readable masters)) can never match, so nobody can read it, and every later by-id write to it answers422 MISSING_REQUIRED_FIELDon the stored-row leg.required:on a master_detail gets a warning and a silently different enforcement path. Under the "make AI-written metadata hard to get wrong" principle this is the wrong default: the mistake is invisible at authoring time and surfaces only as an unreachable row.Possible directions (not a recommendation — needs triage)
relationship/master-detail-requiredfromwarningtoerror, so acontrolled_by_parentmaster reference that is notrequiredfails lint. Loudest, and it breaks any existing app relying on the shape the posture test currently pins as supported.required: trueon amaster_detailundercontrolled_by_parent, so the shape cannot be declared.resolveCbpRelationto therequiredfallback only — but that turns the other shapes into422 INVALID_METADATAat write time rather than at authoring time, which moves the report later, not earlier.requiredmaster references, in which case the guard must not be moved without this being revisited.Note that direction 3 interacts with the
validate-security-posture.test.tspin, and directions 1–2 are authoring-surface changes with migration cost — none of them is a drive-by.Filed unassigned for triage. Related: #8688, #7474.
Generated by Claude Code