Repository navigation
fix(plugin-auth): report at error when a single-posture deployment holds more than one organization (#17010) - #17460
Conversation
…holds more than one organization ADR-0131 §1.2(3) states that "many organizations under `single`" is today a refused boot. It is not: `resolveDefaultOrgId` answers the bootstrap org, else the sole org when exactly one exists, else `null` — silently — and the harm surfaces far away as a platform admin reading zero rows and system writes refused `ambiguous-organization`. Take a `count(sys_organization)` census on the seam that was silent and that already reads the object, and report at `error` when a non-walled deployment holds more than one, naming the posture, the count and both remedies. Boot PROCEEDS — this only reports; the refuse-vs-report fork stays with the maintainer. The per-write refusal (#8844) is untouched. Reached at boot: AuthPlugin runs `backfillMemberships` from `kernel:ready` with `resolveTargetOrg: () => tenancy.defaultOrgId()` under a membership policy that defaults to `auto`. Cost is one `count()` per process, downstream of the walled early return and of the memoized resolution. Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com>
…one in-file caller, no new published symbol Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 13 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 541dc5b0249823ab6735d92ed1fbffb67dc10d70 && git checkout 541dc5b0249823ab6735d92ed1fbffb67dc10d70
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fd62a66b7c48be167deb74467ca93e6ad3223f38 0126be826a9b5849da0b946bec8246478dee199c && git checkout -B drift-repro fd62a66b7c48be167deb74467ca93e6ad3223f38 && git merge --no-ff 0126be826a9b5849da0b946bec8246478dee199c
node scripts/docs-audit/affected-docs.mjs --json fd62a66b7c48be167deb74467ca93e6ad3223f38
|
…ted, not a refused boot §1.2 item 3 called the measured leak's precondition — many organizations with Layer 0 inert — "today a refused boot", citing ADR-0093 D5. Nothing refuses it. D5 refuses a REQUESTED-but-absent wall; a deployment that never requests a walled posture and merely holds more than one `sys_organization` row under `single` boots and serves. Since #17010 (PR #17460) `TenancyService` takes a `count(sys_organization)` census and reports that state at `error` at boot, naming the declared posture, the count and the two remedies — it does not refuse it. Item 3 is the reason §1.2 discounts the measured NULL-row leak, so the corrected item says in one clause what the correction does to that argument: the discount now rests on a reported state, not a refused one. D11 (`Degraded tenancy stays a refused boot.`) is true and is untouched — byte identical. Whether the boot SHOULD be refused stays the maintainer's open fork; nothing here decides it. Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK Co-authored-by: Claude <noreply@anthropic.com>
…ted, not a refused boot (objectstack-ai#17544) §1.2 item 3 called the measured leak's precondition — many organizations with Layer 0 inert — "today a refused boot", citing ADR-0093 D5. Nothing refuses it. D5 refuses a REQUESTED-but-absent wall; a deployment that never requests a walled posture and merely holds more than one `sys_organization` row under `single` boots and serves. Since objectstack-ai#17010 (PR objectstack-ai#17460) `TenancyService` takes a `count(sys_organization)` census and reports that state at `error` at boot, naming the declared posture, the count and the two remedies — it does not refuse it. Item 3 is the reason §1.2 discounts the measured NULL-row leak, so the corrected item says in one clause what the correction does to that argument: the discount now rests on a reported state, not a refused one. D11 (`Degraded tenancy stays a refused boot.`) is true and is untouched — byte identical. Whether the boot SHOULD be refused stays the maintainer's open fork; nothing here decides it. Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK Co-authored-by: Claude <noreply@anthropic.com>
Fixes #17010
Clause-②: no
(declaration line written by the
domain:servicesreview seat, not by the implementer — theClause-②:line is the claim's, andCheck Changesetreads it only as a line-initial literal in this body; a prose or quoted mention is "a near miss, not a declaration". Measured on THIS diff rather than predicted from the card: 0 added lines containingexport, 0 newERROR_CODE_LEDGER/code:entries, 0 addedthrows. This adds a boot-time diagnostic — it accepts nothing new, refuses nothing new, publishes no symbol and changes no wire field. ⛔ Do not delete this line; the gate re-reads the body on the nexteditedevent, so removing it re-reds the PR.)ADR-0131 §1.2(3) states, verbatim: 「The measured leak's precondition — many organizations with Layer 0 inert — is today a refused boot (ADR-0093 D5, cloud#1020, cloud#1664).」 It is not. A deployment that never REQUESTS a walled posture and simply HOLDS more than one
sys_organizationrow undersingleboots, serves, and says nothing.What changed
packages/plugins/plugin-auth/src/tenancy-service.tstakes acount(sys_organization)census and reports aterrorwhen a non-walled deployment holds more than one, naming the posture it DECLARED, the count it HOLDS, and the two remedies the card spells out (declare a walled posture, or hold one organization).⛔ The boot is NOT refused. This only reports — the dispatching seat's ruling for this card, 2026-09-10: making the state visible restores a declared invariant and takes nothing away from anyone, whereas actually refusing the boot would stop deployments that run today (the card's own measured instance,
objectstack-ai/ats, is one). That fork stays open and is the maintainer's; nothing here has to be undone if the answer is "refuse". The per-writeambiguous-organizationrefusal (#8844) is untouched — it is this card's evidence, not its target.Why the reading lives on
TenancyService.defaultOrgId()and not in a new boot hookThat is the seam the card names as silent, it already reads
sys_organization, and it already knows the REQUESTED posture — so the census needs no second reader and no second store read to keep in step with.It is reached at boot on a default deployment:
AuthPluginregisters akernel:readyhook that runsbackfillMembershipswithresolveTargetOrg: () => tenancy.defaultOrgId(), and the membership policy gating it defaults toauto(AuthManager.getMembershipPolicy()returnsthis.config.membershipPolicy ?? 'auto'). That claim is not asserted in prose only — it is pinned against the realbackfillMembershipspass in the suite, so the day the wiring stops reaching this seam, the test says so. A deployment that opts out of that pass (OS_SKIP_MEMBERSHIP_BACKFILL=1, or aninvite-onlypolicy) takes the census at the first single-posture organization resolution instead — still once per process, still before anybody has been mis-bound.The alternative placement — a new
kernel:readyprobe wired fromauth-plugin.ts, the shapeboot-sign-in-reachability.tsuses — was NOT taken:auth-plugin.tsis outside this card's declared file surface and is held by open draft PR #17443.The cost of the reading
One
count(sys_organization)per process — oneSELECT COUNT(*), viaIDataEngine.count, over a table whose population undersingleis by definition one row (N greater than one IS the defect being reported).postureEnforcesWallearly return. Pinned (engine.countasserted never called for bothgroupandisolated).defaultOrgId()calls, onecount()).count— pays nothing and stays silent, and the census is not latched, so a store that comes up later is still censused. Pinned.deps.getEngine()the existingsys_organizationread uses. Nothing was added atinit(), where the engine is not yet resolvable.Negative control
Exactly one organization under
singlestays silent, and so does a store with none yet — pinned as two separate tests, because a check that fires on a healthy install is worse than no check. Both stayed GREEN under the ablation below, which is the point: they do not depend on the check existing.Ablation
Mutation: the census call removed from
defaultOrgId(), on the committed tree (7c6720e9e).if (!organizationCensusTaken) {counted 1 before, 0 after; blob9db367209c21a7bf0052561260c71520309e787ebecame439b3dc2d08267734d4b1157adfaeb8f77b2505a.AssertionError: expected "vi.fn()" to be called 1 times, but got 0 times— the six that assert the report fires, including the boot-reachability pin. The negative controls stayed green.git diff HEADempty, andgit hash-objectback to9db367209c21a7bf0052561260c71520309e787e— equal to the HEAD blob. Restored withgit checkout HEAD -- pathfrom anEXIT INT TERMtrap holding an absolute path.Verification
Run at
0126be826.pnpm --filter @objectstack/plugin-auth exec vitest run src/tenancy-service.test.ts— 34 passed.pnpm --filter @objectstack/plugin-auth typecheck— exit 0 (includescheck:test-typecheck, which is what covers the test layer here: the package tsconfig excludes**/*.test.ts).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(3 changed paths), reconciled with--ran: 60 commands, 58 exit 0, 2 exit 3.check:dual-build-cjs-loadsandcheck:type-check-debtboth exit 3 = PREREQUISITE NOT MET (they read built output of the whole tree). Recorded as NOT MEASURED, ⛔ not as a pass — CI builds the closure and measures them.pnpm check:optional-error-sink— exit 0 (the new sink is in its census: optionalerrorbeside a REQUIREDwarn), andpnpm check:durability-log-level— exit 0.pnpm lintand the rest of the lint workflow) are CI's run, not this branch's.Acceptance notes
docs/adr/**is a governed surface and a code PR editing it is exactly what that guardrail stops, so it is named here and routed to the PM as its own card rather than fixed in this diff.tenancy-service.tsis byte-identical toorigin/main— which is also what keeps the dispatch'sClause-②: nodeclaration true.defaultOrgId()returnsnullbefore any read, and ADR-0093 D5 already refuses that boot. Observation only, no card.🤖 Generated with Claude Code
https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
Generated by Claude Code