Uh oh!
There was an error while loading. Please reload this page.
fix(sharing): honour the declared organizationId in managerOf (#10231) - #11285
Conversation
ITeamGraphService.managerOf declares an organization parameter that TeamGraphService.managerOf spelled _organizationId and discarded, while expandRoleUsers on the same class applied organization_id to its read. The BusinessUnitGraphService standalone fallback read sys_user the same unscreened way, so the answer depended on whether a teamGraph was passed to the ctor. Both limbs now apply the screen #10153 landed for the identical column on the approvals side: a manager provably outside the caller's organization is dropped. The read is sys_member because sys_user carries no organization_id — it is the global better-auth identity table — so filtering the sys_user read would match nothing and silently return null for every lookup. Fail-open on an absent tenancy fact, and no query at all when no organization is in play. The manager cache key is now organization-qualified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
…r honest The double declared unused insert/update/delete members, enrolling it in the engine-double dispatch contract for methods managerOf never reaches, and its WHERE matcher walked Object.entries unconditionally — it would have read a combinator key as a field name and answered "no match" instead of refusing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
📓 Docs Drift CheckThis PR changes 1 package(s): 28 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 — 8 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 3b7f51dc91047bb22eaf47d78c95578a8b4b88ba && git checkout 3b7f51dc91047bb22eaf47d78c95578a8b4b88ba
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e353c9b50f830e2a6a533b4e45ad1faa5b6a4aab a620ecdf806788d0c390d404c40470f3bb559e6e && git checkout -B drift-repro e353c9b50f830e2a6a533b4e45ad1faa5b6a4aab && git merge --no-ff a620ecdf806788d0c390d404c40470f3bb559e6e
node scripts/docs-audit/affected-docs.mjs --json e353c9b50f830e2a6a533b4e45ad1faa5b6a4aab
|
os-sam
commented
Aug 23, 2026
PM: CI red on Job The signatureIt died in the Why this is not attributable to the diffTwo independent readings, neither of them "it looks flaky":
⛔ For the record, this is not me calling it a flake and moving on. "Flake" is not a root cause. The sanctioned category here is narrower and this failure sits squarely inside it: a job that died before any test body ran (checkout / install / runner loss) may be re-run once, in total. That is the whole of the allowance and I am not stretching it. What I did, and what happens next
Branch head is unchanged at ⛔ If the re-run reproduces the same signature, that is a different conclusion — a second identical failure stops being a bootstrap blip and becomes a real signal, and it will not be re-run a third time. Equally, ⛔ if a different signature appears, it is a new problem and gets diagnosed from scratch, not re-thrown at the queue. Generated by Claude Code |
os-sam
commented
Aug 23, 2026
Correction to my previous comment — the head has moved, and the re-run allowance was never spent. I wrote: "Branch head is unchanged at The branch head is now Consequences, so nobody acts on the stale reading:
Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10231
ITeamGraphService.managerOfdeclares an organization parameter — in prose, ittakes a user id plus an optional organization id and returns a promise of
string-or-null.
TeamGraphService.managerOfspelled that parameter_organizationIdand discarded it, whileexpandRoleUserson the same classapplied
organization_idto its own read. TheBusinessUnitGraphServicestandalone fallback read
sys_userthe same unscreened way.Both limbs now honour it.
The card's premise moved, and the correction matters
Triage scoped this as
IHierarchyScopeResolver.managerOfbeing adeclared-but-unenforced parameter on that seam. Measured on
main, thatattribution is wrong in a way worth recording:
IHierarchyScopeResolverdoes not declaremanagerOfat all. Its onlymember is
resolveOwnerIds(packages/spec/src/contracts/sharing-service.ts).managerOfis declared onITeamGraphServiceandIBusinessUnitGraphService.in-repo, and
sharing-service.tssays "Returns null in the open edition". Sothere was no OSS implementation of it discarding anything.
managerOfis never reached from the hierarchy-scope path; its only in-repocallers are
expandPrincipaland theBusinessUnitGraphServiceproxy.That matters because the fork clause was conditioned on the open-tier seam's
"fail-closed to own" posture intending the parameter as advisory. That posture
belongs to a different interface with a different method, so it never governed
this parameter — the fork's antecedent is false rather than unresolved, which
is why this landed as a patch instead of a report. #7497 remains open and is
untouched by this change.
Why the screen reads
sys_membersys_usercarries noorganization_id. It is the global better-authidentity table (
managedBy: 'better-auth',protection.lock: 'full',ADR-0010 section 3.7), so a membership row is the only tenancy fact that exists
for a user. Verified by grep, counter-checked against
sys_member,sys_teamandsys_business_unit, which all do carry the column.So the sibling asymmetry the card leans on is structural, not a posture:
every place the org filter is applied backs onto a table that has the column;
the one place it is not is the table that has none.
Adding
organization_idto thesys_userpredicate is therefore not astricter version of this screen — it is a broken one. The column does not
exist, so the predicate matches nothing and every manager lookup given an
organization returns null, silently, because the surrounding
catchswallowsthe driver's complaint. That is the over-screening defect, and on this seam it
is as severe as under-screening: it would empty every approver slate and every
managersharing recipient while looking like "no manager on file".The shape is the one #10153 already landed
13f533a43ascreened the identical column (sys_user.manager_id) on theapprovals side. This PR mirrors
managerIsProvablyOutsideOrgexactly:exactly as it was (fail open on an absent tenancy fact);
One implementation, exported from
team-graph.tsand imported bybusiness-unit-graph.tsrather than restated, so the two limbs cannot drift.It is deliberately not re-exported from the package index.
Two deliberate deviations from the approvals version, both narrowing:
TeamGraphServicehas no logger seam, and adding one wouldenrol this package's modules into
check:optional-error-sink, whose ledger isbeing paid down shrink-only. That is a contract decision for that family, not
a side effect here.
org::userId),mirroring the composite key
expandRolealready used. A user-keyed cachewould have served one screened
nullto every unscoped reader behind it —turning a screen into a permanent outage. Pinned by two tests.
File surface
The claim comment listed
team-graph.tsonly, but theBusinessUnitGraphServicestandalone fallback — explicitly in triage's scope — lives in
business-unit-graph.ts. Both files are edited. That is the sole divergencefrom the dispatched file list, and no other file is touched: no
packages/spec,no
docs/adr, nocontent/docs/releases, no.claude.Verification
20 new tests, and the positive half is the longer one on purpose.
Reverse verification: reverting only the two source files (trap-restored) gives
7 failed / 13 passed — the split predicted before running. The screen, cache
and BU-fallback tests go red; the positive and absent-organization tests stay
green, which is the point — those are what would catch over-screening. The
ablation is source-resolved (the suite imports
./team-graph.jsrelatively, sono
distis involved) and the mutation was confirmed on disk by anchored grepcounts in both directions before each leg.
Gate union derived with
node scripts/pm/dispatch-gates.mjs— no hand-suppliedpaths — on the final commit
a620ecdf80with a clean tree. All 19 to a realverdict:
check:changeset-gate-self-tests,check:objectui-changeset,check:published-files,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check-adr-0087-registration.mjs,check-changeset-no-major.mjs,check-ci-filter-parity.mjs,check-empty-changeset.mjs,check-plugin-teardown-shape.mjs,docs-audit/check-affected-docs.mjscheck:query-options-erasure,check:type-check-coverage,check:type-check-debt,check:engine-double-contract,check:cross-package-test-inputs,check:where-matcher,check:i18ncheck:i18nfirst returnedPREREQUISITE NOT MET — the workspace CLI is not built/ "Nothing was checked". That is NOT MEASURED, not a pass, so the CLI wasbuilt and it was re-run to a real verdict:
check-i18n-bundles: OK (9 package(s) — all bundles in sync).check:type-check-debtlikewise needs the builtclosure; after building it:
--re-measure: OK — 33 ledger entr(ies) re-measured, 1897 raw tsc error(s) total, none above its recorded number.The derivation also caught two conventions the first draft of the test double
tripped — it declared unused
insert/update/deletemembers and its WHEREmatcher walked
Object.entriesunconditionally. Both fixed at the source ratherthan by moving a baseline: the double is now read-only (these paths call
findand nothing else) and the matcher refuses combinator keys and operator
objects instead of reading them as field names.
pnpm --filter @objectstack/plugin-sharing test— 26 files, 644 passedpnpm --filter @objectstack/plugin-sharing typecheck—tsc --noEmit, cleanDownstream sweep, narrowed and declared. Rather than run all 27 dependents,
the blast radius was measured:
TeamGraphServiceandBusinessUnitGraphServiceare constructed at four sites, all inside
plugin-sharingitself(
position-graph.ts,sharing-rule-service.ts), and the package index exportsno new symbol — so no consumer's behaviour can change except through this
package. Confirmed on the two highest-value consumers anyway:
plugin-approvals(30 files, 565 passed — the sibling consumer of this exactcolumn) and
plugin-security(72 files, 1377 passed).Generated by Claude Code