Filing unassigned — recording, not claiming. Measured while landing #9625 (docs/comment correction for the explicit-vs-defaulted set_null divergence); the fixture below ships in that PR pinning the CURRENT behaviour, so this card is the decision, not the pin.
Measured
cascadeDeleteRelations, packages/objectql/src/engine.ts. The escalation
if(behavior==='set_null'&&fdef.required===true){behavior='restrict';}runs before the multiValued branch and keys on required alone. Measured with a real engine + stub driver, a required: true, multiple: true lookup whose child set holds two members:
- child
roster.accounts = [acct_a, acct_b], both live DELETE acct_a — refused, code: DELETE_RESTRICTED, status: 409, dependentCount: 1- the set is left
[acct_a, acct_b]; no member removal ran; the parent survives
Same outcome whether deleteBehavior: 'set_null' is written explicitly or left to default.
Removing acct_a would have left [acct_b] — a non-empty array, which the #9447 ruling (2026-08-18, required on a multi-value lookup means NON-EMPTY array) accepts. So the refusal is broader than the contract requires.
Why the escalation's own rationale does not reach this case
The engine's stated reason for escalating is that a set_null write would clear a required FK and trip the child's required-field validator with a misleading " is required" 400. On a multiple: true field the set_null limb does not clear the slot — since #9438 it removes the deleted MEMBER and writes the remainder. So whenever the remainder is non-empty, the premise of the escalation is simply false, and the delete is refused for a reason that does not apply.
The escalation IS correct for the sub-case where the deleted member is the LAST one: the write would produce [], which under #9447 does violate required. The right discrimination is therefore per-ROW (does removing this member empty THIS row's set?), not per-FIELD — and today it runs per-field, before dependents has even been probed.
Why this is not a straightforward "narrow it" — and why it is blocked
Today the blanket refusal is load-bearing. #9476 records that the required-means-non-empty half is declared but not enforced: isMissing in the record validator treats only undefined, null and blank strings as missing, so [] passes the required check on both insert and update. Narrow the escalation now and the last-member case stops being refused loudly and starts landing silently as an empty required set — strictly worse than the current over-restriction.
So the ordering is: #9476 first, then this. Grading it as deliberate conservatism versus over-restriction is not either/or — it is over-restriction in principle that is currently doing real work.
(2026-08-19 update, PM session: #9476 closed via merged PR #9780, so the ordering constraint above is satisfied and the spent Blocked-by: #9476 line has been removed from this body per protocol. See the ruling comment.)
Shape of the fix, once unblocked
Move the emptiness judgement after the dependents probe and the multi-value narrowing, and decide per row: a row whose set would still be non-empty after member removal takes the set_null limb; a row that would be emptied keeps the restrict refusal. Note the refusal envelope's dependentCount would then need to count only the rows that would be emptied, not every referencing row — a count that names rows the delete no longer refuses over is its own small defect.
Current behaviour is pinned
packages/objectql/src/engine-cascade-delete.test.ts (via #9625) carries [#9625] refuses a required MULTI-VALUE lookup even when member removal would leave the set non-empty, plus the control showing the same shape WITHOUT required does remove the member. Changing the behaviour here means updating that pin deliberately, which is the point of pinning it.
Refs: #9625 (where this was measured), #9476 (the enforcement gap this waited on — closed), #9447 (the ruling), #9438 (member removal).
Generated by Claude Code
Filing unassigned — recording, not claiming. Measured while landing #9625 (docs/comment correction for the explicit-vs-defaulted
set_nulldivergence); the fixture below ships in that PR pinning the CURRENT behaviour, so this card is the decision, not the pin.Measured
cascadeDeleteRelations,packages/objectql/src/engine.ts. The escalationruns before the
multiValuedbranch and keys onrequiredalone. Measured with a real engine + stub driver, arequired: true, multiple: truelookup whose child set holds two members:roster.accounts = [acct_a, acct_b], both liveDELETE acct_a— refused,code: DELETE_RESTRICTED,status: 409,dependentCount: 1[acct_a, acct_b]; no member removal ran; the parent survivesSame outcome whether
deleteBehavior: 'set_null'is written explicitly or left to default.Removing
acct_awould have left[acct_b]— a non-empty array, which the #9447 ruling (2026-08-18,requiredon a multi-value lookup means NON-EMPTY array) accepts. So the refusal is broader than the contract requires.Why the escalation's own rationale does not reach this case
The engine's stated reason for escalating is that a
set_nullwrite would clear a required FK and trip the child's required-field validator with a misleading" is required"400. On amultiple: truefield theset_nulllimb does not clear the slot — since #9438 it removes the deleted MEMBER and writes the remainder. So whenever the remainder is non-empty, the premise of the escalation is simply false, and the delete is refused for a reason that does not apply.The escalation IS correct for the sub-case where the deleted member is the LAST one: the write would produce
[], which under #9447 does violaterequired. The right discrimination is therefore per-ROW (does removing this member empty THIS row's set?), not per-FIELD — and today it runs per-field, beforedependentshas even been probed.Why this is not a straightforward "narrow it" — and why it is blocked
Today the blanket refusal is load-bearing. #9476 records that the
required-means-non-empty half is declared but not enforced:isMissingin the record validator treats onlyundefined,nulland blank strings as missing, so[]passes the required check on both insert and update. Narrow the escalation now and the last-member case stops being refused loudly and starts landing silently as an empty required set — strictly worse than the current over-restriction.So the ordering is: #9476 first, then this. Grading it as deliberate conservatism versus over-restriction is not either/or — it is over-restriction in principle that is currently doing real work.
(2026-08-19 update, PM session: #9476 closed via merged PR #9780, so the ordering constraint above is satisfied and the spent
Blocked-by: #9476line has been removed from this body per protocol. See the ruling comment.)Shape of the fix, once unblocked
Move the emptiness judgement after the
dependentsprobe and the multi-value narrowing, and decide per row: a row whose set would still be non-empty after member removal takes theset_nulllimb; a row that would be emptied keeps therestrictrefusal. Note the refusal envelope'sdependentCountwould then need to count only the rows that would be emptied, not every referencing row — a count that names rows the delete no longer refuses over is its own small defect.Current behaviour is pinned
packages/objectql/src/engine-cascade-delete.test.ts(via #9625) carries[#9625] refuses a required MULTI-VALUE lookup even when member removal would leave the set non-empty, plus the control showing the same shape WITHOUTrequireddoes remove the member. Changing the behaviour here means updating that pin deliberately, which is the point of pinning it.Refs: #9625 (where this was measured), #9476 (the enforcement gap this waited on — closed), #9447 (the ruling), #9438 (member removal).
Generated by Claude Code