Skip to content

Commit 5997207

Browse files
os-warrenclaude
andauthored
fix(spec): materialize deleteBehavior only on reference field types (#11903)
* fix(spec): materialize deleteBehavior only on reference field types Gate the relocated .overwrite() default so bare non-reference fields (text/datetime/number/...) no longer materialize the inert deleteBehavior: 'set_null' into parse output and built artifacts. lookup/tree keep byte-identical materialization; master_detail keeps omitting it (#9689 idempotent-materialization ruling); authored values on any type round-trip verbatim (accept-set unchanged). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy * docs(fixtures): mark installed-base deleteBehavior fixtures as the pre-#9784 era; add changeset The two #4447 audit-governance fixtures pin the PRE-#9784 artifact shape (materialized deleteBehavior on a datetime) — artifacts of that shape remain in the installed base and must keep loading, so the bytes stay; the comments now say which era they are. Changeset: @objectstack/spec minor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dd33bf9 commit 5997207

5 files changed

Lines changed: 160 additions & 14 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): materialize `deleteBehavior` only on reference field types (#9784)
6+
7+
`FieldSchema` no longer materializes the `deleteBehavior: 'set_null'` default
8+
onto non-reference field types (`text` / `datetime` / `number` / every other
9+
non-relational type, `user` included). The key has no meaning there — the
10+
engine's `cascadeDeleteRelations` reads it exclusively on `master_detail` /
11+
`lookup` fields carrying a `reference` — yet the materialized default shipped
12+
in every built app artifact, where a parse-time default becomes an apparent
13+
explicit declaration downstream (the #4447 shadowing mechanism) and reads as
14+
meaningful to AI authors browsing the artifact.
15+
16+
What changes and what does not:
17+
18+
- **Bare non-reference fields** parse to output that **omits** `deleteBehavior`
19+
(previously: `deleteBehavior: 'set_null'` materialized on every type). Built
20+
artifacts thin accordingly — measured on the showcase app: 210 fields, the
21+
key drops from 206 fields to 16.
22+
- **`lookup` and `tree`** keep materializing `set_null` byte-identically, at
23+
shape position.
24+
- **`master_detail`** keeps omitting it (the #9689 idempotent-materialization
25+
ruling, unchanged).
26+
- **The accept-set is untouched**: an authored `deleteBehavior` on any field
27+
type parses exactly as before and round-trips verbatim, so artifacts built
28+
by earlier versions (which carry the materialized key on every field) remain
29+
fully legal inputs. `parse(parse(x))` holds across the boundary.
30+
31+
No authored metadata needs any change: no key is removed, renamed or
32+
re-shaped, and no authoring spelling that parsed before is refused now.

packages/metadata-protocol/src/protocol.audit-field-governance.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,11 @@ function makeStubEngine() {
129129
* spelled `false` on two of the four and left ABSENT on the other two, because
130130
* absent defaults to false as well and the read must not be right only for the
131131
* spelling that happens to be explicit.
132+
*
133+
* `deleteBehavior: 'set_null'` on the datetime is the PRE-#9784 materializing
134+
* era — newly built artifacts omit the key on non-reference fields, but
135+
* artifacts of this shape remain in the installed base and must keep loading;
136+
* keep the bytes as they shipped.
132137
*/
133138
const artifactObject = (name: string) => ({
134139
name,

packages/objectql/src/engine-audit-anchor-write.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,12 @@ describe('[#4447] a declared audit field cannot loosen the platform posture', ()
244244
fields: {
245245
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
246246
title: { name: 'title', label: 'Title', type: 'text' as const },
247-
// Verbatim from examples/app-showcase/dist/objectstack.json.
247+
// Verbatim from the PRE-#9784 examples/app-showcase/dist/objectstack.json
248+
// — the materializing era, when FieldSchema baked `deleteBehavior:
249+
// 'set_null'` onto every type. Newly built artifacts omit the key on
250+
// non-reference fields (#9784), but artifacts of this shape remain in
251+
// the installed base and this suite pins that they cannot loosen audit
252+
// governance — keep the bytes as they shipped.
248253
created_at: {
249254
label: 'Created At', type: 'datetime' as const, required: false,
250255
searchable: false, multiple: false, unique: false,

packages/spec/src/data/field.test.ts

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -492,8 +492,10 @@ describe('FieldSchema', () => {
492492
// default the schema itself would refuse as authored — a bare
493493
// `master_detail` parses to output that OMITS `deleteBehavior`, so
494494
// `parse(parse(x))` holds on the mainline `create()` → `defineStack`
495-
// path; every OTHER type keeps byte-identity with the `.default()` era,
496-
// which is what the rest of this block pins.
495+
// path. The reference types that still materialize (`lookup`/`tree`)
496+
// keep byte-identity with the `.default()` era — that half is pinned
497+
// here; #9784 (the block below) gates materialization off every
498+
// NON-reference type.
497499
describe('[#9689] deleteBehavior: set_null on master_detail is a parse-time rejection', () => {
498500
const md = (extra: Record<string, unknown> = {}) => ({
499501
name: 'parent_id',
@@ -571,19 +573,21 @@ describe('FieldSchema', () => {
571573
expect(FieldSchema.parse({ ...lookup, required: true }).deleteBehavior).toBe('set_null');
572574
});
573575

574-
it('keeps non-reference types accepting and defaulting the key (installed-base artifact shape, #4447)', () => {
575-
// Verbatim shape from examples/app-showcase/dist/objectstack.json — a
576-
// materialized datetime carrying only FieldSchema defaults. Built
577-
// artifacts ship this on EVERY field type; it must stay legal.
576+
it('keeps non-reference types ACCEPTING the key (installed-base artifact shape, #4447) — materialization moved to the #9784 block below', () => {
577+
// Verbatim shape from the pre-#9784 examples/app-showcase/dist/
578+
// objectstack.json — a materialized datetime carrying only FieldSchema
579+
// defaults. Built artifacts of the materializing era ship this on
580+
// EVERY field type; it must STAY legal (accept-set unchanged), and the
581+
// authored value must round-trip verbatim, even though a bare
582+
// datetime no longer materializes it.
578583
const showcaseVerbatim = {
579584
label: 'Created At', type: 'datetime', required: false,
580585
searchable: false, multiple: false, unique: false,
581586
deleteBehavior: 'set_null', hidden: false,
582587
readonly: false, sortable: true, externalId: false,
583588
};
584589
expect(() => FieldSchema.parse(showcaseVerbatim)).not.toThrow();
585-
// And a bare text field still gets the materialized default.
586-
expect(FieldSchema.parse({ name: 'title', label: 'Title', type: 'text' }).deleteBehavior).toBe('set_null');
590+
expect(FieldSchema.parse(showcaseVerbatim).deleteBehavior).toBe('set_null');
587591
});
588592

589593
it('keeps FieldSchema.shape enumerable (no pipe degradation from the relocation)', () => {
@@ -595,6 +599,73 @@ describe('FieldSchema', () => {
595599
});
596600
});
597601

602+
// [#9784] `deleteBehavior` materializes ONLY on reference types. On every
603+
// other type the key was inert by construction — the engine's
604+
// `cascadeDeleteRelations` reads it exclusively behind a
605+
// `master_detail`/`lookup` + `fdef.reference` guard — yet the materialized
606+
// default shipped in every built artifact as an apparent explicit
607+
// declaration (#4447 mechanism) and read as meaningful to AI authors
608+
// (ADR-0033 direction). The accept-set is UNTOUCHED: authored values on
609+
// any type round-trip verbatim (the installed-base test above).
610+
describe('[#9784] deleteBehavior materializes only on reference types', () => {
611+
const bare = (type: string) => ({ name: 'f1', label: 'F1', type });
612+
613+
it('omits deleteBehavior from bare non-reference fields (text/datetime/number)', () => {
614+
for (const type of ['text', 'datetime', 'number']) {
615+
const result = FieldSchema.parse(bare(type));
616+
expect(result.deleteBehavior, `type=${type}`).toBeUndefined();
617+
expect('deleteBehavior' in result, `type=${type}`).toBe(false);
618+
}
619+
});
620+
621+
it('omits deleteBehavior from bare `user` fields — outside today\'s cascade guard, same as text', () => {
622+
// `user` is stored identically to `lookup` but the engine's cascade
623+
// guard admits only `master_detail`/`lookup`, so the key is inert on
624+
// `user` exactly as on `text`. It takes the non-reference side of the
625+
// line; an authored value still round-trips (below).
626+
const result = FieldSchema.parse({ ...bare('user'), reference: 'sys_user' });
627+
expect('deleteBehavior' in result).toBe(false);
628+
});
629+
630+
it('still materializes set_null on bare lookup and tree, at shape position (byte-identity)', () => {
631+
// Key ORDER is part of the byte-identity contract (#4447):
632+
// `deleteBehavior` sits between `reference` and `hidden` in the shape.
633+
const lookupJson = JSON.stringify(FieldSchema.parse({
634+
name: 'account_id', label: 'Account', type: 'lookup', reference: 'account',
635+
}));
636+
expect(lookupJson).toContain('"reference":"account","deleteBehavior":"set_null","hidden":false');
637+
const treeJson = JSON.stringify(FieldSchema.parse({
638+
name: 'parent_id', label: 'Parent', type: 'tree', reference: 'category',
639+
}));
640+
expect(treeJson).toContain('"reference":"category","deleteBehavior":"set_null","hidden":false');
641+
});
642+
643+
it('keeps an AUTHORED deleteBehavior on non-reference types, verbatim (accept-set unchanged)', () => {
644+
expect(FieldSchema.parse({ ...bare('text'), deleteBehavior: 'cascade' }).deleteBehavior).toBe('cascade');
645+
expect(FieldSchema.parse({ ...bare('number'), deleteBehavior: 'restrict' }).deleteBehavior).toBe('restrict');
646+
expect(FieldSchema.parse({ ...bare('datetime'), deleteBehavior: 'set_null' }).deleteBehavior).toBe('set_null');
647+
expect(FieldSchema.parse({ ...bare('user'), deleteBehavior: 'set_null' }).deleteBehavior).toBe('set_null');
648+
});
649+
650+
it('parse(parse(x)) is byte-stable for bare and authored spellings across the type boundary', () => {
651+
const cases = [
652+
bare('text'),
653+
bare('datetime'),
654+
bare('number'),
655+
{ ...bare('user'), reference: 'sys_user' },
656+
{ name: 'account_id', label: 'Account', type: 'lookup', reference: 'account' },
657+
{ name: 'parent_id', label: 'Parent', type: 'tree', reference: 'category' },
658+
{ ...bare('text'), deleteBehavior: 'cascade' },
659+
{ name: 'account_id', label: 'Account', type: 'lookup', reference: 'account', deleteBehavior: 'restrict' },
660+
];
661+
for (const input of cases) {
662+
const once = FieldSchema.parse(input);
663+
const twice = FieldSchema.parse(once);
664+
expect(JSON.stringify(twice), `type=${(input as { type: string }).type}`).toBe(JSON.stringify(once));
665+
}
666+
});
667+
});
668+
598669
it('should accept the relatedList prominence tri-state (false | true | primary)', () => {
599670
for (const relatedList of [false, true, 'primary'] as const) {
600671
const field: Field = {

packages/spec/src/data/field.zod.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,6 +1002,16 @@ export const FieldSchema = lazySchema(() => {
10021002
* `.default('set_null')` era. The `default` annotation states the contract
10031003
* default to schema consumers without touching parse order — the
10041004
* `autonumberFormat` pattern below.
1005+
*
1006+
* #9784 — the `.overwrite` materializes the default ONLY on the reference
1007+
* types where the key has meaning (`lookup` / `tree`; `master_detail` omits
1008+
* it per the #9689 idempotent-materialization ruling). On every other type
1009+
* the key was inert by construction — the engine reads it exclusively
1010+
* behind a `master_detail`/`lookup` + `reference` guard — yet the
1011+
* materialized value shipped in every built artifact as an apparent
1012+
* explicit declaration (the #4447 shadowing mechanism). A bare `text` /
1013+
* `datetime` / `number` field now parses to output WITHOUT the key; an
1014+
* authored value on any type is preserved verbatim (accept-set unchanged).
10051015
*/
10061016
deleteBehavior: z.enum(['set_null', 'cascade', 'restrict']).optional().meta({
10071017
description: 'What happens if referenced record is deleted',
@@ -1708,11 +1718,13 @@ export const FieldSchema = lazySchema(() => {
17081718
// the superRefine above always sees the pre-materialized value. The key is
17091719
// re-inserted at its SHAPE position (Zod emits parse output in shape
17101720
// order), so output is byte-identical to the `.default('set_null')` era on
1711-
// every field type EXCEPT `master_detail` — see the ruling below. The one
1712-
// accepted cost, same as the currency precedent's: the INFERRED output
1713-
// type now declares `deleteBehavior?` even though a parsed non-
1714-
// `master_detail` field always carries it (ADR-0122 forbids hand-narrowing
1715-
// the inferred type); the runtime contract is the enforced one.
1721+
// the reference types that still materialize it (`lookup` / `tree`) — see
1722+
// the two rulings below for why `master_detail` and every non-reference
1723+
// type omit it instead. The one accepted cost, same as the currency
1724+
// precedent's: the INFERRED output type declares `deleteBehavior?` even
1725+
// though a parsed `lookup`/`tree` field always carries it (ADR-0122
1726+
// forbids hand-narrowing the inferred type); the runtime contract is the
1727+
// enforced one.
17161728
if (field.deleteBehavior !== undefined) return field;
17171729
// #9689 (maintainer ruling 2026-08-24, idempotent materialization —
17181730
// 「四维分析一致的,接手你的建议。」): NEVER materialize a default the
@@ -1731,6 +1743,27 @@ export const FieldSchema = lazySchema(() => {
17311743
// byte-identity, and the #7918 currency `precision` twin of this landmine
17321744
// is #11423 — same principle, its own card.
17331745
if (field.type === 'master_detail') return field;
1746+
// #9784 — materialize the default ONLY on reference types. `deleteBehavior`
1747+
// has no meaning on a non-reference field: the engine's
1748+
// `cascadeDeleteRelations` reaches the key exclusively on
1749+
// `master_detail`/`lookup` fields carrying a `reference`
1750+
// (`packages/objectql/src/engine.ts`, the type + `fdef.reference` guards),
1751+
// so on a `text`/`datetime`/`number` field the materialized value was
1752+
// inert by construction — yet it shipped in every built app artifact,
1753+
// where a default materialized at parse becomes an EXPLICIT declaration
1754+
// downstream (the #4447 shadowing mechanism), and where an AI author
1755+
// reading the artifact reasonably concludes the key is meaningful there
1756+
// (ADR-0033 direction). Non-reference fields therefore parse to output
1757+
// that OMITS the key. The accept-set is untouched: an AUTHORED
1758+
// `deleteBehavior` on any type still parses exactly as before (the
1759+
// `!== undefined` early return above), so stored artifacts from the
1760+
// materializing era stay legal. `tree` (hierarchical reference) keeps
1761+
// materializing with `lookup`: it is in the relational family, where the
1762+
// key states delete semantics — the conservative byte-identity side of
1763+
// the line. `user` is stored identically to `lookup` but sits outside
1764+
// today's cascade guard exactly like `text` does, so it takes the
1765+
// non-reference side; an authored value there still round-trips.
1766+
if (field.type !== 'lookup' && field.type !== 'tree') return field;
17341767
const withDefault: Record<string, unknown> = { ...field, deleteBehavior: 'set_null' };
17351768
const out: Record<string, unknown> = {};
17361769
for (const key of shapeOrder) {

0 commit comments

Comments
 (0)