Repository navigation
Commit f8c5fb3
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
File tree
- .changeset
- docs/adr
- packages
- objectql/src
- plugins/plugin-security/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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
8 | | - | |
9 | | - | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
10 | 16 | | |
11 | 17 | | |
12 | 18 | | |
| |||
26 | 32 | | |
27 | 33 | | |
28 | 34 | | |
29 | | - | |
| 35 | + | |
| 36 | + | |
30 | 37 | | |
31 | 38 | | |
32 | 39 | | |
| |||
198 | 205 | | |
199 | 206 | | |
200 | 207 | | |
201 | | - | |
202 | | - | |
203 | | - | |
204 | | - | |
205 | | - | |
206 | | - | |
207 | | - | |
208 | | - | |
209 | | - | |
210 | | - | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
211 | 233 | | |
212 | 234 | | |
213 | 235 | | |
| |||
226 | 248 | | |
227 | 249 | | |
228 | 250 | | |
229 | | - | |
| 251 | + | |
230 | 252 | | |
231 | 253 | | |
232 | 254 | | |
233 | 255 | | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
234 | 273 | | |
235 | 274 | | |
236 | 275 | | |
| |||
269 | 308 | | |
270 | 309 | | |
271 | 310 | | |
272 | | - | |
| 311 | + | |
| 312 | + | |
273 | 313 | | |
274 | 314 | | |
275 | 315 | | |
| |||
284 | 324 | | |
285 | 325 | | |
286 | 326 | | |
287 | | - | |
| 327 | + | |
288 | 328 | | |
289 | 329 | | |
290 | 330 | | |
| |||
0 commit comments