Skip to content

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

Merged
os-zhuang merged 6 commits into
mainfrom
claude/issue-25009-readable-ids-delegated-compose
Oct 21, 2026
Merged

os-zhuang merged 6 commits into
mainfrom
claude/issue-25009-readable-ids-delegated-compose

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 2879fdae7f2d → 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 (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.
  • 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 caf66c649f18 → 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 (caf66c649f18) 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

objectstack-fleet Bot and others added 5 commits October 21, 2026 01:10
…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>
…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 21, 2026
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/plugin-security, touching 6 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/field-level-security.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/index.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/sharing-rules.mdx (via getReadFilter (symbol, a method of class SecurityPlugin))
  • content/docs/permissions/system-context.mdx (via getReadFilter (symbol, a method of class SecurityPlugin))
  • content/docs/plugins/packages.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/releases/v15.mdx (via getReadFilter (symbol, a method of class SecurityPlugin))
  • content/docs/releases/v17/17-6.mdx (via getReadFilter (symbol, a method of class SecurityPlugin))
  • content/docs/releases/v18/18-0.mdx (via SecurityPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 72 pages)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 601b258d72cc6dde7e838a79d20eef18ba381103 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 427878d9328cfb487290467052ff7086d318e329 — the merge of head 2a8d0d5b4132ea6dacecbb0bdb73f1dfc59b5ad1 into base 601b258d72cc6dde7e838a79d20eef18ba381103, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 601b258d72cc6dde7e838a79d20eef18ba381103 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4c31b7cc93dc2ca485f32bc3c17f1c09b0f3164b
Local-runs: none
Scope: docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md + packages/objectql/src/engine.ts (composeReadableIdsAtBoundary, refuseUncomposableReadableIdsClasses, readableIdsScopeOf), packages/plugins/plugin-security/src/security-plugin.ts (getReadFilter, computeDelegatedReadFilter, resolveSharingReadFilter, canReadObject / objectReadAdmission, resolveOperationPrincipals, the read middleware's steps 2.5 / 2.6 / 3, computeRlsFilter, computeControlledByParentFilter, collectRLSPolicies), packages/plugins/plugin-security/src/explain-engine.ts (resolveDelegatorContext), packages/plugins/plugin-sharing/src/sharing-plugin.ts (the read middleware's two buildReadFilter legs) and sharing-service.ts (buildReadFilter, shouldBypass), packages/spec/src/contracts/security-service.ts (getReadFilter and canReadObject docblocks), packages/spec/src/data/readable-ids.ts, docs/adr/0090-permission-model-v2-concept-convergence.md D10, and every getReadFilter caller on origin/main (4d991588f): packages/services/service-analytics/src/plugin.ts (the getReadScope bridge and the admitObjectRead bridge beside it) plus the leaf consumers' probe routing in packages/plugins/plugin-audit/src/parent-record-read-gate.ts and packages/services/service-storage/src/attachment-access-hooks.ts.

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 origin/main...4c31b7cc9 (812 / 107, merge base 73432299c, which the branch carries; origin/main is two commits past it, neither in these regions); the 38 check-runs on the head, read once at 02:25Z. reviewFace hits: the ADR alone; the scope above is the widening this record chose because the amended D5.1 describes a runtime security boundary.

① Derived judgments

The governed text against the code it describes — each claim named right or wrong.

  • D5 retitled, D5.1 "composed, not refused": RIGHT. refuseUncomposableReadableIdsClasses no longer takes a context (engine.ts 6245–6250); the delegated throw is gone; D5.2 (object-scoped middleware parent) and D5.3 (other datasource) keep their named refusals with the same envelope and text (6254–6263).
  • "asks both halves with the delegated context exactly as it stands, onBehalfOf intact": RIGHT. readableIdsScopeOf passes context unchanged to canReadObject(parent, context) and getReadFilter(parent, context) (6273–6282); the objectql pin reads the link off both calls.
  • "canReadObject already intersected the object half": RIGHT on main already — canReadObject resolves the delegator once (6453–6460) and objectReadAdmission arms 4 and 6 ask the capability gate and the find grant of the delegator's sets too.
  • "getReadFilter now answers the row half through computeDelegatedReadFilter, the six layers the engine middleware chain ANDs into a delegated find of P": RIGHT, primitive for primitive. The chain's read path (security middleware step 3, 4396–4425) ANDs computeLayeredRlsFilter layer0+layer1 and computeControlledByParentFilter for the principal and, when delegatorSets resolved, the same two over delegatorContext; computeRlsFilter is exactly andComposeLayers(layer0, layer1) over computeLayeredRlsFilter (7651–7660). plugin-sharing's middleware (sharing-plugin.ts 1288–1316) ANDs buildReadFilter(object, exec) and buildReadFilter(object, {...exec, userId: delegator, onBehalfOf: undefined, __readScope: exec.__delegatorReadScope}). computeDelegatedReadFilter (6033–6078) makes the same six calls with the same sets (resolvePermissionSetsForContext reads positions + permissions, not the link, so the principal's sets are the ceiling in both paths), the same delegator context (resolveDelegatorContext, the one resolver), the same depths (step 2.6's intersectDelegatedScope(getDeclaredScope(ceiling), getEffectiveScope(delegator)) for the agent leg, getEffectiveScope(delegator) for the delegator leg — depthFor at 2945–2949 is the same two evaluator calls), and buildReadFilter reads only userId, guest, __readScope and shouldBypass (isSystem / bypass objects), none of which differ between the two spellings. The other three registerMiddleware sites in plugin-security (4551 position-catalog, 4820 bootstrap replay, 4843 org-admin grant) narrow no read, so six is the whole read-narrowing set.
  • "the engine derives no second intersection": RIGHT. The chain intersects nothing beyond those six on a read; the engine composes what it is answered and marks it 'policy'.
  • "the closed doors are the chain's": RIGHT, see the security answer below.
  • "resolveDelegatorContext in explain-engine.ts", "collectRLSPolicies / isDelegatedPrincipalLeg keeps the ceiling's silence from collecting the object default": RIGHT (explain-engine.ts export; security-plugin.ts 406–409, 9717).
  • D6 amended with the ruling quoted verbatim, the order (b)(i) → (b)(ii) → (c), the two things not taken (A stop-gap, the predicate removal), "ADR-0055 §2 (b) stands until the switch; ADR-0055 moves only in that switch's edit": RIGHT against 6303721609 items 1–4; the ADR edits ADR-0055 and ADR-0090 nowhere. "Leaves unchanged" and the E2 row carry the same.
  • The getReadFilter contract docblock (security-service.ts 384–387) still holds unchanged: it promises the deny filter "on a path that cannot compute the delegator intersection", a condition this head no longer meets, so no spec text moves. No exported signature changes in either package (resolveSharingReadFilter's depth and refuseUncomposableReadableIdsClasses's dropped parameter are both private); packages/spec untouched, no generated artifact owed.
  • readable-ids.ts: the sentinel-by-identity contract is honoured by the new branch (every deny returns RLS_DENY_FILTER by reference; the leaf's scope === RLS_DENY_FILTER test at 6287 fires).

The security question, consumer by consumer. On main, getReadFilter answered the deny sentinel for every on-behalf-of context (the removed [#2852] block); on this head it answers the D10 intersection. Runtime call sites of getReadFilter on origin/main, by a strict .getReadFilter( sweep over every non-test source: exactly two (security-plugin.ts 2121 is the registration, not a consumer).

  1. The engine's ReadableIds leaf (engine.ts 6282): does a delegated caller now read more than the engine's own delegated find chain admits? No. The leaf's scope for parent P = canReadObject(P) (object-level, both legs) ∧ the six-layer row scope above, which is row for row what a delegated find(P) runs under; and the leaf sits inside a find of the child that itself already passed the chain under the same delegated context, so it only narrows. Pinned on the real stack by rows (readable-ids-delegated-compose.test.ts: c_both, c_shared only; find and count agree; the parity pin compares getReadFilter's scope with the delegated direct read). Live traffic: none — every shipped consumer still routes a delegated caller to its bounded probe (isDelegatedCaller at parent-record-read-gate.ts 451 for activity / ledger / comments / reactions, attachment-access-hooks.ts 1020 for attachments), so no leaf is written for a delegated caller today.
  2. The analytics raw-SQL / dataset path (service-analytics/src/plugin.ts 672, the getReadScope auto-bridge): does a delegated caller now read more than the chain admits? No. The same door bridges admitObjectRead to canReadObject (plugin.ts ~798), so a delegated analytics read is object admission intersected ∧ the six-layer rows — the chain's own two questions. It widens relative to main's deny-everything, by design and by the ruling, and never past a delegated direct read. Latent, not live: ADR-0090 D10's note says an agent principal is produced at the /mcp OAuth door alone, and no MCP / AI tool names analytics.
  3. No other consumer exists (the lint rule text, the audit matrix note and the docblocks mention the name only; scripts/analytics-reconcile/boot.ts supplies its own stub provider).

Fails closed exactly as the chain does? Yes, in every case, with the shape the scope path owns: the chain throws (403 PERMISSION_DENIED or the find fails), the scope path answers the deny sentinel by identity, which the leaf turns into $in: [] and analytics into zero rows. Case by case against resolveOperationPrincipals (8903–8947) and step 2.5: principal resolves no set → chain deny baseline / new RLS_DENY_FILTER (6037); delegator missing → chain 403 / new canReadObject false and the sentinel with the D10 invariant sentence logged (6041–6049); delegator resolves no set → chain step 2.5 checkObjectPermission([]) false → 403 / new sentinel (6052) and objectReadAdmission arm 6 false; a throw in resolvePermissionSetsForContext, buildContextForUser, getObjectSecurityMeta, either computeRlsFilter, either resolveSharingReadFilter → propagates to getReadFilter's catch → sentinel (5926–5938); a throw that still escaped → the engine's own readableIdsScopeOf catch → $in: []. resolveDelegatorContext's third arm (none) is unreachable on both paths (both branch on onBehalfOf.userId first) and the new guard del.kind !== 'resolved' denies it anyway. Order matches the chain's prologue: isSystem bypass, then the principal-less refusal, then the delegated branch.

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; canReadObject false / sentinel / throw on either half ⇒ $in: []; D5.2 and D5.3 refuse with code and status, no driver call, service unasked; a control without the link). The five respelled plugin-security pins keep their assertions and now name the branch a sys_user-less rig actually reaches (a delegator who does not exist); default-report-sink.test.ts reads the exact D10 sentence the missing-delegator line keeps verbatim. Ablation legs A and B each red the expected pins and restored clean, per the report.

Check-runs on the head, as read once at 02:25Z: 38 runs, 0 failure; 24 success (Build Core, Lint · ESLint, Lint · docs & code guards, Lint · CI release & engine gates, Lint · PM dispatch-gates, the three scoped lint legs, Type Check · source gates, Type Check · debt ledger, Check Changeset, Check PR Size, Governed Surface Queue Guard, the claim / single-writer / part-of checks, Check Documentation Links, Dogfood Regression Gate, Auto Label, filter, flag-docs); 8 skipped (opt-in and full-battery jobs); 10 in_progress (Test Core 1–6/6, Type Check · consumer gates, Type Check · workspace, Lint · repo & tooling gates). Not polled. The required contexts are therefore not all concluded at this read; this record is the contract-tier review of record for the head and is not a landing record — docs/adr/** is Tier H, so the landing waits on the maintainer's approval and on every check green.

② Semver level

.changeset/25009-readable-ids-delegated-compose.md: @objectstack/objectql minor, @objectstack/plugin-security minor — RIGHT. Exactly the two packages whose source changed; plugin-audit, service-storage, spec untouched and absent. Minor is the level of a capability added with no public signature change and no authorable surface moved; it matches the precedent the changeset cites (the ADR-0139 E2 and E5 landings). The body states Before / Now / What a reader sees / Unchanged accurately against the code (the analytics door's widening from deny to intersection is named; the consumers' unchanged routing is named).

Clause-②: no — RIGHT and consistent across the PR body, the changeset body and the dispatch claim: no spec key, schema or authorable accept set changes; the runtime read-scope change is the ruling's, as the claim states. No (widening) / (narrowing) arm, so no ADR-0087 marker is owed. Check Changeset success on the head.

③ Boundary flags

Dev deviations (report 6306430776), each answered:

  1. Worktree takeover with the inherited hunks committed as a restore point before verification, no stash — answered: the process the dispatch set; every inherited hunk is re-verified at this head.
  2. origin/main merged before verification — answered: the merge base 73432299c is on the branch; net diff is the 11 files, no generated artifact moved.
  3. check:dual-build-cjs-loads and check:i18n NOT MEASURED locally (full-build prerequisite) — answered: CI's Build Core is success on the head; the lint leg that carries the rest is in_progress at this read and is the gate's verdict, not this record's.
  4. check-engine-split-ratio --days 90 refused on the shallow clone — answered: a push / schedule metric, not a PR gate; its self-test row passed.
  5. The derived-gate loop moved to background, 12 families run twice, reconciled on the deduplicated record — answered: a reporting artefact, no gate verdict depends on it.
  6. Ablation leg A refused once by the tool and respelled — answered: nothing ran on the refused attempt, restore proven.
  7. Report keys outside the role file's template — answered: additive.
  8. The inherited WIP's red default-report-sink pin fixed by keeping the D10 sentence verbatim rather than loosening the pin — answered: the right direction (the assertion is unchanged; the sentence is in the branch a bare instance reaches).
  9. Worktree removed after the report — answered: the branch holds every commit (three pushes, no force).

Out-of-scope findings, each answered or escalated:

Card (c) scope (#24932), held: the two existing computeControlledByParentFilter call sites in the middleware and the one in the non-delegated getReadFilter are byte-unchanged; the id-list pre-scan (ql.find(rel.master, …, isSystem)) is unchanged; ADR-0055 is not in the file list. The delegated path adds two callers of that unchanged mechanism (principal and delegator legs), which is the parity the chain already has — not a switch, and inside card (b)(i). The ruling's item 3 (the HotCRM-scale 500 stays until (b) lands) is unaffected.

Nits for the maintainer's Tier H read of the ADR, no verdict weight: the "Open items" list still carries O5 — whether … becomes a seventh consumer (D6) without noting it is answered (the bullet points at D6, which now holds the verbatim ruling; a three-word tail would close the loop). The E2 row's Pins column still reads "a delegated … parent ⇒ the loud refusal", historical at E2's landing and now contradicted by the What-lands cell's parenthetical. In code, computeControlledByParentFilter's ancestors docblock counts "the four" call sites, now six. None of these changes a decision or a row.

Implemented-by: claude/issue-25009-readable-ids-delegated-compose
Reviewed-by: session_01QK6pdbxKZiHdFApL8RYVh9

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2a8d0d5b4132ea6dacecbb0bdb73f1dfc59b5ad1
Local-runs: none
Scope: docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md + packages/objectql/src/engine.ts (composeReadableIdsAtBoundary, refuseUncomposableReadableIdsClasses, readableIdsScopeOf), packages/plugins/plugin-security/src/security-plugin.ts (getReadFilter, computeDelegatedReadFilter, resolveSharingReadFilter, canReadObject / objectReadAdmission, resolveOperationPrincipals, the read middleware's steps 2.5 / 2.6 / 3, computeRlsFilter, computeControlledByParentFilter, collectRLSPolicies), packages/plugins/plugin-security/src/explain-engine.ts (resolveDelegatorContext), packages/plugins/plugin-sharing/src/sharing-plugin.ts (the read middleware's two buildReadFilter legs) and sharing-service.ts (buildReadFilter, shouldBypass), packages/spec/src/contracts/security-service.ts (getReadFilter and canReadObject docblocks), packages/spec/src/data/readable-ids.ts, docs/adr/0090-permission-model-v2-concept-convergence.md D10, and every getReadFilter caller on origin/main (4d991588f): packages/services/service-analytics/src/plugin.ts (the getReadScope bridge and the admitObjectRead bridge beside it) plus the leaf consumers' probe routing in packages/plugins/plugin-audit/src/parent-record-read-gate.ts and packages/services/service-storage/src/attachment-access-hooks.ts.

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 origin/main...2a8d0d5b4 — 818 / 111 over the same 11 files, merge base 73432299c unchanged — and git diff 4c31b7cc9 2a8d0d5b4: 2 files, 7 insertions, 5 deletions, one commit); the previous record 6306657655 on 4c31b7cc9 (PASS); the 38 check-runs on the new head, read once at 02:59Z. origin/main has moved from 4d991588f to 601b258d7 since that record — four commits, none touching the 11 files or any file in the scope below, and the runtime .getReadFilter( caller census on it is the same two (engine.ts 6282, service-analytics plugin.ts 672). reviewFace hits: the ADR alone. Scope: is copied from 6306657655 because the net diff outside the delta is byte-identical to what that record judged (verified in ①). Why a new record: the head moved by one commit that edits governed text (docs/adr/**) and one TSDoc block, and a governed-text edit is not a prose face, so 6306657655 no longer governs this head. This record is the contract-tier review of record for 2a8d0d5b4 and is not a landing record: docs/adr/** is Tier H, so the landing waits on the maintainer's approval and on every check green.

① Derived judgments

The delta, judged in full — the one commit 2a8d0d5b4, which answers the three Nits of 6306657655.

  1. O5 marked Answered (ADR lines 311–312). RIGHT. The bullet keeps its question verbatim ("whether controlled_by_parent's id set becomes a seventh consumer (D6)") and appends "Answered (2026-10-21): the ruling is recorded verbatim in D6, as amended." It points at where the ruling is recorded and nowhere else; it neither restates the answer (no "yes", no "end-state form", no paraphrase of the order) nor alters it. D6's blockquote (ADR 260–263, not touched by the delta) is word for word the ruling as the card body quotes it from 6303721609 — compared clause by clause — and the bullet's date matches the status line's and D6's "Amended 2026-10-21".

  2. E2 row, Pins cell (ADR line 327). RIGHT. Each clause is one the amended D5.1 states and one pin in packages/objectql/src/engine-readable-ids-composition.test.ts asserts, read on this head:

    • "a delegated caller composes (both halves asked with the delegation link intact …)" — D5.1: "asks both halves — canReadObject and getReadFilter — with the delegated context exactly as it stands, onBehalfOf intact, and composes what it is answered". Pinned at line 387: asked equals canReadObject:rid_parent, getReadFilter:rid_parent; every context either half received carries onBehalfOf equal to the link, userId the principal, principalKind agent; one driver call carrying the composed leaf, scope by reference, marked policy; for find, findOne, count and aggregate. Also at 416 (a middleware-written leaf on the child; both contexts carry the link) and the 476 control (no link, the same composition, so the link is not what the engine keys on).
    • "the closed doors still $in: []" — D5.1: "any resolution failure answer false or the deny sentinel, and the leaf is $in: []". Pinned at 437: canReadObject false, the sentinel by identity, a throw on either half — four doors across the four verbs — and the $in comparand the driver receives is [].
    • "a middleware-gated or federated parent ⇒ the loud refusal, a delegated context included" — D5's lead sentence over its two remaining classes ("refuses to compose a leaf for it (loud, as D3's envelope)"). Pinned at 463 under the delegated context (envelope code INTERNAL_ERROR, status 500; the message names the class; zero driver calls; the service unasked) and at 508 / 516 without it, the same assertions.
    • The two leading clauses ("canReadObject false ⇒ no row; deny sentinel ⇒ no row") are unchanged and pinned at 212 and 228.
      The cell claims nothing the pins do not assert, drops nothing D5.1 states, and the contradiction the previous record named — Pins reading "a delegated … parent ⇒ the loud refusal" against the What-lands parenthetical — is closed.
  3. computeControlledByParentFilter's ancestors docblock (security-plugin.ts 8745–8750). RIGHT. Counted on this head: computeControlledByParentFilter( has exactly six call expressions in the repository, all in packages/plugins/plugin-security/src/security-plugin.ts (the method is private; no test calls it; every other occurrence is a TSDoc link tag, a lint-rule comment, CHANGELOG or ADR-0055 prose). Five pass three arguments, so ancestors takes its [] default: 4406 and 4424 (the read middleware registered in start: the caller's leg and the D10 delegator's leg), 5977 (inside getReadFilter, declared at 5910), 6072 and 6074 (inside computeDelegatedReadFilter, declared at 6033: the principal's and the delegator's legs). The sixth, 8835, is the method's own recursive hop and passes [...ancestors, object]. "Empty at five of the six call sites … grown by one at the sixth, the recursive hop" is the count, and the list names all six by site. Every changed line of this hunk lies inside the TSDoc block; no executable line moved.

Everything else carries over from 6306657655 unchanged. Verified: git diff 4c31b7cc9 2a8d0d5b4 --stat is exactly those two files (the ADR +3 / −2, security-plugin.ts +4 / −3; 7 insertions, 5 deletions, the one commit above), and the other nine files of the 11-file list have identical blob ids on both heads (engine.ts 2879fdae7, the changeset e2af54e04, the composition test 5ce354099, the new real-stack test 042c9a632, and the five respelled plugin-security tests). So every judgment of that record — the D5 / D5.1 / D6 claims against the code, the six-layer parity of computeDelegatedReadFilter with the middleware chain, the consumer-by-consumer security question (the engine leaf, the analytics bridge, no third consumer), the fail-closed case table against resolveOperationPrincipals, the pins and the two ablation legs — stands on this head exactly as rendered there, and is not re-derived here.

Check-runs on the head, read once at 02:59Z (the runs were created 02:56Z): 38 runs, 0 failure; 15 success (Governed Surface Queue Guard, Check Changeset, Check PR Size, Lint · PM dispatch-gates, Lint · CI release & engine gates, Lint · scoped · tooling self-tests, the claim / single-writer / part-of checks, Check Documentation Links, Dogfood Regression Gate, Flag docs affected by code changes, Auto Label, filter); 7 skipped (Build Docs, Temporal Conformance, Console Pin Gate, Dogfood Verify CLI, the dogfood shard matrix placeholder, the two opt-in harnesses); 16 in_progress (Build Core, Test Core 1–6/6, Lint · ESLint, Lint · repo & tooling gates, Lint · docs & code guards, the two other scoped lint legs, Type Check · workspace / source gates / consumer gates / debt ledger). Not polled. The required contexts are therefore not all concluded at this read; in_progress is not a pass, and this record vouches for none of them.

② Semver level

Unchanged from 6306657655: .changeset/25009-readable-ids-delegated-compose.md is byte-identical on both heads — @objectstack/objectql minor, @objectstack/plugin-security minor, RIGHT, the two packages whose source changed and no other — and the delta moves no executable line, no signature and no authorable key (a TSDoc block and ADR prose). Clause-②: no stands, consistent across the PR body, the changeset and the dispatch claim; no (widening) / (narrowing) arm, so no ADR-0087 marker is owed. Check Changeset success on this head.

③ Boundary flags

Patch-round deviations (report 6306903617), each answered:

  1. The docblock count is "not a bare swap four to six" (on main four call expressions, but the old sentence listed three sites as "the four") — answered: verified above; the new sentence's arithmetic and its list agree with the code, and it names the two sites this PR added.
  2. The O5 bullet does not repeat the answer — answered: that is what the Nit asked and what keeps the ruling recorded once, in D6.
  3. Gate derivation on the PM's two-path form (60 families, 54 run, 6 NOT MEASURED, 0 UNRUN) rather than the no-path form (83) — answered: the nine files outside this round are byte-identical to the head the previous round derived 83 families on (81 run), so the two rounds cover the PR between them; CI's farm is the verdict in any case.
  4. Six families NOT MEASURED locally (check:dts-closure, check:dual-build-cjs-loads, check:i18n, check:lean-entry-closure, check:sourcemap-no-sources-content, check:doc-formula-expressions — each a built-dist prerequisite, exit 3) — answered: CI's Build Core and lint legs carry them; in_progress at this read; the gate's verdict, not this record's.
  5. No package test, typecheck or ablation this round — answered: correct for the change. Every changed line is ADR prose or inside a TSDoc block, so no behaviour a package test could read moved; the previous head's verification (two suites green, two ablation legs red-then-restored) stands for the identical code, and CI re-runs the suites on the new head regardless.
  6. No WIP push before the gate sweep; the edit was committed locally as the restore point and pushed once — answered: a 12-line prose and comment round; the one commit the dispatch asked for is what the branch holds (normal push, no force, no amend of the pushed head).
  7. The model-free commit trailer pair — answered: the commit carries exactly the pair AGENTS.md requires (Claude-Session and Co-authored-by: Claude), as every other authored commit on the branch does.
  8. Report keys outside the role file's template kept; a fresh worktree built from the remote branch and removed after the report — answered: additive; the remote branch holds the commit.

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: claude/issue-25009-readable-ids-delegated-compose
Reviewed-by: session_01QK6pdbxKZiHdFApL8RYVh9

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读

改了什么。 一个 agent 或服务以某个用户的名义(on-behalf-of)读取「主从关系」下的子记录时,引擎现在会把主记录的可读范围组合进同一条查询:只放行代理方自己能看、被代理的用户也能看的行(ADR-0090 D10 的交集)。此前引擎遇到这类调用直接报 500。为此,plugin-security 的 getReadFilter 在代理上下文下不再一律回答「全拒」,改为回答同一个交集。ADR-0139 同步修订:O5 记为已答(按你在 #24932 的裁决 B),D5.1 从「拒绝」改为「组合」。

为什么改。 这是你在 #24932 上裁决 B 的第 (b)(i) 步。下一步是嵌套主从链 #25010,之后 plugin-security 再一次性切换,并退役主表 id 预扫描;那条预扫描在 HotCRM 的规模下实测会报 500。不做这一步,代理读经叶子只能得到空集,后面的切换无从谈起。

风险与代价(含回滚)。

席位意见。 建议批准。复审记录在本评论下方链接:契约复审两轮 PASS,第二轮在当前 head 上;两包全量测试绿;两处消融都按预期转红再复原;改动严格停在裁决划定的 (b)(i) 范围内,没有碰卡 (c) 的切换。

你要做的。 批准本 PR(它改动了 docs/adr/**,属 Tier H)。批准后由本席入队落地,随即开始同区串行的 #25010。

席位核对

Seat review · domain:engine seat 2 · session_01QK6pdbxKZiHdFApL8RYVh9 · 2026-10-21T03:30Z · head 2a8d0d5b4 · verdict: ACCEPT — Tier H, awaits the maintainer's approval


Generated by Claude Code

Merged via the queue into main with commit f8c5fb3 Oct 21, 2026
50 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-25009-readable-ids-delegated-compose branch October 21, 2026 04:38
os-support-ai pushed a commit that referenced this pull request Oct 21, 2026
… (PR #25032, the stack base, landed as f8c5fb3)

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

3 participants