Skip to content

Commit c699d42

Browse files
fix(objectql)!: a detail write asks the caller’s read door for its master-detail header — an unreadable header answers as a nonexistent one (#22519) (#22891)
Fixes #22519 Clause-②: no (narrowing) Implements the maintainer's ruling 6107211425 (letter A) on #22519. A write of a master-detail detail whose header the caller cannot read now answers exactly what a write naming a header that exists nowhere answers. This holds for every detail, `controlled_by_parent` or not, on insert, update (a repoint included) and the bulk paths. System callers are unchanged. The plain `lookup` reference probe is not touched, and ADR-0055 is not edited. ## Mechanism (`packages/objectql/src/engine.ts`) The write's master-detail header read now uses the two-read shape the preview already uses (`resolvePreviewParents`): 1. **`masterDetailHeadersCallerReads`** (new): the caller's read door. It is one `find` under the caller's own context, projected to `id`. One id is asked by equality and a batch by `$in`, which are the predicate shapes the elevated reads already send. A header it does not return binds as a missing header binds. That covers a header hidden by row scope, a refused read of the header object, and a failed read (logged once at `warn`). A system caller asks no read door. 2. The existing elevated, tenant-kept read (#19837), run only for the headers read 1 returned. A header the caller can read is still judged on its stored values, including a column that field-level security hides or masks for that caller. The preview's per-column masking stays the preview's own. Every write path goes through three places, and each one takes the rule: | Seat | Write paths it serves | |:---|:---| | `resolveMasterDetailParent` | update by id: the landing header, the header named during the FK-lock settlement, and the repoint's `previousParent` | | `resolveMasterDetailParents` | `insert` (single and array), `insertMany` (which calls `insert`), and the predicate (bulk) update's landing and previous headers | | `assertReferencesResolve` calls the new `masterDetailHeaderExists` | a field of type `master_detail` only, on insert, update by id and the predicate update. A `lookup` keeps `referenceExists`, unchanged | How the write paths were enumerated: the engine's write methods are `insert`, `insertMany` and `update` (by id and predicate). `execute` and the scoped-context and transaction wrappers route into these. Every call site of the two resolvers and of the reference check was found with `grep`, and none is left on the old read. **Docblocks.** The `resolveMasterDetailParent` paragraph that called the elevation "settled upstream" now states this rule. `resolveMasterDetailParents` and `assertReferencesResolve` gained the matching sections. Four comments described the write's header read as elevated-only, and each now says the write asks the read door too: - the preview docblock; - the `validate` docblock; - the inline comment in `validate`; - the `resolvePredicateRelated` docblock. The preview's code is unchanged. Its read 2 calls `resolveMasterDetailParents`, which now asks the read door again, so a preview pays one extra id-projected query (see Acceptance notes). ## Pins (`engine-unreadable-master-detail-header.test.ts`, synthetic read scope as in #22474's preview pins) Every refusal pin asserts the ADR-0112 envelope (`status` and `code`, plus the field code). It also asserts that the message, with the id taken out, matches the message for a nonexistent id. - **(a) The reference check**, through four doors: insert, batch insert, repoint by id and bulk repoint. An unreadable header in either state answers `400 VALIDATION_FAILED` / `reference_not_found`, which is what a nonexistent id answers, and nothing is written. A refused read of the header object answers the same. In the partial `insertMany`, an unreadable row fails exactly as a nonexistent row does, while a readable row lands. - **(b) `requiredWhen` over `parent`**: insert, update by id and bulk, and a repoint by id and in bulk. An unreadable header in either state answers what a nonexistent header answers (ADR-0137 D2's unevaluable refusal). A refused read of the header object answers the same. - **(c) `readonlyWhen` over `parent`**, by id and in bulk: the field locks with the same drop report. Under `strictReadonlyWrites` the refusal is the same. - **The mechanism:** the read door is asked first, then the elevated read, both for the binding and for the reference check. - **Controls:** - a readable header is unchanged: locked requires, open lands, the lock holds or lets the field through; - a readable header whose state column the read door hides or masks is still judged on its stored value; - a nonexistent id answers as before; - a `controlled_by_parent` detail under a readable header is unchanged, and under an unreadable header it takes the same rule; - a system caller is unchanged: the header is bound whoever owns it, and no read door is asked; - a plain `lookup` to the same object keeps its elevated probe and asks no read door. **Existing pins updated.** Each of these pinned the old read shape: - `engine-reference-tenant-scope.test.ts`: - the tenancy-disabled master control now counts two finds; - the `group` posture control now counts two finds, each carrying the union; - the "header read runs ELEVATED" pin now asserts door, then elevated, for each read, with the tenant on all six. - `engine-required-when-parent.test.ts`: the cost pin now counts one batched door read and one batched elevated read per insert, plus one door read per supplied header for the reference check. - `validate-preview-parent.test.ts`: a header comment. ## Ablation (one-off proof, not kept as a test) The ablation was run from committed `eefbde9b6c` through `scripts/ablation-replace.mjs`, which restores the file on exit. It removed the read door by forcing `masterDetailHeadersCallerReads` to return every id: the anchor went from 1 to 0, and the blob from `513539a2e224` to `118559d7a44d`. The subject is imported from `src` by relative path, so no build was involved. - **Mutated:** 22 failed and 81 passed across the four files. All 18 unreadable-header pins and the mechanism pin turned red, as did the 3 tenant-scope read-shape pins and the cost pin. **All 12 behaviour controls stayed green.** - **Restored:** the blob equals HEAD (`513539a2e224`) and `git diff HEAD` is empty. ## Tests run These ran on `bb5d7399d9` (the code before merging `origin/main`), then were re-scoped on the merged head `fef935481d`: - `@objectstack/objectql` full `vitest run --project local`: **401 files / 7840 tests passed**. - `@objectstack/plugin-security` full `vitest run` (its vitest aliases `@objectstack/objectql` to `src`, so the real `SecurityPlugin` rules ran against this change, the `controlled-by-parent-*` suites included): **196 files passed, 3973 passed / 45 skipped**. - On `fef935481d`, after merging `origin/main`, rebuilding `@objectstack/objectql...` and running `pnpm install --frozen-lockfile`: - `pnpm --filter @objectstack/objectql typecheck` passed (`tsc --noEmit` and `check:test-typecheck`; the three edited or new test files are in the `tsconfig.test.json` program); - 11 targeted files passed (200 tests): the 4 files above plus the 7 whose test doubles first showed the single-id equality need. - An earlier full run at `01ed9ca0e2` turned up 47 red tests in 7 files. In each of them, the test double's `find` handles flat equality but not `$in`. That is why one id is now asked by equality, which adds no driver-capability requirement beyond what the elevated reads already send. **Gates, run on the head `fef935481d`.** The list comes from `dispatch-gates --commands`, re-derived on the real diff: 98 commands, the dispatch's 68 plus 30 that the docs-page edit adds. All 98 exited 0, and `--ran` reconciled 98 derived, 98 run, 0 NOT-MEASURED. One of them, `check-engine-split-ratio --days 90`, first exited 2 (a shallow-clone prerequisite). After `git fetch --shallow-since=2026-07-06 origin main` it exited 0. Each command and its exit code is in the report comment on #22519. **Lint was narrowed, not skipped.** The repository-wide `pnpm lint` belongs to CI. Locally, `eslint --no-inline-config --format json` ran over the 5 changed `.ts` files and found 5 files, 0 errors and 0 warnings. That narrowing excludes nothing, for three reasons: - the population comes from `eslint.config.mjs`, whose base block covers every `**/*.{ts,…}` file; - the JSON output counted 5 files linted; - the config never enables type-aware linting (it says so itself, no `parserOptions.project`), so this diff cannot change the verdict on any untouched file. The `.md` and `.mdx` changes are outside ESLint's file set. ## Acceptance notes - **Outside the declared file surface: `content/docs/permissions/system-context.mdx`** has one new row (29c) and its census counts regenerated by `pnpm gen:system-context-census`. `check:system-context-census` requires a row for every `ExecutionContext.isSystem` read, and the system-caller stand-down the ruling keeps is one new read. Without the row the gate stays red. - **The ruling's invariant still has one gap, outside this PR's seam.** A one-hop predicate traversal through the master-detail FK goes through `resolvePredicateRelated`, which is the #18682 related read and its documented "accepted cost". For a tenanted master it still reads the header elevated, and its verdict comes before the reference check. So under such a rule, the answer for an unreadable header still depends on the header's stored state; a scratch probe confirmed this, and the probe was not committed. Handed to the seat as a finding: whether ruling A extends to the related read over the master-detail FK is a decision this PR does not take. - **Cost:** one id-projected read under the caller's context per header resolution (once per batch), and one per supplied `master_detail` value in the reference check. A preview with a header pays one more id read, because its read 2 now carries the write's own read door. Carrier: none. - **Line count:** 812 changed lines (+754 / −58) against the ruling's 300–600 estimate. The surplus is the pin file (496 lines) and the census row and counts. - Reachability in real deployments was not measured. The ruling struck that step because the rule only tightens. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a7e2f7a commit c699d42

7 files changed

Lines changed: 754 additions & 58 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
fix(objectql)!: a detail write asks the caller's read door for its master-detail header before judging it — a header the caller cannot read answers as a header that exists nowhere (#22519)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is one runtime read, in two places: the write's master-detail header read (the `parent` binding) and the write's reference check of a `master_detail` field now ask the caller's read door before their elevated read, so a write naming a header the caller cannot read is answered as a write naming a header that exists nowhere. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no exported declaration is removed or narrowed (not runtime-interface-only or type-surface-only). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
A write of a master-detail DETAIL reads its header for two things: the reference check of the `master_detail` value the caller supplied, and the `parent` that a parent-scoped `requiredWhen` / `readonlyWhen` judges.
14+
15+
**FROM.** Both read the header elevated (row-level access bypassed, the caller's tenant kept), on the premise that the caller's right to write the detail settled its right to see the header. ADR-0055 §3 guarantees that only for a `controlled_by_parent` detail (its master-edit gate). For any other detail, a non-system caller who could write the detail but could not read its header met:
16+
17+
- a reference check that admitted the header id, where a header id that exists nowhere answers `reference_not_found`;
18+
- a parent-scoped rule judged against the header's stored state, so the write's answer depended on a row the caller cannot read.
19+
20+
**TO.** The header is read in two steps, the shape `validate()`'s preview already uses:
21+
22+
1. The caller's read door, under the caller's own context, so every enforcement layer in the middleware chain answers it. A header it does not return (hidden by row scope, a refused read of the header object, or a failed read) binds as a missing header binds: the reference check answers `reference_not_found` (`400 VALIDATION_FAILED`), and a parent-scoped `requiredWhen` / `readonlyWhen` meets an unbound `parent` (the requirement is refused as unevaluable, the lock holds), exactly as for a header id that exists nowhere.
23+
2. The elevated, tenant-kept read, only for the headers step 1 returned. A header the caller can read is still judged on its STORED values, a column field-level security hides or masks for the caller included. The preview's per-column masking stays the preview's own.
24+
25+
**Bindings.**
26+
27+
- Every detail, `controlled_by_parent` or not, on every write door: `insert` (single and batch), `insertMany`, update by id (a repoint included) and the predicate (bulk) update.
28+
- System callers (`isSystem`: seed replay, boot, package install) ask no read door and resolve exactly as before.
29+
- A plain `lookup`'s reference check keeps its elevated existence probe, unchanged.
30+
- No new error code and no status change: the answers are the ones a header that exists nowhere already got.
31+
- Cost: one id-projected read under the caller's context per header resolution (once per batch), and one per supplied `master_detail` value in the reference check.
32+
33+
**Fix, if a write relied on the old answer:** give the writer read access to the header — an object permission or sharing rule on the master, or declare the detail `controlled_by_parent` so the header's access governs it.
34+
35+
**Not measured.** Which deployed configurations let a caller write a detail whose header it cannot read was not measured: the rule only tightens, so it holds whatever that answer is.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
1010
service self-write, a migration.
1111

1212
This page is **the authority** for what that flag actually does. It exists
13-
because the flag is not one concept: it is a single boolean read at **124
13+
because the flag is not one concept: it is a single boolean read at **125
1414
distinct sites across 20 packages**, and knowing three of those behaviours gives
1515
no hint that the other hundred-and-four exist. Every documented app-side bug
1616
traced to `isSystem` had the same shape — the metadata was complete and correct,
@@ -138,11 +138,12 @@ that silently does not happen.
138138
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `packages/objectql/src/engine.ts#dependentCountIsDisclosable` |
139139
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `packages/objectql/src/engine.ts#recordReferenceCheckElevation` |
140140
| 29b | A traversing validation rule's related read skips the **caller's bounds** | objectql | Get: no row bound from the caller's own read on a related object no organization wall scopes, and a related read even with no organization under a walled posture (a `tenantId` on the context still scopes it). Every caller that is not system is held to both | `packages/objectql/src/engine.ts#resolvePredicateRelated` |
141+
| 29c | A detail write's master-detail header read asks **no read door** | objectql | Get: the write binds the header a parent-scoped `requiredWhen` / `readonlyWhen` reads as `parent` from the header's stored row whoever can read it, as before. Every caller that is not system meets its own read door first: a header that read does not return answers as a header that exists nowhere — an unbound `parent`, and `reference_not_found` on the reference check (which a system caller's write skips altogether) | `packages/objectql/src/engine.ts#masterDetailHeadersCallerReads` |
141142
| 30 | **Bulk data event `organizationId` OMITTED** — the batch is published "not asserted" | plugin-security | Get: nothing — the `data.records.*` event still publishes. Lose: the per-organization attribution: this exit is taken before the security middleware composes any tenant wall, so it records no Layer 0 verdict on the operation (`OperationContext.tenantLayer0Verdict`, #15813), and the engine's bulk producer — which reads that recorded verdict and nothing else — omits the key rather than filling it from the caller's `tenantId`; a tenant-scoped consumer then does not deliver the event inside an organization wall (#15225) | `packages/plugins/plugin-security/src/security-plugin.ts#start` |
142143

143144
### 3. Sharing (`plugin-sharing`)
144145

145-
The largest single consumer — **17 of the 124 sites**.
146+
The largest single consumer — **17 of the 125 sites**.
146147

147148
| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
148149
|:--|:---|:---|:---|
@@ -285,7 +286,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
285286
independent decisions, and a seed loader plausibly wants the first two but not
286287
the third. The concept is nevertheless **staying as one boolean**:
287288

288-
- **Shipped semantics.** `isSystem` is a published contract with 124 read sites
289+
- **Shipped semantics.** `isSystem` is a published contract with 125 read sites
289290
in 20 packages. Splitting it is a breaking contract change across all of them.
290291
(The ruling was taken when the census read 80 sites in 18 packages; the count
291292
has grown, which strengthens rather than weakens the argument.)
@@ -359,16 +360,16 @@ still holds equal to the census on every pull request:
359360
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
360361
| — parsed as a declaration | 28 | ✅ |
361362
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
362-
| — parsed as a property **read** | 129 | ✅ |
363+
| — parsed as a property **read** | 130 | ✅ |
363364
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
364365
| — the remainder: text inside comments and string literals | 358 | — |
365366
| Of those reads: reads of one of the unrelated metadata fields | 5 | ✅ |
366-
| Of those reads: reads of `ExecutionContext.isSystem` | **124** | ✅ |
367-
| — behaviour-bearing (rows 1–61 above) | 121 | ✅ |
367+
| Of those reads: reads of `ExecutionContext.isSystem` | **125** | ✅ |
368+
| — behaviour-bearing (rows 1–61 above) | 122 | ✅ |
368369
| — carry the flag onward only (rows 62–64 above) | 3 | ✅ |
369370
| Packages containing at least one elevation read | **20** | ✅ |
370371
| Files containing at least one elevation read | 58 | ✅ |
371-
| — the distinct symbols those reads live in — what this page anchors | 106 | ✅ |
372+
| — the distinct symbols those reads live in — what this page anchors | 107 | ✅ |
372373
| — of those files, the ones holding more than one read in one symbol | 8 | ✅ |
373374

374375
The six rows marked — are a **dated decomposition, not a live claim**: they were

‎packages/objectql/src/engine-reference-tenant-scope.test.ts‎

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -651,10 +651,12 @@ describe('[#19837] the master-detail parent binding is scoped to the caller\'s o
651651
observed.length = 0;
652652
const global = await refusalOf(() => insertLine('gh_locked', 'pb_gline'));
653653
expect(envelopeOf(global)).toEqual({ status: 400, code: 'VALIDATION_FAILED', fields: [{ field: 'note', code: 'required' }] });
654-
// Bound because the engine withheld the tenant for this master (ADR-0066).
654+
// Bound because the engine withheld the tenant for this master (ADR-0066),
655+
// on both of the write's header reads (#22519: the caller's read door, then
656+
// the elevated read).
655657
const read = observed.filter((c) => c.object === 'pb_gheader' && c.method === 'find');
656-
expect(read).toHaveLength(1);
657-
expect(read[0].options?.tenantId).toBeUndefined();
658+
expect(read).toHaveLength(2);
659+
for (const r of read) expect(r.options?.tenantId).toBeUndefined();
658660
});
659661

660662
it('`group` posture: a header inside the caller\'s membership set binds — the union reaches the header read', async () => {
@@ -669,9 +671,11 @@ describe('[#19837] the master-detail parent binding is scoped to the caller\'s o
669671
engine.insert('pb_line', { id: 'ln_grp', header: 'hy_locked' }, { context: GROUP_MEMBER } as any));
670672

671673
expect(envelopeOf(inside)).toEqual({ status: 400, code: 'VALIDATION_FAILED', fields: [{ field: 'note', code: 'required' }] });
674+
// Both of the write's header reads (#22519: the caller's read door, then
675+
// the elevated read) reach the union.
672676
const read = observed.filter((c) => c.object === 'pb_header' && c.method === 'find');
673-
expect(read).toHaveLength(1);
674-
expect([...(read[0].options?.tenantIds ?? [])].sort()).toEqual([ORG_X, ORG_Y].sort());
677+
expect(read).toHaveLength(2);
678+
for (const r of read) expect([...(r.options?.tenantIds ?? [])].sort()).toEqual([ORG_X, ORG_Y].sort());
675679

676680
// Lit control: a member whose set is org X alone cannot see the org-Y
677681
// header, so it binds absent and the write answers as an absent header does
@@ -683,27 +687,36 @@ describe('[#19837] the master-detail parent binding is scoped to the caller\'s o
683687
expect(envelopeOf(outside)).toEqual({ status: 400, code: 'VALIDATION_FAILED', fields: [{ field: 'note', code: 'rule_violation' }] });
684688
});
685689

686-
it('the header read runs ELEVATED and TENANT-SCOPED — the two halves of `{ ...context, isSystem: true }`', async () => {
690+
it('the header read asks the caller\'s read door first, then runs ELEVATED and TENANT-SCOPED — the two halves of `{ ...context, isSystem: true }`', async () => {
687691
const reads: Array<{ operation: string; context: ExecutionContext }> = [];
688692
engine.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => {
689693
if (opCtx.object === 'pb_header') reads.push({ operation: opCtx.operation, context: opCtx.context });
690694
await next();
691695
});
692696
storeFor('pb_line').set('ln_x', { id: 'ln_x', header: 'hx_open', note: 'n', organization_id: ORG_X });
693697

694-
// Insert: `resolveMasterDetailParents` is the batch `find`; the `findOne`
695-
// behind it is #19808's reference probe (the payload names the header).
698+
// [#22519] Every elevated header read is preceded by the caller's read
699+
// door, a `find` under the caller's own context.
700+
// Insert: `resolveMasterDetailParents` is the batch `find` pair; the
701+
// `find` + `findOne` behind it is #19808's reference probe (the payload
702+
// names the header).
696703
await insertLine('hx_open');
697704
// Update by id, header not in the payload: no reference probe, so the one
698705
// `findOne` is `resolveMasterDetailParent` reading the stored row's header.
699706
await engine.update('pb_line', { note: 'm' }, { where: { id: 'ln_x' }, context: MEMBER_X } as any);
700707

701-
expect(reads.map((r) => r.operation)).toEqual(['find', 'findOne', 'findOne']);
708+
const shape = (r: { operation: string; context: ExecutionContext }) =>
709+
`${r.operation}:${r.context?.isSystem === true ? 'elevated' : 'door'}`;
710+
expect(reads.map(shape)).toEqual([
711+
'find:door', 'find:elevated',
712+
'find:door', 'findOne:elevated',
713+
'find:door', 'findOne:elevated',
714+
]);
702715
for (const { context } of reads) {
703-
expect(context).toMatchObject({ isSystem: true, tenantId: ORG_X, userId: 'u_x' });
716+
expect(context).toMatchObject({ tenantId: ORG_X, userId: 'u_x' });
704717
}
705718
// …and the tenant reaches the driver on every one of them.
706719
const driverReads = observed.filter((c) => c.object === 'pb_header');
707-
expect(driverReads.map((c) => c.options?.tenantId)).toEqual([ORG_X, ORG_X, ORG_X]);
720+
expect(driverReads.map((c) => c.options?.tenantId)).toEqual([ORG_X, ORG_X, ORG_X, ORG_X, ORG_X, ORG_X]);
708721
});
709722
});

‎packages/objectql/src/engine-required-when-parent.test.ts‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -334,20 +334,33 @@ describe('parent-scoped requiredWhen is enforced server-side (#4977)', () => {
334334
// `resolveMasterDetailParents`), so that is what is counted here — the
335335
// `findOne`s on the same object belong to the #4441 reference guard and are
336336
// not this change's cost.
337-
const reads: string[] = [];
337+
//
338+
// [#22519] The resolution is two reads, each ONE batched `find`: the
339+
// caller's read door (under the caller's own context), then the elevated
340+
// read for the headers it returned. The reference guard asks the read door
341+
// too, one single-id `find` per supplied header, ahead of its `findOne`.
342+
const reads: Array<{ object: string; ids: unknown; elevated: boolean }> = [];
338343
const original = (engine as any).find.bind(engine);
339344
(engine as any).find = async (name: string, q: any, o?: any) => {
340-
reads.push(name);
345+
const id = q?.where?.id;
346+
const ids = Array.isArray(id?.$in) ? id.$in : id === undefined ? undefined : [id];
347+
reads.push({ object: name, ids, elevated: q?.context?.isSystem === true });
341348
return original(name, q, o);
342349
};
350+
const headerReads = () => reads.filter((r) => r.object === 'showcase_invoice');
351+
const naming = (n: number) => (r: { ids: unknown }) => Array.isArray(r.ids) && r.ids.length === n;
343352
// Two rows under the SAME master, and two more under another: N rows, M
344-
// masters, ONE query.
353+
// masters, ONE query per read.
345354
await engine.insert('showcase_invoice_line', [
346355
{ invoice: 'INV-SENT', description: 'a', quantity: 1 },
347356
{ invoice: 'INV-SENT', description: 'b', quantity: 1 },
348357
{ invoice: 'INV-DRAFT', quantity: 1 },
349358
] as any);
350-
expect(reads.filter((r) => r === 'showcase_invoice')).toHaveLength(1);
359+
expect(headerReads().filter((r) => r.elevated)).toHaveLength(1);
360+
expect(headerReads().filter((r) => !r.elevated && naming(2)(r))).toHaveLength(1);
361+
// The rest: the reference guard's read door, one per supplied header.
362+
expect(headerReads().filter((r) => !r.elevated && naming(1)(r))).toHaveLength(3);
363+
expect(headerReads()).toHaveLength(5);
351364

352365
// An object with only record-scoped requirements pays nothing extra.
353366
engine.registry.registerObject({
@@ -360,7 +373,9 @@ describe('parent-scoped requiredWhen is enforced server-side (#4977)', () => {
360373
} as any, 'test-package');
361374
reads.length = 0;
362375
await engine.insert('plain_line', { invoice: 'INV-SENT', quantity: 1 });
363-
expect(reads.filter((r) => r === 'showcase_invoice')).toHaveLength(0);
376+
// No header resolution: the one `find` is the reference guard's read door.
377+
expect(headerReads().filter((r) => r.elevated)).toHaveLength(0);
378+
expect(headerReads()).toHaveLength(1);
364379
});
365380

366381
// ── #6457 — the header is TOTAL over the MASTER's declared fields ──────────

0 commit comments

Comments
 (0)