Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-approvals): screen the team approver expansion to the request's organization - #10546
Conversation
…quest's organization `team` was the last approver expansion that resolved people without asking which organization was asking. A request in org_a routed to a team stamped `organization_id: org_b` placed that team's members into pending_approvers, handing approval authority over the record outside its tenant. Screen the TEAM rather than its members: `sys_team` carries `organization_id` outright, so a team id transitively names exactly one organization and one row answers the question — unlike `sys_user` (#10153's `manager` screen), which carries no tenancy fact and must be placed via `sys_member`. The screen is fail-open on an ABSENT tenancy fact (null org stamp, missing row, unreadable table, request with no organization) and fail-closed only on a present and negative one, matching `managerIsProvablyOutsideOrg` and `businessUnitOrgScope`. Both call sites are threaded: the static `team` branch screens against the request's own organization, and the `expression` / `resolveAs: 'team'` branch against `directoryOrg` — `expression` IS org-scoped, so a declaration there retargets a sibling organization legitimately. Fixes#10230 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…d ledger `check:engine-double-contract` retained the new test file's delete/update doubles as unrecorded coverage. Regenerated with --write: 2 rows added, 0 lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
📓 Docs Drift CheckThis PR changes 1 package(s): 24 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 832ee1ee7606de5ccb7a9c3c0afcfcd9e3ae9624 && git checkout 832ee1ee7606de5ccb7a9c3c0afcfcd9e3ae9624
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 359f5956d7910aed7ae9f8fccc9fbb988b3e4882 20f511ece0b50f358e9a2bf21c858f307caf9f42 && git checkout -B drift-repro 359f5956d7910aed7ae9f8fccc9fbb988b3e4882 && git merge --no-ff 20f511ece0b50f358e9a2bf21c858f307caf9f42
node scripts/docs-audit/affected-docs.mjs --json 359f5956d7910aed7ae9f8fccc9fbb988b3e4882
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10230
teamwas the last approver expansion that resolved people without asking whichorganization was asking. A request raised in
org_a, routed to ateamapproverwhose
sys_team.organization_idisorg_b, placed that team's members intopending_approvers— approval authority over a record they are not a tenant of.Premise, reproduced before editing anything
One tree, one run, on
dd41df30c, from this package's existing harness:Exactly the card's readings: the unscreened type routes the foreign people, the
screened sibling next to it in the same run does not.
Why "thread an organization" and not "bring it under
bounded(...)" — measured, not inferredThe card named the
bounded(...)asymmetry as the core clue:teamis the onlygraph-shaped type not wrapped in it. The asymmetry is real, but the obvious repair
it suggests is a no-op, and this is the measurement rather than the reading:
bounded(...)appliesfilterApproversWhoCanReadonly whencrossOrg === (directoryOrg !== organizationId).directoryOrgcomes fromresolveApproverDirectoryOrg(...), which returns therequest's own organization when no
organizationis declared, and throws(
VALIDATION_FAILED) when one is declared on a type that is not org-scoped.APPROVER_ORG_SCOPED.teamisfalse. So for a staticteamapprovercrossOrgis structurally always false — the wrapper can never fire.Measured on this tree by temporarily wrapping the team branch in
bounded(...):An unchanged reading is the ambiguous result, so it carries a positive control:
forcing the same branch to yield nobody flipped the same probe to
["team:team_b"]and turned the test red, proving the edit reached the runtime.Both temporary edits were reverted; the file's blob hash is byte-identical to its
committed state.
⇒
bounded(...)would have added a wrapper that never runs. The fix has to makethe expansion itself ask.
The screen: the team, not its members
This is where the twin cards genuinely diverge, and #10334's shape is deliberately
not copied.
sys_usercarries no tenancy fact at all, so #10153'smanagerscreen can only place a person through his
sys_memberrows.sys_teamcarriesorganization_idoutright, so a team id transitively names exactly oneorganization: one row answers the question, and a team that fails the screen
never fans out to its members at all.
Screening the members instead would be both a wider read and a different
assertion — it would rule on #7497 (does approver routing imply record read
visibility?), which this card does not.
Posture is the sibling's, fail-open on an absent tenancy fact and fail-closed
only on a present and negative one:
organization_idpresent, ≠ request orgorganization_idpresent, = request orgorganization_idnullsys_teamrow at allsys_teamunreadableThe null limb is #3807's ruling, not timidity: null on a platform object means
"owned by no organization" — what a seed writes, since a seed cannot know the id
the runtime mints at boot — and #3807 is the card where treating that as "not
mine" made every seeded
departmentapprover resolve to nobody. Measured heretoo: this package's own
team_okexpansion fixture is such a stack.Both call sites, and why they pass different organizations
teambranch → the request's ownorganizationId.teamis notorg-scoped, so a declaration on it is refused upstream and
directoryOrgisprovably equal here; naming the request org says what the screen asserts.
expression/resolveAs: 'team'→directoryOrg.expressionisorg-scoped, so a declaration there retargets a sibling organization
legitimately, and the team must belong to the directory actually being
consulted.
filterApproversWhoCanReadthen applies the D2 read screen on top,exactly as it already does for the other
resolveAskinds.Failure shape: consistent with
position, and that is a measurementThe card asked whether a different failure shape is warranted here. Measured in
one run on one tree (
X1): both fall back to the deadtype:valueliteral.No reason to diverge: the literal fallback is this file's single answer to "the
graph resolved nobody", it keeps 15.x stored slots and substring fixtures working,
and #3807's warning makes it visible instead of silent.
Contract impact (Clause ②) — this narrows an externally observable accept set
Kept
yes, and it reproduces on this branch rather than being inherited from thesibling card. Under the non-default
onEmptyApprovers: 'fail'policy a node whosesole approver is a cross-org team used to open a request and now throws
NO_APPROVERS(C-a). Under the defaultadmin_rescuethe node still opens(
C-a2), which confines the flip to one non-default policy. That throw ispre-existing code and a bare
Error, not a minted ADR-0112 envelope, so there isno
code/statusto assert on it — the same reading #10153's file recorded forits own flip.
PR intentionally stays draft pending
needs:contract-review. Not flippedready; no auto-merge.
What this implies for
APPROVER_ORG_SCOPED— and why that table is untouched⛔ No
packages/specfile is modified by this PR.approval.zod.tsis read only.The card is explicit that
team: false/manager: falseanswers ADR-0105 D9retargetability ("does an
organization:declaration apply to this type"), not"is the expansion screened to the request's organization". Both flags stay
falseand both remain correct: neither type consults an org-scoped directory, so an
organizationdeclaration on either would still have no effect and is stillrightly refused.
What has changed is that the pairing no longer implies anything shared. Before
#10153 the two
falserows also happened to be the two unscreened expansions;after this PR they are simply the two types you cannot retarget, and both are
screened. That removes the reading the card warned about — that moving one out
of the pair would be "splitting the class" — because there is no longer a
screening class hiding behind the retargetability table. The table's own docblock
does carry a now-stale justification (it explains
team'sfalsepartly via"
sys_team_membercarries no organization column and the engine never scoped it",which is still true of
sys_team_memberbut no longer describes the engine).Correcting that comment is a
packages/specedit this lane does not own; filedseparately as #10548 rather than smuggled in.
Tests
New:
packages/plugins/plugin-approvals/src/team-approver-org-screen.test.ts— 12pins, two-directional throughout:
T1cross-org team screened out,T2same-org team still resolves,
T3/T4/T5the three absent-tenancy-fact limbsstill resolve,
T6no-org request untouched and reads nosys_team,T7the dropis loud,
E1/E2both directions through theexpressionpath,X1the siblingshape contrast,
C-a/C-a2the accept-to-reject flip and its confinement.Inverted:
manager-approver-org-screen.test.ts'sPROBE W, which #10153 wroteexplicitly to hand this gap off to this card.
Ablation
Predicted signature written before mutating; mutation = deleting the screen call
from
expandTeamUsers.T1,T7,E1,X1,C-a,C-a2,W(7).T2,T3,T4,T5,T6,E2(6) — every one of theseasserts routing is unchanged, which an unscreened build also satisfies. That is
precisely why the one-directional half cannot be the only pinning.
Tests 7 failed | 15 passed (22), the named seven, exactly.git checkoutthengit hash-object→b7a5aed819fd9f546b9a9838d467d7c7d6075fd9, identical to the committed blob(ablated blob was
1ac86f7fffc740aee46bece1cddd459fa42afd9c). Restore legre-measured green:
Tests 22 passed (22).Resolution path: these tests import
./approval-service.jsrelatively, withinthe same package, so vitest resolves to
src/and nodist/is involved — andthat is established by the positive control above rather than assumed.
Package suite:
Tests 519 passed (519)(baseline before this change: 507; +12 new).typecheck: clean.Gates
Union derived by
node scripts/pm/dispatch-gates.mjs(no hand-written path list),re-derived after the final commit — which is how
check:cross-package-test-inputsentered the set, matched via the
scripts/**ledger path that only the secondcommit created. All run on a clean worktree at
20f511ece, exit codes capturedbefore any pipe:
check:changeset-gate-self-tests·check:cross-package-test-inputs·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:engine-double-contract·check:where-matcher·check:query-options-erasure·check:i18n·check:nul-bytes·check:type-check-coverage·check:type-check-debt --re-measure— 17/17 exit 0.Verdict lines the ratchets printed for themselves:
check:engine-double-contractretained the new file'sdelete/updatedoubles asunrecorded coverage and prescribed its own repair; regenerated with
--write—2 rows added, 0 lost, no DEBT entry raised.
@objectstack/plugin-auth'sTEST_DEBT is untouched at 109 (its file is unmodified in this branch, and the
--re-measureabove re-ran it).Lint & Repo Gatesmay still go red oncheck-query-options-erasure-ratchetwith
packages/spec/src/migrations/registry.ts:0:0 — Parsing error: Maximum call stack size exceeded. That is #10449 (the gate calls ESLint in-process and bypasses--stack-size=4000), red onmain's population and unrelated to this diff.Release 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'scross-org routing for those teams, by the deliberate #3807 fail-open above. That is
the accepted residual, and it is a condition on the fix's security value rather
than a footnote: for this to actually close the boundary in a group-posture
deployment, teams must be created through better-auth's
create-team(which stampsorganizationId) or otherwise backfilled. Teams created by seed and neverbackfilled remain routable across the boundary.
Out of scope, filed as #10547:
sys_team_memberrows carry no tenancy fact oftheir own, so a team that passes this screen still routes to every listed member
even if that member holds no membership in the organization.
Generated by Claude Code