Skip to content

Docs and engine disagree on deleteBehavior: 'set_null' written EXPLICITLY on a required lookup — the escalation to restrict cannot see the difference #9625

Closed

Description

@os-steve

Filing unassigned — recording, not claiming. Found while writing the multi-value set_null sentences for #9521 (the page carrying the wrong claim is one of that card's six), and deliberately not fixed there: different defect class, and the correct form is a decision rather than a mechanical edit.

Measured

Read on main at ba0a84685. cascadeDeleteRelations, packages/objectql/src/engine.ts:10265-10281:

let behavior: string =
  fdef.type === 'master_detail'
    ? (fdef.deleteBehavior === 'restrict' ? 'restrict' : 'cascade')
    : (fdef.deleteBehavior || 'set_null');
// ...
if (behavior === 'set_null' && fdef.required === true) {
  behavior = 'restrict';
}

The escalation keys on the resolved behavior, so by construction it cannot distinguish a defaulted set_null from one the author wrote out. A field declaring deleteBehavior: 'set_null' explicitly on a required: true lookup is escalated to restrict exactly like the default.

Two places state the opposite:

  • content/docs/protocol/objectql/types.mdx, the callout under On Delete Options: "An explicit set_null or cascade is always honored as written."
  • the engine's own comment, engine.ts:10276-10278: "This only escalates the defaulted set_null; an explicit cascade/restrict is untouched." — true of cascade/restrict, not of an explicit set_null.

Nothing pins either reading. packages/objectql/src/engine-cascade-delete.test.ts covers a defaulted set_null on a required FK (escalates; DELETE_RESTRICTED, 409) and an explicit cascade on a required FK (honored, no escalation). No fixture declares deleteBehavior: 'set_null' explicitly, so this is an unpinned divergence rather than a ruling-versus-pin conflict.

Same branch, second observation: the multi-value case

Since the #9447 maintainer ruling (2026-08-18), required on a multiple: true lookup means non-empty array — the contract now landed verbatim in the required and multiple doc blocks of packages/spec/src/data/field.zod.ts. The escalation above runs before the multi-value branch and keys on required alone, so deleting one referenced record is refused with DELETE_RESTRICTED even when the child's set holds other members and member removal would leave it non-empty — a state the ruled contract accepts. Whether that is deliberate conservatism or an over-restriction the ruling now supersedes is not mine to decide.

Not prescribing the fix

At least three shapes are available and they are not equivalent: correct the doc sentence (and the engine comment) to say that any set_null on a required lookup escalates; or honor an explicit set_null and settle what the child's required-field validator then does with the cleared FK; or make the multi-value case judge emptiness rather than presence. The lane that owns packages/objectql should pick.

Refs: #9521 (the docs card this fell out of), #9447 (the ruling), #9438 / #9520 (multi-value member removal), #9476 (the required-means-non-empty enforcement gap).


Generated by Claude Code

Activity

  1. os-steve commented on Aug 18, 2026

    @os-steve
    CollaboratorAuthor

    Claiming this card.

    • session: session_01XqDQYVU5smx29ts9pAErja
    • branch: claude/issue-9625-explicit-set-null-escalation

    Plan per dispatch: measure the behaviour first (explicit deleteBehavior: 'set_null' on a required: true lookup) rather than infer it from the code read, measure the multi-value case separately, sweep every documentation surface that carries a claim about explicit-vs-defaulted deleteBehavior, and land a fixture that pins whatever is measured. No engine behaviour change in this PR.

    Generated by Claude Code


    Generated by Claude Code

  2. claude commented on Aug 18, 2026

    @claude
    Contributor
    {
      "issue": 9625,
      "status": "done",
      "branch": "claude/issue-9625-explicit-set-null-escalation",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9690",
      "premise_still_valid": true,
      "summary": "H1 MEASURED, ANSWER IS YES: an explicit deleteBehavior:'set_null' on a required:true lookup DOES escalate to restrict — DELETE_RESTRICTED/409, parent and child untouched, identical to the defaulted case down to the delete_restricted_required message key. Measured with a real engine + stub driver before anything was described; the card's prediction is confirmed, not merely repeated. Per the ruling no engine behaviour was changed: the four false documentation surfaces and the half-true engine comment are corrected, and the behaviour is pinned by four new fixtures in engine-cascade-delete.test.ts (the divergence survived precisely because coverage had a defaulted set_null and an explicit cascade and nothing between them). H2 reproduces and is graded; H4 found one more instance of the same defect class.",
      "tests": "All run in worktree ../objectstack-9625, union re-run AFTER the final commit at b10b603aa (git status --porcelain empty at that sha). Deps built first: pnpm --filter '@objectstack/objectql^...' build (EXIT=0). pnpm --filter @objectstack/objectql test — 'Test Files 217 passed (217) / Tests 3840 passed (3840)'; baseline was 3836, so exactly the 4 added fixtures. pnpm --filter @objectstack/objectql typecheck — EXIT=0, script name echoed in output (not a zero-match silent pass). REVERSE VERIFICATION, both ablations run from the COMMITTED state and restored via git checkout to a byte-identical tree (git status --porcelain empty, verified): (1) escalation disabled -> 3 red, and the explicit-set_null pin failed with code 'VALIDATION_FAILED' — the misleading child-validator 400 the escalation exists to replace, which is the direction predicted before running; both controls stayed green, which is what separates 'refused because required' from 'refused because multi-value'. (2) master_detail ternary changed to honor set_null -> exactly 1 red, the master-detail pin, nothing else. GATES (derived from changed paths via node scripts/pm/dispatch-gates.mjs, not recalled): check:nul-bytes OK (6185 files); check:docs-audit-scope OK (179 docs); check:docs-redirects OK (92 entries); check:role-word OK (43 baselined, no new); check:durability-log-level OK; check:stack-collection-maps OK; check:where-matcher OK — 255 matchers, 0 silently-wrong, none new (load-bearing here: this PR hand-extends a WHERE matcher with $contains/$or); check:engine-double-contract OK (319 pinned, 2 exempt); check:query-options-erasure OK; check:type-check-coverage OK; spec check:empty-state / check:liveness / check:strictness-ledger / check:variant-docs all OK; scripts/docs-audit/check-affected-docs.mjs OK; scripts/check-engine-split-ratio.mjs OK. MDX: no local gate parses MDX, so all 4 edited pages were compiled through @mdx-js/mdx 3.1.1 directly — 4/4 OK, with a deliberately unclosed tag observed FAILING (positive control, so the harness is not silently green). NOT RUN LOCALLY: check:type-check-debt --re-measure, which requires the whole workspace closure built; left to CI, which runs the farm exactly once anyway. No ablation of a dist/-resolved package was involved (dogfood ablation N/A — this suite runs against src via vitest, not dist).",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #9688: cascadeDeleteRelations refuses a required multi-value lookup delete even when member removal would leave the set non-empty (H2 — measured and graded; carries Blocked-by: #9476)",
        "filed as #9689: FieldSchema accepts deleteBehavior:'set_null' on a master_detail and the engine silently resolves it to cascade (H4 — the judgement half; the doc/comment/fixture half is folded into this PR)"
      ]
    }

    Generated by Claude Code

  3. added a commit that references this issue on Aug 23, 2026
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