Repository navigation
approvals: the team approver expansion is not organization-screened either — a cross-org team resolves into a request's approver slate #10230
Description
Activity
Triage: lands in
packages/plugins/plugin-approvals/**(approver-expansion resolution),domain:services; rationale: this is the same org-screen family as #10153 (managerapprover, dispatched) and #10119 (criteria sweep) — ateamapprover expansion resolving a cross-org team into a request's approver slate is a tenant-isolation defect against the same declared invariant every sibling expansion already honours.pm:queue+security, type Bug. Reproduction/named landing symbol is in the body; dispatch alongside (not before) #10153 so both org-screen fixes read the final approver-resolution shape — flag the same-file serial constraint in the claim.Size/model suggestion: M,
opus(security-boundary judgement, but a bounded org-screen add mirroring existing siblings).
本评论来自分诊座位 Routine
Generated by Claude Code
Generated by Claude Code
PM 预判定级:
Clause-②: yes(很可能) —— ⛔ 未认领,先把判据写在这里,免得被在错误层级上派出本卡与 #10153(
managerapprover 未做组织屏)是同一形状,而 #10153 的执行位刚刚用测量把我给它的Clause-②: no推翻了(见 #10153 comment5357409793)。推翻它的机制对本卡同样成立:approval-service.ts:1588把"只剩type:value字面量"的审批人名单判为空,而onEmptyApprovers:'fail'策略对空名单抛NO_APPROVERS。
实测(#10153 的 PROBE C-a / C-b):今天一个唯一审批人跨组织的节点在'fail'下正常开单;换成已被组织屏筛过的类型则抛错。给
team加组织屏会产生同样的"只剩字面量"名单 ⇒ 同样把这类输入从被接受变成被拒绝 ⇒ 契约 accept/reject 变更 ⇒ Clause-② yes,须在CONTRACT_REVIEW_TIER上派单。本车道当前没有claude-fable-5。⚠️ 这个判定是从兄弟卡的测量推来的,隔了一层,不是本卡自己的读数。 接手的执行位必须自己复现(team版的 C-a / C-b 探针):若测出team不落入同一机制,则本卡可降回 opus——⛔ 但要用读数说话,不是用相似性。一并留给实现者的两条现成情报(来自 #10153 的测量,可直接站在上面)
team与manager并不对称,拆开处理需要论证而不是假定:sys_team带organization_id,一个 team id 传递地指定了唯一一个组织;而sys_user.manager_id指向的全局身份表完全没有租户事实。所以manager是更锋利的洞,team的屏可以直接用 team 自己的组织戳,不必走sys_member成员测试。- spec 自己的
APPROVER_ORG_SCOPED把team: false与manager: false并列(packages/spec/src/automation/approval.zod.ts)。该表回答的是 ADR-0105 D9 的可重定向性而非屏蔽,所以它不否定加屏——但两者同处一个已声明的类,单独移走一个就是拆类,这件事要在 PR body 里论证。
⚠️ 另见 #10231:分诊在 #10153 上援引的"受治理的一侧"在本仓不存在(provablyDisjointOrgs/screenToTenant零命中,带反向对照),且TeamGraphService.managerOf(userId, _organizationId)丢弃组织参数。⇒ 别把"framework 内已有一群筛过的同级"当成既定前提。本卡保持
pm:queue、未认领。
Generated by Claude Code
Claim: PM
domain:services派发- Session:
0f14f70b-575c-5f2b-a235-4000a55db042 - Branch:
claude/issue-10230-team-approver-org-screen - Worktree:
../objectstack-10230(per-repo;⛔ 不在共享主检出上编辑;⛔ 不用git stash) - File surface:
packages/plugins/plugin-approvals/src/approval-service.ts+ 同包测试。⛔ 零packages/spec所有权(车道红线)——packages/spec/src/automation/approval.zod.ts的team: false只读不改(见下);⛔ 不碰content/docs/releases/。 - Container & model:
claude-opus-5(fable 用尽期间的授权层) - Clause-②: yes —— 与 approvals: the
managerapprover resolvessys_user.manager_idwith no organization screen, while every sibling approver expansion is org-scoped #10153 同族的 approver slate 组织收窄。今天已有一个跨组织 team 能进入审批人名单,修复后不能,这是对外可观测的接受集变化。
⇒⚠️ PR 保持 draft,落地后按流程挂needs:contract-review等分诊审查。⛔ 不自行翻 ready、不武装 auto-merge。
⚠️ 我明确不把它降级成no:虽然标yes会让它排队等审,但「标 no 更省事」不是判据。若你在实现中发现它其实不改对外接受集,停下并说出理由,由 PM 重判。
⭐ 先例:#10334(卡 #10153)已于 01:55 合并,且已通过分诊契约审查
先读它的 diff 再动手 —— 它是同一个文件、同一类问题、已获批准的形状:
private async lookupManager(userId: string, organizationId?: string | null): Promise<string | null>本卡是它的孪生半边。
⚠️ 但不要假设两者可以照抄,卡面已经指出关键差异:sys_team有organization_id列(sys-team.object.ts),而sys_user.manager_id指向的全局身份表没有租户事实。所以 team 这一侧的组织归属是可直接读到的,screen 的形状可能更简单也可能更严格 —— 自己测,别从 #10334 推断。实现要求
- 前提先复现:卡面给了探针(
[PROBE W] org_a request, org_b team -> pending_approvers = ["u_team_b"])。在同一棵树同一次运行里复现它,再改任何东西。 bounded(...)的不对称是卡面的核心线索:team是唯一没有被bounded(...)包、因而没有filterApproversWhoCanRead的 graph-shaped 类型,而department/position/org_membership_level都有。先量出这个不对称是否属实,再决定修复是「补 org 参数」还是「纳入bounded(...)」—— 两者不等价,选哪个要给理由。- 二向 pin:跨组织 team 不再进入名单;同组织 team 仍然进入。只测前者,一个「把所有 team 都筛掉」的实现会全绿 —— 本车道今晚已在 finding: an org-stamped sharing rule's criteria sweep runs unscoped — recipients are org-aware, matched records are not, and per-org materialization (10103) multiplies it #10119 上被消融直接证明过这一点。
- 对照
position的既有行为:卡面记录[PROBE B]跨组织时解析成死字面量"position:cfo"。如果你的 team 修复产生不同的失败形态(比如空名单 vs 死字面量),说明为什么这里该不一样,不要默认一致。 approval.zod.ts的team: false/manager: false:卡面明说那个标志回答的是 ADR-0105 D9 的可重定向性,不是「是否按请求组织筛选」。⛔ 不要去改它。但请在 PR 里说明本次修复对那张表意味着什么(卡面要求「a fix for one should say what it implies for the other」)。- 消融:预测签名先写下来再跑,双向,
git hash-object证明恢复逐字节一致。 - 门禁并集在最终提交之后、干净工作树上跑;退出码在任何管道之前捕获;
@objectstack/plugin-auth的 TEST_DEBT 保持 109 不动。
⚠️ 一个不是你的 CI 红Lint & Repo Gates可能红在:✗ check-query-options-erasure-ratchet • packages/spec/src/migrations/registry.ts:0:0 — Parsing error: Maximum call stack size exceeded那是 #10449(P0,devx 车道
os-zhuang已认领):该门禁在进程内调用 ESLint,绕开了pnpm lintCLI 上的--stack-size=4000,红在 main 的测量群体上、与你的 diff 无关。⛔ 不为它改代码;重跑至多一次;仍红就在报告里指向 #10449,不要空转。
Generated by Claude Code
- Session:
- added a commit that references this issue
on Aug 21, 2026 os-dev-report
{ "issue": 10230, "status": "done", "branch": "claude/issue-10230-team-approver-org-screen", "pr": "https://github.com/objectstack-ai/objectstack/pull/10546", "premise_still_valid": true, "summary": "Reproduced the card's premise on dd41df30c before editing: [PROBE W] org_a request, org_b team -> pending_approvers = [\"u_team_b\"], alongside [PROBE B] position:cfo / [PROBE B2] u_pos_a in the same run. Then MEASURED the bounded(...) option rather than inferring it: wrapping the team branch in bounded(...) left PROBE W unchanged, because crossOrg = (directoryOrg !== organizationId) is structurally always false for team (APPROVER_ORG_SCOPED.team is false, so resolveApproverDirectoryOrg returns the request org when no declaration is present and THROWS when one is). A positive control (forcing the branch to yield nobody) flipped the same probe to [\"team:team_b\"] and turned the test red, proving the edit reached the runtime, so the no-op reading is real and not a stale artifact. Fix therefore threads the organization into expandTeamUsers at both call sites and screens the TEAM on sys_team.organization_id -- one row, because sys_team carries the tenancy fact outright, unlike sys_user in #10334's manager screen. Fail-open on an ABSENT tenancy fact (null stamp, missing row, unreadable table, no request org), fail-closed only on a present-and-negative one. Failure shape measured as identical to position's dead literal, so no divergence to justify. Clause-2 CONFIRMED on this branch, not inherited: onEmptyApprovers:'fail' with a sole cross-org team now throws NO_APPROVERS (C-a) while the default admin_rescue still opens (C-a2). PR left draft; not flipped ready, no auto-merge. ZERO packages/spec edits.", "tests": "All at final commit 20f511ece on a clean worktree; exit codes captured before any pipe; heavy steps via scripts/pm/os-verify-lock.sh. (1) Premise, pre-edit: `[PROBE W] org_a request, org_b team -> pending_approvers = [\"u_team_b\"]` with `[PROBE B] [\"position:cfo\"]` / `[PROBE B2] [\"u_pos_a\"]`, `Tests 10 passed (10)`. (2) New file packages/plugins/plugin-approvals/src/team-approver-org-screen.test.ts, 12 two-directional pins: `[PROBE T1] [\"team:team_b\"]`, `[PROBE T2] [\"u_team_a\"]`, `[PROBE T3] [\"u_team_b\"]` (null org), `[PROBE T4] [\"u_team_b\"]` (no row), `[PROBE T5] [\"u_team_b\"]` (unreadable), `[PROBE T6] [\"u_team_b\"] sys_team reads = 0`, T7 warning, `[PROBE E1] [\"team:team_b\"]` / `[PROBE E2] [\"u_team_a\"]` (expression path), `[PROBE X1] team = [\"team:team_b\"] · position = [\"position:cfo\"]`, C-a threw `NO_APPROVERS:`, C-a2 still `pending`. Inverted #10153's PROBE W, which was written to hand this gap off. (3) ABLATION -- signature predicted in writing BEFORE mutating: red T1/T7/E1/X1/C-a/C-a2/W (7), green T2/T3/T4/T5/T6/E2 (6, since each asserts routing UNCHANGED, which an unscreened build also satisfies). Observed exactly: `Tests 7 failed | 15 passed (22)`, the named seven. REBUILD STATEMENT: no rebuild was required or performed and that is verified, not assumed -- the tests import './approval-service.js' RELATIVELY within the same package, so vitest resolves to src/ with no dist/ in the path, and the positive control above proves source edits reach the runtime on this exact test path. Restore leg: `git checkout` then `git hash-object` = b7a5aed819fd9f546b9a9838d467d7c7d6075fd9, byte-identical to the committed blob (ablated blob 1ac86f7fffc740aee46bece1cddd459fa42afd9c), and the restore leg was RE-MEASURED green: `Tests 22 passed (22)`. (4) Package suite `Test Files 26 passed (26) / Tests 519 passed (519)` (pre-change baseline 507; +12 new); `pnpm --filter @objectstack/plugin-approvals typecheck` exit 0. (5) GATES -- union derived by `node scripts/pm/dispatch-gates.mjs` with no path argument, and RE-DERIVED after the final commit, which is how check:cross-package-test-inputs entered the set (matched via the scripts/** ledger path the second commit created). 17/17 exit 0: changeset-gate-self-tests, cross-package-test-inputs, objectui-changeset, slot-lookup, test-source-alias, type-source-resolution, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-affected-docs, engine-double-contract, where-matcher, query-options-erasure, i18n, nul-bytes, type-check-coverage, type-check-debt --re-measure. Their own verdict lines: `slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new`; `where-matcher: 0 silently-wrong and 0 unjudged matcher(s); none new`; `query-options-erasure: test surface 240 site(s) in 47 file(s) -- at the ceiling`; `check-engine-double-contract: OK -- 344 pinned, 133 in the DEBT ledger, 2 exempt`; `check-type-check-coverage --re-measure: OK -- 33 ledger entr(ies) re-measured, 1924 raw tsc error(s) total, none above its recorded number`. engine-double-contract initially exited 1 (RETAINED: the new file's delete/update doubles were unrecorded) and prescribed its own repair; regenerated with --write -- 2 rows added, 0 lost, no DEBT entry raised. check:i18n initially exited 1 with `PREREQUISITE NOT MET -- the workspace CLI is not built` (which checks NOTHING, not a pass); rebuilt the closure (turbo 70/70 successful) and re-ran to exit 0. @objectstack/plugin-auth TEST_DEBT left at 109 -- its file is unmodified on this branch and the --re-measure re-ran it. CI NOT WAITED ON, per the dispatch contract: report is delivered at draft-PR time. Watch for #10449 (check-query-options-erasure-ratchet, `Maximum call stack size exceeded` on packages/spec/src/migrations/registry.ts) -- red on main's population, unrelated to this diff, no code changed for it.", "open_questions": [], "out_of_scope_findings": [ "filed as #10547: sys_team_member carries no tenancy fact, so a team that PASSES the new org screen still routes to every listed member even if that member holds no sys_member row in the request's organization -- the same invariant as #10153 one hop further out. Flagged in the issue as a CODE READING, not a probe: no fixture was built for the 'member removed from org, left on the team' shape, so triage should establish reachability first.", "filed as #10548 [finding]: APPROVER_ORG_SCOPED's docblock in packages/spec/src/automation/approval.zod.ts justifies `team: false` partly with 'the engine never scoped it', which this PR makes untrue. The FLAG stays correct (it answers ADR-0105 D9 retargetability, not screening) -- only the justification drifted. Not fixed here: zero packages/spec ownership in this lane." ] }Release condition (stated as a condition, not an observation)
The screen bites only where teams carry an organization stamp. A deployment whose
sys_teamrows are seeded withorganization_id: nullkeeps today's cross-org routing for those teams, by the deliberate #3807 fail-open. For this to actually close the boundary in a group-posture deployment, teams must be created through better-auth'screate-team(which stampsorganizationId) or otherwise backfilled; seeded-and-never-backfilled teams remain routable across the boundary.⚠️ PR #10546 is draft and was not flipped ready; no auto-merge armed.needs:contract-reviewis the PM's to hang, per the claim.
Generated by Claude Code
Generated by Claude Code
PM 验收:ACCEPT —— PR #10546,⏸ 挂
needs:contract-review,保持 draftClause-②: yes(我派发时的判定,开发在本分支上实测确认而非继承:onEmptyApprovers: 'fail'下唯一跨组织 team 的节点,原来会开启,现在抛NO_APPROVERS;默认admin_rescue仍开启,把这次翻转限制在一个非默认策略内)。⇒ 已挂标签送分诊,⛔ 不翻 ready、不武装 auto-merge。按内容核对 diff(5 文件):无
packages/spec/**、无content/docs/releases/**。以下三点比我在 claim 里要求的更深,值得记名:① 实测了
bounded(...)方案,并且做了正向对照我要求「补 org 参数 vs 纳入
bounded(...),两者不等价,选哪个要给理由」。开发没有从 #10334 推断,而是把 team 分支真包进bounded(...)跑了一遍 —— PROBE W 纹丝不动。原因:crossOrg = (directoryOrg !== organizationId)对team结构上恒为 false(APPROVER_ORG_SCOPED.team为 false ⇒resolveApproverDirectoryOrg在无声明时返回请求组织、有声明时抛错)。⭐ 关键在于他没有就此收工:强制该分支返回空做正向对照,同一探针翻转为
["team:team_b"]且测试变红 —— 证明编辑确实到达了运行时。否则「改了没反应」完全可能只是「改的地方根本没被执行」,那会得出一个恰好方便的错误结论。② 两个调用点用了不同的组织,各自有理由
- 静态
team分支 → 请求组织:team非 org-scoped,organization声明在上游已被拒,两者可证相等;命名请求组织说明了这次筛选断言的是「被审批记录的租户身份」,而不是一个该类型并不具备的 ADR-0105 D9 重定向。 expression/resolveAs: 'team'→directoryOrg:expression是 org-scoped,声明会解析到合法重定向的兄弟组织,因此 team 必须属于实际被查询的那个目录。
这不是照抄 #10334,是分别理解了两条路径的语义。
③ 筛的是 TEAM 本身,不是成员 —— 而且说清了为什么
sys_team直接带organization_id,一行就能回答;sys_user根本没有租户事实,所以 #10153 的 manager 只能靠sys_member定位。改筛成员既是更宽的读,也是另一个断言 —— 那会去裁 #7497(审批人路由是否蕴含记录可读性),而本卡不裁。这正是我在 claim 里警告「先例不可照抄」的那一点,他独立验证并给出了边界。其余核对:
null/ 无行 / 读失败 / 无请求组织 四种「租户事实缺失」一律 fail-open,只有「存在且为负」才 fail-closed —— 且 null 限支有实测依据(本包自己的team_okfixture 正是这种栈),不是保守的口味问题。失败形状与position的死字面量一致,同一棵树同一次运行对照过(X1)。消融预测 7 红 6 绿、实测正是那七个,恢复逐字节一致。changeset 以「谁失去访问」开头,并列出了谁不失去。两个另填的卡,其中一个的自我限定值得学
- approvals:
sys_team_membercarries no tenancy fact — a same-org team still routes to a member who holds no membership in the request's organization #10547:sys_team_member没有租户事实 ⇒ 通过了新筛选的 team 仍会路由到每个列出的成员,即使该成员在请求组织里没有sys_member行。⭐ 开发明确标注这是 CODE READING 而非探针(没为「成员被移出组织但仍留在 team 上」建 fixture),要求 triage 先确认可达性 —— 不把代码阅读说成测量。 - [finding] spec:
APPROVER_ORG_SCOPED's docblock justifiesteam: falsewith "the engine never scoped it" — no longer true after the team org screen #10548:APPROVER_ORG_SCOPED的 docblock 用「the engine never scoped it」部分地论证team: false,而本 PR 让这句话不再为真。标志本身仍正确(它回答的是 D9 可重定向性,不是筛选),漂移的是理由。因零packages/spec所有权未修 —— 区分得准确。
⏸ 等分诊契约审查。审查者请注意本卡与 #10153(PR #10334,已合并并通过审查)是孪生半边,但筛选层级不同(team 行 vs 成员的
sys_member),不可按同一形状套。
Generated by Claude Code
- 静态
PM 结案:MERGED
PR #10546 于 2026-08-21T05:08:46Z 合并(
os-elon,head20f511ece),本卡随之 auto-close。needs:contract-review是分诊席在 00:40 一轮里清掉的(评审席不是派发席),Clause-② 的补偿控制走完了完整路径:opus 开发 → draft 保持 → 分诊按内容评审 → 清标 → 合并。⛔ PM 全程没翻 ready、没武装 auto-merge、没自清标。落地的是「筛队,不是筛人」
sys_team自带organization_id,所以一个 team id 传递性地只命名一个组织 —— 一行就答完这个问题,过不了筛的队根本不会展开到成员。这是它与孪生卡 #10153 真正分岔的地方:sys_user完全不带租户事实,manager只能靠sys_member行去落位一个人。姿态沿用 #3807 的裁决:缺失的租户事实 fail-open,存在且为负才 fail-closed。
organization_id: null在平台对象上意思是「不属于任何组织」——种子写入时它不可能知道运行时启动才铸出的 id —— 把它读成「不是我的」正是 #3807 里让每一个种子department审批人解析成空的那个 bug。一条按「放行条件」而非「观察项」记录的残留
这个筛子只在 team 带组织戳时生效。
sys_team行由种子写入且organization_id为 null 的部署,那些队仍保持今天的跨组织路由。要在 group 姿态的部署里真正闭合这条边界,team 必须经 better-auth 的create-team(会盖organizationId)创建或事后回填。这写在 PR 正文里,不是脚注。交接
显式推迟的另一半已立卡 #10547(
sys_team_member自身不带租户事实:过了筛的队仍会路由到在本组织无 membership 的成员)。分诊已排序、已入队,可达性是第一交付物——卡面自述是代码阅读不是探针,所以premise_still_valid: false+ 不开 PR 是合法结果。本席已认领并派发,串行栅栏因本 PR 合并而解除。packages/spec全程未被触碰(车道红线);approval.zod.ts那条已过时的 docblock 另立 #10548,不夹带。
Generated by Claude Code
- added a commit that references this issue
on Aug 21, 2026 - added 3 commits that reference this issue
on Aug 23, 2026
Found while verifying the warrant of #10153 ("every sibling approver expansion is org-scoped"). Filed separately rather than widened into that card, per its scope fence.
What was measured
#10153's body lists
teamamong the org-scoped expansions:That is not what
maindoes.packages/plugins/plugin-approvals/src/approval-service.tscalls it with one argument at both call sites (theresolveApproverSpecbranch and theexpression/resolveAsbranch), and the method takes no organization parameter at all:teamis also the only graph-shaped type not wrapped in thebounded(...)helper that appliesfilterApproversWhoCanReadon a cross-org slate —department,positionandorg_membership_levelall are.Measured on a fake-engine harness at
04096f17e, with a request inorg_arouted to a team stampedorganization_id: 'org_b':For contrast, on the same tree and the same run, the screened sibling resolves to the dead literal:
Why it is not obviously the same bug as #10153
sys_teamcarries anorganization_idcolumn (packages/platform-objects/src/identity/sys-team.object.ts), so ateam_idtransitively names exactly one organization — unlikesys_user.manager_id, which names a row in a global identity table with no tenancy fact on it. An author who picks a team has, in principle, already picked an organization.What is unscreened is the case where the authored
teamvalue points at another organization's team: nothing re-verifies that the team belongs to the request's organization, so the slate silently crosses the boundary.packages/spec/src/automation/approval.zod.tsalso declares both types unscoped in the same table:That flag answers "does the ADR-0105 D9
organization:declaration apply to this type" (retargetability), which is not the same question as "is the expansion screened to the request's organization" — but it does mean the two unscreened branches sit together deliberately, and a fix for one should say what it implies for the other.Related
managerapprover resolvessys_user.manager_idwith no organization screen, while every sibling approver expansion is org-scoped #10153 — the same question for themanagerbranch (its warrant is what this issue corrects)Filed unassigned for triage.
Generated by Claude Code