Repository navigation
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
Conversation
…legated caller; getReadFilter threads the D10 intersection Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
…9 revision draft, taken over unverified The delegated-compose pins beside the leaf tests (objectql) and the real-stack test in plugin-security, the three plugin-security pins respelled for a delegator who does not exist, and the ADR-0139 draft (O5 answered, D5.1 amended). Committed as found in the interrupted run's worktree; verified in the commits that follow. Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
…adable-ids-delegated-compose
…elegated caller with the delegator intersection; the changeset, the ADR E2 row and the dangling-delegator report line The missing-delegator refusal in computeDelegatedReadFilter carries the D10 invariant sentence the report-sink pin reads; the two bare-instance pins' docblocks say which refusal they now reach. The changeset for @objectstack/objectql and @objectstack/plugin-security (minor, as the earlier ADR-0139 composition cards). ADR-0139's E2 row notes D5.1's amendment beside its historical "three classes". Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
…0 invariant sentence verbatim, as the report-sink pin reads it Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 427878d9328cfb487290467052ff7086d318e329 && git checkout 427878d9328cfb487290467052ff7086d318e329
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 601b258d72cc6dde7e838a79d20eef18ba381103 2a8d0d5b4132ea6dacecbb0bdb73f1dfc59b5ad1 && git checkout -B drift-repro 601b258d72cc6dde7e838a79d20eef18ba381103 && git merge --no-ff 2a8d0d5b4132ea6dacecbb0bdb73f1dfc59b5ad1
node scripts/docs-audit/affected-docs.mjs --json 601b258d72cc6dde7e838a79d20eef18ba381103
|
Contract reviewServed-tier: Inputs: card #25009 (body, the dispatch claim 6305174176, the dev report 6306430776); ruling 6303721609 on #24932; PR #25032 body, its 11-file list and the net diff ① Derived judgmentsThe governed text against the code it describes — each claim named right or wrong.
The security question, consumer by consumer. On
Fails closed exactly as the chain does? Yes, in every case, with the shape the scope path owns: the chain throws (403 Pins. The objectql suite's old refusal pin is replaced by five cases (each verb asks both halves with the link intact; a middleware-written leaf composes; Check-runs on the head, as read once at 02:25Z: 38 runs, 0 ② Semver level
③ Boundary flagsDev deviations (report 6306430776), each answered:
Out-of-scope findings, each answered or escalated:
Card (c) scope (#24932), held: the two existing Nits for the maintainer's Tier H read of the ADR, no verdict weight: the "Open items" list still carries Implemented-by: VERDICT: PASS Generated by Claude Code |
…ded D5.1; the ancestors docblock counts six call sites - Open items: O5 is marked answered, pointing at D6 where the ruling is recorded. - E2 row, Pins cell: a delegated caller composes (both halves asked with the link intact, the closed doors still `$in: []`); a middleware-gated or federated parent keeps the loud refusal, a delegated context included. - security-plugin.ts: computeControlledByParentFilter's `ancestors` docblock counts the six call sites (five with empty ancestors, the recursive hop the sixth). Comment only. Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs: card #25009 (body; the dispatch claim 6305174176; the dev report 6306430776; the patch-round report 6306903617); PR #25032 (body, its 11-file list, the net diff ① Derived judgmentsThe delta, judged in full — the one commit
Everything else carries over from 6306657655 unchanged. Verified: Check-runs on the head, read once at 02:59Z (the runs were created 02:56Z): 38 runs, 0 ② Semver levelUnchanged from 6306657655: ③ Boundary flagsPatch-round deviations (report 6306903617), each answered:
Carried over from 6306657655, unchanged by the delta:
Nits: none. The three of 6306657655 are closed by this delta, and nothing in it opens another. Implemented-by: VERDICT: PASS Generated by Claude Code |
维护者速读改了什么。 一个 agent 或服务以某个用户的名义(on-behalf-of)读取「主从关系」下的子记录时,引擎现在会把主记录的可读范围组合进同一条查询:只放行代理方自己能看、被代理的用户也能看的行(ADR-0090 D10 的交集)。此前引擎遇到这类调用直接报 500。为此,plugin-security 的 为什么改。 这是你在 #24932 上裁决 B 的第 (b)(i) 步。下一步是嵌套主从链 #25010,之后 plugin-security 再一次性切换,并退役主表 id 预扫描;那条预扫描在 HotCRM 的规模下实测会报 500。不做这一步,代理读经叶子只能得到空集,后面的切换无从谈起。 风险与代价(含回滚)。
席位意见。 建议批准。复审记录在本评论下方链接:契约复审两轮 PASS,第二轮在当前 head 上;两包全量测试绿;两处消融都按预期转红再复原;改动严格停在裁决划定的 (b)(i) 范围内,没有碰卡 (c) 的切换。 你要做的。 批准本 PR(它改动了 席位核对Seat review ·
Generated by Claude Code |
… (PR #25032, the stack base, landed as f8c5fb3) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
Fixes #25009
Engine card (b)(i) of the maintainer's ruling B on #24932 (comment 6303721609): the engine composes its
ReadableIdsleaf 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 touchesdocs/adr/**(Tier H), so it waits for the maintainer's approval. The nested master chain is #25010 and remains open; the plugin-security switch ofcontrolled_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).refuseUncomposableReadableIdsClassesno 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.composeReadableIdsAtBoundaryaskscanReadObjectandgetReadFilterwith the delegated context as it stands (onBehalfOfintact) 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 reachesgetReadFilter". Measured onorigin/main(05b28c1, the merge base of this PR) before editing:getReadFilteranswered 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 onmainanswers a delegated context the D10 intersection on the read-scope path (the engine middleware composes it inline intoast.whereforfind/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 tocomputeDelegatedReadFilterinstead 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 delegatedfindof the object, in the middleware's own primitives —computeRlsFilterandcomputeControlledByParentFilterover the principal's sets with the delegated context as it stands, the same two over the delegator's sets with the contextresolveDelegatorContextrebuilds (the middleware's D10 half), and plugin-sharing'sbuildReadFilterunder 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 becausedefault-report-sink.test.tsreads it.resolveSharingReadFilter(~5421): an optionaldepthparameter, so the agent leg is stamped the intersected depth (intersectDelegatedScopeof 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 delegatedfind. Without it the agent leg would be stampedgetEffectiveScopeof the ceiling's sets —ownfor 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.getReadFilterdocblock 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, markedpolicy); a middleware-written leaf composes for a delegated caller; the closed doors under a delegated context (canReadObjectfalse, 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 oldD5 — a delegated (on-behalf-of) callerrefusal 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 —ObjectQLover better-sqlite3, the realSecurityPluginwith the realSharingService, the delegated context asresolveAuthzContextandassembleExecutionContextbuild 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; notc_caller_only, notc_del_only),findandcountagree, 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 ⇒ 403PERMISSION_DENIEDat admission,canReadObjectfalse, 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) incontrolled-by-parent-sharing.test.ts,rls-deny-sentinel-identity.test.ts,security-plugin.test.ts;default-report-sink.test.tsandstart-logger-binding.test.tskeep their assertions, their docblocks name the refusal a bare instance now reaches.Changeset
.changeset/25009-readable-ids-delegated-compose.md:@objectstack/objectqlminor,@objectstack/plugin-securityminor — a capability, not a defect repair, levelled as the earlier ADR-0139 composition cards were (feat(objectql)E2 andfeat(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
codeandstatusin 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, andcontrolled_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
getReadFilteris 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 answersp_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 throughscripts/pm/os-verify-lock.sh, verdict read off its VERDICT line)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 tosrc/(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 typecheckexit 0;pnpm --filter @objectstack/plugin-security typecheckexit 0 (tsc, the scripts project andcheck:test-typecheck, 0 errors held for plugin-security).plugin-auditparent-record-read-leaf.integration+comment-parent-pair.integration34 passed;service-storageattachment-read-leaf.integration10 passed.test:repo(the vitestrepoproject) not run locally: CI'sTest Coreruns it on the PR.Ablation (two behaviours changed, one leg each;
scripts/ablation-replace.mjs, mutation and restore proven on disk)packages/objectql/src/engine.ts, anchorconst parents = this.refuseUncomposableReadableIdsClasses(object, operation, sites);, a throw ofreadableIdsRefusalErrorfor an on-behalf-of context planted before it):ok mutation landed: anchor 1 → 0, blob 2879fdae7f2d → 592ebdd1d394. Under it:engine-readable-ids-composition.test.ts4 failed | 23 passed(the four delegated cases, including D5.2/D5.3 which met the planted refusal first);readable-ids-delegated-compose.test.ts3 failed | 7 passed(the delegated child read, the one-statement pin, the unadmitted-delegator leaf case). Restore:ok restored: blob == HEAD (2879fdae7f2d) 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.getReadFilter's delegated deny restored (packages/plugins/plugin-security/src/security-plugin.ts, anchorreturn await this.computeDelegatedReadFilter(object, context);→return RLS_DENY_FILTER;):ok mutation landed: anchor 1 → 0, blob caf66c649f18 → 9265a52c175c. Under it:readable-ids-delegated-compose.test.ts3 failed | 7 passed(the delegated child read serves nothing, the parity pin, the one-statement pin). Restore:ok restored: blob == HEAD (caf66c649f18) and git diff HEAD is empty.src/(objectql's own test imports./engine.js; the plugin-security suite aliases@objectstack/objectqltosrc/index.tsin its vitest config), so no dist rebuild was on the resolution path;git status --porcelainempty 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, includingcheck: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'sdist/, 55 packages unbuilt here; CI'sBuild Corebuilds the repo and runs it) andpnpm check:i18n(exit 3: the same full-closure prerequisite).node scripts/check-engine-split-ratio.mjs --days 90(the push/scheduleengine-split-metric.ymljob, 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-testrow exit 0. The full farm is CI's; read the PR's check-runs.Acceptance notes
'delegated-caller'probe routing (plugin-auditparent-record-read-gate.ts:451,service-storageattachment-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.#24932dev 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.resolveDelegatorContextleaves the delegator'srlsMembershipbags unresolved (its own docblock); the delegator leg ofcomputeDelegatedReadFilterinherits that fail-closed narrowing exactly as the middleware's D10 half does. Not a finding — parity with the middleware is the contract.origin/main(05b28c1, four commits since the branch base, one of them#25008insecurity-plugin.tsat 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