Repository navigation
Commit c699d42
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
File tree
- .changeset
- content/docs/permissions
- packages/objectql/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
13 | | - | |
| 13 | + | |
14 | 14 | | |
15 | 15 | | |
16 | 16 | | |
| |||
138 | 138 | | |
139 | 139 | | |
140 | 140 | | |
| 141 | + | |
141 | 142 | | |
142 | 143 | | |
143 | 144 | | |
144 | 145 | | |
145 | | - | |
| 146 | + | |
146 | 147 | | |
147 | 148 | | |
148 | 149 | | |
| |||
285 | 286 | | |
286 | 287 | | |
287 | 288 | | |
288 | | - | |
| 289 | + | |
289 | 290 | | |
290 | 291 | | |
291 | 292 | | |
| |||
359 | 360 | | |
360 | 361 | | |
361 | 362 | | |
362 | | - | |
| 363 | + | |
363 | 364 | | |
364 | 365 | | |
365 | 366 | | |
366 | | - | |
367 | | - | |
| 367 | + | |
| 368 | + | |
368 | 369 | | |
369 | 370 | | |
370 | 371 | | |
371 | | - | |
| 372 | + | |
372 | 373 | | |
373 | 374 | | |
374 | 375 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
651 | 651 | | |
652 | 652 | | |
653 | 653 | | |
654 | | - | |
| 654 | + | |
| 655 | + | |
| 656 | + | |
655 | 657 | | |
656 | | - | |
657 | | - | |
| 658 | + | |
| 659 | + | |
658 | 660 | | |
659 | 661 | | |
660 | 662 | | |
| |||
669 | 671 | | |
670 | 672 | | |
671 | 673 | | |
| 674 | + | |
| 675 | + | |
672 | 676 | | |
673 | | - | |
674 | | - | |
| 677 | + | |
| 678 | + | |
675 | 679 | | |
676 | 680 | | |
677 | 681 | | |
| |||
683 | 687 | | |
684 | 688 | | |
685 | 689 | | |
686 | | - | |
| 690 | + | |
687 | 691 | | |
688 | 692 | | |
689 | 693 | | |
690 | 694 | | |
691 | 695 | | |
692 | 696 | | |
693 | 697 | | |
694 | | - | |
695 | | - | |
| 698 | + | |
| 699 | + | |
| 700 | + | |
| 701 | + | |
| 702 | + | |
696 | 703 | | |
697 | 704 | | |
698 | 705 | | |
699 | 706 | | |
700 | 707 | | |
701 | | - | |
| 708 | + | |
| 709 | + | |
| 710 | + | |
| 711 | + | |
| 712 | + | |
| 713 | + | |
| 714 | + | |
702 | 715 | | |
703 | | - | |
| 716 | + | |
704 | 717 | | |
705 | 718 | | |
706 | 719 | | |
707 | | - | |
| 720 | + | |
708 | 721 | | |
709 | 722 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
334 | 334 | | |
335 | 335 | | |
336 | 336 | | |
337 | | - | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
338 | 343 | | |
339 | 344 | | |
340 | | - | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
341 | 348 | | |
342 | 349 | | |
| 350 | + | |
| 351 | + | |
343 | 352 | | |
344 | | - | |
| 353 | + | |
345 | 354 | | |
346 | 355 | | |
347 | 356 | | |
348 | 357 | | |
349 | 358 | | |
350 | | - | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
351 | 364 | | |
352 | 365 | | |
353 | 366 | | |
| |||
360 | 373 | | |
361 | 374 | | |
362 | 375 | | |
363 | | - | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
364 | 379 | | |
365 | 380 | | |
366 | 381 | | |
| |||
0 commit comments