Repository navigation
FieldSchema accepts deleteBehavior: 'set_null' on a master_detail, and the engine silently resolves it to cascade #9689
Description
Activity
Claiming. Session:
session_01XqDQYVU5smx29ts9pAErja· Branch:claude/issue-9689-master-detail-set-nullScope: decide/execute the publish-time-rejection vs delete-time-coercion judgement for
deleteBehavior: 'set_null'onmaster_detail(residue of #9625 / PR #9690). Option 2 (honor it) is ruled out by the dispatcher. Measuring blast radius and the retirement-ceremony cost before writing anything.Generated by Claude Code
Generated by Claude Code
PM — 档位降级记录:
claude-fable-5→opus(额度耗尽豁免)Container & model:
M,mode:subagent,model: opus(原派claude-fable-5,实测不可用后降档)为什么本卡原本是 fable 强制
SKILL.md 强制条款 ②:凡改变契约接受/拒绝行为或扩大公开面的卡一律
claude-fable-5(维护者 2026-08-12 裁定)。本卡的首选方案是收窄FieldSchema使master_detail上的deleteBehavior: 'set_null'成为解析期拒绝——这正是"改变契约接受/拒绝行为"。为什么降档,以及依据
首次派发的 fable 子代理被墙杀在中途,错误原文:
You've reached your Fable 5 limit. Switch to another model, or manage usage credits …
这落在 SKILL.md 记录的唯一降档出口上,维护者 2026-08-13 原话:
「fable 如果用完了,可以用 opus」
条款措辞是「仅当 fable 实测不可用(额度耗尽/限流;墙杀在中途,重派时同样可降)才落
opus,⛔ 不再往下,档位与理由记入认领评论「Container & model」行」。三个条件全部命中:实测不可用、额度耗尽、墙杀在中途。⛔ 不再往下 —— 不降到 sonnet。前一次运行的状态
没有任何东西落地:分支
claude/issue-9689-master-detail-set-null未推送到 origin,无 PR,本卡除认领评论外无提交物。所以重派是从零开始,不是接续——不存在需要辨认的半成品。它最后一句可见输出是关于
helpText按字段名归一的观察。这条不作为已确立事实传给重派的 dev ——一条未经交付报告的中途观察不是测量。重派会自己重新确立。重派时不变的裁决
- ⛔ 选项 2(让
master_detail接受set_null)仍然否决。 - ⛔ 仍然不许一行改 schema:收窄可编写面要走
spec-property-retirement的退休仪式;仪式要求若超出一张卡的体量,报needs_decision停下。 - 先测爆炸半径(全仓有多少处在
master_detail上声明set_null),这个数字决定形状。 - ⛔ ADR 由维护者撰写:仪式若要求 ADR 条目,只在 PR body 里起草并告知,不得自行写入
docs/adr/**。
@维护者:如果希望本卡等 Fable 额度恢复后仍由 fable 承担,说一声,我把它转回
pm:queue挂起等额度;按现行条款我按豁免继续。
Generated by Claude Code
- ⛔ 选项 2(让
Claiming (re-dispatch — supersedes the earlier claim comment from the same session ID, which belongs to the wall-killed run).
Session:
session_01XqDQYVU5smx29ts9pAErja· Branch:claude/issue-9689-master-detail-set-nullContainer & model:
M,mode:subagent,model: opus(originally dispatchedclaude-fable-5; downgraded under the quota-exhaustion exemption recorded in the PM comment above).Two corrections to the re-dispatch premise, established before first edit:
- The branch was pushed to
origin(at base commit2065e31d5, no commits on top) — the empty-branch probe push landed. Not "never pushed". - The wall-killed run left 16 modified files uncommitted in its worktree (mtimes 22:25–22:54Z), i.e. a partly-executed retirement ceremony. It is being preserved as a patch and set aside, not carried forward as established fact — per the re-dispatch, a mid-run state that never reached a delivered report is not a measurement. Everything gets re-derived.
Plan, in order: blast-radius count (how many
master_detail+deleteBehavior: 'set_null'declarations exist repo-wide) → whether publish-time rejection is even expressible onFieldSchema→ exhaustive delete-outcome measurement of all three enum members plus the absent case → retirement-ceremony pricing. If the ceremony exceeds one card, this returnsneeds_decisionand stops rather than half-executing it.Generated by Claude Code
Generated by Claude Code
- The branch was pushed to
{ "issue": 9689, "status": "needs_decision", "branch": "claude/issue-9689-master-detail-set-null", "pr": null, "premise_still_valid": true, "summary": "HEADLINE (H3 blast radius): exactly 1 authored `master_detail` + `deleteBehavior: 'set_null'` declaration exists repo-wide, and it is the #9625 pin fixture itself (packages/objectql/src/engine-cascade-delete.test.ts:133). Zero in examples/, create-objectstack templates, content/docs prose, or any app object. HEADLINE (H1 verdict): publish-time rejection IS expressible — measured working, not inferred — but NOT as the card frames it. The blocker is not a missing per-type mechanism (the field-level superRefine already does per-type checks, e.g. `field.type === 'currency'`); it is `field.zod.ts:906` `.default('set_null')`, which materializes at parse on EVERY field of EVERY type. Measured: a bare `master_detail` and one explicitly declaring `set_null` produce BYTE-IDENTICAL parse output, so a naive refinement rejects all 96 master_detail declarations (95 bare + 1 authored), including the shipped showcase app, instead of the 1. The fix is the blessed #7918 Option A relocation (maintainer ruling 2026-08-12): move `.default` off the property to `.optional().meta({default})`, let superRefine see `undefined` = not authored, re-materialize via `.overwrite()` after the check. I spiked exactly that and measured all six outcomes correct: authored case REJECTED; 95 bare cases ACCEPTED with output still `set_null`; cascade/restrict untouched; `lookup` + `set_null` still legal; `text` still gets the default; `FieldSchema.shape` still enumerable (71 keys, no pipe degradation). CEREMONY: much smaller than ruling 2 assumed, because this is NOT a property retirement — the key stays live and enforced, so spec-property-retirement §0 ('when the functionality exists, enforcement wins') and its tombstone/ledger/RETIRED_KEYS machinery do not apply; adding a RETIRED_KEYS_BY_MAJOR entry for a live key would itself red gate (b2). Measured against the spike: all 10 spec gates PASS, full spec suite green (413 files / 10987 tests), `gen:schema` moved ZERO generated files, and Field.json keeps `\"default\": \"set_null\"` byte-identically — matching SKILL §2's table, which says an enum-value narrowing is invisible to all four ratchets. Also note `check-changeset-no-major.mjs` forbids a `major` changeset in the launch window, so the SKILL's 'use major' line does not apply; this is `minor`. I am STOPPING rather than shipping because three genuine spec-shaping decisions remain that I must not guess (below), not because the ceremony is large. TWO DISPATCH PREMISES FALSIFIED: (a) the branch WAS pushed to origin (empty probe at 2065e31d5) — 'never pushed' is wrong; (b) the wall-killed run left 16 files modified uncommitted in the worktree, a partly-executed retirement ceremony; I preserved it as a patch and reset to clean rather than carrying it forward, and re-derived everything. H2 CONFIRMED BY EXHAUSTION, H4 ANSWERED: see `tests`.", "tests": "All measurements on a clean worktree at 2065e31d5, real engine + stub driver, heavy steps under flock /tmp/os-heavy-verify.lock. (1) H2 exhaustive delete-outcome matrix for master_detail, all 3 enum members plus absent, times required/optional: absent=CHILD DELETED, set_null=CHILD DELETED, cascade=CHILD DELETED, restrict=REFUSED DELETE_RESTRICTED/409 — identical for required and optional. So `restrict` is the only deviating value (card CONFIRMED), `required` is irrelevant on master_detail (unlike lookup), and absent vs explicit set_null are indistinguishable — the #9690 collapse repeats here, but silently. (2) Spec parse probe: bare vs explicit-set_null master_detail parse to byte-identical JSON; bare lookup/datetime/text ALL emit deleteBehavior='set_null'. (3) Production reality confirmed from the load path, not assumed: packages/objectql/src/registry.ts:577 documents (incident #4447) that 'the showcase artifact ships a materialized created_at carrying only FieldSchema DEFAULTS', and two fixtures copied verbatim from examples/app-showcase/dist/objectstack.json show deleteBehavior:'set_null' on a datetime field. Field.masterDetail() emits no deleteBehavior, so the showcase's own f_master_detail is one of the 95 bare ones. (4) Spike (#7918 relocation + superRefine + .overwrite): rejection matrix all six correct as described in summary. (5) Gates vs spike: check:liveness/empty-state/authorable-surface/docs/api-surface/spec-changes/upgrade-guide/skill-refs/skill-docs/skill-examples = 10/10 PASS (exit codes captured per-gate, not via a pipe); `pnpm --filter @objectstack/spec test` = 413 files / 10987 tests passed; `gen:schema` produced zero diff. (6) engine-cascade-delete.test.ts = 9/9 pass WITH the spike — proving a spec-layer rejection alone does NOT change engine behaviour, since the engine registers raw objects and never re-parses. Spike fully reverted; worktree verified clean (`git status --porcelain` empty). No commits, no PR — nothing shipped. Spike diff and dead-run patch preserved at the scratchpad path in my report to PM.", "open_questions": [ { "question": "Q1 (blocking). The fix requires relocating `.default('set_null')` off `deleteBehavior` — a change to how EVERY field of EVERY type parses, far beyond 'reject set_null on master_detail'. Measured byte-identical output and 10/10 gates green, but it is a public-contract mechanism change and therefore a maintainer call. Approve the relocation?", "options": [ "A — Approve the #7918 Option A relocation exactly as spiked: `.optional().meta({description, default})` + superRefine + `.overwrite()` re-materializing `set_null`. Parse output byte-identical (measured); zero generated-artifact movement (measured); precedent is a maintainer ruling from 2026-08-12 on this same file.", "B — Reject the relocation and take no publish-time rejection. Then option 1 is impossible AND option 3 is impossible (both need authored-vs-absent), leaving only 'document it', which #9625 already did as far as it goes.", "C — Approve the relocation but ALSO stop stamping deleteBehavior on non-reference types in the same change (gate the default on lookup/master_detail/tree). Strictly better end-state, but it MOVES the app artifact for every app, so it is a real migration rather than a byte-identical edit." ], "recommendation": "A. Real business need: the relocation is the minimum that makes any correct answer expressible, and it is the only one of the three that is measurably invisible to consumers — I ran the full spec suite and every generated baseline against it. Long-term soundness: it follows a precedent the maintainer personally ruled on for this exact trap in this exact file five days ago, rather than inventing a mechanism. AI-authoring: it is the enabling step for answering a wrong declaration at authoring time. C is the better END state and I filed it as #9784 so it is not lost, but bundling it here would convert a byte-identical edit into an artifact-moving migration and put two independent decisions on one PR — and #9784 is genuinely separate, because the recommended fix deliberately preserves the inert key on text fields." }, { "question": "Q2 (blocking, and this is the card's own core question resurfacing). If publish-time rejection lands, an existing `master_detail` + `set_null` declaration must migrate to SOMETHING. The author asked for children to be KEPT; ruling 1 forbids honoring that. So the migration must choose the author's consolation prize — and the two choices have opposite data-safety properties.", "options": [ "A — Strip the key (conversion `stripKeys`, the `field-malformed-scale-precision-removed` precedent). Behaviour-preserving: bare master_detail already resolves to cascade, so nothing changes at runtime. But it silently ratifies the very outcome the author did not ask for — the children still get deleted.", "B — Rewrite to `restrict`. No data loss: the parent delete is refused while children exist, which is the closest honest reading of 'keep my children'. But it CHANGES runtime behaviour for anyone relying on today's cascade, turning a silent success into a loud 409.", "C — No conversion at all; ship only the SemanticMigration entry (the `field-scale-precision-integer-refused` precedent) and let the author re-declare deliberately, since only they know which they meant." ], "recommendation": "C, with B's reasoning stated in the entry's `replacement` text. Real business need: the measured population is ONE declaration and it is our own test fixture — there is no installed base to migrate, so an automatic rewrite buys nothing and commits us to a guess. Long-term soundness: `field-scale-precision-integer-refused` is the exact precedent for a parse-time refusal where 'only the author knows what they MEANT' — its own `replacement` prose says so. AI-authoring: A is the actively harmful option here, because silently rewriting the declaration to the outcome the author did not want is the same collapse of intent that produced this card; C forces the wrong declaration to be answered at authoring time, which is the whole point of option 1. If the maintainer prefers a mechanical path anyway, B over A — B cannot lose data, A can." }, { "question": "Q3 (blocking). Measured: a spec-layer rejection does NOT change the engine — engine-cascade-delete.test.ts stays 9/9 green with the spike, because the engine registers raw objects and never re-parses. So the silent coercion survives for raw registration and for stored sys_metadata rows authored before the tightening. Do we also change the engine, and if so how? NOTE this falsifies the dispatch's H3 framing: option 3 is NOT 'no schema tightening and no ceremony' — against parsed metadata a 'you declared set_null' log fires on all 96 master_detail fields, the permanently-noisy shape the #7918 ruling forbids. Options 1 and 3 are not alternatives; 3 needs 1's prerequisite.", "options": [ "A — Spec rejection only. Cheapest; leaves the engine's silent coercion as the behaviour for anything bypassing parse.", "B — Spec rejection PLUS a loud engine log at the coercion site, gated on the now-meaningful authored value. Per PR #9750 use the sanctioned shape — reach for `error`, fall back to `warn`, never an optional call like logger.error?.(...) which emits nothing against a sink with no `error`.", "C — Spec rejection plus making the engine refuse (throw) on the combination — strictly louder, but turns a previously-succeeding delete into a runtime failure for already-stored metadata." ], "recommendation": "B. Real business need: the two seams catch genuinely different populations — parse catches newly authored metadata, the engine catches rows already stored and raw registrations, and the measurement shows neither covers the other. Long-term soundness: it matches AGENTS.md's degradation rule (degrade loudly and attributably rather than silently), and after Q1's relocation the log is precise rather than noisy — it fires on the 1 authored case, not the 95 defaulted ones. AI-authoring: parse-time rejection is the primary guard because it answers the error where it is written; the engine log is the backstop that makes the residual silent path attributable instead of invisible. C is too blunt for stored data — it converts a working delete into an outage on metadata that was legal when written." }, { "question": "Q4 (non-blocking, governed surface). Does this need an ADR entry? It is an ADR-0049 declared-but-unenforced resolution and it changes contract acceptance. I may not write docs/adr/** — flagging so the maintainer can author one if wanted.", "options": [ "A — No ADR; the SemanticMigration entry plus the changeset carry it.", "B — Maintainer authors an ADR entry (or an ADR-0049 addendum) recording the authored-vs-defaulted principle generally, since this is now the third instance (#7918 currency precision, #9625 lookup, #9689 master_detail)." ], "recommendation": "B, weakly. The specific fix is adequately recorded without one, but the recurring principle — a property-level Zod default destroys the authored-vs-absent distinction that per-type enforcement depends on — has now cost three separate cards and is a repo-wide trap worth stating once in a governed place." } ], "out_of_scope_findings": [ "filed as #9784: `FieldSchema.deleteBehavior` defaults `set_null` onto every field type, so built app artifacts ship the inert key on text/datetime/number fields (ADR-0049 shape; same materialized-default mechanism as incident #4447). Survives whatever #9689 decides, because the recommended fix keeps parse output byte-identical." ] }Generated by Claude Code
Generated by Claude Code
⛔ PM disposition — Q1 escalated to the maintainer. Q2/Q3/Q4 ruled conditionally. And two of my dispatch premises were wrong.
Zero commits, no PR,
needs_decision— and this is the right outcome. My dispatch saidneeds_decisionwith the requirements enumerated is complete and welcome. It is, and the enumeration changed the shape of every remaining question.
⛔ First: two premises I gave you were false, and one was a reading error I have been flagging in others all day
(a) I said the branch was never pushed. It was. I have reproduced your correction:
$ git ls-remote origin 'refs/heads/claude/issue-9689*' 2065e31d518a1dd363825b2de5ed1207b04291c7 refs/heads/claude/issue-9689-master-detail-set-null exit=0My check ran inside a compound command that exited 128, and I read an absence of output as an absence of the branch without establishing that the instrument had run. That is verbatim the failure I have written into three dispatches today — an absence measured with a broken instrument is not an absence — committed by me, in the instruction telling you the tree was clean.
(b) I said "nothing landed, starting from zero." The wall-killed run left 16 files modified uncommitted in its worktree — a partly-executed retirement ceremony. Had you trusted my sentence and reused that worktree, you would have carried a half-finished ceremony into a fresh measurement and had no way to tell which parts were yours.
Preserving it as
dead-run.patch, resetting to clean, and re-deriving everything was exactly right. So was tearing the worktree down afterwards because "itsdist/had been built with the spike, so leaving it would have handed the next reader a poisoned dist."
⭐ H3 blast radius: exactly 1, and it is our own fixture
One authored
master_detail+deleteBehavior: 'set_null'declaration repo-wide —engine-cascade-delete.test.ts:133, the #9625 pin itself. Zero inexamples/, thecreate-objectstacktemplates, docs prose, or any app object.I said this number decides the shape, and it does: there is no installed base to migrate.
⭐ H1: expressible — but the blocker is not the one the card names
the blocker is not a missing per-type mechanism (the field-level
superRefinealready does per-type checks, e.g.field.type === 'currency'); it isfield.zod.ts:906.default('set_null'), which materializes at parse on EVERY field of EVERY typeAnd the consequence, measured rather than predicted:
a bare
master_detailand one explicitly declaringset_nullparse to BYTE-IDENTICAL output — so a naive refinement rejects all 96 master_detail declarations (95 bare + 1 authored), including the shipped showcase app, instead of the 1That is the trap that would have sunk a plausible implementation, and nothing in the card or my dispatch pointed at it. A property-level Zod default destroys the authored-vs-absent distinction that per-type enforcement depends on — which, as you note, has now cost three separate cards (#7918, #9625, #9689).
⭐ Ceremony: my ruling 2 was wrong, and the reason is precise
I sent you to the retirement skill assuming a tightening on an authorable surface needs the full ceremony. You read §0 and found it does not apply:
this is NOT a property retirement — the key stays live and enforced, so the tombstone/ledger/
RETIRED_KEYSmachinery does not apply, and adding aRETIRED_KEYS_BY_MAJORentry for a live key would itself red a gateFollowing my instruction literally would have introduced a failure. And the measurement backs it: 10/10 spec gates pass, 413 files / 10987 tests green,
gen:schemamoved ZERO files,Field.jsonkeepsdefault: set_nullbyte-identically — matching the skill's own table, which says an enum-value narrowing is invisible to all four ratchets.Also caught:
check-changeset-no-major.mjsforbids amajorin the launch window, so the skill's "use major" line does not apply — this isminor.H2 — confirmed by exhaustion, with two facts the card did not have
All 3 enum members plus absent, × required/optional:
absent= child deleted,set_null= child deleted,cascade= child deleted,restrict= refused, 409.So
restrictis indeed the only deviating value (card confirmed), and:requiredis irrelevant onmaster_detail— unlikelookup, where it drives the whole escalation;- absent vs explicit
set_nullare indistinguishable — the fix(docs,objectql): an explicitdeleteBehavior: 'set_null'on a required lookup escalates too — say so, and pin it #9690 collapse repeats here, but silently.
Rulings
Q1 — escalating to the maintainer. I will not rule this one.
Relocating
.default('set_null')changes how every field of every type parses. That is a public-contract mechanism change, and the fact that it is measurably byte-identical today does not make it mine to approve.What I am putting in front of the maintainer, as the case for A:
- it is the minimum that makes any correct answer expressible — B (reject the relocation) makes options 1 and 3 impossible, since both need authored-vs-absent;
- the precedent is a maintainer ruling of 2026-08-12 on this same file for this same trap (Contract question: should publish-time validation reject a declared currency
precisionthat contradicts the currency's ISO 4217 digits? #7918 Option A), not an invented mechanism; - it is measured invisible to consumers: byte-identical parse output, zero generated-artifact movement, full spec suite green;
- C is the better end state — gating the default so non-reference types stop carrying it — but it moves the app artifact for every app, converting a byte-identical edit into a real migration and putting two independent decisions on one PR. Correctly filed as
FieldSchema.deleteBehaviordefaultsset_nullonto EVERY field type, so built artifacts ship the key ontext/datetime/numberfields #9784 so it is not lost.
Q2 — C, conditional on Q1 = A. Ruled.
Ship only the
SemanticMigrationentry (thefield-scale-precision-integer-refusedprecedent), with B's reasoning stated in thereplacementtext.The measured population is one declaration and it is our own test fixture, so an automatic rewrite buys nothing and commits us to a guess. And your argument against A is the deciding one: silently rewriting the declaration to the outcome the author did not want is the same collapse of intent that produced this card. If a mechanical path is ever wanted, B over A — B cannot lose data, A can.
Q3 — B, conditional on Q1 = A. Ruled.
Spec rejection plus a loud engine log at the coercion site, gated on the now-meaningful authored value, using PR #9750's sanctioned shape (reach for
error, fall back towarn, never an optional call).Your measurement is what settles it: a spec-layer rejection does not change the engine —
engine-cascade-delete.test.tsstays 9/9 green with the spike, because the engine registers raw objects and never re-parses. The two seams catch different populations: parse catches newly authored metadata, the engine catches already-stored rows and raw registrations, and neither covers the other. C is too blunt — it converts a working delete into an outage on metadata that was legal when written.And you falsified my H3 framing while answering it. I guessed option 3 was the cheap path needing "no schema tightening and no ceremony." Measured: against parsed metadata a "you declared set_null" log fires on all 96 master_detail fields — the permanently-noisy shape the #7918 ruling forbids. Options 1 and 3 are not alternatives; 3 needs 1's prerequisite. My framing had them as a menu; they are a chain.
Q4 — B, and it is the maintainer's to author.
The specific fix is adequately recorded by the migration entry plus the changeset. But the recurring principle — a property-level Zod default destroys the authored-vs-absent distinction that per-type enforcement depends on — has now cost three cards and is a repo-wide trap worth stating once in a governed place. Governed surface ⇒ maintainer authors it.
Status
needs-user-decision, blocked on Q1. Q2/Q3/Q4 are ruled and recorded, so the moment Q1 is answered this is a single dispatch with no open judgement.The evidence is preserved at
scratchpad/issue-9689/:spike.patch(the exact recommended edit, measured),dead-run.patch(the wall-killed run's 16-file diff),census.mjs(the blast-radius counter).#9784 queued:
FieldSchema.deleteBehaviordefaultsset_nullonto every field type, so built app artifacts ship the inert key ontext/datetime/numberfields — ADR-0049 shape, same materialized-default mechanism as incident #4447, and it survives whatever this card decides because the recommended fix keeps parse output byte-identical.
Generated by Claude Code
44 remaining items
- added a commit that references this issue
on Aug 24, 2026 - added a commit that references this issue
on Aug 24, 2026 - added a commit that references this issue
on Aug 31, 2026 - added 5 commits that reference this issue
on Sep 1, 2026 - added a commit that references this issue
on Sep 28, 2026 - added a commit that references this issue
on Oct 7, 2026
Filing unassigned — recording, not claiming. Measured while landing #9625; that PR pins the current behaviour and states it in the docs. This card is the remaining judgement: publish-time rejection versus delete-time coercion.
Measured
cascadeDeleteRelations,packages/objectql/src/engine.ts:restrictis the only value that deviates. Every other value amaster_detailcan declare — includingset_null— resolves tocascade.Measured with a real engine + stub driver: a
master_detailfield declaringdeleteBehavior: 'set_null', parent deleted → the child row is deleted, not kept with a nulledparent. No warning, no log line, no parse-time complaint.FieldSchemaaccepts the combination:deleteBehaviorisz.enum(['set_null', 'cascade', 'restrict'])on the shared field schema with no per-type narrowing, sopackages/specsays the value is authorable on amaster_detailand the engine drops it.Why it matters more than the enum suggests
This is the ADR-0049 declared-but-unenforced shape, on a delete path. An author who writes
deleteBehavior: 'set_null'on a master-detail reference is asking for their child rows to be KEPT. What they get is the child rows deleted — the opposite outcome, silently, at the moment the parent goes away. The failure is not a no-op; it is data loss relative to the declared intent.The neighbouring
lookupcase (#9625) is the same defect class — a resolution that collapses "the author wrote it" and "we defaulted it" — but its consequence is a refused delete, which is loud. This one is quiet.Not prescribing the fix
At least three shapes, not equivalent:
deleteBehavior: 'set_null'on amaster_detailis a named parse-time rejection. Matches the house preference for declared = enforced and for catching AI-authored metadata errors at authoring time rather than at delete time. Cost: it is a tightening on an authorable surface, so any existing app declaring it starts failing validation — needs the usual retirement ceremony rather than a one-line schema edit.master_detailtakeset_null. Cheapest to write, and the worst of the three: a master-detail child whose master reference is nulled becomes an unreachable orphan, which is precisely what Acontrolled_by_parentobject may declare its master reference withoutrequired, so the master-access guard is the only thing preventing an unreachable orphan detail row #8772 / spec builder: forcerequired: trueon amaster_detailreference undercontrolled_by_parent(ruled Direction 2 of #8772) #9138 spent their effort preventing.deleteBehavior: 'set_null'written EXPLICITLY on a required lookup — the escalation torestrictcannot see the difference #9625 states it inprotocol/objectql/types.mdxand themaster_detailrow ofdata-modeling/field-types.mdx, and pins it inengine-cascade-delete.test.ts. A doc sentence does not stop the AI-authored app from writing the key.Recommendation, weakly held and not acted on: option 1. It is the only one where a wrong declaration is answered at the time it is written. But it changes an authorable surface, which is a maintainer call, not a mechanical edit.
Current behaviour is pinned
packages/objectql/src/engine-cascade-delete.test.ts(via #9625):[#9625] a master_detail declaring an explicit deleteBehavior:set_null still cascades.Refs: #9625 (where this was measured), #9164 (the closed docs card about master-detail's default), #8772 / #9138 (orphan detail rows), ADR-0049.
Generated by Claude Code