Uh oh!
There was an error while loading. Please reload this page.
fix(engine): judge the required multi-value cascade escalation per row, not per field (#9688) - #9987
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
…cade-required-multivalue-per-row
os-elon
commented
Aug 19, 2026
Hot-file region declaration — |
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 056eb80180f46f0552db82ae88f7f7bc24261152 && git checkout 056eb80180f46f0552db82ae88f7f7bc24261152
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5ed8ee6808cc7551dc209a75a15f870bde352691 adfc69ddefc105f8a4b150935d29ea690f3e26ec && git checkout -B drift-repro 5ed8ee6808cc7551dc209a75a15f870bde352691 && git merge --no-ff adfc69ddefc105f8a4b150935d29ea690f3e26ec
node scripts/docs-audit/affected-docs.mjs --json 5ed8ee6808cc7551dc209a75a15f870bde352691
|
os-elon
commented
Aug 19, 2026
PM review — ACCEPT on substance; enqueue held on the load-bearing jobsReviewed against GitHub. ⛔ Not flipping ready until Divergence — including the hot file this card was warned aboutMerge base
The design decision that makes this safe
That is the whole risk of this change in one sentence, and the shape removes it structurally instead of by two readings that happen to match today. It also reuses Equally important and easy to miss: The |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#9688
What changed
cascadeDeleteRelationsescalatedset_null→restrictonfdef.required === truebefore themultiValuedbranch and beforedependentshad been probed, so the refusal landed for every row referencing the deleted record, whatever else that row's set held.The escalation's own rationale is what bounds it: it exists because clearing a required FK issues an UPDATE the child's validator rejects with a misleading
"<field> 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 that failure is reachable only for a row the removal would empty.The judgement is now made per row, after the dependents probe and after the exact multi-value narrowing:
DELETE_RESTRICTED/ 409 stands —[]violatesrequiredunder #9447, and is rejected by the record validator since #9476dependentCountcounts only the rows that would be emptiedThe single-valued half of the escalation is unchanged and still evaluated where it was: clearing a scalar FK always writes
null, so its premise needs no row to hold.remainderAfterMemberRemovalis a new private static shared by the per-row judgement and theset_nullwrite — one function, two call sites, so the predicate that clears the write cannot predict a shape the write would not produce.Why this is safe now — re-derived on
origin/main, not recalledThe card was blocked because
required-means-non-empty was declared but not enforced. Verified by reading the source (not a grep for a claim about it):packages/objectql/src/validation/record-validator.tsdefinesand both
requiredread sites call it, so[]on a required multi-value field is refused on INSERT and UPDATE (#9476, merged as #9780).isMissingis untouched by this PR — its short-circuit is what keeps[]on a non-required multi-value field flowing to the array-shape branch, and the two predicates stay separate.Verification
The "before" was reproduced first, on the unmodified tree: the #9625 fixture passed, with the engine's own log line showing the refusal it pins —
Cannot delete acct (r_1): 1 dependent roster record(s) reference it via accounts (accounts is required, so it cannot be cleared), thrown fromcascadeDeleteRelations, set left[acct_a, acct_b].Reverse verification — the fix reverted to
origin/main'sengine.tswith the new tests in place, then restored (byte-identical to the committed fix, verified withgit diff --quiet HEAD). No rebuild was needed and none would have helped: the suite imports the subject as./engine.js, a relative in-package specifier vitest resolves tosrc/engine.ts, so nodist/sits between the test and the change. Predicted direction was 3 red / 2 green, and that is what ran:The two that stayed green are the ones that should — the last-member refusal and the authored-
restrictcontrol pass under the old broad escalation too, which is exactly why they are controls rather than discriminators.The #9625 pin was changed deliberately, not repaired.
[#9625] refuses a required MULTI-VALUE lookup even when member removal would leave the set non-emptyis now[#9625→#9688] a required MULTI-VALUE lookup now REMOVES the member when the remainder stays non-empty, carrying a comment that says the assertion is the inverse of what #9625 pinned and why the ruling inverted it. Four pins sit beside it:codeandstatus— because an over-narrowed engine would fail it by throwing the child's ownrequired400 instead, which a baretoThrow()would accept);dependentCountreports 1 of 2 referencing rows, and neither row is written — the refusal precedes every member-removal write;set_nullreaches the same per-row judgement as the explicit one, pinned in both directions on one row;deleteBehavior: 'restrict'on a required multi-value field is not narrowed at all — the control that keeps the narrowing inside the escalation.Gates. Union re-derived with
node scripts/pm/dispatch-gates.mjs(no paths passed — the script derives its own change set) and run atadfc69dde, the head of this branch after mergingorigin/main. All 25 exit 0, each captured with a redirect before$?, never through a pipe:check:changeset-gate-self-tests·check:cross-package-test-inputs·check:doc-anchors·check:docs-audit-scope·check:docs-redirects·check:durability-log-level·check:objectui-changeset·check:published-readme-links·check:role-word·check:slot-lookup·check:stack-collection-maps·check:query-options-erasure·check:engine-double-contract·check:where-matcher·check:type-check-coverage·check:nul-bytes· speccheck:empty-state/check:liveness/check:strictness-ledger/check:variant-docs·check-adr-0087-registration·check-changeset-no-major·check-empty-changeset·check-engine-split-ratio·docs-audit/check-affected-docsTheir own verdict lines, quoted rather than inferred:
check-engine-double-contract: OK — 321 pinned, 133 in the DEBT ledger, 2 exempt;where-matcher conformance holds: 259 matcher(s) discovered … 0 silently-wrong … none new;check-type-check-coverage: OK — 64/77 workspace packages type-checked;✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added). No baseline moved in either direction — the tree carries no modified ledger artifact.Package-scoped, at the same head:
pnpm --filter @objectstack/objectql test→ 221 files / 3899 tests passed;pnpm --filter @objectstack/objectql typecheck→ clean (dependency closure built first withpnpm --filter '@objectstack/objectql^...' build).Declared narrowing:
check:type-check-debt --re-measurewas not run locally — it demands a full workspace build, and objectql'stsconfig.jsonexcludes**/*.test.tswith notest-typecheck-debt.jsonledger entry for the package, so this PR's only test-file edit is outside every program it re-measures. CI runs it on the whole farm regardless.Docs
Four passages stated the blanket refusal as fact and would have contradicted the engine the moment this merged — the exact defect class #9625 was filed for. Updated in the same PR:
protocol/objectql/types.mdx(the "Required foreign keys" callout),api/data-api.mdx(the DELETE section),data-modeling/field-types.mdx(thedeleteBehaviortable row),deployment/troubleshooting.mdx(the "delete restricted" cause list).Out of scope, filed instead
#9984 —
content/docs/data-modeling/validation-rules.mdxstill carries a warn callout saying therequired-means-non-empty half is "declared but not yet enforced … validate emptiness in application code until that lands (tracked in #9476)". Every claim in it is false since #9476 landed, and it instructs authors to write redundant checks. It is the docs twin of #9781, which corrected the same stale premise in code comments. Not touched here — #9984 remains open and is not addressed by this PR.The refusal's
developerMessagefor the last-member case still reads(<field> is required, so it cannot be cleared). Left verbatim: it is accurate enough for an emptied set, and rewording it for the multi-value case would mint a new operation-message key and its locale surface, which this card does not ask for.Generated by Claude Code