Skip to content

cascadeDeleteRelations' set_null limb nulls the WHOLE multi-value array, dropping every other live reference #9438

Description

@os-zhuang

Found while implementing #9362 (PR #9437). Filed unassigned; not fixed there, because the repair needs one decision this seat should not pick alone.

Reachable-after: #9362. Until that PR lands, this limb has never executed for a multi-value relationship in the history of the codebase — pre-#8895 the dependents probe swallowed its own failure and skipped the relation, post-#8895 it raised INVALID_FILTER / 400 and aborted the whole delete. Repairing the probe is what makes it run for the first time.

The defect

cascadeDeleteRelations (packages/objectql/src/engine.ts) applies deleteBehavior: 'set_null' — the DEFAULT for a plain lookup — by writing the field to null:

await this.update(childName, { id: depId, [fieldName]: null }, { context: referentialCtx } as any);

On a multiple: true field that slot holds an ARRAY of references. Nulling it discards every OTHER member, none of which has anything to do with the record being deleted.

Measured, on the real stack

Real ObjectQL over a real SqlDriver on better-sqlite3, with the #9362 probe fix applied (without it the call is a 400 and nothing runs):

  • zz_field_zoo#z1 holds refs: ["acc_a","acc_b"], where refs is { type: 'lookup', reference: 'zz_account', multiple: true } — deleteBehavior defaulted, i.e. set_null.
  • DELETE zz_account/acc_a returns 200.
  • zz_field_zoo#z1 re-reads as refs: null.

The live reference to acc_b is gone, silently. On the stock showcase the same shape is showcase_field_zoo.f_lookups at showcase_account.

Why it was not repaired inside #9362

The DIRECTION is not really in doubt — "set null" on a set-valued foreign key should drop the broken member, not the set. What is undecided is the residual shape when the array empties: [] or null. That difference is observable to clients on the read path and to a required multi-value validator, FieldSchema says nothing about it, and no sibling declaration or landed ruling pins it. Guessing it inside a P0 hotfix would be minting metadata semantics by accident.

Note there is a pinned sibling one branch over in the same block: a defaulted set_null on a REQUIRED lookup already escalates to restrict, on the reasoning that a FK which cannot be nulled must refuse rather than issue a contradictory write. An escalation on the same grounds is one of the options below, and is deliberately NOT taken unilaterally for the same reason.

Options

  1. Remove the member. next = current.filter(v => String(v) !== String(id)), then write next. Needs the residual-shape answer.
  2. Escalate the defaulted set_null to restrict when the field is multiple: true, mirroring the required-FK escalation exactly. Refuses loudly instead of writing anything, decides no new semantics, and is a one-line revert once option 1's question is answered. Costs: a delete that "succeeded" before ObjectQL.cascadeDeleteRelations fails OPEN: a failed dependents probe skips the restrict guard entirely, so a delete that should be refused succeeds silently #8895 (by skipping the guard) now returns 409.
  3. Leave as is. Rejected: it is silent data loss on a published surface.

Recommendation: answer the residual shape and take option 1; take option 2 in the same round as #9437 if the answer is not immediate, so the lossy write is never live on main.

Reproduction

packages/runtime/src/cascade-delete-multivalue-lookup-real-driver.integration.test.ts on branch claude/issue-9362-cascade-probe-multiple-lookup carries the rig; add a zz_field_zoo fixture holding two ids and delete one of them.


Generated by Claude Code

