Skip to content

Commit 11ef32a

Browse files
os-steveclaude
andauthored
fix(docs,objectql): make the explicit-vs-defaulted set_null claim true and pin the measured behaviour (#9690)
The escalation in cascadeDeleteRelations tests the RESOLVED deleteBehavior, after `deleteBehavior || 'set_null'` has erased the difference between an absent value and an authored one. So an explicitly written `deleteBehavior: 'set_null'` on a required lookup escalates to restrict exactly like the default. Four documentation surfaces and the engine's own comment said or implied otherwise; nothing pinned either reading. Measured, then described: docs and comment corrected, current behaviour pinned by fixtures. No engine behaviour change. Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4012a70 commit 11ef32a

6 files changed

Lines changed: 255 additions & 21 deletions

File tree

content/docs/api/data-api.mdx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,12 @@ Delete a record.
223223
**Response**: `{ object: "account", id: "1", success: true }`
224224

225225
Every relation pointing at the deleted record honours its own `deleteBehavior`
226-
(`cascade` / `set_null` / `restrict`). On a `multiple: true` reference, `set_null`
226+
(`cascade` / `set_null` / `restrict`) — with one substitution: on a
227+
`required: true` lookup, `set_null` is escalated to `restrict` and the delete is
228+
refused with `409 DELETE_RESTRICTED`. That happens whether the `set_null` was
229+
defaulted or written out explicitly (see
230+
[Required foreign keys](/docs/protocol/objectql/types#lookup)). On a
231+
`multiple: true` reference where `set_null` does run, it
227232
removes just the deleted id from the array and keeps the rest, and a reference set
228233
emptied that way reads back as `[]` — never `null`, so a client that branches on
229234
`null` for "no link" misses the emptied case (the `multiple` doc block in

content/docs/data-modeling/field-types.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ Reference to a record in another object (foreign key).
315315
|:---|:---|:---|:---|
316316
| `reference` | `string` | **required** | Target object name (snake_case) |
317317
| `referenceFilters` | `string[]` || **Removed** (#2377, ADR-0049) — no longer a recognized field property (unknown keys are stripped by the schema). Use structured `lookupFilters` + `dependsOn` instead; see [Relationships](/docs/data-modeling/relationships) |
318-
| `deleteBehavior` | `'restrict' \| 'cascade' \| 'set_null'` | `'set_null'` | Behavior when referenced record is deleted (a *required* lookup left at the default `set_null` is escalated to `restrict`, since a NOT NULL foreign key cannot be cleared). On a `multiple: true` lookup `set_null` removes only the deleted **member** — the other members are kept, and a set emptied that way is stored as `[]`, never `null` |
318+
| `deleteBehavior` | `'restrict' \| 'cascade' \| 'set_null'` | `'set_null'` | Behavior when referenced record is deleted. On a *required* lookup `set_null` is escalated to `restrict`, since a NOT NULL foreign key cannot be cleared**whether the `set_null` was defaulted or written out explicitly**, and on a `multiple: true` required lookup too. `cascade` and `restrict` are the values honored as written. Where `set_null` does run, a `multiple: true` lookup loses only the deleted **member** — the other members are kept, and a set emptied that way is stored as `[]`, never `null` |
319319

320320
```typescript
321321
{ name: 'company', label: 'Company', type: 'lookup', reference: 'account' }
@@ -337,7 +337,7 @@ Parent-child relationship (cascading delete by default).
337337
|:---|:---|:---|:---|
338338
| `reference` | `string` | **required** | Target (master) object name |
339339
| `referenceFilters` | `string[]` || **Removed** (#2377, ADR-0049) — no longer a recognized field property (unknown keys are stripped by the schema). Use structured `lookupFilters` + `dependsOn` instead; see [Relationships](/docs/data-modeling/relationships) |
340-
| `deleteBehavior` | `'restrict' \| 'cascade' \| 'set_null'` | `'cascade'` | Behavior when parent is deleted (master-detail cascades unless set to `restrict`) |
340+
| `deleteBehavior` | `'restrict' \| 'cascade' \| 'set_null'` | `'cascade'` | Behavior when parent is deleted. `restrict` is the only value that deviates: master-detail cascades on everything else, so an explicit `set_null` here is **not** honored — the child is deleted with the parent |
341341
| `inlineEdit` | `boolean \| 'grid' \| 'form'` || Edit child records inline on the parent create/edit form (`true` = auto-pick, `'grid'`, or `'form'`) |
342342
| `inlineColumns` | `array` || Optional explicit inline grid columns |
343343
| `inlineAmountField` | `string` || Numeric child field used for the inline running total |

content/docs/deployment/troubleshooting.mdx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -204,17 +204,22 @@ client.data.find('project_task', { /* query */ });
204204

205205
### "Cannot delete record: delete restricted"
206206

207-
**Cause:** The record has dependent child records via `lookup` or `master_detail` fields with `deleteBehavior: 'restrict'`.
207+
**Cause:** The record has dependent child records via a `lookup` or `master_detail` field, and that field resolves to `restrict`. Two routes get there:
208+
209+
1. The field declares `deleteBehavior: 'restrict'`.
210+
2. The field is a `required: true` lookup whose behavior is `set_null`. A required foreign key cannot be cleared, so `set_null` is escalated to `restrict` — including when `set_null` is written out explicitly, and including a `required: true` lookup with `multiple: true`. Check the refusal's `developerMessage`: the escalated route says `(<field> is required, so it cannot be cleared)`.
208211

209212
**Fix:**
210-
1. Delete the dependent records first
211-
2. Change `deleteBehavior` to `'cascade'` (deletes children) or `'set_null'` (clears reference)
213+
1. Delete or reassign the dependent records first
214+
2. Change `deleteBehavior` to `'cascade'` (deletes children), or make the child's reference optional and use `'set_null'` (clears reference)
212215

213216
```typescript
214217
// Option A: Cascade delete (children are deleted with parent)
215218
{ name: 'project', type: 'master_detail', reference: 'project', deleteBehavior: 'cascade' }
216219

217-
// Option B: Set null (children keep existing, reference cleared)
220+
// Option B: Set null (children keep existing, reference cleared).
221+
// The field must NOT be required — on a required lookup `set_null` is
222+
// escalated to `restrict` and writing it explicitly changes nothing.
218223
{ name: 'project', type: 'lookup', reference: 'project', deleteBehavior: 'set_null' }
219224
```
220225

content/docs/protocol/objectql/types.mdx

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -636,13 +636,27 @@ const opportunities = await engine.find('opportunity', {
636636
> `packages/spec/src/data/field.zod.ts` (rendered in the
637637
> [Field reference](/docs/references/data/field)).
638638

639-
> **Required foreign keys.** A `required: true` lookup cannot be nulled, so the
640-
> *default* `set_null` automatically escalates to `restrict` on such a field —
641-
> deleting the parent is refused with `409 DELETE_RESTRICTED` (the response
642-
> carries `dependentObject` and `dependentCount`) instead of a confusing
643-
> "&lt;field&gt; is required" validation error. To delete the children along with
644-
> the parent, set `deleteBehavior: cascade` explicitly. An explicit `set_null`
645-
> or `cascade` is always honored as written.
639+
> **Required foreign keys.** A `required: true` lookup cannot be nulled, so
640+
> `set_null` escalates to `restrict` on such a field — deleting the parent is
641+
> refused with `409 DELETE_RESTRICTED` (the response carries `dependentObject`
642+
> and `dependentCount`) instead of a confusing "&lt;field&gt; is required"
643+
> validation error. To delete the children along with the parent, set
644+
> `deleteBehavior: cascade` explicitly.
645+
>
646+
> The escalation applies to **any** `set_null` on a required lookup — the
647+
> default and one written out as `deleteBehavior: set_null` alike. The engine
648+
> tests the *resolved* behavior, so it cannot tell the two apart: writing
649+
> `set_null` explicitly on a required lookup does not opt out of the refusal,
650+
> and it does not change the outcome in any way. `cascade` and `restrict` are
651+
> the two values that are honored as written. On a `multiple: true` required
652+
> lookup the refusal comes first as well, before the member-removal rule below
653+
> applies — so the parent delete is refused even when the child's set holds
654+
> other members.
655+
>
656+
> On `master_detail` the same reading applies from the other side: `restrict`
657+
> is the only value that deviates from `cascade`, so an explicit
658+
> `deleteBehavior: set_null` on a master-detail reference is *not* honored —
659+
> the child is cascaded away.
646660
>
647661
> The refusal carries **two** messages, for two audiences. `error` is written for
648662
> the person who clicked delete: it is rendered in the caller's locale from the

packages/objectql/src/engine-cascade-delete.test.ts

Lines changed: 186 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,32 @@
88
* default to `set_null`, issuing an UPDATE that cleared the required FK — which
99
* the child's validator rejected with a misleading "<field> is required" 400
1010
* naming a field that isn't even on the object being deleted (CRM e2e gap).
11-
* A required FK can't be nulled, so the defaulted `set_null` now escalates to
12-
* `restrict`: the delete is refused with a clear dependent-count message
11+
* A required FK can't be nulled, so `set_null` escalates to `restrict`: the
12+
* delete is refused with a clear dependent-count message
1313
* (`DELETE_RESTRICTED`, 409). Explicit `cascade`/`restrict` and optional
1414
* (nullable) lookups are unaffected.
15+
*
16+
* ## [#9625] What "explicit" does and does not buy you
17+
*
18+
* The escalation tests the RESOLVED behavior, one statement after
19+
* `deleteBehavior || 'set_null'` has already erased the difference between an
20+
* absent value and an authored one. So an explicitly written
21+
* `deleteBehavior: 'set_null'` on a required lookup escalates exactly like the
22+
* default — measured, not inferred, and pinned below.
23+
*
24+
* That was an UNPINNED divergence, which is why it survived: this file covered
25+
* a defaulted `set_null` (escalates) and an explicit `cascade` (honored) and
26+
* nothing between them, so the docs sentence claiming an explicit `set_null` is
27+
* "always honored as written" contradicted the engine with every gate green.
28+
* Two more shapes are pinned alongside it for the same reason — a required
29+
* `multiple: true` lookup is refused even when member removal would leave the
30+
* set non-empty, and a `master_detail` declaring an explicit `set_null` is
31+
* silently resolved to `cascade`.
32+
*
33+
* These pin CURRENT behaviour. Whether the multi-value refusal should judge
34+
* emptiness instead of presence, and whether the spec should reject `set_null`
35+
* on a `master_detail` at publish time rather than dropping it at delete time,
36+
* are open questions carded separately — not decided by this suite.
1537
*/
1638

1739
import { describe, it, expect, beforeEach } from 'vitest';
@@ -55,17 +77,94 @@ const taskCascade = {
5577
account: { name: 'account', type: 'lookup' as const, reference: 'acct', required: true, deleteBehavior: 'cascade' },
5678
},
5779
};
80+
// [#9625] The fixture the divergence existed for: a required FK carrying an
81+
// EXPLICITLY WRITTEN `set_null`. Before this file pinned it, coverage had the
82+
// defaulted `set_null` (escalates) and an explicit `cascade` (honored) and
83+
// nothing in between, so both readings of "does writing it out opt me out?"
84+
// were compatible with a green suite.
85+
const quoteExplicitSetNull = {
86+
name: 'quote',
87+
label: 'Quote',
88+
fields: {
89+
id: { name: 'id', type: 'text' as const, primaryKey: true },
90+
account: {
91+
name: 'account', type: 'lookup' as const, reference: 'acct',
92+
required: true, deleteBehavior: 'set_null',
93+
},
94+
},
95+
};
96+
// [#9625] Required + `multiple: true`: the escalation runs BEFORE the
97+
// member-removal branch and keys on `required` alone, so the refusal lands
98+
// even when removal would leave the set non-empty.
99+
const rosterRequiredMulti = {
100+
name: 'roster',
101+
label: 'Roster',
102+
fields: {
103+
id: { name: 'id', type: 'text' as const, primaryKey: true },
104+
accounts: {
105+
name: 'accounts', type: 'lookup' as const, reference: 'acct',
106+
required: true, multiple: true, deleteBehavior: 'set_null',
107+
},
108+
},
109+
};
110+
// [#9625] The control for the pair above — same shape, `required` dropped.
111+
// Without it, a suite that only asserted the refusal could not tell
112+
// "refused because required" from "refused because multi-value".
113+
const watchlistOptionalMulti = {
114+
name: 'watchlist',
115+
label: 'Watchlist',
116+
fields: {
117+
id: { name: 'id', type: 'text' as const, primaryKey: true },
118+
accounts: {
119+
name: 'accounts', type: 'lookup' as const, reference: 'acct',
120+
multiple: true, deleteBehavior: 'set_null',
121+
},
122+
},
123+
};
124+
// [#9625] The neighbouring resolution that collapses the same two facts:
125+
// `master_detail` maps every non-`restrict` value onto `cascade`, so an
126+
// explicit `set_null` here is dropped without a word.
127+
const lineExplicitSetNull = {
128+
name: 'line',
129+
label: 'Line Item',
130+
fields: {
131+
id: { name: 'id', type: 'text' as const, primaryKey: true },
132+
parent: {
133+
name: 'parent', type: 'master_detail' as const, reference: 'acct',
134+
deleteBehavior: 'set_null',
135+
},
136+
},
137+
};
58138

59139
function makeStubDriver() {
60140
const stores = new Map<string, Map<string, Record<string, unknown>>>();
61141
const storeFor = (o: string) => { let s = stores.get(o); if (!s) { s = new Map(); stores.set(o, s); } return s; };
62142
let nextId = 0;
143+
// [#9625] `$contains` and `$or` are answered because `referenceProbeFilter`
144+
// spells a `multiple: true` reference probe that way (#9362) — a double
145+
// that ignored them would report "no dependents" for every multi-value
146+
// relation and turn the refusals asserted below into silent successes,
147+
// which is the fail-OPEN direction #8895 ruled out for this guard.
148+
// `$contains` is answered as MEMBERSHIP over the stored array, matching
149+
// what the engine narrows to afterwards via `storedReferenceIncludes`.
150+
const matchOne = (stored: unknown, spec: unknown): boolean => {
151+
if (spec !== null && typeof spec === 'object' && !Array.isArray(spec)) {
152+
const [op, cmp] = Object.entries(spec as Record<string, unknown>)[0] ?? [];
153+
if (op === '$contains') {
154+
const values = Array.isArray(stored) ? stored : [stored];
155+
return values.some((v) => v != null && typeof v !== 'object' && String(v) === String(cmp));
156+
}
157+
if (op === '$eq') return (stored ?? null) === ((cmp as any) ?? null);
158+
return false;
159+
}
160+
return (stored ?? null) === ((spec as any) ?? null);
161+
};
63162
const matches = (row: Record<string, unknown>, where: any): boolean => {
64163
if (!where || typeof where !== 'object') return true;
65164
for (const [k, v] of Object.entries(where)) {
165+
if (k === '$or') { if (!(v as any[]).some((sub) => matches(row, sub))) return false; continue; }
66166
if (k.startsWith('$')) continue;
67-
const exp = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;
68-
if ((row[k] ?? null) !== (exp ?? null)) return false;
167+
if (!matchOne(row[k], v)) return false;
69168
}
70169
return true;
71170
};
@@ -99,7 +198,10 @@ describe('cascadeDeleteRelations — required FK escalates set_null → restrict
99198
const { driver } = makeStubDriver();
100199
engine.registerDriver(driver, true);
101200
await engine.init();
102-
for (const o of [acct, oppRequired, noteOptional, taskCascade]) engine.registry.registerObject(o);
201+
for (const o of [
202+
acct, oppRequired, noteOptional, taskCascade,
203+
quoteExplicitSetNull, rosterRequiredMulti, watchlistOptionalMulti, lineExplicitSetNull,
204+
]) engine.registry.registerObject(o);
103205
});
104206

105207
it('refuses to delete a parent with a REQUIRED-FK child (DELETE_RESTRICTED, 409) and leaves both rows', async () => {
@@ -147,6 +249,85 @@ describe('cascadeDeleteRelations — required FK escalates set_null → restrict
147249
expect(await engine.findOne('task', { where: { id: t.id } })).toBeNull();
148250
});
149251

252+
// ── [#9625] Explicit vs defaulted `set_null` on a required lookup ──────
253+
//
254+
// The escalation two lines above the probe reads the RESOLVED behavior, by
255+
// which point `deleteBehavior: 'set_null'` and an absent `deleteBehavior`
256+
// are the same string. These pin that consequence in both directions: the
257+
// explicit spelling escalates exactly like the default (first two), and
258+
// the values that really are honored as written still are (`cascade`
259+
// above, and the optional multi-value control below).
260+
261+
it('[#9625] escalates an EXPLICITLY written deleteBehavior:set_null on a required lookup, exactly like the default', async () => {
262+
const a = await engine.insert('acct', { name: 'Acme' });
263+
const q = await engine.insert('quote', { account: a.id });
264+
265+
// ADR-0112 envelope — `code` AND `status`, never a bare toThrow(): an
266+
// unescalated engine would fail this by throwing the child validator's
267+
// "account is required" 400 instead, which a bare toThrow() accepts.
268+
const err = await engine.delete('acct', { where: { id: a.id } } as any).catch((e) => e);
269+
expect(err).toMatchObject({
270+
code: 'DELETE_RESTRICTED', status: 409, dependentObject: 'quote', dependentCount: 1,
271+
});
272+
// The refusal is attributed to `required`, not to an authored
273+
// `restrict` — this is the sentence that tells an author why writing
274+
// `set_null` did not take effect.
275+
expect(err.developerMessage).toContain('account is required, so it cannot be cleared');
276+
277+
// Nothing moved: the parent survives and the FK was never cleared.
278+
expect(await engine.findOne('acct', { where: { id: a.id } })).toBeTruthy();
279+
expect((await engine.findOne('quote', { where: { id: q.id } }) as any).account).toBe(a.id);
280+
});
281+
282+
it('[#9625] refuses a required MULTI-VALUE lookup even when member removal would leave the set non-empty', async () => {
283+
// The escalation runs before the multi-value branch and keys on
284+
// `required` alone, so the other live member does not save the delete.
285+
// Pinned as CURRENT behaviour, deliberately not changed here: `[]`
286+
// still satisfies `required` in the record validator (#9476), so the
287+
// blanket refusal is what stops an emptied required set landing
288+
// silently.
289+
const a = await engine.insert('acct', { name: 'Acme' });
290+
const b = await engine.insert('acct', { name: 'Beta' });
291+
const r = await engine.insert('roster', { accounts: [a.id, b.id] });
292+
293+
const err = await engine.delete('acct', { where: { id: a.id } } as any).catch((e) => e);
294+
expect(err).toMatchObject({
295+
code: 'DELETE_RESTRICTED', status: 409, dependentObject: 'roster', dependentCount: 1,
296+
});
297+
// The set is untouched — no member removal ran.
298+
expect((await engine.findOne('roster', { where: { id: r.id } }) as any).accounts).toEqual([a.id, b.id]);
299+
expect(await engine.findOne('acct', { where: { id: a.id } })).toBeTruthy();
300+
});
301+
302+
it('[#9625] control: the same multi-value shape WITHOUT required removes the member and deletes the parent', async () => {
303+
// Pairs with the test above: it is `required`, not multi-valued-ness,
304+
// that produces the refusal. Without this the suite could not tell the
305+
// two causes apart, and a change that refused every multi-value delete
306+
// would sit green.
307+
const a = await engine.insert('acct', { name: 'Acme' });
308+
const b = await engine.insert('acct', { name: 'Beta' });
309+
const w = await engine.insert('watchlist', { accounts: [a.id, b.id] });
310+
311+
await engine.delete('acct', { where: { id: a.id } } as any);
312+
expect(await engine.findOne('acct', { where: { id: a.id } })).toBeNull();
313+
expect((await engine.findOne('watchlist', { where: { id: w.id } }) as any).accounts).toEqual([b.id]);
314+
});
315+
316+
it('[#9625] a master_detail declaring an explicit deleteBehavior:set_null still cascades', async () => {
317+
// The neighbouring resolution with the same blind spot: `restrict` is
318+
// the only value that deviates, so `set_null` is accepted by
319+
// `FieldSchema` on this type and then dropped here. Pinned so the
320+
// silent coercion is a documented fact rather than an absence.
321+
const a = await engine.insert('acct', { name: 'Acme' });
322+
const l = await engine.insert('line', { parent: a.id });
323+
324+
await engine.delete('acct', { where: { id: a.id } } as any);
325+
expect(await engine.findOne('acct', { where: { id: a.id } })).toBeNull();
326+
// Cascaded away — NOT kept with a nulled `parent`, which is what
327+
// honoring the declared `set_null` would have produced.
328+
expect(await engine.findOne('line', { where: { id: l.id } })).toBeNull();
329+
});
330+
150331
it('[#3023] tags the referential set_null write with __referentialFieldClear so the owner guard can exempt it', async () => {
151332
// The cascade FK clear is an engine-internal integrity write. It must
152333
// carry the server-set marker plugin-security's ownership-anchor guard

0 commit comments

Comments
 (0)