Skip to content

Commit 8c433a8

Browse files
qq9340100Claudeclaude
authored
docs(adr): anchor the default-active-org session hook to ADR-0093 D9 (#8474) (#8529)
`session.create.before` -> `defaultActiveOrg` was cited across plugin-auth, plugin-sharing and the dogfood suite as "ADR-0081 D1". ADR-0081 in this repo is the trusted `kind:'react'` page tier and has no D-numbered decisions at all, so the citation resolved to a real, accepted, plausible-looking record that says nothing about active organizations. Record the shipped behaviour as ADR-0093 D9 (seam, selection, only-when-absent, best-effort, opt-out, and the #8245/#8247 ordering) and re-point every active-org-stamp citation at it. ADR-0093 already owns `sys_member` and the membership lifecycle the stamp reads, ADR-0123 already anchors the hook there, and `scripts/adr-anchors/...auth-manager.ts.json` already lists ADR-0093 as that file's governing record. Behaviour is unchanged — comments and prose only. Part of #8474 Claude-Session: https://claude.ai/code/session_01Jqe56GnYFddggeAyfkZFVz Co-authored-by: Claude <sales@objectstack.ai> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 87e2faa commit 8c433a8

6 files changed

Lines changed: 69 additions & 12 deletions

File tree

docs/adr/0093-tenancy-mode-and-membership-lifecycle.md

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,13 @@ Decision:
6161
- **D8** — Non-goals: single-org membership still does **not** gate data access;
6262
RLS strip semantics, the dual frontend feature flags, and better-auth's
6363
ownership of member CRUD are all unchanged.
64+
- **D9** *(recording, not a new ruling)* — The `session.create.before` hook
65+
resolves a session's `activeOrganizationId` from the caller's `sys_member`
66+
row (owner-preferred, else oldest), only when the draft lacks one,
67+
best-effort, opt-out via `autoActiveOrganization: false`. Shipped behaviour,
68+
previously cited in code as a pre-repo "ADR-0081 D1" whose number now
69+
collides with this repo's ADR-0081; anchored here because it reads exactly
70+
the invariant D1 states and D2 owns.
6471

6572
## Context
6673

@@ -84,10 +91,10 @@ so paths that create users *outside* better-auth's org flows (`/admin/create-use
8491
`/admin/import-users`, plain email signup) produce **member-less users**. In
8592
single-org mode a member-less user is degraded in concrete ways:
8693

87-
- the ADR-0081-D1 `session.create.before` hook resolves `activeOrganizationId`
88-
from the user's `sys_member` row — no row ⇒ **null active org for every
89-
session**, so better-auth org endpoints can't resolve an org for them and
90-
`{current_org_id}` navigation tokens fall back;
94+
- the `session.create.before` hook resolves `activeOrganizationId` from the
95+
user's `sys_member` row (recorded as D9) — no row ⇒ **null active org for
96+
every session**, so better-auth org endpoints can't resolve an org for them
97+
and `{current_org_id}` navigation tokens fall back;
9198
- the Setup app's Members list omits them, right next to an Invite flow that
9299
*does* create membership — the operator-visible inconsistency reported against
93100
PR #2882.
@@ -386,6 +393,56 @@ admin surfaces.
386393
actor-active-org inheritance, etc. remain the province of the flows that
387394
have real context (JIT, invites, host hooks).
388395

396+
### D9 — The session's active organization is resolved from membership, here
397+
398+
> **This decision is a RECORDING, not a new ruling.** The behaviour below has
399+
> shipped since before this ADR, and nothing about it changes. What changes is
400+
> that it now has an anchor. The code carried it as **"ADR-0081 D1"**, a label
401+
> inherited from a decision record that predates this repo's ADR series (the
402+
> same pre-repo record the *Relates to* line names for the default-org
403+
> bootstrap). That number now collides with this repo's
404+
> [ADR-0081](./0081-trusted-react-page-tier.md) — the trusted `kind:'react'`
405+
> page tier — so a reader following the citation landed in a document about
406+
> React pages with no signal they were in the wrong record. The decision is
407+
> restated here because this is the record that owns the fact it depends on:
408+
> `sys_member` and the membership lifecycle (D1/D2).
409+
410+
On session create, plugin-auth stamps the session's `activeOrganizationId` from
411+
the caller's membership. Mechanically (`AuthManager.composeDatabaseHooks`
412+
`defaultActiveOrg`):
413+
414+
- **Seam.** better-auth's `session.create.before` database hook. A
415+
host-supplied `session.create.before` chains **first** and keeps precedence,
416+
exactly as D2's reconciler yields to host hooks on `user.create.after`.
417+
- **Selection.** Owner-preferred, else the oldest `sys_member` row for the
418+
user — one selection helper, so every call site resolves identically. The
419+
read runs through the system context, because the caller has no organization
420+
yet at this point.
421+
- **Only when absent.** A draft session that already carries an
422+
`activeOrganizationId` is never overwritten.
423+
- **Best-effort.** Failures are swallowed; login never fails on this
424+
bookkeeping.
425+
- **Opt-out.** `auth.autoActiveOrganization: false` restores raw better-auth
426+
behaviour (sessions start org-less). Default `true`.
427+
- **Ordering (#8245 / #8247).** The hook settles membership through D2's
428+
reconciler *before* resolving the organization, so a user's first session is
429+
not minted tenant-less by a race with better-auth's deferred
430+
`user.create.after`. This is an ordering change only — same reconciler, same
431+
policy, same target-org resolution — so it never widens who gets bound.
432+
433+
**Why it lives in this record.** The stamp is a *read* of the invariant D1
434+
states and D2 owns: it can only resolve an organization when a `sys_member` row
435+
exists, and which row exists is entirely decided by D1's policy and D2's
436+
reconciler. Recording it anywhere else would separate the read from the write
437+
that determines it.
438+
439+
**What it does NOT decide.** When no `sys_member` row exists the hook declines,
440+
and the resulting session is authenticated with no active organization. That is
441+
a legal, declared state, and its semantics belong to
442+
[ADR-0123](./0123-no-active-organization-session-semantics.md) — not here. D9
443+
records how the organization is *found*; ADR-0123 governs what happens when
444+
there is none.
445+
389446
## Rollout
390447

391448
| Phase | Contents | Risk |

packages/plugins/plugin-auth/src/auth-manager.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3669,7 +3669,7 @@ describe('AuthManager', () => {
36693669
});
36703670
});
36713671