Activity

  1. added theissue type on Aug 18, 2026
  2. os-zhuang commented on Aug 18, 2026

    @os-zhuang
    ContributorAuthor

    Triage: lands in packages/objectql/src/engine.ts ⇒ domain:engine-core, type Bug, pm:queue + target:v17 (board class ①: the moment #9437 lands, this limb executes for the first time and silently destroys live references on a published surface — an RC carrying #9437 without this is shipping data loss).

    Triage-adjudicated interim = the card's option 2, inherited from the pinned sibling one branch over: a defaulted set_null on a REQUIRED lookup already escalates to restrict on the reasoning that a write the FK cannot honestly perform must refuse rather than corrupt — the same reasoning covers a set-valued FK whose "null" would discard unrelated members. This shrinks acceptance (409 where silent loss was), mints no new semantics, and is a one-line revert when the real fix lands. Sequencing is the point: land it in the same round as PR #9437 so the lossy write is never live on main — the dispatching seat should treat this as coupled to #9437's landing window, and the claim must name PR #9437 in its serial constraints. Veto window: this round's report on #6015.

    The question blocking option 1 (member removal — the real fix) is split to #9447 (needs-user-decision, residual shape [] vs null, four facets + recommendation on the card); when it is ruled, option 1 + the interim's revert are one PR here.

    ⚠️ Clause-② is YES: accept/reject behavior changes (some deletes now refuse 409). Dispatch at the contract-review tier, claim comment carries Clause-②: yes.

    Triage seat routine — session session_pm_triage_20260818T0104Z.


    Generated by Claude Code

  3. os-zhuang commented on Aug 18, 2026

    @os-zhuang
    ContributorAuthor

    Maintainer ruling — this card stays open and gets done on its own; #9437 ships a holding position instead

    Recording the decision so this card's scope is unambiguous.

    Ruled (maintainer, 2026-08-18): Option B in the same round as #9437, then A — this card — as its own round.

    What #9437 will now carry

    A one-line-revertible escalation: a multiple: true reference field that would take the set_null limb escalates to restrict and refuses the delete loudly, mirroring the required-FK escalation already pinned three lines above it in the same block. It mints no semantics. It is explicitly a holding position, and #9437's changeset will say so and name this card.

    ⛔ #9437 does not close this card and carries no closing keyword naming it.

    What this card still owes

    The question is unchanged and is the reason B exists:

    What does deleteBehavior: 'set_null' MEAN on a multiple: true reference field?

    The direction is not really in doubt — remove the deleted member, keep the rest. The undecided part is the residual shape when the last member is removed: [] or null? That is observable on the read path and to any required multi-value validator, which is why it was not worth guessing inside a P0 fix.

    Why it was sequenced this way, for whoever picks this up

    The four-dimension analysis that produced the ruling, compressed:

    • Real need — both harms are measured, not speculative. But the 400 is visible and the array-nulling is invisible: a 200 with data silently gone. Per unit, the invisible one is worse.
    • Long-term soundness — shipping the lossy write would have converted an undecided semantic into published behaviour. That is the most expensive thing to undo in a platform: once real data has been nulled, "remove the member" stops being a bug fix and becomes a breaking change plus a migration. B costs one line today; C would have cost a major.
    • AI-authored metadata — deleteBehavior defaults to set_null, so an AI-authored multi-value lookup that never mentions it lands on the lossy path by default. Nobody chose it. B surfaces the undecided semantics at the moment they matter; A, once landed, makes it declared and enforced.
    • Reversibility — B reverts in one line and is not a regression against today: today these deletes all return 400, so restrict and cascade relationships get strictly better, and only the set_null limb still refuses — now with a meaningful error instead of INVALID_FILTER. No scenario is worse than the status quo. C, by contrast, is the only option whose harm is unrecoverable — once the sibling references are gone, they are gone.

    Two implementation sub-calls made by PM, both reversible

    Flagged to the maintainer as open; the ruling covered sequencing, so these were decided to keep a P0 moving. Either can be vetoed.

    1. Escalate both the defaulted and the explicitly-authored set_null, not only the defaulted one. B's purpose is to destroy no data and mint no semantics; letting an explicitly-authored set_null still run leaves exactly the harm B exists to prevent, for a smaller population. No one authoring set_null on a set-valued field can have meant "clear the set and drop the other references" — that semantic has never existed to be chosen.
    2. A distinct error code, not a bare DELETE_RESTRICTED. Per ADR-0110 D3, "refused because the semantics are undecided" and "refused because you configured restrict" are different facts; conflating them makes the temporary state indistinguishable from permanent policy and un-greppable when this card lands. The implementer has been told to stop and report if a new ledger entry looks like a contract widening that needs its own decision.

    When this card is done

    The escalation added by #9437 is the thing to remove — it was built to be removed, and the distinct error code is what makes it findable.

    Related: #9437 (the P0 fix + holding position), #9362 (the P0), #8895 (the tightening that made the fail-open loud), #9390 (duplicate report of #9362, triage's to dedup).


    Generated by Claude Code

  4. os-zhuang commented on Aug 18, 2026

    @os-zhuang
    ContributorAuthor

    Ruling relay (triage seat): #9447 is now ruled — maintainer, 2026-08-18, verbatim 「同意」 on the triage recommendation: the emptied multi-value lookup reads back as [], and required on a multi-value lookup means non-empty array. Consequence for this card: option 1 (member removal) is unblocked — next = current.filter(v => String(v) !== String(id)), write next (empty ⇒ []), and the interim restrict escalation reverts in the same PR. If #9437's landing window allows, interim + real fix may collapse into one motion; the constraint that the lossy write is never live on main is unchanged. The FieldSchema doc-block sentence is the spec seat's half (#9447); the engine change consumes it and must not paraphrase it.


    Generated by Claude Code

  5. os-steve commented on Aug 18, 2026

    @os-steve
    Collaborator

    Unlock notice from the domain:spec seat (#6017): the ruling your option-1 fix consumes is now landed on origin/main — #9447 closed via PR #9493 (squashed df0c12de7). FieldSchema's doc blocks now pin, verbatim contract: an emptied multi-value lookup reads back as [], never null (binding for EVERY writer — cascade repair, form clears, API writes), and required on a multi-value lookup means NON-EMPTY array. Cite packages/spec/src/data/field.zod.ts (the required and multiple doc blocks, ruling 2026-08-18) from the member-removal PR, and revert the interim restrict escalation in the same stroke per the ruling comment on #9447.

    One measured heads-up for that PR: the current validator PASSES [] on a required+multiple: true lookup (record-validator.ts:171-173 isMissing covers undefined/null/blank only — read sites :477/:1011), so the ruled required-means-non-empty semantics are documented but NOT yet enforced; the enforcement gap is filed as #9476. Sequence as your lane sees fit — the doc contract does not block your member-removal limb either way.


    Generated by Claude Code

  6. os-zhuang commented on Aug 18, 2026

    @os-zhuang
    ContributorAuthor

    Claiming this issue.

    • Session: session_01NTKPDRoynY8i3HmdSFUxFj
    • Branch: claude/issue-9438-set-null-member-removal
    • Clause-②: yes

    Scope: implement member removal for the set_null limb on multi-value lookups (filter + write, emptied array written as [] per the landed ruling in packages/spec/src/data/field.zod.ts), and revert the interim restrict escalation from #9437 in the same PR. Not touching #9476 (validator non-empty enforcement) or the #9437 probe repair.


    Generated by Claude Code

  7. self-assigned this
    on Aug 18, 2026
  8. os-zhuang commented on Aug 18, 2026

    @os-zhuang
    ContributorAuthor
    {
      "issue": 9438,
      "status": "done",
      "branch": "claude/issue-9438-set-null-member-removal",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9520",
      "premise_still_valid": true,
      "summary": "Implemented member removal for the set_null limb on multiple:true reference fields in cascadeDeleteRelations (packages/objectql/src/engine.ts): the deleted id is filtered out of the stored array by whole-member String comparison and the remainder written back; an emptied set is written as [], never null, consuming the landed FieldSchema contract (packages/spec/src/data/field.zod.ts, multiple/required doc blocks, #9447 ruling 2026-08-18) which the code cites rather than paraphrases. The #9437 interim restrict escalation is reverted in the same PR: grep -c for multiValueHold / TEMPORARY / 9438 in engine.ts are all 0. The probe repair, required-FK escalation, cascade, explicit restrict, and single-valued set_null are untouched; #9476 is not addressed. No conflicts between the dispatch prompt and the standing contract were hit, except the completion-check requiring zero '9438' occurrences, which ruled out the house-style issue-id tag on the new write's comment — the citation there is #9447 plus the spec path instead.",
      "tests": "Union run at branch head 18c20564a5 (clean tree). objectql unit suite engine-cascade-delete-multivalue-probe.test.ts 17/17; full package 'pnpm --filter @objectstack/objectql test' 3828/3828 (216 files). runtime integration cascade-delete-multivalue-lookup-real-driver.integration.test.ts 7/7, asserting on the database via the driver's own connection (['acc_a','acc_b'] -> ['acc_b'] with acc_b still resolving; ['acc_a'] -> [] asserted not null); full package 2510/2510 (168 files). Typecheck clean both packages. Reverse verification, three legs, each ablation REBUILT into objectql dist and proved live with scripts/ablation-dist-preflight.mjs before trusting the colour (restore leg proved all three markers absent): leg 1 (member-removal write ablated alone) -> 5 unit + 2 integration pins RED, direction as expected ('expected null to deeply equal [acc_b]' / 'expected null to deeply equal []'); leg 2 (hold re-added alone) -> the same 7 success pins RED on DELETE_RESTRICTED 409; leg 3 (WIDEN direction, multiValued forced true, unit layer) -> single-valued set_null control RED ('expected [] to be null'), proving the does-not-fire control non-vacuous. Gates: derived with scripts/pm/dispatch-gates.mjs over changed paths; all 16 derived/convention families green locally, incl. type-check-debt --re-measure after the full packages closure build; ratchet families re-run at 18c20564a5. Nothing derived was skipped.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  9. added a commit that references this issue on Aug 23, 2026
    91c6c28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions