Uh oh!
There was an error while loading. Please reload this page.
fix(approvals): screen expanded team members to the request's organization - #10739
Conversation
…ation (#10547) #10230 made a `team` approver prove the TEAM's tenancy and deferred its members on purpose. `sys_team_member` carries `team_id` and `user_id` and no organization column, so a team that passed that screen still routed every user id it listed. Measured on a fixture before the change, not read off the schema — an `org_a` request against an `org_a` team whose member holds membership only in `org_b`: [PROBE M1] pending_approvers = ["u_outsider"] [PROBE M8] sys_member reads = 0 The expansion now screens the members with the provably-outside (fail-open) posture `managerIsProvablyOutsideOrg` and `teamIsProvablyOutsideOrg` already pin, in ONE `$in` read for the whole slate: - membership rows exist and none is the request's organization => present and NEGATIVE => dropped, loudly; - no rows, an unreadable table, a possibly-truncated read, or a request carrying no organization => ABSENT => routing unchanged, and the last case reads nothing. A truncated read fails OPEN deliberately: this read is the only evidence that a member IS a tenant here, so incomplete evidence must not be spent as proof of absence. #3807 is the recorded cost of reading an absent fact as a negative one. Decides nothing #7497 asks: no reads are granted and no read screen is applied to any approver type that lacks one today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0f14f70b-575c-5f2b-a235-4000a55db042
`check:engine-double-contract` reported the new `team-member-org-screen.test.ts` double as RETAINED — pinned coverage the ledger did not yet record, so it protected nothing. Regenerated with `--write`: 2 rows added (one delete, one update), none lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0f14f70b-575c-5f2b-a235-4000a55db042
📓 Docs Drift CheckThis PR changes 1 package(s): 29 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f79af62bc7e4da6ae2650f7889babf6846c75051 && git checkout f79af62bc7e4da6ae2650f7889babf6846c75051
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5f2e54cc66330cbc53a17f6e3746acdfcdc14704 35dfee3537c6026e510f2e3d265a2071a4f2719a && git checkout -B drift-repro 5f2e54cc66330cbc53a17f6e3746acdfcdc14704 && git merge --no-ff 35dfee3537c6026e510f2e3d265a2071a4f2719a
node scripts/docs-audit/affected-docs.mjs --json 5f2e54cc66330cbc53a17f6e3746acdfcdc14704
|
Uh oh!
There was an error while loading. Please reload this page.
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32492087926 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
…sink Drops the `?` from `warn` on ApprovalServiceOptions['logger'] so a sink declaring an optional `error` always has a guaranteed durability-report channel (#9754, #10556). `error` stays optional. This is the thirteenth of the card's thirteen mechanical repairs -- held out of #10691 to serialize against PR #10547, which owned this file while open; that fence cleared when #10739 merged. Deletes the now-stale row from the shrink-only scripts/optional-error-sink-contract.baseline.json (the checker itself named the row as no-longer-red). The other two rows -- plugin-security's default-sink design call and service-settings' no-fallback design call -- are untouched; both remain escalated to the maintainer. No construction site needed a widening fix: the sole non-test call (ApprovalsServicePlugin.start) passes ctx.logger, whose warn is already required by the core Logger contract. Part of #10556. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0f14f70b-575c-5f2b-a235-4000a55db042
Fixes#10547
Reachability first — the card asked to be verified before it was believed
#10547 filed itself as a code reading, not a probe, and triage made the
measurement the first deliverable. Measured on this tree, one fixture, before
any change (
origin/mainblobb7a5aed8):The fixture is the one the card describes:
sys_team.organization_id = org_a(so it passes #10230's screen) and one
sys_team_memberrow naming a userwhose only
sys_memberrow is inorg_b. He enteredpending_approvers, andsys_memberwas never read at all. The hole is reachable — the premiseholds.
The two unreachability hypotheses the card raised, checked rather than assumed
remove-membercascade — real, and it does close thefirst-named route.
deleteMember(better-auth 1.7.1,plugins/organization/adapter.mjs:305) deletes the user'steamMemberrows,and ObjectStack enables the feature it is gated on (
teams: { enabled: true },plugin-auth/src/auth-manager.ts:2249). A member removed from theorganization through that endpoint is taken off that organization's teams.
enumerates the teams whose
organizationIdequals the organization themember was removed from, and deletes only within those teams. Routes it does
not cover, all still live:
/organization/update-teamtakes
teamSchema.partial(), which includesorganizationId, and nothingrevisits the existing member rows;
sys_team_memberrow written by a seed or the engine rather thanthrough
add-team-member;organization_id: null— invisible to the cascade'sequality query, while approvals: the
teamapprover expansion is not organization-screened either — a cross-org team resolves into a request's approver slate #10230's screen deliberately lets it route (its T3).SYSTEM_CTX.The fix
expandTeamUsersnow screens the expanded members with the provably-outside(fail-open) posture
managerIsProvablyOutsideOrgandteamIsProvablyOutsideOrgalready pin, in one$inread for the wholeslate:
negative) — dropped, loudly;
sys_member, a possibly-truncated read, or a requestcarrying no organization (absent) — routing unchanged; the last case
performs no read at all.
Holding membership elsewhere is not disqualifying; holding none here is.
A truncated read fails open deliberately: this read is the only evidence
that a member is a tenant here, so an incomplete result must not be spent as
proof of absence — that would invert the posture into fail-closed exactly where
it must not. #3807 is the recorded cost of reading an absent fact as a negative
one (
nullon a platform object means "owned by no organization", which is whata seed writes).
Both call sites are covered, because both route through
expandTeamUsers: thestatic
teambranch (screened against the request org) and theexpression/resolveAs: 'team'branch (screened againstdirectoryOrg, thedirectory actually being consulted) — each against the same organization whose
tenancy the team itself just proved.
#7497 is not ruled on here. The change grants no reads and applies no read
screen to any approver type that lacks one today — the same reading #10153
recorded for its own screen. #10230 landed under this posture without waiting on
#7497, and so does this.
Pins — two directions, because one direction is not a test
sys_memberrow at all still routes (absent fact, #3807)sys_memberstill routes (fail-open on a fault)expressionpathM6 is the anti-"screen everyone" pin in a single assertion: an implementation
that dropped every member yields
['team:team_a'], an unscreened one yieldsboth users, and only the correct one yields exactly the insider. Pinning M1
alone would sit green over the first of those — which is what PR #10546's
ablation leg B demonstrated on the sibling card.
Ablation — predicted before it was run, both legs, hash-proved
Predicted signature, written before mutating: reverting
approval-service.tsto
origin/mainshould turn the screen pins red (M1, M6, M8, M9, M10, E1,C-b, C-b2) while leaving every absent-fact limb green (M2, M3, M4, M5, M7,
E2), because those limbs assert behaviour the fix does not change.
Observed, exactly that: 8 failed | 6 passed (14) on the reverted tree,
14 passed (14) restored. The six that pass in both states are the
fail-open limbs, which is what makes them evidence rather than decoration.
Restore proved byte-identical, not asserted:
Resolution — measured, not assumed:
packages/plugins/plugin-approvals/distdoes not exist, and
plugin-approvalswas never rebuilt between the twolegs, yet editing
src/approval-service.tsalone flipped 8 tests red and backto green. The suite therefore resolves the subject through
src/(the relative./approval-service.jsimport), so the ablation measured the tree it claims to.Release condition
The screen can only act on tenancy facts that exist. A deployment that stamps
an organization on its approval requests but does not materialize
sys_memberrows sees no behaviour change at all — by design, and it is the same residual
#10230 shipped with (teams seeded
organization_id: nullkeep today's routing).This is a condition on the fix's security value, not a footnote: the hole is
closed for stacks that record membership, and unchanged for stacks that do not.
Behaviour change
Confined to one non-default policy. A node whose only approver is a team staffed
entirely by users provably outside the organization now resolves to nobody:
under the default
onEmptyApprovers: 'admin_rescue'it still opens (routed tothe dead
team:plus the team id literal, as any unresolved slate is); underonEmptyApprovers: 'fail'it now throwsNO_APPROVERSwhere it previouslyopened. That throw is pre-existing code and a bare
Error, not a mintedADR-0112 envelope, so C-b asserts the message shape and invents no
code/status.Verification
Gate union derived with
node scripts/pm/dispatch-gates.mjs, no patharguments, after the final commit on a clean tree, at
a5c2b69ae. Exit codescaptured before any pipe.
Green: package
typecheck, packagetest(520 passed),check:changeset-gate-self-tests,check:objectui-changeset,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check:cross-package-test-inputs,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-affected-docs,check:query-options-erasure,check:type-check-coverage,check:engine-double-contract,check:where-matcher,check:i18n,check:nul-bytes,check:route-envelope --self-test,check:dispatcher-error-vocabulary --self-test.Two gates went red and were fixed rather than waved through:
check:engine-double-contract— "RETAINED [delete] / [update]: … pins 1engine double(s) that the pinned ledger does not record". Regenerated with
--write(2 rows added, 0 lost), committed asa5c2b69ae.check:i18n— "PREREQUISITE NOT MET — the workspace CLI is not built …Nothing was checked". That is a not-measured, not a verdict. Built
@objectstack/cliand re-ran: green.Class #10309, fourth consecutive time in this lane: the derivation did
not name
check:route-envelopeorcheck:dispatcher-error-vocabulary. Bothwere added by hand and both are green. Committing the ledger fix also changed
the answer: re-deriving on the final diff pulled in
check:cross-package-test-inputs(green — "13 package(s) read outsidethemselves, all declared"), which the first derivation could not have named.
The union is a function of the final diff, not the initial one, which is why
it is derived after the last commit rather than before the first.
Not done
check:type-check-debt --re-measureis not run here — it requires a fullworkspace build, and the shared verify lock did not yield one inside this
session's budget. Declared, not silently skipped. Its structural half
(
check:type-check-coverage) is green, the packagetypecheckis green, andplugin-approvalscarries no test-typecheck debt ledger entry, so the expectedrisk is low; CI runs the farm regardless.
@objectstack/plugin-auth's TEST_DEBT was not touched and--lowerwas neverrun.
Generated by Claude Code