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

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