Skip to content

FieldSchema.deleteBehavior defaults set_null onto EVERY field type, so built artifacts ship the key on text/datetime/number fields #9784

Description

@os-steve

Filing unassigned — recording, not claiming. Measured while working #9689; this finding survives whatever #9689 decides, which is why it is carded separately rather than folded in.

Measured

packages/spec/src/data/field.zod.ts (one line, currently ~906):

deleteBehavior: z.enum(['set_null','cascade','restrict']).optional().default('set_null')

The default is on the shared field schema with no per-type gating, so it materializes at parse for every field of every type. Measured with a real FieldSchema.safeParse:

bare type='lookup' -> deleteBehavior = "set_null"
bare type='datetime' -> deleteBehavior = "set_null"
bare type='text' -> deleteBehavior = "set_null"

deleteBehavior has no meaning on a non-reference field — nothing reads it there. cascadeDeleteRelations only ever reaches the key after an fdef.reference guard, so on a text field the value is inert by construction.

Why it is not merely cosmetic

The materialized key ships in the app artifact. packages/objectql/src/registry.ts already documents the consequence for a sibling key, from a real incident (#4447):

the showcase artifact ships a materialized created_at carrying only FieldSchema DEFAULTS (readonly: false), which shadowed AUDIT_FIELD_DEFS.created_at (readonly: true)

That is the same mechanism: a default materialized at parse becomes an explicit declaration downstream, and explicit declarations win merges. Two fixtures in the tree carry the artifact shape verbatim and show deleteBehavior: 'set_null' sitting on a datetime field:

  • packages/objectql/src/engine-audit-anchor-write.test.ts:251 (commented "Verbatim from examples/app-showcase/dist/objectstack.json")
  • packages/metadata-protocol/src/protocol.audit-field-governance.test.ts:144

This is the ADR-0049 declared-but-inert shape, and it is also an AI-authoring hazard in the direction ADR-0033 cares about: a model reading a built artifact sees deleteBehavior on a text field and reasonably concludes the key is meaningful there.

Interaction with #9689 (why this is separate)

#9689 asks whether deleteBehavior: 'set_null' should be refused on a master_detail. The measured answer is that any such rule — and equally any delete-time "you declared set_null" log — must first relocate this default (the #7918 Option A pattern), because otherwise it fires on all 95 bare master_detail declarations rather than the 1 authored one.

But the recommended relocation deliberately keeps parse output byte-identical (.overwrite() re-materializes set_null), so after #9689 lands, text fields still carry the inert key. This finding therefore outlives it.

Not prescribing the fix

Roughly two shapes, and they are not equivalent:

  1. Gate the default on reference types — materialize deleteBehavior only for lookup / master_detail / tree. Cleanest, but changes parse output for every non-reference field, so it moves the app artifact and is a real (if mechanical) migration.
  2. Drop the property-level default entirely and let each consumer apply its own fallback — the engine already spells fdef.deleteBehavior || 'set_null' on the lookup branch, so the fallback exists there already.

Blocked-by: #9689 (the relocation it needs is the same edit; doing them in the other order means touching the line twice).

Refs: #9689, #9625, #7918 (the default-relocation precedent), #4447 (the materialized-default shadowing incident), 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