Skip to content

FieldSchema accepts deleteBehavior: 'set_null' on a master_detail, and the engine silently resolves it to cascade #9689

Description

@os-steve

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:

let behavior: string =
  fdef.type === 'master_detail'
    ? (fdef.deleteBehavior === 'restrict' ? 'restrict' : 'cascade')
    : (fdef.deleteBehavior || 'set_null');

restrict is the only value that deviates. Every other value a master_detail can declare — including set_null — resolves to cascade.

Measured with a real engine + stub driver: a master_detail field declaring deleteBehavior: 'set_null', parent deleted → the child row is deleted, not kept with a nulled parent. No warning, no log line, no parse-time complaint.

FieldSchema accepts the combination: deleteBehavior is z.enum(['set_null', 'cascade', 'restrict']) on the shared field schema with no per-type narrowing, so packages/spec says the value is authorable on a master_detail and 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 lookup case (#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:

  1. Reject at publish time. Narrow the schema so deleteBehavior: 'set_null' on a master_detail is 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.
  2. Honor it. Let a master_detail take set_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 A controlled_by_parent object may declare its master reference without required, so the master-access guard is the only thing preventing an unreachable orphan detail row #8772 / spec builder: force required: true on a master_detail reference under controlled_by_parent (ruled Direction 2 of #8772) #9138 spent their effort preventing.
  3. Leave the coercion and document it. Already done as far as it goes — Docs and engine disagree on deleteBehavior: 'set_null' written EXPLICITLY on a required lookup — the escalation to restrict cannot see the difference #9625 states it in protocol/objectql/types.mdx and the master_detail row of data-modeling/field-types.mdx, and pins it in engine-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

Activity

  1. os-steve commented on Aug 18, 2026

    @os-steve
    CollaboratorAuthor

    Claiming. Session: session_01XqDQYVU5smx29ts9pAErja · Branch: claude/issue-9689-master-detail-set-null

    Scope: decide/execute the publish-time-rejection vs delete-time-coercion judgement for deleteBehavior: 'set_null' on master_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

  2. claude commented on Aug 18, 2026

    @claude
    Contributor

    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

  3. os-steve commented on Aug 18, 2026

    @os-steve
    CollaboratorAuthor

    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-null

    Container & model: M, mode:subagent, model: opus (originally dispatched claude-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:

    1. The branch was pushed to origin (at base commit 2065e31d5, no commits on top) — the empty-branch probe push landed. Not "never pushed".
    2. 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 on FieldSchema → exhaustive delete-outcome measurement of all three enum members plus the absent case → retirement-ceremony pricing. If the ceremony exceeds one card, this returns needs_decision and stops rather than half-executing it.

    Generated by Claude Code


    Generated by Claude Code

  4. os-steve commented on Aug 18, 2026

    @os-steve
    CollaboratorAuthor
    {
      "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

  5. claude commented on Aug 18, 2026

    @claude
    Contributor

    ⛔ 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 said needs_decision with 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=0
    

    My 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 "its dist/ 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 in examples/, the create-objectstack templates, 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 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

    And the consequence, measured rather than predicted:

    a bare master_detail and one explicitly declaring set_null parse 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 1

    That 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_KEYS machinery does not apply, and adding a RETIRED_KEYS_BY_MAJOR entry for a live key would itself red a gate

    Following my instruction literally would have introduced a failure. And the measurement backs it: 10/10 spec gates pass, 413 files / 10987 tests green, gen:schema moved ZERO files, Field.json keeps default: set_null byte-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.mjs forbids a major in the launch window, so the skill's "use major" line does not apply — this is minor.

    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 restrict is indeed the only deviating value (card confirmed), and:


    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:

    Q2 — C, conditional on Q1 = A. Ruled.

    Ship only the SemanticMigration entry (the field-scale-precision-integer-refused precedent), with B's reasoning stated in the replacement text.

    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 to warn, never an optional call).

    Your measurement is what settles it: 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. 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.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, and it survives whatever this card decides because the recommended fix keeps parse output byte-identical.


    Generated by Claude Code

  6. 44 remaining items

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions