Skip to content

Commit de43f94

Browse files
os-zhuangclaude
andauthored
fix(objectql): materialise the master-detail header a parent predicate reads (#6457) (#6788)
`resolveMasterDetailParent(s)` handed the driver-read header row through as-is, so a `parent.<field>` predicate faulted with `No such key` on any driver that did not echo back the column it reads. That fault is NOT `Unknown variable: parent` — `parent` IS bound — so it missed #4889's fail-closed carve-out and took the ordinary fail-OPEN exit: a `readonlyWhen` lock was let through and a `requiredWhen` requirement was not enforced. The header is now made TOTAL over the MASTER object's declared fields inside the two resolvers, which are the only place holding both the master's schema and the just-read row. One change serves both consumers; no strip or validator signature moves; the same `materializeDeclaredFields` helper as every other server seam (#1871/#4649/#4953); no extra query. The fail-closed line is preserved exactly: materialisation only ever applies to a header row that EXISTS, so an unresolvable header still leaves `parent` unbound, still faults as `Unknown variable: parent`, and is still read as LOCKED. Both paths pinned, and told apart by fault channel rather than by the write's outcome. Claude-Session: https://claude.ai/code/session_01GNUt6cLqqcaLVbiDbin27R Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4df747c commit de43f94

6 files changed

Lines changed: 522 additions & 5 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): materialise the master-detail header a `parent`-scoped predicate reads (#6457)
6+
7+
`readonlyWhen: parent.status == 'paid'` and `requiredWhen: parent.status == 'sent'`
8+
are documented **server** guarantees (#4889 / #4977 bound the `parent` root at the
9+
write path so they are enforced by the engine and not only by the inline grid).
10+
What was still storage-dependent is what that bound header CONTAINS. The engine
11+
resolved it with a plain driver read and passed the row through as-is, so a driver
12+
that returns only the columns it stored handed the predicate a header missing the
13+
very key it reads.
14+
15+
CEL is strict about missing keys, and the resulting fault is `No such key: status`
16+
**not** `Unknown variable: parent`, because `parent` IS bound. That misses
17+
#4889's fail-closed carve-out and takes the ordinary fail-OPEN exit, so:
18+
19+
- a `readonlyWhen` lock was **let through** and the frozen field was written;
20+
- a `requiredWhen` requirement was **not enforced** and the record was accepted
21+
with the field empty.
22+
23+
Whether a declared lock or requirement enforced anything therefore depended on
24+
which columns the driver happened to echo back — something the author who wrote
25+
the predicate cannot see or control. This is #4953's trap on a different root:
26+
the 2026-08-06 ruling made `record` / `previous` total at every server seam and
27+
deliberately left `parent` out, as an ABSENT `parent` is the fail-closed signal.
28+
29+
**The header is now made TOTAL over the MASTER object's declared fields** inside
30+
`ObjectQL.resolveMasterDetailParent` and `resolveMasterDetailParents` — the only
31+
place holding both the master's schema and the just-read row, so one change
32+
serves both consumers and no strip/validator signature moves. It reuses the same
33+
`materializeDeclaredFields` helper as every other server seam (#1871/#4649/#4953),
34+
covers the single-id, bulk and insert paths, adds no query (the declared-field
35+
table is a registry lookup, read once per batch), and copies each header before
36+
materialising so the stored row never gains materialised nulls.
37+
38+
**Verdicts move, in both directions, and only for a header that RESOLVED:**
39+
40+
| header state | before | now |
41+
|---|---|---|
42+
| carries the key | evaluates per verdict | unchanged |
43+
| resolved, key absent | fault ⇒ fail-OPEN (lock let through / requirement skipped) | evaluates — the key reads `null`, so locks lock and requirements enforce |
44+
| unresolvable (`null`) | `readonlyWhen` LOCKED (#4889) / `requiredWhen` fail-OPEN (#4977) | **unchanged** |
45+
46+
The bottom row is the one thing this change does not touch. Materialisation is
47+
only ever applied to a header row that exists, so an unresolvable header still
48+
leaves `parent` unbound, still faults as `Unknown variable: parent`, and is still
49+
read as LOCKED for `readonlyWhen` — and still fail-OPEN for `requiredWhen`, the
50+
deliberate #4977 asymmetry. The two cases stay distinguishable by fault channel,
51+
and both are pinned.
52+
53+
**Consequences worth knowing before writing a `parent`-scoped predicate**, the
54+
same two `declared-fields.ts` states for `record`:
55+
56+
- `has(parent.<declared field>)` is now uniformly TRUE — a materialised `null` is
57+
a PRESENT key holding null (CEL's own rule). `has()` guards against an
58+
UNDECLARED key on the header, not against an empty value; test emptiness with
59+
`parent.x != null`.
60+
- Scope is the master's DECLARED fields only. A typo (`parent.stauts`) stays
61+
unevaluable and therefore reportable rather than silently reading as `null`.
62+
63+
If you have a `parent`-scoped `readonlyWhen` that was quietly failing open on a
64+
sparse-returning driver, it starts locking; a `requiredWhen` in the same position
65+
starts rejecting writes that leave the field empty. That is the declaration being
66+
enforced as written.

packages/objectql/src/engine-readonly-when-parent.test.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@
1616
// The record-scoped contrast the issue drew — `showcase_invoice.tax_rate` with
1717
// `readonlyWhen: record.status == 'paid'`, which worked all along — is pinned in
1818
// the same file so a future change cannot fix one by breaking the other.
19+
//
20+
// #6457 extends this suite rather than starting its own, because what it changes
21+
// is the same binding this file already owns: the header the engine resolves is
22+
// now TOTAL over the MASTER object's declared fields, so a `parent.<field>`
23+
// predicate no longer depends on which columns the driver echoed back. Only the
24+
// MIDDLE row of the issue's verdict table moves (resolved-but-sparse: fail-OPEN
25+
// ⇒ evaluated); the fail-CLOSED line above (unresolvable ⇒ LOCKED) is asserted
26+
// unchanged, on the same fixtures, in the same block.
1927

2028
import { describe, it, expect, beforeEach } from 'vitest';
2129
import { ObjectQL } from './engine.js';
@@ -126,18 +134,55 @@ describe('parent-scoped readonlyWhen is enforced server-side (#4889)', () => {
126134
unit_price: { type: 'currency', readonlyWhen: "parent.status == 'paid'" },
127135
// No lock at all — a line is still editable in the ways the author left open.
128136
description: { type: 'text' },
137+
// [#6457] The issue's own predicate — a lock that reads a header key the
138+
// driver may or may not have echoed back. TRUE on a header carrying no
139+
// `status`, which is what makes the middle-row flip observable.
140+
locked_until_status: { type: 'text', readonlyWhen: 'parent.status == null' },
141+
// [#6457] The recorded CONSEQUENCE (#4953's, one root over): a
142+
// materialised `null` is a PRESENT key, so `has()` over a DECLARED
143+
// master field is uniformly TRUE.
144+
has_guard: { type: 'text', readonlyWhen: 'has(parent.status)' },
145+
// [#6457] The #4649 line, unmoved: materialisation covers the master's
146+
// DECLARED fields only, so an author typo on the header stays
147+
// unevaluable — and therefore fail-OPEN — instead of reading as null.
148+
typo_guard: { type: 'text', readonlyWhen: 'parent.stauts == null' },
129149
},
130150
} as any);
131151

132152
storeFor('showcase_invoice').set('INV-1003', { id: 'INV-1003', invoice_number: 'INV-1003', status: 'paid', tax_rate: 8 });
133153
storeFor('showcase_invoice').set('INV-1004', { id: 'INV-1004', invoice_number: 'INV-1004', status: 'draft', tax_rate: 8 });
154+
// [#6457] A header row the driver returned WITHOUT its `status` column — the
155+
// MIDDLE row of the issue's verdict table. `status` is declared on the
156+
// master; this row simply does not carry the key.
157+
storeFor('showcase_invoice').set('INV-SPARSE', { id: 'INV-SPARSE', invoice_number: 'INV-SPARSE' });
134158
storeFor('showcase_invoice_line').set('line_paid', { id: 'line_paid', invoice: 'INV-1003', quantity: 6, unit_price: 49.99, description: 'seat' });
135159
storeFor('showcase_invoice_line').set('line_draft', { id: 'line_draft', invoice: 'INV-1004', quantity: 3, unit_price: 10, description: 'seat' });
160+
storeFor('showcase_invoice_line').set('line_sparse', {
161+
id: 'line_sparse', invoice: 'INV-SPARSE', quantity: 1, unit_price: 5,
162+
// A description OUTSIDE the 'seat' match set the bulk tests above use.
163+
description: 'sparse', locked_until_status: 'kept', has_guard: 'kept', typo_guard: 'kept',
164+
});
136165
});
137166

138167
const line = (id: string) => storeFor('showcase_invoice_line').get(id);
139168
const invoice = (id: string) => storeFor('showcase_invoice').get(id);
140169

170+
/** Everything the engine warned during one write — the strip's own channel,
171+
* which is how the fail-OPEN exit and the LOCKED exits are told apart. */
172+
async function warningsDuring(run: () => Promise<unknown>): Promise<string[]> {
173+
const warns: string[] = [];
174+
const base = (engine as any).logger;
175+
(engine as any).logger = new Proxy(base, {
176+
get: (t: any, k: string) => (k === 'warn' ? (m: string) => warns.push(String(m)) : t[k]),
177+
});
178+
try {
179+
await run();
180+
} finally {
181+
(engine as any).logger = base;
182+
}
183+
return warns;
184+
}
185+
141186
it('THE REGRESSION: a paid invoice\'s frozen line survives the PATCH that used to rewrite it', async () => {
142187
// Verbatim from the issue: PATCH {"quantity":9999,"unit_price":0.01} on a
143188
// line of the PAID invoice INV-1003 returned 200 and PERSISTED.
@@ -219,4 +264,141 @@ describe('parent-scoped readonlyWhen is enforced server-side (#4889)', () => {
219264
await engine.update('showcase_invoice_line', { id: 'line_paid', description: 'note' });
220265
expect(reads.filter((r) => r === 'showcase_invoice')).toHaveLength(0);
221266
});
267+
268+
// ── #6457 — the resolved header is TOTAL over the MASTER's declared fields ──
269+
//
270+
// The issue's three-row verdict table, pinned end-to-end. Exactly one row
271+
// moves. The other two — including #4889's fail-CLOSED line — are asserted on
272+
// the same fixtures precisely so the move cannot quietly take them with it,
273+
// and they are told apart by FAULT CHANNEL, not by the write's outcome: rows 2
274+
// and 3 can both end in "the field was not written", and only the warning says
275+
// whether that was an evaluated verdict or a refusal to guess.
276+
277+
it('ROW 1 (header carries the key): evaluates as it always did, verdict unchanged', async () => {
278+
// INV-1003 carries `status: 'paid'`, so `parent.status == null` is FALSE and
279+
// the field is writable. No fault ⇒ nothing on the fail-open channel.
280+
const warns = await warningsDuring(() =>
281+
engine.update('showcase_invoice_line', { id: 'line_paid', locked_until_status: 'written' }));
282+
expect(line('line_paid')).toMatchObject({ locked_until_status: 'written' });
283+
expect(warns.some((w) => w.includes('failed to evaluate — change allowed through'))).toBe(false);
284+
});
285+
286+
it('ROW 2 — THE FIX: a header missing the key now LOCKS instead of failing open', async () => {
287+
// Before #6457 this was `No such key: status` on a BOUND `parent`, so
288+
// `unknownVariableOf` did not match, the ordinary fail-OPEN exit ran, and
289+
// the declared lock was let through. The header is now total over the
290+
// master's declared fields, so `status` reads `null`, the predicate is TRUE,
291+
// and the field is stripped.
292+
const warns = await warningsDuring(() =>
293+
engine.update('showcase_invoice_line', { id: 'line_sparse', locked_until_status: 'forged' }));
294+
expect(line('line_sparse')).toMatchObject({ locked_until_status: 'kept' });
295+
// The verdict came from an EVALUATION: the fail-open exit is not on the
296+
// channel at all…
297+
expect(warns.some((w) => w.includes('failed to evaluate — change allowed through'))).toBe(false);
298+
expect(warns.some((w) => w.includes("Field 'locked_until_status' is read-only (readonlyWhen)"))).toBe(true);
299+
// …and it is NOT #4889's unbound-root exit either — `parent` IS bound here.
300+
// This is the assertion that keeps ROW 2 and ROW 3 distinguishable.
301+
expect(warns.some((w) => w.includes("reads 'parent'"))).toBe(false);
302+
});
303+
304+
it('ROW 2: the strip is reported to the caller as `readonly_when` (#3407)', async () => {
305+
const events: any[] = [];
306+
await engine.update(
307+
'showcase_invoice_line',
308+
{ id: 'line_sparse', locked_until_status: 'forged' },
309+
{ onFieldsDropped: (e: any) => events.push(e) } as any,
310+
);
311+
expect(events).toEqual([
312+
{ object: 'showcase_invoice_line', fields: ['locked_until_status'], reason: 'readonly_when' },
313+
]);
314+
});
315+
316+
it('ROW 3 (unresolvable header): still LOCKED, and still by the UNBOUND-ROOT exit (#4889)', async () => {
317+
// The fail-closed line, unmoved and byte-identical: materialisation is only
318+
// ever applied to a row that EXISTS, so a header that resolves to nothing
319+
// still leaves `parent` unbound and still faults as `Unknown variable`.
320+
storeFor('showcase_invoice_line').set('orphan', { id: 'orphan', invoice: 'GONE', locked_until_status: 'kept' });
321+
const warns = await warningsDuring(() =>
322+
engine.update('showcase_invoice_line', { id: 'orphan', locked_until_status: 'forged' }));
323+
expect(line('orphan')).toMatchObject({ locked_until_status: 'kept' });
324+
expect(warns.some((w) => w.includes("reads 'parent'") && w.includes('LOCKED'))).toBe(true);
325+
});
326+
327+
it('a sparse header that answers FALSE allows the change — by a VERDICT, not by a fault', async () => {
328+
// `parent.status == 'paid'` over a header carrying no status now evaluates
329+
// to FALSE. The write lands either way; what changed is why, and the why is
330+
// what every other predicate on that header depends on.
331+
const warns = await warningsDuring(() =>
332+
engine.update('showcase_invoice_line', { id: 'line_sparse', quantity: 42 }));
333+
expect(line('line_sparse')).toMatchObject({ quantity: 42 });
334+
expect(warns.some((w) => w.includes('failed to evaluate — change allowed through'))).toBe(false);
335+
});
336+
337+
it('CONSEQUENCE: `has(parent.<declared>)` is uniformly TRUE — it locks even on a sparse header', async () => {
338+
// CEL's own rule, the same one #4953 recorded for `record`: a materialised
339+
// `null` is a PRESENT key. `has()` guards against an UNDECLARED key on the
340+
// header, not against an empty value — test emptiness with `!= null`.
341+
await engine.update('showcase_invoice_line', { id: 'line_sparse', has_guard: 'forged' });
342+
expect(line('line_sparse')).toMatchObject({ has_guard: 'kept' });
343+
});
344+
345+
it('BOUNDARY: an UNDECLARED key on the header stays unevaluable — fail-OPEN (#4649 unmoved)', async () => {
346+
// `parent.stauts` is a typo, not a sparse column. Materialising it would
347+
// paper over the bug; it must stay reportable.
348+
const warns = await warningsDuring(() =>
349+
engine.update('showcase_invoice_line', { id: 'line_sparse', typo_guard: 'written' }));
350+
expect(line('line_sparse')).toMatchObject({ typo_guard: 'written' });
351+
expect(warns.some((w) => w.includes('failed to evaluate — change allowed through'))).toBe(true);
352+
});
353+
354+
it('does NOT mutate the stored header row — the materialised copy stays local', async () => {
355+
// The in-memory driver hands back the stored object BY REFERENCE, which is
356+
// exactly how a materialisation leaks: the header would silently gain a
357+
// `status: null` column that every later reader (and after-hooks) sees.
358+
await engine.update('showcase_invoice_line', { id: 'line_sparse', locked_until_status: 'forged' });
359+
expect('status' in invoice('INV-SPARSE')).toBe(false);
360+
expect(invoice('INV-SPARSE')).toEqual({ id: 'INV-SPARSE', invoice_number: 'INV-SPARSE' });
361+
});
362+
363+
it('BULK: the batch path materialises its headers too, per matched row', async () => {
364+
await engine.update(
365+
'showcase_invoice_line',
366+
{ locked_until_status: 'forged' },
367+
{ where: { description: 'sparse' }, multi: true } as any,
368+
);
369+
expect(line('line_sparse')).toMatchObject({ locked_until_status: 'kept' });
370+
});
371+
372+
it('BULK: a batch under a header that DOES carry the key still writes', async () => {
373+
await engine.update(
374+
'showcase_invoice_line',
375+
{ locked_until_status: 'written' },
376+
{ where: { description: 'seat' }, multi: true } as any,
377+
);
378+
expect(line('line_paid')).toMatchObject({ locked_until_status: 'written' });
379+
expect(line('line_draft')).toMatchObject({ locked_until_status: 'written' });
380+
});
381+
382+
it('BULK: an unresolvable header still LOCKS the batch (fail-CLOSED, bulk twin)', async () => {
383+
storeFor('showcase_invoice_line').set('orphan_b', {
384+
id: 'orphan_b', invoice: 'GONE', description: 'orphaned', locked_until_status: 'kept',
385+
});
386+
await engine.update(
387+
'showcase_invoice_line',
388+
{ locked_until_status: 'forged' },
389+
{ where: { description: 'orphaned' }, multi: true } as any,
390+
);
391+
expect(line('orphan_b')).toMatchObject({ locked_until_status: 'kept' });
392+
});
393+
394+
it('costs no extra header read — materialisation is a registry lookup, not a query', async () => {
395+
const reads: string[] = [];
396+
const original = (engine as any).findOne.bind(engine);
397+
(engine as any).findOne = async (name: string, q: any, o?: any) => {
398+
reads.push(name);
399+
return original(name, q, o);
400+
};
401+
await engine.update('showcase_invoice_line', { id: 'line_sparse', locked_until_status: 'forged' });
402+
expect(reads.filter((r) => r === 'showcase_invoice')).toHaveLength(1);
403+
});
222404
});

0 commit comments

Comments
 (0)