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:

letbehavior: 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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions