diff --git a/.changeset/field-deletebehavior-reference-only.md b/.changeset/field-deletebehavior-reference-only.md new file mode 100644 index 0000000000..c4936c3e5a --- /dev/null +++ b/.changeset/field-deletebehavior-reference-only.md @@ -0,0 +1,32 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): materialize `deleteBehavior` only on reference field types (#9784) + +`FieldSchema` no longer materializes the `deleteBehavior: 'set_null'` default +onto non-reference field types (`text` / `datetime` / `number` / every other +non-relational type, `user` included). The key has no meaning there — the +engine's `cascadeDeleteRelations` reads it exclusively on `master_detail` / +`lookup` fields carrying a `reference` — yet the materialized default shipped +in every built app artifact, where a parse-time default becomes an apparent +explicit declaration downstream (the #4447 shadowing mechanism) and reads as +meaningful to AI authors browsing the artifact. + +What changes and what does not: + +- **Bare non-reference fields** parse to output that **omits** `deleteBehavior` + (previously: `deleteBehavior: 'set_null'` materialized on every type). Built + artifacts thin accordingly — measured on the showcase app: 210 fields, the + key drops from 206 fields to 16. +- **`lookup` and `tree`** keep materializing `set_null` byte-identically, at + shape position. +- **`master_detail`** keeps omitting it (the #9689 idempotent-materialization + ruling, unchanged). +- **The accept-set is untouched**: an authored `deleteBehavior` on any field + type parses exactly as before and round-trips verbatim, so artifacts built + by earlier versions (which carry the materialized key on every field) remain + fully legal inputs. `parse(parse(x))` holds across the boundary. + +No authored metadata needs any change: no key is removed, renamed or +re-shaped, and no authoring spelling that parsed before is refused now. diff --git a/packages/metadata-protocol/src/protocol.audit-field-governance.test.ts b/packages/metadata-protocol/src/protocol.audit-field-governance.test.ts index 35dfda6666..5a9d760eea 100644 --- a/packages/metadata-protocol/src/protocol.audit-field-governance.test.ts +++ b/packages/metadata-protocol/src/protocol.audit-field-governance.test.ts @@ -129,6 +129,11 @@ function makeStubEngine() { * spelled `false` on two of the four and left ABSENT on the other two, because * absent defaults to false as well and the read must not be right only for the * spelling that happens to be explicit. + * + * `deleteBehavior: 'set_null'` on the datetime is the PRE-#9784 materializing + * era — newly built artifacts omit the key on non-reference fields, but + * artifacts of this shape remain in the installed base and must keep loading; + * keep the bytes as they shipped. */ const artifactObject = (name: string) => ({ name, diff --git a/packages/objectql/src/engine-audit-anchor-write.test.ts b/packages/objectql/src/engine-audit-anchor-write.test.ts index a96954f563..78e24750c2 100644 --- a/packages/objectql/src/engine-audit-anchor-write.test.ts +++ b/packages/objectql/src/engine-audit-anchor-write.test.ts @@ -244,7 +244,12 @@ describe('[#4447] a declared audit field cannot loosen the platform posture', () fields: { id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, title: { name: 'title', label: 'Title', type: 'text' as const }, - // Verbatim from examples/app-showcase/dist/objectstack.json. + // Verbatim from the PRE-#9784 examples/app-showcase/dist/objectstack.json + // — the materializing era, when FieldSchema baked `deleteBehavior: + // 'set_null'` onto every type. Newly built artifacts omit the key on + // non-reference fields (#9784), but artifacts of this shape remain in + // the installed base and this suite pins that they cannot loosen audit + // governance — keep the bytes as they shipped. created_at: { label: 'Created At', type: 'datetime' as const, required: false, searchable: false, multiple: false, unique: false, diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index 9121d683a8..b1cc7e6447 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -492,8 +492,10 @@ describe('FieldSchema', () => { // default the schema itself would refuse as authored — a bare // `master_detail` parses to output that OMITS `deleteBehavior`, so // `parse(parse(x))` holds on the mainline `create()` → `defineStack` - // path; every OTHER type keeps byte-identity with the `.default()` era, - // which is what the rest of this block pins. + // path. The reference types that still materialize (`lookup`/`tree`) + // keep byte-identity with the `.default()` era — that half is pinned + // here; #9784 (the block below) gates materialization off every + // NON-reference type. describe('[#9689] deleteBehavior: set_null on master_detail is a parse-time rejection', () => { const md = (extra: Record = {}) => ({ name: 'parent_id', @@ -571,10 +573,13 @@ describe('FieldSchema', () => { expect(FieldSchema.parse({ ...lookup, required: true }).deleteBehavior).toBe('set_null'); }); - it('keeps non-reference types accepting and defaulting the key (installed-base artifact shape, #4447)', () => { - // Verbatim shape from examples/app-showcase/dist/objectstack.json — a - // materialized datetime carrying only FieldSchema defaults. Built - // artifacts ship this on EVERY field type; it must stay legal. + it('keeps non-reference types ACCEPTING the key (installed-base artifact shape, #4447) — materialization moved to the #9784 block below', () => { + // Verbatim shape from the pre-#9784 examples/app-showcase/dist/ + // objectstack.json — a materialized datetime carrying only FieldSchema + // defaults. Built artifacts of the materializing era ship this on + // EVERY field type; it must STAY legal (accept-set unchanged), and the + // authored value must round-trip verbatim, even though a bare + // datetime no longer materializes it. const showcaseVerbatim = { label: 'Created At', type: 'datetime', required: false, searchable: false, multiple: false, unique: false, @@ -582,8 +587,7 @@ describe('FieldSchema', () => { readonly: false, sortable: true, externalId: false, }; expect(() => FieldSchema.parse(showcaseVerbatim)).not.toThrow(); - // And a bare text field still gets the materialized default. - expect(FieldSchema.parse({ name: 'title', label: 'Title', type: 'text' }).deleteBehavior).toBe('set_null'); + expect(FieldSchema.parse(showcaseVerbatim).deleteBehavior).toBe('set_null'); }); it('keeps FieldSchema.shape enumerable (no pipe degradation from the relocation)', () => { @@ -595,6 +599,73 @@ describe('FieldSchema', () => { }); }); + // [#9784] `deleteBehavior` materializes ONLY on reference types. On every + // other type the key was inert by construction — the engine's + // `cascadeDeleteRelations` reads it exclusively behind a + // `master_detail`/`lookup` + `fdef.reference` guard — yet the materialized + // default shipped in every built artifact as an apparent explicit + // declaration (#4447 mechanism) and read as meaningful to AI authors + // (ADR-0033 direction). The accept-set is UNTOUCHED: authored values on + // any type round-trip verbatim (the installed-base test above). + describe('[#9784] deleteBehavior materializes only on reference types', () => { + const bare = (type: string) => ({ name: 'f1', label: 'F1', type }); + + it('omits deleteBehavior from bare non-reference fields (text/datetime/number)', () => { + for (const type of ['text', 'datetime', 'number']) { + const result = FieldSchema.parse(bare(type)); + expect(result.deleteBehavior, `type=${type}`).toBeUndefined(); + expect('deleteBehavior' in result, `type=${type}`).toBe(false); + } + }); + + it('omits deleteBehavior from bare `user` fields — outside today\'s cascade guard, same as text', () => { + // `user` is stored identically to `lookup` but the engine's cascade + // guard admits only `master_detail`/`lookup`, so the key is inert on + // `user` exactly as on `text`. It takes the non-reference side of the + // line; an authored value still round-trips (below). + const result = FieldSchema.parse({ ...bare('user'), reference: 'sys_user' }); + expect('deleteBehavior' in result).toBe(false); + }); + + it('still materializes set_null on bare lookup and tree, at shape position (byte-identity)', () => { + // Key ORDER is part of the byte-identity contract (#4447): + // `deleteBehavior` sits between `reference` and `hidden` in the shape. + const lookupJson = JSON.stringify(FieldSchema.parse({ + name: 'account_id', label: 'Account', type: 'lookup', reference: 'account', + })); + expect(lookupJson).toContain('"reference":"account","deleteBehavior":"set_null","hidden":false'); + const treeJson = JSON.stringify(FieldSchema.parse({ + name: 'parent_id', label: 'Parent', type: 'tree', reference: 'category', + })); + expect(treeJson).toContain('"reference":"category","deleteBehavior":"set_null","hidden":false'); + }); + + it('keeps an AUTHORED deleteBehavior on non-reference types, verbatim (accept-set unchanged)', () => { + expect(FieldSchema.parse({ ...bare('text'), deleteBehavior: 'cascade' }).deleteBehavior).toBe('cascade'); + expect(FieldSchema.parse({ ...bare('number'), deleteBehavior: 'restrict' }).deleteBehavior).toBe('restrict'); + expect(FieldSchema.parse({ ...bare('datetime'), deleteBehavior: 'set_null' }).deleteBehavior).toBe('set_null'); + expect(FieldSchema.parse({ ...bare('user'), deleteBehavior: 'set_null' }).deleteBehavior).toBe('set_null'); + }); + + it('parse(parse(x)) is byte-stable for bare and authored spellings across the type boundary', () => { + const cases = [ + bare('text'), + bare('datetime'), + bare('number'), + { ...bare('user'), reference: 'sys_user' }, + { name: 'account_id', label: 'Account', type: 'lookup', reference: 'account' }, + { name: 'parent_id', label: 'Parent', type: 'tree', reference: 'category' }, + { ...bare('text'), deleteBehavior: 'cascade' }, + { name: 'account_id', label: 'Account', type: 'lookup', reference: 'account', deleteBehavior: 'restrict' }, + ]; + for (const input of cases) { + const once = FieldSchema.parse(input); + const twice = FieldSchema.parse(once); + expect(JSON.stringify(twice), `type=${(input as { type: string }).type}`).toBe(JSON.stringify(once)); + } + }); + }); + it('should accept the relatedList prominence tri-state (false | true | primary)', () => { for (const relatedList of [false, true, 'primary'] as const) { const field: Field = { diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 4bfe78eb37..5320a80a86 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1002,6 +1002,16 @@ export const FieldSchema = lazySchema(() => { * `.default('set_null')` era. The `default` annotation states the contract * default to schema consumers without touching parse order — the * `autonumberFormat` pattern below. + * + * #9784 — the `.overwrite` materializes the default ONLY on the reference + * types where the key has meaning (`lookup` / `tree`; `master_detail` omits + * it per the #9689 idempotent-materialization ruling). On every other type + * the key was inert by construction — the engine reads it exclusively + * behind a `master_detail`/`lookup` + `reference` guard — yet the + * materialized value shipped in every built artifact as an apparent + * explicit declaration (the #4447 shadowing mechanism). A bare `text` / + * `datetime` / `number` field now parses to output WITHOUT the key; an + * authored value on any type is preserved verbatim (accept-set unchanged). */ deleteBehavior: z.enum(['set_null', 'cascade', 'restrict']).optional().meta({ description: 'What happens if referenced record is deleted', @@ -1708,11 +1718,13 @@ export const FieldSchema = lazySchema(() => { // the superRefine above always sees the pre-materialized value. The key is // re-inserted at its SHAPE position (Zod emits parse output in shape // order), so output is byte-identical to the `.default('set_null')` era on - // every field type EXCEPT `master_detail` — see the ruling below. The one - // accepted cost, same as the currency precedent's: the INFERRED output - // type now declares `deleteBehavior?` even though a parsed non- - // `master_detail` field always carries it (ADR-0122 forbids hand-narrowing - // the inferred type); the runtime contract is the enforced one. + // the reference types that still materialize it (`lookup` / `tree`) — see + // the two rulings below for why `master_detail` and every non-reference + // type omit it instead. The one accepted cost, same as the currency + // precedent's: the INFERRED output type declares `deleteBehavior?` even + // though a parsed `lookup`/`tree` field always carries it (ADR-0122 + // forbids hand-narrowing the inferred type); the runtime contract is the + // enforced one. if (field.deleteBehavior !== undefined) return field; // #9689 (maintainer ruling 2026-08-24, idempotent materialization — // 「四维分析一致的,接手你的建议。」): NEVER materialize a default the @@ -1731,6 +1743,27 @@ export const FieldSchema = lazySchema(() => { // byte-identity, and the #7918 currency `precision` twin of this landmine // is #11423 — same principle, its own card. if (field.type === 'master_detail') return field; + // #9784 — materialize the default ONLY on reference types. `deleteBehavior` + // has no meaning on a non-reference field: the engine's + // `cascadeDeleteRelations` reaches the key exclusively on + // `master_detail`/`lookup` fields carrying a `reference` + // (`packages/objectql/src/engine.ts`, the type + `fdef.reference` guards), + // so on a `text`/`datetime`/`number` field the materialized value was + // inert by construction — yet it shipped in every built app artifact, + // where a default materialized at parse becomes an EXPLICIT declaration + // downstream (the #4447 shadowing mechanism), and where an AI author + // reading the artifact reasonably concludes the key is meaningful there + // (ADR-0033 direction). Non-reference fields therefore parse to output + // that OMITS the key. The accept-set is untouched: an AUTHORED + // `deleteBehavior` on any type still parses exactly as before (the + // `!== undefined` early return above), so stored artifacts from the + // materializing era stay legal. `tree` (hierarchical reference) keeps + // materializing with `lookup`: it is in the relational family, where the + // key states delete semantics — the conservative byte-identity side of + // the line. `user` is stored identically to `lookup` but sits outside + // today's cascade guard exactly like `text` does, so it takes the + // non-reference side; an authored value there still round-trips. + if (field.type !== 'lookup' && field.type !== 'tree') return field; const withDefault: Record = { ...field, deleteBehavior: 'set_null' }; const out: Record = {}; for (const key of shapeOrder) {