Uh oh!
There was an error while loading. Please reload this page.
fix(docs,objectql): an explicit deleteBehavior: 'set_null' on a required lookup escalates too — say so, and pin it - #9690
Conversation
…e and pin the measured behaviour The escalation in cascadeDeleteRelations tests the RESOLVED deleteBehavior, after `deleteBehavior || 'set_null'` has erased the difference between an absent value and an authored one. So an explicitly written `deleteBehavior: 'set_null'` on a required lookup escalates to restrict exactly like the default. Four documentation surfaces and the engine's own comment said or implied otherwise; nothing pinned either reading. Measured, then described: docs and comment corrected, current behaviour pinned by fixtures. No engine behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
📓 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 seeCoarse 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 c3b2e549600a50306ad82bf7505c779bfcb3a99c && git checkout c3b2e549600a50306ad82bf7505c779bfcb3a99c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f6c71ead8fd277b7423f14df2b288a3bd77e225c b10b603aad14bdfece43c1f72a4540e8f23d7b3b && git checkout -B drift-repro f6c71ead8fd277b7423f14df2b288a3bd77e225c && git merge --no-ff b10b603aad14bdfece43c1f72a4540e8f23d7b3b
node scripts/docs-audit/affected-docs.mjs --json f6c71ead8fd277b7423f14df2b288a3bd77e225c
|
✅ PM ACCEPT — #9625 / PR #9690Verified independently: 6 files ⭐ H1 — measured before described, and that is the whole difference
Run against a real engine + stub driver before anything was written down. The card predicted this from a code read and the reasoning looked sound — but a confirmed prediction and a repeated prediction are indistinguishable in a PR body, and only one of them is evidence. You produced the evidence. The "down to the message key" detail is what makes it airtight: not merely "it also refuses" but "it refuses via the exact same path", which is the claim the docs were getting wrong. Ruling 1 discharged where it actually matteredThe card's central fact is that neither reading was pinned — coverage had a defaulted ⭐ Ablation 1 is sharper than a red/green
So the ablation does not just show the fixture is load-bearing; it reproduces the original symptom the escalation was written to prevent, and shows the fixture catches precisely that. Predicting that direction before running it is the part that makes it a measurement. And the controls:
Two independent reasons produce the same 409. A pin that cannot tell them apart would pass for the wrong reason forever. Building the controls that discriminate is the difference between a test and a coincidence. Ablation 2 (master_detail ternary changed to honour On the gate that was load-bearing here
And #9688 — H2 measured and graded
#9689 — H4 found a second instance of the same class
That is precisely what H4 was fishing for, and I did not expect it to land. The Verdict: ACCEPT. Arming once the three running gates converge. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#9625
An explicit
deleteBehavior: 'set_null'on arequired: truelookup is escalated torestrict— measured, not inferred. The card predicted this from a code read; this PR ran it before describing it.H1 — measured first
A fixture declaring
deleteBehavior: 'set_null'explicitly on arequired: truelookup, parent deleted:Parent survives, child FK untouched — identical to the defaulted case, down to the
delete_restricted_requiredmessage key. The card's reading is confirmed: the escalation tests the resolved behavior, one statement afterdeleteBehavior || 'set_null'has already erased the difference between an absent value and an authored one.Per the ruling, no engine behaviour changed here. The documentation and the code comment are made true, and the behaviour is pinned.
H2 — measured and graded
A
required: true, multiple: truelookup whose child set holds[a, b]: deletingais refused withDELETE_RESTRICTED, the set is left[a, b], and no member removal runs — even though removal would have left[b], non-empty, which the #9447 contract accepts. Reproduces on the explicit and the defaulted spelling alike.Grading: over-restriction in principle, but load-bearing conservatism today. The escalation's own stated rationale — a
set_nullwrite would clear a required FK and trip the child's validator — does not hold when the remainder is non-empty, so the refusal is broader than its justification. But the safe narrowing is not available yet: #9476 records thatrequired-means-non-empty is declared and not enforced, so[]passes the required check right now. Drop the blanket refusal today and emptying a required set becomes a silent success rather than a loud failure. The refinement becomes correct once #9476 lands, so it is filed as #9688,Blocked-by: #9476, with the fixture attached.H3 — every documentation surface carrying a claim
Swept
content/docs/**plus thedescribestrings the generated reference renders.protocol/objectql/types.mdxset_nullorcascadeis always honored as written"protocol/objectql/types.mdxset_nullautomatically escalates"data-modeling/field-types.mdx(lookup row)set_nullis escalated"api/data-api.mdxdeleteBehavior"deployment/troubleshooting.mdxDELETE_RESTRICTEDremedy offersset_null; the Cause names onlydeleteBehavior: 'restrict'packages/objectql/src/engine.tscommentreferences/data/field.mdx+spec/src/data/field.zod.tsdeleteBehaviordescribe: "What happens if referenced record is deleted"data-modeling/fields.mdx,data-modeling/validation-rules.mdxThe generated reference does not lie, so
field.zod.tsis untouched. The troubleshooting page was the most damaging of the set: it is the page a reader reaches because they hit this refusal, and its Option B remedy changes nothing on a required lookup.H4 — the neighbour has the same blind spot
master_detail's ternary maps every non-restrictvalue ontocascade, so an explicitdeleteBehavior: 'set_null'on a master-detail reference is accepted byFieldSchemaand then silently dropped — measured: the child is cascaded away, not kept with a nulledparent.Same defect class, same fix, so it is folded in: the corrected sentence in
types.mdxand themaster_detailrow infield-types.mdxnow say it, the engine comment says it, and a fixture pins it. What is not folded in is the judgement — whether the spec should reject the combination at publish time instead of the engine dropping it at delete time. That is filed as #9689.Pinned, because the divergence survived by being unpinned
Coverage had a defaulted
set_null(escalates) and an explicitcascade(honored) and nothing between them, so both readings sat green. Four fixtures added toengine-cascade-delete.test.ts:set_null+ required lookup escalates (codeandstatus, per ADR-0112 — a baretoThrow()would accept the child validator's 400, which is the failure this guard exists to prevent)multiple: truerefused with the set left intactrequiredremoves the member and deletes the parent — without it the suite cannot tell "refused because required" from "refused because multi-value"master_detail+ explicitset_nullstill cascadesThe stub driver gained
$contains/$orso it answers the multi-value probe spelling (#9362); a double that ignored them would report no dependents and turn every refusal above into a silent success.Reverse verification
Both ablations run from the committed state, restored to a byte-identical tree (
git status --porcelainempty).set_nullpin fails withcode: "VALIDATION_FAILED"— the misleading child-validator error the escalation exists to replace, which is the predicted direction. Both controls stayed green.master_detailternary changed to honorset_null→ exactly 1 red, the master-detail pin, nothing else.Verification
Union re-run after the final commit, at
b10b603aa:pnpm --filter @objectstack/objectql test— 217 files, 3840 tests passed (baseline 3836 + the 4 added)pnpm --filter @objectstack/objectql typecheck— OKpnpm check:where-matcher— OK, 255 matchers, 0 silently-wrong, none new (this PR hand-extends a WHERE matcher, so this one is load-bearing)pnpm check:engine-double-contract— OK (319 pinned, 2 exempt)pnpm check:query-options-erasure,check:type-check-coverage— OKpnpm check:nul-bytes— OK (6185 text files, no raw control bytes)pnpm check:docs-audit-scope,check:docs-redirects,check:role-word— OKpnpm check:durability-log-level,check:stack-collection-maps— OKpnpm --filter @objectstack/spec run check:empty-state/check:liveness/check:strictness-ledger/check:variant-docs— OKnode scripts/docs-audit/check-affected-docs.mjs,node scripts/check-engine-split-ratio.mjs— OK@mdx-js/mdx3.1.1 directly — 4/4 OK, with a deliberately unclosed tag observed FAILING, so the harness is not silently green.Gate set derived from the changed paths with
node scripts/pm/dispatch-gates.mjs, not recalled.Docs, one source comment and tests; publishes no package, so
skip-changesetrather than a changeset file — the same disposition as #9627.Generated by Claude Code