3672-
// ADR-0081 D1 — default active-org stamp on session create.
3672+
// ADR-0093 D9 — default active-org stamp on session create.
36733673
describe('composeDatabaseHooks – session.create.before active-org default', () => {
36743674
const OWNER_ROW = { id: 'm1', organization_id: 'org_owner', user_id: 'u1', role: 'owner' };
36753675
const MEMBER_ROW = { id: 'm2', organization_id: 'org_member', user_id: 'u1', role: 'member' };

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -519,7 +519,7 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
519519
appName?: string;
520520

521521
/**
522-
* ADR-0081 D1 — default active-org on session create. When enabled
522+
* ADR-0093 D9 — default active-org on session create. When enabled
523523
* (default), a `session.create.before` hook stamps `activeOrganizationId`
524524
* from the caller's `sys_member` row (owner-preferred) whenever the draft
525525
* lacks one. A host-supplied `session.create.before` (see
@@ -4417,7 +4417,7 @@ export class AuthManager {
44174417
}
44184418
: stamp;
44194419

4420-
// ADR-0081 D1 — default active-org on session create. Without it, a user
4420+
// ADR-0093 D9 — default active-org on session create. Without it, a user
44214421
// with memberships logs in with `activeOrganizationId = null`: better-auth
44224422
// org endpoints can't resolve an active org (single-org invite dead-end)
44234423
// and `{current_org_id}` nav tokens fall back to list views. Resolve the

packages/plugins/plugin-auth/src/impersonation-bearer-rotation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ export async function rotateCallerBearerOnImpersonation(ctx: any): Promise<void>
240240
// admin had selected, which is the only piece of session state they would
241241
// notice losing on the way back. Everything else is re-derived for a session
242242
// minted now — `ipAddress`/`userAgent` from this request, and
243-
// `activeOrganizationId` itself by ADR-0081 D1's `session.create.before`
243+
// `activeOrganizationId` itself by ADR-0093 D9's `session.create.before`
244244
// stamp when the admin had not switched away from their default.
245245
//
246246
// Spreading the whole old row here instead would be handing the producer a

packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export interface SeamPrincipal {
4242
email?: string;
4343
/**
4444
* better-auth `session.activeOrganizationId` — the ONE wire field a real
45-
* login carries the caller's active organization on (ADR-0081 D1 stamps it
45+
* login carries the caller's active organization on (ADR-0093 D9 stamps it
4646
* from the user's `sys_member` row on session create). `null` reproduces a
4747
* membership-less / platform-scoped session.
4848
*/

packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
// This file is that measurement, taken through the real login path rather than
2626
// inferred from reading the resolver. Every step below is the product's own:
2727
// better-auth `sign-up` / `sign-in` mint the session, `session.create.before`
28-
// (ADR-0081 D1) is the hook that would have stamped an active organization and
28+
// (ADR-0093 D9) is the hook that would have stamped an active organization and
2929
// declines to because the user holds no `sys_member` row, `resolveAuthzContext`
3030
// turns that session into the execution context, and the REST route is the one
3131
// the Setup sharing pages call. Nothing here is simulated, and the
@@ -166,8 +166,8 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r
166166
});
167167

168168
it('PRECONDITION: the exposed persona holds no membership, and its SESSION carries no active organization', async () => {
169-
// This is the card's unmeasured half. `session.create.before` (ADR-0081
170-
// D1) stamps `activeOrganizationId` from the caller's `sys_member` row;
169+
// This is the card's unmeasured half. `session.create.before` (ADR-0093
170+
// D9) stamps `activeOrganizationId` from the caller's `sys_member` row;
171171
// with no such row it declines, and nothing downstream re-derives one.
172172
const members = await ql.find('sys_member', { where: { user_id: orgLessUserId }, context: SYS });
173173
expect(Array.isArray(members) ? members : members?.records ?? []).toHaveLength(0);

0 commit comments

Comments
 (0)