Skip to content

Commit f8c5fb3

Browse files
feat(objectql, plugin-security): the ReadableIds leaf composes for a delegated (on-behalf-of) caller with the delegator intersection; ADR-0139 records O5 answered and D5.1 amended (#25032)
Fixes #25009 Engine card (b)(i) of the maintainer's ruling B on #24932 (comment 6303721609): the engine composes its `ReadableIds` leaf for a delegated (on-behalf-of) caller, scoped to the ADR-0090 D10 delegator intersection, instead of refusing it; the ADR-0139 revision rides this PR (O5 answered yes in the end-state form, D5.1 amended). Draft on purpose: the diff touches `docs/adr/**` (Tier H), so it waits for the maintainer's approval. The nested master chain is #25010 and remains open; the plugin-security switch of `controlled_by_parent` (its call sites, the id-list pre-scan's retirement, ADR-0055's text) is card (c), #24932, and remains open — none of it is touched here. Clause-②: no (an engine-internal leaf, not a published accept set; the runtime read-scope change is the human floor's, decided by ruling 6303721609) ## What changed **`packages/objectql/src/engine.ts` (the ReadableIds leaf composition only).** `refuseUncomposableReadableIdsClasses` no longer reads the caller's context: the D5.1 refusal (`for a delegated (on-behalf-of) caller: the parent's read scope does not carry the delegator intersection …`) is gone, and the two remaining D5 classes (a parent an object-scoped read middleware narrows; a parent bound to another datasource) are refused by name exactly as before, same envelope, same text. `composeReadableIdsAtBoundary` asks `canReadObject` and `getReadFilter` with the delegated context as it stands (`onBehalfOf` intact) and composes what the security service answers. The engine derives no second intersection: it has one context, the delegated one, and the delegator's grants are the security service's to resolve. The method docblocks say so. **`packages/plugins/plugin-security/src/security-plugin.ts` — the one prerequisite without which the leaf cannot compose a delegated read correctly.** D5.1's own exit condition is "until the intersection reaches `getReadFilter`". Measured on `origin/main` (05b28c1, the merge base of this PR) before editing: `getReadFilter` answered the deny sentinel for every on-behalf-of context (the `[#2852]` block), so a composed leaf would have been `$in: []` for every delegated read — the very reason D5.1 refused. Nothing on `main` answers a delegated context the D10 intersection on the read-scope path (the engine middleware composes it inline into `ast.where` for `find`/`count`/`aggregate`, and the sharing middleware ANDs its own second call in a sibling plugin; neither is callable as a scope). So the WIP re-implements nothing that exists; each hunk is kept for this reason: - `getReadFilter` (~5917): the on-behalf-of branch routes to `computeDelegatedReadFilter` instead of answering the deny sentinel. Fails CLOSED on every throw, with the deny sentinel by identity. Without this hunk the engine composes `$in: []` for every delegated read. - `computeDelegatedReadFilter` (new, ~5996): the six layers the engine middleware chain ANDs into a delegated `find` of the object, in the middleware's own primitives — `computeRlsFilter` and `computeControlledByParentFilter` over the principal's sets with the delegated context as it stands, the same two over the delegator's sets with the context `resolveDelegatorContext` rebuilds (the middleware's D10 half), and plugin-sharing's `buildReadFilter` under each identity at its own depth (step 2.6's two depth stamps, from the same two evaluator calls). Closed doors are the chain's: a principal that resolves no set, a delegator who does not exist, a delegator who resolves no set, a sharing outage ⇒ the deny sentinel. The missing-delegator refusal keeps the D10 invariant sentence verbatim because `default-report-sink.test.ts` reads it. - `resolveSharingReadFilter` (~5421): an optional `depth` parameter, so the agent leg is stamped the intersected depth (`intersectDelegatedScope` of the ceiling's declared depth and the delegator's effective one) and the delegator leg its own, as step 2.6 stamps them on a delegated `find`. Without it the agent leg would be stamped `getEffectiveScope` of the ceiling's sets — `own` for a silent ceiling — and a delegated read through the leaf would be narrower than the delegated direct read (parity pin red, no leak). Absent on every non-delegated call, which resolves the depth as before. - The `getReadFilter` docblock gains the D10 paragraph; the `[#2852]` deny block and its comment are removed. Not touched: `computeControlledByParentFilter`'s call sites, the id-list pre-scan, ADR-0055, any spec key, any consumer's `'delegated-caller'` probe routing. **`docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md` (Tier H).** The status paragraph records the amendment; D5 is retitled (two classes stay; the delegated caller composes) and D5.1 rewritten as the composition built here, naming the two halves and the six layers; D6 records O5 decided with the ruling's sentence quoted verbatim and the order it binds (delegated composition → nested chain → the plugin-security switch, which corrects ADR-0055's "cached within the request" sentence in its own edit); the "Leaves unchanged" line notes ADR-0055 §2 (b) stands until that switch; E2's execution-plan row notes the amendment beside its historical "three classes". ADR-0055 and ADR-0090 are not edited. **Tests.** `packages/objectql/src/engine-readable-ids-composition.test.ts`: a new describe beside the existing leaf pins — each read verb asks both halves with the delegation link intact and hands the driver one call carrying the composed leaf (scope by reference, marked `policy`); a middleware-written leaf composes for a delegated caller; the closed doors under a delegated context (`canReadObject` false, the deny sentinel, a throw on either half ⇒ `$in: []`); D5.2 and D5.3 keep their refusal (code and status, no driver call, the service unasked); a control without the link. The old `D5 — a delegated (on-behalf-of) caller` refusal pin is replaced (it pinned the branch this PR deletes). `packages/plugins/plugin-security/src/readable-ids-delegated-compose.test.ts` (new): the real stack — `ObjectQL` over better-sqlite3, the real `SecurityPlugin` with the real `SharingService`, the delegated context as `resolveAuthzContext` and `assembleExecutionContext` build it — with the two legs made to disagree on purpose (the ceiling admits parents by content and is silent on depth; the delegator's set is silent on rows, so their leg is the owner-match plus one share): a delegated read through the leaf serves exactly the children whose parent both legs admit (`c_both`, `c_shared`; not `c_caller_only`, not `c_del_only`), `find` and `count` agree, one statement touches the parent table (the subquery, no pre-scan), `getReadFilter`'s scope and the delegated direct read answer the same rows, and the closed doors stay closed (a delegator who does not exist ⇒ 403 `PERMISSION_DENIED` at admission, `canReadObject` false, the sentinel by identity; a delegator the parent object does not admit ⇒ the leaf serves nothing; D5.2 ⇒ `INTERNAL_ERROR` / 500). Three plugin-security pins that asserted "a delegated context denies outright" are respelled for the case they now reach (a delegator who does not exist) in `controlled-by-parent-sharing.test.ts`, `rls-deny-sentinel-identity.test.ts`, `security-plugin.test.ts`; `default-report-sink.test.ts` and `start-logger-binding.test.ts` keep their assertions, their docblocks name the refusal a bare instance now reaches. **Changeset** `.changeset/25009-readable-ids-delegated-compose.md`: `@objectstack/objectql` minor, `@objectstack/plugin-security` minor — a capability, not a defect repair, levelled as the earlier ADR-0139 composition cards were (`feat(objectql)` E2 and `feat(plugin-audit)` E5 both shipped as minor). ## The D5 fork question (measure and report) No fork. The middleware-narrowed parent (D5.2) and the other-datasource parent (D5.3) keep their named 500 refusals under a delegated context too (pinned with `code` and `status` in both test files). Neither blocks "once for every shape" for the delegated class: they are properties of the parent object, orthogonal to who the caller is, and `controlled_by_parent`'s masters are ordinary objects on the same datasource with no object-scoped middleware. They keep the probe by ADR-0139 D5 as written. ## The direct (non-leaf) delegated read of the parent `getReadFilter` is not on the engine middleware's path (the middleware composes its layers inline), so the ordinary door is unchanged by construction; measured anyway on the real stack: the delegated direct read of the parent answers `p_both`, `p_shared` — the delegator's rows the ceiling admits — and the control reads (the delegator alone; the ceiling's rule on a principal who owns the other rows) are pinned beside it. ## Verification (head `4c31b7cc9`, every heavy run through `scripts/pm/os-verify-lock.sh`, verdict read off its VERDICT line) - Build of the dependency closure, `pnpm exec turbo run build --filter='@objectstack/plugin-security...' --concurrency=2`: 19 tasks successful (9 cached), `VERDICT command-exit 0`, 73 s. The closure includes `@objectstack/objectql`, whose changed source the plugin-security suites read through the package's vitest alias to `src/` (so the real-stack pins read this checkout, not a dist). - `pnpm --filter @objectstack/objectql test` (`vitest run --project local`): `Test Files 451 passed (451)`, `Tests 8511 passed (8511)`, 436 s, `VERDICT command-exit 0`. - `pnpm --filter @objectstack/plugin-security test`: `Test Files 200 passed (200)`, `Tests 4124 passed | 45 skipped (4169)`, 249 s, `VERDICT command-exit 0`. - `pnpm --filter @objectstack/objectql typecheck` exit 0; `pnpm --filter @objectstack/plugin-security typecheck` exit 0 (tsc, the scripts project and `check:test-typecheck`, 0 errors held for plugin-security). - The leaf consumers' integration suites, as a courtesy (their delegated-caller cases route to the probe before any leaf is written): `plugin-audit` `parent-record-read-leaf.integration` + `comment-parent-pair.integration` 34 passed; `service-storage` `attachment-read-leaf.integration` 10 passed. - `test:repo` (the vitest `repo` project) not run locally: CI's `Test Core` runs it on the PR. ## Ablation (two behaviours changed, one leg each; `scripts/ablation-replace.mjs`, mutation and restore proven on disk) - **A — the engine's delegated refusal restored** (`packages/objectql/src/engine.ts`, anchor `const parents = this.refuseUncomposableReadableIdsClasses(object, operation, sites);`, a throw of `readableIdsRefusalError` for an on-behalf-of context planted before it): `ok mutation landed: anchor 1 → 0, blob 2879fda → 592ebdd1d394`. Under it: `engine-readable-ids-composition.test.ts` `4 failed | 23 passed` (the four delegated cases, including D5.2/D5.3 which met the planted refusal first); `readable-ids-delegated-compose.test.ts` `3 failed | 7 passed` (the delegated child read, the one-statement pin, the unadmitted-delegator leaf case). Restore: `ok restored: blob == HEAD (2879fda) and git diff HEAD is empty`. The first attempt of this leg was refused by the tool before anything ran (the replacement contained the anchor line, so the anchor count did not drop); respelled and re-run. - **B — `getReadFilter`'s delegated deny restored** (`packages/plugins/plugin-security/src/security-plugin.ts`, anchor `return await this.computeDelegatedReadFilter(object, context);` → `return RLS_DENY_FILTER;`): `ok mutation landed: anchor 1 → 0, blob caf66c6 → 9265a52c175c`. Under it: `readable-ids-delegated-compose.test.ts` `3 failed | 7 passed` (the delegated child read serves nothing, the parity pin, the one-statement pin). Restore: `ok restored: blob == HEAD (caf66c6) and git diff HEAD is empty`. - Both legs ran against `src/` (objectql's own test imports `./engine.js`; the plugin-security suite aliases `@objectstack/objectql` to `src/index.ts` in its vitest config), so no dist rebuild was on the resolution path; `git status --porcelain` empty after each leg. ## Gates (derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, no paths, on this head; exit codes captured to a file before any pipe; reconciled with `--ran`) `83 derived famil(ies) accounted for — 81 run, 2 NOT-MEASURED`. 80 of the 81 exit 0, including `check:adr-anchors`, `check:adr-links`, `check-adr-symbol-anchors` (2374 anchors resolve), `check:changeset`-family rows, `check:test-source-alias`, `check:cross-package-test-inputs`, `check:where-matcher`, `check:objectql-double-limit`, `check:durability-log-level`, `check:swallow-census-controls`, `check:stack-collection-maps`, `check:pm-governed-merges`, `check:pm-prior-rulings`, `check:type-check-coverage`, `check:type-check-debt`. NOT MEASURED locally, each a refusal of its own prerequisite, not a red: `pnpm check:dual-build-cjs-loads` (exit 3: reads every package's `dist/`, 55 packages unbuilt here; CI's `Build Core` builds the repo and runs it) and `pnpm check:i18n` (exit 3: the same full-closure prerequisite). `node scripts/check-engine-split-ratio.mjs --days 90` (the push/schedule `engine-split-metric.yml` job, not a PR gate) exit 2: refused on this shallow clone (oldest visible commit 2026-09-07 is inside the 90-day window); its `--self-test` row exit 0. The full farm is CI's; read the PR's check-runs. ## Acceptance notes - The six consumers' up-front `'delegated-caller'` probe routing (`plugin-audit` `parent-record-read-gate.ts:451`, `service-storage` `attachment-access-hooks.ts:1020`, and the comment/reaction gates) is now a residue of the refusal this PR removes; ADR-0139 D5.1 as amended says it retires per consumer on its own card. Not changed here (a consumer switch is not this card's scope); carrier: none yet — noted, not filed. - The `#24932` dev report's measured difference (2), a caller holding child read but no object-level read on the master, is card (c)'s / the ruling's second question; untouched here. - `resolveDelegatorContext` leaves the delegator's `rlsMembership` bags unresolved (its own docblock); the delegator leg of `computeDelegatedReadFilter` inherits that fail-closed narrowing exactly as the middleware's D10 half does. Not a finding — parity with the middleware is the contract. - The merge of `origin/main` (05b28c1, four commits since the branch base, one of them `#25008` in `security-plugin.ts` at a region far from these hunks) was clean; no generated artifact moved; the closure was rebuilt after it. ## 维护者速读(草稿) **改了什么。** 引擎的 `ReadableIds` 叶子(「对象 P 中当前调用者可读的 id 集」,ADR-0139)现在也为「代理调用者」(一个 agent/服务以某个用户的名义发起的读,on-behalf-of)组合,范围是 ADR-0090 D10 规定的交集:只看到「代理方自己能看到」且「被代理的用户也能看到」的行。此前引擎遇到这类调用直接拒绝(500)。为此 plugin-security 的 `getReadFilter` 在代理上下文下不再一律回答「全拒」,而是按引擎中间件对代理读所做的六层叠加(两方各自的行级安全与 controlled_by_parent 推导,再加两方各自身份下的共享可见性)回答同一个交集。ADR-0139 相应修订:O5 记为「是」(controlled_by_parent 将成为第七个消费者,按裁决的末态形式),D5.1 从「拒绝」改为「组合」。 **为什么改。** 这是维护者在 #24932 上的裁决 B 第 (b)(i) 步:先让引擎把叶子组合覆盖代理调用与嵌套主从链(#25010),再让 plugin-security 一次性把 controlled_by_parent 切到叶子,并退役主表 id 列表预扫描(那条预扫描在 HotCRM 规模下已实测 500)。没有这一步,代理读在叶子上只能得到空集,plugin-security 的切换无从谈起。 **风险与代价(含回滚)。** 行为变化只在两处:① 引擎不再拒绝代理调用者写出的叶子 —— 但今天没有任何消费者为代理调用者写叶子(六个消费者仍把它路由到有界探针),所以线上已发布面无行为变化;② 直接绑定 `getReadFilter` 的门(analytics 原生 SQL 路径)对代理上下文从「全拒」变为「交集」—— 方向是放宽到与 `/data` 直读一致的行集,实测与代理直读逐行一致,关门(被代理用户不存在、无对象级读权限、任一方无权限集、任一层解析失败)仍全部拒绝。真机测试:两包全量套件绿(objectql 8511、plugin-security 4124),两处消融各自把对应的 pin 打红后复原。回滚:revert 本 PR 即回到 D5.1「拒绝」的状态,无数据或 schema 变更;changeset 为 minor。 **席位意见。** **你要做的。** 审阅 `docs/adr/0139` 的修订(O5 记为已答、D5.1 改为组合)并批准本 PR(Tier H);批准后由席位入队落地,#25010(嵌套主从链)随后在同区域串行进行。 --- _Generated by [Claude Code](https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9)_ --------- Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 045858f commit f8c5fb3

11 files changed

Lines changed: 818 additions & 111 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/plugin-security': minor
4+
---
5+
6+
feat(objectql, plugin-security): the engine composes its `ReadableIds` leaf for a delegated (on-behalf-of) caller, scoped to the delegator intersection, instead of refusing it (ADR-0139 D5.1 as amended; O5 answered)
7+
8+
Clause-②: no
9+
10+
**Before.** A read carrying an on-behalf-of link whose final `where` held a `ReadableIds` leaf was refused `INTERNAL_ERROR` / 500 before any driver was asked (ADR-0139 D5.1 as accepted), and `getReadFilter` answered the deny sentinel for every delegated context, so the leaf's consumers routed such a caller to their bounded, fail-closed probe.
11+
12+
**Now.**
13+
14+
- `@objectstack/objectql`: the executor boundary composes the leaf for a delegated caller exactly as for any other. It asks `canReadObject` and `getReadFilter` with the delegated context as it stands (`onBehalfOf` intact) and composes what the security service answers; it derives no intersection of its own. The two remaining D5 classes, a parent an object-scoped read middleware narrows and a parent bound to another datasource, are still refused by name with the same envelope.
15+
- `@objectstack/plugin-security`: `getReadFilter` answers a delegated context the ADR-0090 D10 intersection, the six layers the engine middleware ANDs into a delegated `find` of the same object: the principal's row-level security and `controlled_by_parent` derivation, the delegator's two over the delegator's own rebuilt context, and the sharing visibility filter under each identity at its own depth. A delegated read through the leaf serves exactly the rows a delegated direct read of the parent serves. It fails closed as the chain does: a delegator who does not exist, a delegator the object does not admit, a principal or a delegator that resolves no permission set, and any resolution failure answer the deny sentinel, and the leaf is `$in: []`.
16+
17+
**What a reader sees.** No shipped consumer writes a leaf for a delegated caller yet (each still routes it to its probe; that routing retires per consumer on its own card), so a delegated read of `sys_activity`, `sys_audit_log`, `sys_comment`, `sys_comment_reaction` or `sys_attachment` is served as before. A door that binds `getReadFilter` directly, such as the analytics raw-SQL path, scopes a delegated context to the intersection now; as accepted it denied that context outright.
18+
19+
**Unchanged.** `controlled_by_parent`'s own read scope (ADR-0055 §2 mechanism (b), the pre-resolved id set) and the nested master chain, which keep their own cards; the delegated `find`, `findOne`, `count` and `aggregate` through the engine middleware, which already applied the intersection.

‎docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md‎

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,14 @@
55
D2's position sentence names the executor boundary — after the middleware chain and the before-hooks, immediately
66
before each driver call — in place of the seam where `lowerRelationConditions` runs, which precedes the chain D4's
77
emitters write into (ruling [`6121741984`](https://github.com/objectstack-ai/objectstack/issues/22861#issuecomment-6121741984)
8-
on [#22861](https://github.com/objectstack-ai/objectstack/issues/22861), letter A). **This record declares; it
9-
implements nothing.** It became **Accepted** on the maintainer's approval (`docs/adr/**` is Tier H, Prime
8+
on [#22861](https://github.com/objectstack-ai/objectstack/issues/22861), letter A). **Amended** (2026-10-21): O5 is
9+
answered — `controlled_by_parent` becomes the seventh consumer, in the end-state form — and D5.1 is amended from a
10+
refusal to a composition: the engine composes the leaf for a delegated (on-behalf-of) caller, the scope being the
11+
ADR-0090 D10 delegator intersection `getReadFilter` now answers for that context (ruling
12+
[`6303721609`](https://github.com/objectstack-ai/objectstack/issues/24932#issuecomment-6303721609) on
13+
[#24932](https://github.com/objectstack-ai/objectstack/issues/24932), letter B; the engine card
14+
[#25009](https://github.com/objectstack-ai/objectstack/issues/25009)). D6 and the execution plan carry the rest of that
15+
ruling. **This record declares; it implements nothing.** It became **Accepted** on the maintainer's approval (`docs/adr/**` is Tier H, Prime
1016
Directive #14); the execution cards under [Execution plan](#execution-plan-after-acceptance) are cut after acceptance
1117
and ⛔ never before it. Until then the platform stayed in the ruling's letter D state, and letter C (a located 400 at
1218
the bound, after objectui#12081 item 8) is the `domain:services` lane's interim on
@@ -26,7 +32,8 @@ keys on), [ADR-0090](./0090-permission-model-v2-concept-convergence.md) D10 (del
2632
D5 (principal-less refusal), [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove: why no
2733
capability bit returns), [ADR-0112](./0112-error-code-vocabulary-and-ledger.md) (the refusal envelope),
2834
[ADR-0057](./0057-system-data-lifecycle-and-retention.md) (the `telemetry` datasource split that makes a parent federated).
29-
**Leaves unchanged**: ADR-0055 §2's chosen mechanism (b) for `controlled_by_parent`, its `rlsMembership` IN-form and
35+
**Leaves unchanged**: ADR-0055 §2's chosen mechanism (b) for `controlled_by_parent` (until the plugin-security switch
36+
the 2026-10-21 amendment of D6 orders lands, in its own Tier H edit of that record), its `rlsMembership` IN-form and
3037
the fail-closed RLS compiler and its closed pushdown form set (historically four, broader today); ADR-0053 D-D1
3138
(drivers receive lowered input) for every form but this one leaf.
3239
**Consumers**: `@objectstack/spec` (the leaf's declaration), `@objectstack/objectql` (composition and push-down),
@@ -198,16 +205,31 @@ restriction".
198205
`managedBy: 'append-only'`, so the backfill goes through the migration door, never the CRUD write path). A row the
199206
backfill cannot judge keeps the fail-closed answer it has today.
200207

201-
### D5 — Three classes stay on the bounded fail-closed probe, by name
202-
203-
Each is recognised by the engine, which **refuses to compose a leaf for it** (loud, as D3's envelope), and routed by the
204-
consumer to today's probe before the leaf is written. ⛔ No silent fallback: the probe is chosen up front, never
205-
reached by catching a refusal.
206-
207-
1. **A delegated (on-behalf-of) caller** (ADR-0090 D10). `packages/plugins/plugin-security/src/security-plugin.ts#getReadFilter`
208-
answers the deny sentinel for such a context because the delegator intersection is not threaded through the
209-
read-scope path, while the CRUD middleware intersects it; a composed leaf would serve a delegated reader nothing.
210-
The probe, which reads through the middleware, stays until the intersection reaches `getReadFilter`.
208+
### D5 — Two classes stay on the bounded fail-closed probe, by name; the delegated caller composes (amended 2026-10-21)
209+
210+
Each of the two remaining classes is recognised by the engine, which **refuses to compose a leaf for it** (loud, as D3's
211+
envelope), and routed by the consumer to today's probe before the leaf is written. ⛔ No silent fallback: the probe is
212+
chosen up front, never reached by catching a refusal.
213+
214+
1. **A delegated (on-behalf-of) caller** (ADR-0090 D10) — **composed, not refused** (amended 2026-10-21; as accepted
215+
this class was refused "until the intersection reaches `getReadFilter`", and it has). The engine's boundary
216+
(`packages/objectql/src/engine.ts#composeReadableIdsAtBoundary`) asks both halves — `canReadObject` and
217+
`getReadFilter` — with the delegated context exactly as it stands, `onBehalfOf` intact, and composes what it is
218+
answered; it reads no delegation link of its own
219+
(`packages/objectql/src/engine.ts#refuseUncomposableReadableIdsClasses` judges the two classes below and nothing
220+
about the caller). The security service answers each half for the delegated principal, which under ADR-0090 D10 is
221+
the INTERSECTION of the principal's leg and the delegator's: `canReadObject` already intersected the object half;
222+
`packages/plugins/plugin-security/src/security-plugin.ts#getReadFilter` now answers the row half through
223+
`packages/plugins/plugin-security/src/security-plugin.ts#computeDelegatedReadFilter`, the six layers the engine
224+
middleware chain ANDs into a delegated `find` of P — the principal's RLS and `controlled_by_parent` derivation, the
225+
delegator's two (over the context `packages/plugins/plugin-security/src/explain-engine.ts#resolveDelegatorContext`
226+
rebuilds), and plugin-sharing's visibility filter under each identity at its own depth. So a delegated read through
227+
the leaf serves exactly the rows a delegated direct read of P serves (measured: `find`, `count`, one statement per
228+
read, the parent inside the subquery). The closed doors are the chain's: a delegator who does not exist, one the
229+
parent object does not admit, a principal that resolves no set, and any resolution failure answer `false` or the
230+
deny sentinel, and the leaf is `$in: []`. ⛔ The engine derives no second intersection: it has one context, the
231+
delegated one, and the delegator's grants are the security service's to resolve. The six consumers' up-front
232+
`'delegated-caller'` probe routing (D4) is now a residue of the refusal, retired per consumer on its own card.
211233
2. **A parent whose rows are narrowed by an object-scoped read middleware** (`packages/objectql/src/engine.ts#registerMiddleware`
212234
with an `object`): `sys_attachment`, `sys_comment`, `sys_comment_reaction`, `sys_approval_request`, and the two
213235
gated objects themselves. Their WHERE is built at read time and is no part of `getReadFilter`, so a leaf over them
@@ -226,11 +248,28 @@ compiled output of the RLS compiler, whose closed pushdown form set (historicall
226248
IN-form and deliberate lack of a subquery form are unchanged
227249
(`packages/plugins/plugin-security/src/rls-compiler.ts#RLSCompiler`, `#compileExpression`: a subquery shape compiles to
228250
`null` and fails closed). §2's mechanism (b) for
229-
`controlled_by_parent` stands, set-size ceiling included; whether that id set becomes a seventh consumer is ⛔ not
251+
`controlled_by_parent` stands, set-size ceiling included; whether that id set becomes a seventh consumer was ⛔ not
230252
decided here. ADR-0056's non-goal stands as written for the compiler and is revisited only as the engine-side leaf.
231253
Each record carries one pointer to this one, landed with this record — ADR-0055's status line, ADR-0056's non-goal
232254
bullet; nothing else in either moves.
233255

256+
**Amended 2026-10-21 — O5 is decided, by the maintainer's ruling
257+
[`6303721609`](https://github.com/objectstack-ai/objectstack/issues/24932#issuecomment-6303721609) on
258+
[#24932](https://github.com/objectstack-ai/objectstack/issues/24932) (letter B), recorded here verbatim:**
259+
260+
> O5 is answered yes, in the end-state form. `controlled_by_parent` becomes the seventh consumer of the engine-internal
261+
> semi-join leaf, once for every shape: the engine first composes the leaf for a delegated (on-behalf-of) caller (the
262+
> exit condition D5.1 itself names) and for a nested master chain; then plugin-security switches the caller, the
263+
> delegator intersection and `getReadFilter` in one change, and the id-list pre-scan retires.
264+
265+
The order the ruling binds: the delegated composition (D5.1 as amended, this revision's card), then the nested master
266+
chain (a leaf inside a composed scope is composed; its own `domain:engine` card), then the plugin-security switch,
267+
"which in the same Tier H edit corrects ADR-0055's 'cached within the request' sentence to what the code does". Two
268+
things the ruling ⛔ does not take: an interim with two spellings of one scope, and "interim removal of the predicate for
269+
an unrestricted master scope: it would expose rows with a null or dangling foreign key (measured)". ADR-0055 §2's
270+
mechanism (b) therefore stands until the switch lands, and ADR-0055's own text moves only in that switch's edit, never
271+
here.
272+
234273
## Consequences
235274

236275
- **Positive.** A reader gets every row they may see and a `total` that is true, on any table size; the pre-scan,
@@ -269,7 +308,8 @@ bullet; nothing else in either moves.
269308
- **O3** — `driver-mongodb`: the cost of a pipeline form is unprototyped; it refuses until measured.
270309
- **O4** — `packages/plugins/plugin-sharing/src/sharing-service.ts` reads a caller's record-share grants at
271310
`limit: 5000` with no warning; that scope rides into the subquery unchanged. A separate finding, not this record's.
272-
- **O5** — whether `controlled_by_parent`'s id set becomes a seventh consumer (D6).
311+
- **O5** — whether `controlled_by_parent`'s id set becomes a seventh consumer (D6). **Answered** (2026-10-21): the
312+
ruling is recorded verbatim in D6, as amended.
273313

274314
## Acceptance criteria
275315

@@ -284,7 +324,7 @@ and none changes `packages/spec` before it.
284324
| Card | Decisions | What lands | Pins (red before, green after) | Order |
285325
|:--|:--|:--|:--|:--|
286326
| **E1** — the leaf | D1 | `ReadableIds` in `packages/spec/src/data/`; the comparand door admits the branded value and refuses the unbranded one | a JSON round-trip of a `where` holding the leaf is refused; the plain-object spelling is refused 400 | first |
287-
| **E2** — composition | D2, D5 | the engine seam composes the leaf, refuses it on write verbs and for D5's three classes, marks the scope `'policy'`; the conformance case-set | `canReadObject` false ⇒ no row; deny sentinel ⇒ no row; a delegated, middleware-gated or federated parent ⇒ the loud refusal | after E1 |
327+
| **E2** — composition | D2, D5 | the engine seam composes the leaf, refuses it on write verbs and for D5's three classes as accepted (D5.1 amended 2026-10-21: the delegated caller composes), marks the scope `'policy'`; the conformance case-set | `canReadObject` false ⇒ no row; deny sentinel ⇒ no row; a delegated caller composes (both halves asked with the delegation link intact, the closed doors still `$in: []`); a middleware-gated or federated parent ⇒ the loud refusal, a delegated context included | after E1 |
288328
| **E3** — SQL family | D3 | `driver-sql` compiles the leaf inside `packages/drivers/driver-sql/src/sql-driver.ts#applyTenantScope`; wasm and local Turso inherit; the remote transport compiles it | a reader with more than 2,000 readable candidates gets every visible row; an unreadable row stays hidden; a deleted parent is excluded; `count` equals the rows served | after E2 |
289329
| **E4** — memory and mongodb | D3 | memory implements; mongodb refuses with the envelope | the conformance table records both | with E3 |
290330
| **E5** — the activity and ledger switch | D4, D5 | `packages/plugins/plugin-audit/src/parent-record-read-gate.ts#computeParentRecordFilter` emits leaves, no pre-scan; D5's classes keep the probe; letter C's refusal retired from these two | the measured scenario answers 1,808 of 1,808; the exemption capability unchanged; the outside class as a predicate | after E3 |

0 commit comments

Comments
 (0)