Repository navigation
approvals: sys_team_member carries no tenancy fact — a same-org team still routes to a member who holds no membership in the request's organization #10547
Description
Activity
Triage: lands in
packages/plugins/plugin-approvals⇒domain:services,pm:queue, type Bug — suspected continuation of the #10153 invariant ("a person provably a member of other organizations and not of the request's should not hold approval authority") one hop out, with a named landing site (approval-service.tsteam-member expansion).Premise-first, binding on the dispatch: reachability is unverified and is the first deliverable. Build the "member removed from the org, still on the team" fixture (or establish that better-auth's
remove-member/ RLS makes it unreachable).premise_still_valid: false+ no PR is a legal and valuable outcome. If reachable, the fix follows the established posture: screen expandeduser_ids againstsys_memberwith the provably-outside (fail-open) shapemanagerIsProvablyOutsideOrg/teamIsProvablyOutsideOrgalready pin. #7497 (routing vs read visibility) is open but non-blocking — #10230 landed under the same posture without waiting on it; this card should too.
Generated by Claude Code
Claim: PM
domain:services派发- Session:
0f14f70b-575c-5f2b-a235-4000a55db042 - Branch:
claude/issue-10547-team-member-org-screen - Worktree:
../objectstack-10547(per-repo;⛔ 不在共享主检出上编辑;⛔ 不用git stash) - File surface:
packages/plugins/plugin-approvals/src/approval-service.ts+ 同包测试。⛔ 零packages/spec所有权(车道红线)——approval.zod.ts只读不改;⛔ 不碰content/docs/releases/。 - Container & model:
claude-opus-5 - Clause-②: 待你自己判定,我的首判是 yes —— 若前提成立且你落了筛子,它会像 approvals: the
teamapprover expansion is not organization-screened either — a cross-org team resolves into a request's approver slate #10230 一样在非默认onEmptyApprovers: 'fail'下把「开」翻成NO_APPROVERS。⇒ PR 保持 draft、挂needs:contract-review、⛔ 不翻 ready、⛔ 不武装 auto-merge。(本车道 fable 已用尽;CONTRACT_REVIEW_TIER现读为claude-fable-5,scripts/pm/dispatch-gates.mjs:1932——按维护者 2026-08-20 授权,opus 开发 + 分诊审核作为补偿控制。)
⚠️ 若你测出接受/拒绝集完全不动(例如前提本就不可达),把 Clause-② 降为no并在报告里说明理由。
文件占用(已替你查过,可推翻)
approval-service.ts的上一位持有者是 #10230 —— PR #10546 已于 2026-08-21T05:08:46Z 合并(os-elon)。本卡的串行栅栏因此已解除。⚠️ 建分支前请自己再确认一次origin/main上expandTeamUsers/teamIsProvablyOutsideOrg已在树内;若不在,以你的测量为准并停下上报。实现要求
- 可达性是第一交付物,也是分诊的约束性排序(见 triage 评论)。先造「成员已被移出组织、但仍留在 team 上」的 fixture:
sys_team.organization_id = org_a(过筛),其某条sys_team_member指向的 user 的sys_member行只在org_b。premise_still_valid: false+ 不开 PR 是合法且有价值的结果。若 better-auth 的remove-member级联清了sys_team_member、或 RLS 已挡住,如实说出来,别硬造一个不可达的修复。⚠️ 卡面自述这是代码阅读、不是探针。不要把卡面的读法当成已测事实。
- 若可达:沿用既有姿态——
managerIsProvablyOutsideOrg/teamIsProvablyOutsideOrg的 provably-outside(fail-open) 形状。缺失租户事实 ⇒ 不动路由;只有存在且为负才筛掉。approvals: adepartmentapprover never resolves when the business unit hasorganization_id = null(every seeded BU) #3807 的裁决在这里同样成立(null不等于「不是我的」)。 - 一次读,不是一人一读:扩展出的
user_id集合应当用一次$in查sys_member,不要在循环里逐人查。 - 三向 pin,双向断言:① 证明在外的成员被筛掉;② 同组织成员照常路由;③ 三条缺失事实的肢(无
sys_member行 / 表不可读 / 请求无组织)路由不变。只钉①会让一个「筛掉所有人」的实现全绿——这正是 fix(plugin-approvals): screen theteamapprover expansion to the request's organization #10546 的 leg B 教训。 - Design: does approver routing imply record read visibility? (#7345 model half) #7497 开着但不阻塞(triage 明确):approvals: the
teamapprover expansion is not organization-screened either — a cross-org team resolves into a request's approver slate #10230 就是在同一姿态下落地的。不要在本卡里裁决「路由是否蕴含读可见性」。 - 消融:预测签名先写下来再跑,双向;
git hash-object证明恢复逐字节一致。 - 门禁并集在最终提交之后、干净工作树上由
node scripts/pm/dispatch-gates.mjs不带路径参数推导后跑;退出码在任何管道之前捕获。
⚠️ class [finding] Every PM dispatch list is short by the same ~5 changeset-triggered gate families — they are path-derivable, but the changeset does not exist yet when the list is derived #10309:推导出的并集已知会短。plugin-approvals相关的check:route-envelope与check:dispatcher-error-vocabulary请显式补跑,并在报告里说明推导是否点名了它们——这是本车道连续第三次靠手写补进去才躲过一次红。 @objectstack/plugin-auth的 TEST_DEBT([finding]@objectstack/plugin-auth's TEST_DEBT records 109 where tsc now reports 98 — an 11-error surplus opened by PR #10614 #10615,记 109)⛔ 不要动、不要跑--lower。
⚠️ CI 上一个不是你的红check-query-options-erasure-ratchet在packages/spec/src/migrations/registry.ts:0:0 — Parsing error: Maximum call stack size exceeded上可能红。这是 #10449,与你的 diff 无关。⛔ 不要为它改代码;重跑不超过一次;仍红就在报告里注明并指向 #10449。
Generated by Claude Code
- Session:
{ "issue": 10547, "status": "done", "branch": "claude/issue-10547-team-member-org-screen", "pr": "https://github.com/objectstack-ai/objectstack/pull/10739", "premise_still_valid": true, "summary": "REACHABILITY FIRST (binding, measured before any code): the fixture triage specified — sys_team.organization_id=org_a so it passes #10230's screen, one sys_team_member row naming a user whose only sys_member row is in org_b — put that user on the slate. Verbatim, on origin/main blob b7a5aed8: '[PROBE M1] pending_approvers = [\"u_outsider\"]' and '[PROBE M8] sys_member reads = 0'. Nothing read tenancy at all. The premise HOLDS. Both unreachability hypotheses were checked in source, not assumed: better-auth 1.7.1 deleteMember DOES cascade into teamMember and ObjectStack enables the teams flag it is gated on (auth-manager.ts:2249), so the card's first-named route (remove-member) is largely closed — but the cascade enumerates only teams whose organizationId equals the org removed from, so a re-parented team (/organization/update-team takes teamSchema.partial(), which includes organizationId), a seed-written row, and a null-org team (invisible to the cascade's equality query, and deliberately routed by #10230's T3) all survive it. RLS is not a mitigation: the expansion reads under SYSTEM_CTX. FIX: expandTeamUsers now screens expanded members with the provably-outside/fail-open shape via ONE $in read of sys_member — present-and-negative drops loudly, absent (no rows / unreadable / truncated / no request org) leaves routing untouched, and the no-org case reads nothing. A truncated read fails OPEN deliberately, since this read is the only evidence a member IS a tenant here. Both call sites covered (both route through expandTeamUsers). #7497 NOT ruled on: no reads granted, no read screen applied to any type lacking one.", "tests": "All at final sha a5c2b69ae, clean tree, exit codes captured before any pipe (cmd > f 2>&1; EXIT=$?), all heavy work through scripts/pm/os-verify-lock.sh. PINS 14/14 green: M1 outsider screened out · M2 same-org routes · M3 no sys_member row routes · M4 unreadable sys_member routes · M5 no-request-org routes AND reads nothing · M6 MIXED TEAM (outsider dropped AND insider kept in one expansion — the anti-'screen everyone' pin #10546 leg B taught) · M7 member of both orgs routes · M8 exactly ONE sys_member read for a 6-member team · M9 loud warning names users+both orgs+card · M10 truncated read fails OPEN · E1/E2 expression path both directions · C-b/C-b2 the flip and its confinement. ABLATION, predicted before mutating: revert approval-service.ts to origin/main ⇒ screen pins red, absent-fact limbs green. Observed exactly that: 'Tests 8 failed | 6 passed (14)' reverted, 'Tests 14 passed (14)' restored; the 6 passing in BOTH states are the fail-open limbs, which is what makes them evidence. Restore byte-identical, proved not asserted: base blob b7a5aed819fd9f546b9a9838d467d7c7d6075fd9 == BEFORE_HASH; AFTER_HASH f7c52f891022bc04efbc76cb30e1aa8af5e4f90c == committed blob. RESOLUTION positive control: packages/plugins/plugin-approvals/dist DOES NOT EXIST and plugin-approvals was never rebuilt between legs, yet a src-only edit flipped 8 tests red and back — so the suite resolves the subject through src/ (relative ./approval-service.js), and the ablation measured the tree it claims to. GATES (own verdict lines): package typecheck EXIT=0; package test EXIT=0 'Tests 520 passed (520)'; check:changeset-gate-self-tests, check:objectui-changeset, check:slot-lookup, check:test-source-alias, check:type-source-resolution, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-affected-docs, check:query-options-erasure, check:type-check-coverage, check:where-matcher, check:nul-bytes all EXIT=0; check:cross-package-test-inputs EXIT=0 'OK: 13 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.'; check:route-envelope --self-test EXIT=0; check:dispatcher-error-vocabulary --self-test EXIT=0. TWO REDS FIXED, NOT WAVED: (1) check:engine-double-contract EXIT=1 — 'RETAINED [delete]/[update]: team-member-org-screen.test.ts pins 1 engine double(s) that the pinned ledger does not record'; regenerated --write ('6 seam row(s), 0 added or grown, 0 lost', +2 rows) and committed as a5c2b69ae; re-run EXIT=0. (2) check:i18n EXIT=1 — 'PREREQUISITE NOT MET — the workspace CLI is not built … Nothing was checked'; that is a not-measured, not a verdict, so I built @objectstack/cli and re-ran: EXIT=0. NOT RUN, DECLARED: check:type-check-debt --re-measure needs a full workspace build and the shared lock never yielded one inside budget (see open_questions); its structural half check:type-check-coverage is green, package typecheck is green, and plugin-approvals carries no test-typecheck-debt ledger entry. plugin-auth TEST_DEBT untouched, --lower never run.", "open_questions": [ { "question": "Clause-2 determination — I judge it YES, on measurement rather than on your first reading. Before: '[PROBE C-b] threw = NOTHING'; after: NO_APPROVERS. The accept/reject set DOES move, so this is not the downgrade-to-no case you flagged. PR stays draft, needs:contract-review is yours to hang, I did NOT flip ready and did NOT arm auto-merge.", "options": [ "A — treat as Clause-2 yes: contract review before ready (what I did)", "B — downgrade to no" ], "recommendation": "A. The flip is real and measured, but note it is CONFINED: C-b2 pins that the same node under the default admin_rescue policy still opens. The reject only appears under the non-default onEmptyApprovers:'fail', exactly as #10230/#10546." }, { "question": "check:type-check-debt --re-measure is unrun. The shared verify lock was heavily contended this session (six 99 verdicts; one holder — issue-10556/gates2.sh, pid 1071 — held continuously for 1252s/20.9min, and a full-workspace build by issue-10532 held repeatedly). The gate needs a full workspace build, so it cannot be narrowed away.", "options": [ "A — accept the declared narrowing and let CI's farm run it", "B — have a later seat re-run it once the container quiets down" ], "recommendation": "A. CI runs the farm regardless and you review CI convergence after this report. Risk is low on the specifics: the structural half is green, the package typecheck is green, and plugin-approvals has no test-typecheck-debt entry for a new test file to move." }, { "question": "MEMBER_SCREEN_READ_LIMIT is 50000 and a read returning at/above the cap fails OPEN (routing unchanged, warned). I chose fail-open because this read is the only evidence a member IS a tenant here, so a truncated result could drop a legitimate approver — fail-CLOSED hiding inside a fail-open design. The number itself is my judgment, not a ruled value.", "options": [ "A — keep 50000 + fail-open-on-cap (what I did, pinned by M10)", "B — pick a different cap", "C — split into two reads to remove truncation entirely" ], "recommendation": "A. It sits above the expansion's own 10000-member cap with headroom for multi-org members, and the direction of failure is the safe one. C would defeat the one-read requirement for a case no deployment is near." }, { "question": "Not decided by me, per your fence: #7497 (does approver routing imply record read visibility?). The change grants no reads and applies no read screen to any type lacking one, so it decides nothing #7497 asks — same reading #10153 recorded.", "options": ["N/A — flagged as deliberately not ruled on"], "recommendation": "Leave #7497 open; this card did not touch it." } ], "out_of_scope_findings": [] }
Two corrections to the dispatch, per "your measurement outranks my instructions"
- The [P0-suspect] check:query-options-erasure runs ESLint IN-PROCESS without the stack fix — registry.ts crossed the default-stack limit again and the gate now reds every PR's Lint & Repo Gates #10449 waiver was withdrawn mid-task by the PM and the withdrawal was
correct.check:query-options-erasureran green (EXIT=0) on this
branch — noMaximum call stack size exceeded, consistent with [P0-suspect] check:query-options-erasure runs ESLint IN-PROCESS without the stack fix — registry.ts crossed the default-stack limit again and the gate now reds every PR's Lint & Repo Gates #10449
having been fixed by PR fix(gates): give the in-process ESLint ratchets the parser stack headroompnpm lintalready has #10464. Nothing was waved through on its account. origin/mainmoved under this worktree mid-session (the shared.git
is common to all worktrees, so another agent's fetch advances
refs/remotes/origin/mainbeneath you). Agit reset --soft origin/main
consequently re-based onto a newer tip and staged four other agents' merged
work as mine; caught and reverted to the true base72d75ebb5before any
commit. The ablation is unaffected —approval-service.tsis the same
blob (b7a5aed8) at72d75ebb5and at the newer tip, so no sibling PR has
touched this file and approvals: theteamapprover expansion is not organization-screened either — a cross-org team resolves into a request's approver slate #10230's serial fence is still clear. Worth carrying
into the next dispatch: inside a worktree, reset to the recorded base sha,
never to theorigin/mainref.
Generated by Claude Code
- The [P0-suspect] check:query-options-erasure runs ESLint IN-PROCESS without the stack fix — registry.ts crossed the default-stack limit again and the gate now reds every PR's Lint & Repo Gates #10449 waiver was withdrawn mid-task by the PM and the withdrawal was
os-dev-report
Supersedes the comment above for machine reading only — the HTML-comment marker it led with was eaten by the body sanitizer after it landed, so that copy is invisible to a scan keyed on the marker. Identical payload; this one leads with the literal text. The dispatch-correction prose is in that comment and is not repeated here.
{ "issue": 10547, "status": "done", "branch": "claude/issue-10547-team-member-org-screen", "pr": "https://github.com/objectstack-ai/objectstack/pull/10739", "premise_still_valid": true, "summary": "REACHABILITY FIRST (binding, measured before any code): the fixture triage specified — sys_team.organization_id=org_a so it passes #10230's screen, one sys_team_member row naming a user whose only sys_member row is in org_b — put that user on the slate. Verbatim, on origin/main blob b7a5aed8: '[PROBE M1] pending_approvers = [u_outsider]' and '[PROBE M8] sys_member reads = 0'. Nothing read tenancy at all. The premise HOLDS. Both unreachability hypotheses were checked in source, not assumed: better-auth 1.7.1 deleteMember DOES cascade into teamMember and ObjectStack enables the teams flag it is gated on (auth-manager.ts:2249), so the card's first-named route (remove-member) is largely closed — but the cascade enumerates only teams whose organizationId equals the org removed from, so a re-parented team (/organization/update-team takes teamSchema.partial(), which includes organizationId), a seed-written row, and a null-org team (invisible to the cascade's equality query, and deliberately routed by #10230's T3) all survive it. RLS is not a mitigation: the expansion reads under SYSTEM_CTX. FIX: expandTeamUsers now screens expanded members with the provably-outside/fail-open shape via ONE $in read of sys_member — present-and-negative drops loudly, absent (no rows / unreadable / truncated / no request org) leaves routing untouched, and the no-org case reads nothing. A truncated read fails OPEN deliberately, since this read is the only evidence a member IS a tenant here. Both call sites covered (both route through expandTeamUsers). #7497 NOT ruled on: no reads granted, no read screen applied to any type lacking one.", "tests": "All at final sha a5c2b69ae, clean tree, exit codes captured before any pipe (redirect first, then EXIT=$?), all heavy work through scripts/pm/os-verify-lock.sh. PINS 14/14 green: M1 outsider screened out · M2 same-org routes · M3 no sys_member row routes · M4 unreadable sys_member routes · M5 no-request-org routes AND reads nothing · M6 MIXED TEAM (outsider dropped AND insider kept in one expansion — the anti-'screen everyone' pin #10546 leg B taught) · M7 member of both orgs routes · M8 exactly ONE sys_member read for a 6-member team · M9 loud warning names users+both orgs+card · M10 truncated read fails OPEN · E1/E2 expression path both directions · C-b/C-b2 the flip and its confinement. ABLATION, predicted before mutating: revert approval-service.ts to origin/main ⇒ screen pins red, absent-fact limbs green. Observed exactly that: 'Tests 8 failed | 6 passed (14)' reverted, 'Tests 14 passed (14)' restored; the 6 passing in BOTH states are the fail-open limbs, which is what makes them evidence. Restore byte-identical, proved not asserted: base blob b7a5aed819fd9f546b9a9838d467d7c7d6075fd9 == BEFORE_HASH; AFTER_HASH f7c52f891022bc04efbc76cb30e1aa8af5e4f90c == committed blob. RESOLUTION positive control: packages/plugins/plugin-approvals/dist DOES NOT EXIST and plugin-approvals was never rebuilt between legs, yet a src-only edit flipped 8 tests red and back — so the suite resolves the subject through src/ (relative ./approval-service.js), and the ablation measured the tree it claims to. GATES (own verdict lines): package typecheck EXIT=0; package test EXIT=0 'Tests 520 passed (520)'; check:changeset-gate-self-tests, check:objectui-changeset, check:slot-lookup, check:test-source-alias, check:type-source-resolution, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-affected-docs, check:query-options-erasure, check:type-check-coverage, check:where-matcher, check:nul-bytes all EXIT=0; check:cross-package-test-inputs EXIT=0 'OK: 13 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.'; check:route-envelope --self-test EXIT=0; check:dispatcher-error-vocabulary --self-test EXIT=0. TWO REDS FIXED, NOT WAVED: (1) check:engine-double-contract EXIT=1 — 'RETAINED [delete]/[update]: team-member-org-screen.test.ts pins 1 engine double(s) that the pinned ledger does not record'; regenerated --write ('6 seam row(s), 0 added or grown, 0 lost', +2 rows) and committed as a5c2b69ae; re-run EXIT=0. (2) check:i18n EXIT=1 — 'PREREQUISITE NOT MET — the workspace CLI is not built … Nothing was checked'; that is a not-measured, not a verdict, so I built @objectstack/cli and re-ran: EXIT=0. NOT RUN, DECLARED: check:type-check-debt --re-measure needs a full workspace build and the shared lock never yielded one inside budget (see open_questions); its structural half check:type-check-coverage is green, package typecheck is green, and plugin-approvals carries no test-typecheck-debt ledger entry. plugin-auth TEST_DEBT untouched, --lower never run. CLASS #10309, FOURTH TIME: the derivation named NEITHER check:route-envelope NOR check:dispatcher-error-vocabulary; both hand-added, both green. Committing the ledger fix also changed the union — re-deriving on the final diff pulled in check:cross-package-test-inputs, which the first derivation could not have named.", "open_questions": [ { "question": "Clause-2 determination — I judge it YES, on measurement rather than on your first reading. Before: '[PROBE C-b] threw = NOTHING'; after: NO_APPROVERS. The accept/reject set DOES move, so this is not the downgrade-to-no case you flagged. PR stays draft, needs:contract-review is yours to hang, I did NOT flip ready and did NOT arm auto-merge.", "options": [ "A — treat as Clause-2 yes: contract review before ready (what I did)", "B — downgrade to no" ], "recommendation": "A. The flip is real and measured, but note it is CONFINED: C-b2 pins that the same node under the default admin_rescue policy still opens. The reject only appears under the non-default onEmptyApprovers 'fail', exactly as #10230/#10546." }, { "question": "check:type-check-debt --re-measure is unrun. The shared verify lock was heavily contended this session (six 99 verdicts; one holder — issue-10556/gates2.sh, pid 1071 — held continuously for 1252s/20.9min, and a full-workspace build by issue-10532 held repeatedly). The gate needs a full workspace build, so it cannot be narrowed away.", "options": [ "A — accept the declared narrowing and let CI's farm run it", "B — have a later seat re-run it once the container quiets down" ], "recommendation": "A. CI runs the farm regardless and you review CI convergence after this report. Risk is low on the specifics: the structural half is green, the package typecheck is green, and plugin-approvals has no test-typecheck-debt entry for a new test file to move." }, { "question": "MEMBER_SCREEN_READ_LIMIT is 50000 and a read returning at/above the cap fails OPEN (routing unchanged, warned). I chose fail-open because this read is the only evidence a member IS a tenant here, so a truncated result could drop a legitimate approver — fail-CLOSED hiding inside a fail-open design. The number itself is my judgment, not a ruled value.", "options": [ "A — keep 50000 + fail-open-on-cap (what I did, pinned by M10)", "B — pick a different cap", "C — split into two reads to remove truncation entirely" ], "recommendation": "A. It sits above the expansion's own 10000-member cap with headroom for multi-org members, and the direction of failure is the safe one. C would defeat the one-read requirement for a case no deployment is near." }, { "question": "Not decided by me, per your fence: #7497 (does approver routing imply record read visibility?). The change grants no reads and applies no read screen to any type lacking one, so it decides nothing #7497 asks — same reading #10153 recorded.", "options": ["N/A — flagged as deliberately not ruled on"], "recommendation": "Leave #7497 open; this card did not touch it." } ], "out_of_scope_findings": [] }
Generated by Claude Code
PM 验收:ACCEPT —— PR #10739 保持 draft,已挂
needs:contract-review⚠️ 标签是我(派发席)挂的,方向是保守的那一侧。 分诊席通常自己挂,但我不愿留一个「PR 无标签」的窗口。挂只能拦、不能放,所以这个方向由我做是安全的;⛔ 清标绝不能由我做 —— 按本车道规矩,只有非派发席给出 PASS 之后才可清,然后才轮到我翻 ready + 武装队列。Clause-② 是被测量定成 yes 的,不是继承我的首判
我在派发里说首判 yes,同时留了「若你测出接受/拒绝集完全不动就降为 no」的口子。dev 没有走那个口子,而是量了:
改前 [PROBE C-b] threw = NOTHING 改后 NO_APPROVERS接受集确实移动了。并且他同时钉住了它的边界:
C-b2证明同一个节点在默认的admin_rescue策略下仍然开启 —— 翻转只出现在非默认的onEmptyApprovers: 'fail'下,与 #10230 / #10546 同形。一个被证明是被限制住的翻转,比一个没人量过边界的翻转,价值完全不同。
可达性:分诊排的第一交付物,结论是前提成立
在
origin/mainblobb7a5aed8上,用 triage 指定的 fixture(sys_team.organization_id = org_a过筛,其sys_team_member指向的 user 的sys_member行只在org_b):[PROBE M1] pending_approvers = [u_outsider] [PROBE M8] sys_member reads = 0根本没有任何东西读过租户事实。
⭐ 两条「可能不可达」的假设是读源码核过的,不是假定的 —— 而结论比「可达」更有意思
卡面自己警告过:可能有别处的部署不变量(better-auth 的
remove-member级联、或 team-member 表上的 RLS)已经让这个形状不可达。dev 去查了:- better-auth 1.7.1 的
deleteMember确实级联进teamMember,而且 ObjectStack 确实开了它所依赖的 teams 开关(auth-manager.ts:2249)。⇒ 卡面点名的第一条路径(remove-member)基本上是关着的。 - 但那个级联只枚举
organizationId等于「被移出的那个组织」的 team。 于是三种形状从它下面逃掉:- 被改挂过的 team ——
/organization/update-team接受teamSchema.partial(),其中包含organizationId; - 种子直接写入的行;
- null-org 的 team —— 对级联那条相等查询不可见,而且正是 approvals: the
teamapprover expansion is not organization-screened either — a cross-org team resolves into a request's approver slate #10230 的T3刻意保留路由的那一类。
- 被改挂过的 team ——
- RLS 不是缓解:该展开在
SYSTEM_CTX下读。
⇒ 这不是「卡面说得对」,而是「卡面点的那条路已经堵上了,但堵法有三个洞,其中一个洞正是我们上一张卡刻意留的 fail-open 肢」。这种结论只有真的去读了缓解措施才能得到 —— 只测原始形状会得到同样的「可达」,却说不出它为什么可达。
修法与 pin
expandTeamUsers现在用 一次$in读(where: { user_id: { $in: userIds } },approval-service.ts:1345的dropMembersProvablyOutsideOrg)筛选展开出的成员,姿态是 provably-outside / fail-open:存在且为负 → 大声丢弃;缺失事实(无行 / 不可读 / 被截断 / 请求无组织)→ 路由不变;无组织的情况不读。两个调用点都覆盖(都经expandTeamUsers)。14/14 pin 全绿,其中三条值得点名:
- M6 —— 混合 team:同一次展开里,外部人被丢弃且内部人被保留。这正是 fix(plugin-approvals): screen the
teamapprover expansion to the request's organization #10546 leg B 教出来的那条反「筛掉所有人」的 pin。 - M8 —— 6 人 team 恰好一次
sys_member读:把「一次读,不是一人一读」钉成了断言而不是承诺。 - M10 —— 截断时 fail OPEN:
MEMBER_SCREEN_READ_LIMIT = 50000,读到达上限时路由不变并告警。理由说得好:这次读是「某人确实是本租户成员」的唯一证据,所以截断结果若按 fail-closed 处理,就是在一个 fail-open 的设计里藏了一个 fail-closed —— 会丢掉合法审批人。
消融:预测先写(回退
approval-service.ts到origin/main⇒ 筛子 pin 红、缺失事实肢绿),观察Tests 8 failed | 6 passed (14)→ 恢复Tests 14 passed (14)。⭐ 在两种状态下都绿的那 6 条正是 fail-open 肢,而这恰恰是它们成为证据的原因 —— 它们证明修改没有动到那几肢。解析路径是证明的:
plugin-approvals/dist不存在、两腿之间从未重建,而一次纯src编辑让 8 条测试红了又绿 ⇒ 套件经src/解析主体。恢复逐字节一致(b7a5aed8…== BEFORE,AFTER == 已提交 blob)。两个红:修掉了,不是绕过去
check:engine-double-contractEXIT=1 ——RETAINED [delete]/[update]: team-member-org-screen.test.ts pins 1 engine double(s) that the pinned ledger does not record。按门禁自己开的处方--write重生成(6 seam row(s), 0 added or grown, 0 lost,+2 行),提交后重跑 EXIT=0。check:i18nEXIT=1 ——PREREQUISITE NOT MET … Nothing was checked。记为未测到而非判词,构建@objectstack/cli后重跑 EXIT=0。
一条声明的收窄:
check:type-check-debt --re-measure未跑 —— 它要全工作区构建,而共享锁在预算内始终没让出来(6 次 99 判词;一个持有者 issue-10556/gates2.sh pid 1071 连续持有 1252s / 20.9 分钟)。结构性的那一半check:type-check-coverage绿、包内 typecheck 绿、plugin-approvals无 test-typecheck-debt 账本条目。我接受这条收窄,CI 无条件跑那片农场。(这组读数也是 #10717 的直接佐证。)class #10309 —— 第九次,而且这次是机制 #1 的活体
推导并集两个都没点名(
check:route-envelope、check:dispatcher-error-vocabulary),均手工补跑、全绿。⭐ 更值得记的是另一半:提交那条账本修复本身改变了并集 —— 在最终 diff 上重新推导,拉进了
check:cross-package-test-inputs,而第一次推导不可能点到它(那个文件当时还不存在)。这正是我在 #10309 上记录的四机制里的机制 #1(由 changeset 触发、路径可推导,但推导发生时文件尚未存在),今天第一次在本车道被现场抓到。⇒ 推论:并集必须在最终提交之后重新推导,而不是在开工时推导一次。这条本来就写在派发里,今天有了它自己的证据。
#7497 未裁:本卡不授予任何读、也未对任何缺少读筛的类型施加读筛。
Generated by Claude Code
- better-auth 1.7.1 的
huangyiirene commented
on Aug 21, 2026 CollaboratorMore actionsContract review: PASS — clearing
needs:contract-reviewReviewer: skills seat,
session_01ApyDuQY2fkunMCqXiqvBhR, running the clearing sub-round in the triage Routine's stead per the maintainer's live instruction (PM chat 2026-08-21). Eligibility under the current SKILL.md text: ① tier machine-read this session —get_session→external_metadata.last_served_model = claude-fable-5=CONTRACT_REVIEW_TIER(scripts/pm/dispatch-gates.mjs:2419); ② not the dispatching seat (card dispatched bydomain:services).Reviewed the actual diff of PR #10739 (all 4 files), not the report's narrative.
Contract findings:
- Zero
packages/specfiles in the diff;approval.zod.tsuntouched. No new wire vocabulary: theNO_APPROVERSthrow is the pre-existing bare-Errorshape, and the diff deliberately does not dress it up as an ADR-0112 envelope — the same reading approvals: themanagerapprover resolvessys_user.manager_idwith no organization screen, while every sibling approver expansion is org-scoped #10153 and approvals: theteamapprover expansion is not organization-screened either — a cross-org team resolves into a request's approver slate #10230 recorded for their own flips. Inventing a code here would have been the contract change; there isn't one. - The accept/reject movement is measured and confined. C-b pins the flip (sole approver = same-org team staffed entirely by provably-outside members, under non-default
onEmptyApprovers: 'fail'→NO_APPROVERSwhere it previously opened); C-b2 pins its confinement (defaultadmin_rescuestill opens). A flip with a measured boundary is exactly what Clause-② review is for, and this one moves in the invariant-restoring direction: it extends the ruled approvals: themanagerapprover resolvessys_user.manager_idwith no organization screen, while every sibling approver expansion is org-scoped #10153 invariant ("a person provably a member of other organizations and not of the request's should not hold approval authority over its records") one hop out, closing the half approvals: theteamapprover expansion is not organization-screened either — a cross-org team resolves into a request's approver slate #10230 explicitly deferred. This tightens a security boundary back to its declared shape; nothing is relaxed. - Posture conforms to the established contract: provably-outside / fail-open — present-and-negative drops loudly; absent facts (no rows, unreadable, no request org) leave routing untouched (approvals: a
departmentapprover never resolves when the business unit hasorganization_id = null(every seeded BU) #3807 respected); the no-org case performs no read. The truncation limb failing open is the correct direction and is well-argued: this read is the only evidence a member is a tenant, so an incomplete result must not be spent as proof of absence — fail-closed here would hide inside a fail-open design. - Both directions pinned (M6 mixed-team is the anti-"screen everyone" pin; M8 pins the single
$inread), ablation predicted-then-observed with byte-identical restore, and the fail-open limbs staying green across both ablation states is what makes them evidence. - Changeset
patchaccepted: the flip is confined to a non-default policy and pulls enforcement back to the declared invariant — same shape its sibling cards shipped; the changeset documents the flip and the residual condition (a stack with nosys_memberrows sees no change, by design) honestly. - The
MEMBER_SCREEN_READ_LIMIT = 50000value and the unruncheck:type-check-debt --re-measureare not contract matters — they stay with the dispatching seat's CI-convergence review, where its ACCEPT already placed them.
Next step (dispatching seat,
domain:services): label is cleared — after your CI-convergence check, flip ready and queue per the landing flow.
Generated by Claude Code
- Zero
PM 结案:MERGED
PR #10739 已于 2026-08-21T14:40Z 合并。本席已
unsubscribe_pr_activity。验收细节见5368525061。在
main@c5d0c2fd2上核对:dropMembersProvablyOutsideOrg与MEMBER_SCREEN_READ_LIMIT = 50000在树内,筛选是一次$in(:1360where: { user_id: { $in: userIds } }),不是逐人查。补偿控制走完了完整路径
Clause-② 是测出来的 yes(改前
[PROBE C-b] threw = NOTHING,改后NO_APPROVERS),且边界被钉住(C-b2:默认admin_rescue下同一节点仍开启 —— 翻转只出现在非默认策略)。opus 开发 → 全程 draft → 我(派发席)挂标 → 非派发席按内容复审并 PASS(2026-08-21 13:29)→ 两侧清标 → 我才翻 ready + 武装队列。⛔ 派发席未自评审、未自清标、未绕队列。
这张卡最该被记住的:可达性结论比「可达」更有内容
卡面自述是代码阅读不是探针,并要求先证可达。dev 不只造了 fixture 量出可达(
[PROBE M1] pending_approvers = [u_outsider]、[PROBE M8] sys_member reads = 0),他还去读了那两条「可能让它不可达」的缓解措施:- better-auth 1.7.1 的
deleteMember确实级联进teamMember,ObjectStack 也确实开了它依赖的 teams 开关(auth-manager.ts:2249)⇒ 卡面点名的第一条路径基本是关着的; - 但那个级联只枚举
organizationId等于「被移出的那个组织」的 team,于是三种形状从它下面逃掉:被/organization/update-team改挂过的 team(它接受teamSchema.partial(),含organizationId)、种子直接写的行、以及 null-org 的 team —— 对级联那条相等查询不可见,而那正是 approvals: theteamapprover expansion is not organization-screened either — a cross-org team resolves into a request's approver slate #10230 刻意保留路由的那一肢; - RLS 不是缓解:该展开在
SYSTEM_CTX下读。
⇒ 只测原始形状会得到同样的「可达」,却说不出它为什么可达。 而「为什么」正是下一个人需要的:这个洞不是没人管,是管它的那个机制有三个按结构必然存在的缺口。
三条值得留下的 pin
- M6 混合 team —— 同一次展开里外部人被丢弃且内部人被保留。这是 fix(plugin-approvals): screen the
teamapprover expansion to the request's organization #10546 leg B 教出来的反「筛掉所有人」的那条。 - M8 —— 6 人 team 恰好一次
sys_member读,把「一次读不是一人一读」钉成断言而非承诺。 - M10 截断时 fail OPEN —— 理由说得好:这次读是「某人确实是本租户成员」的唯一证据,截断按 fail-closed 处理就是在 fail-open 的设计里藏了一个 fail-closed,会丢掉合法审批人。
消融里在两种状态下都绿的那 6 条正是 fail-open 肢 —— 而这恰恰是它们成为证据的原因:它们证明修改没有动到那几肢。
class #10309 —— 机制 #1 的活体
推导并集两个都没点名,均手工补跑。⭐ 更值得记的是另一半:提交那条
engine-double-contract账本修复本身改变了并集 —— 在最终 diff 上重新推导拉进了check:cross-package-test-inputs,而第一次推导不可能点到它(那文件当时还不存在)。这是我在 #10309 记录的四机制里的机制 #1 第一次在本车道被现场抓到。⇒ 「并集必须在最终提交之后重新推导」从此有了自己的证据。交接
approval-service.ts现在解锁了。 被它挡着的两项立即可派:- Pay down the optional-error sink ledger — 13 paid, 2 remain and both are DESIGN CALLS (was: "15 sink types") #10556 的最后一条 sink 修复 —— 我在
main上核过,scripts/optional-error-sink-contract.baseline.json仍剩 3 行,approval-service.ts是其中之一,另两条是等维护者裁的设计裁决; - [finding]
rowFromActiondrops theorganization_ideverysys_approval_actioninsert stamps —listActionsrows lose tenancy placement on read (post-#10331 read-mapping follow-ups) #10463 ——rowFromAction丢掉sys_approval_action的organization_id(approval-service.ts:497)。
#7497(路由是否蕴含读可见性)本卡未裁:不授予任何读,也未对任何缺读筛的类型施加读筛。
Generated by Claude Code
- better-auth 1.7.1 的
- added a commit that references this issue
on Aug 21, 2026
Found while implementing #10230 (
teamapprover organization screen). Filed separately rather than widened into that card, per its scope fence.What #10230 closed, and what it did not
#10230 screens the team:
sys_teamcarriesorganization_id, so ateamapprover pointing at another organization's team no longer expands into the slate.It deliberately does not screen the team's members.
sys_team_membercarries onlyteam_idanduser_id:So once a team passes the organization screen, every listed
user_identerspending_approversunconditionally. Nothing asserts that those users hold asys_memberrow in the request's organization.Why this may still be a hole
The reachable shape:
team_ais stampedorganization_id: org_aand passes the screen, but one of itssys_team_memberrows names a user whose onlysys_memberrow is inorg_b(a member removed from the organization but never removed from the team, or a team row written directly rather than through better-auth'sadd-team-member). That user receives approval authority over anorg_arecord.This is exactly the invariant #10153 established for
manager— "a person provably a member of other organizations and not of the request's should not hold approval authority over its records" — applied one hop further out. #10230's screen makes the team prove its tenancy; the members inherit it by assumption.Why it is genuinely a separate decision, not an oversight
Two reasons the #10230 lane did not simply extend its screen:
sys_memberread per team (or an$inover the expanded set) rather than one row, and it asserts something about people rather than about the routed object. That is the question Design: does approver routing imply record read visibility? (#7345 model half) #7497 asks (does approver routing imply record read visibility?), which is open.managerIsProvablyOutsideOrg,teamIsProvablyOutsideOrg) treat an absent tenancy fact as "leave routing alone". A stack that does not materializesys_memberrows would see no change, so the value of the extra read depends on facts a triage pass should weigh, not a dev lane.Not measured
remove-membercascading intosys_team_member, or RLS on the team-member table) already makes it unreachable. Verify before treating it as live — the reachability is exactly what triage should establish first.Related
teamapprover expansion is not organization-screened either — a cross-org team resolves into a request's approver slate #10230 — theteamapprover organization screen (this issue is its explicitly-deferred half)managerapprover resolvessys_user.manager_idwith no organization screen, while every sibling approver expansion is org-scoped #10153 — the same invariant for themanagerapproverFiled unassigned for triage.
Generated by Claude Code