Skip to content

Commit 794b769

Browse files
refactor(plugin-auth): move AuthManager's WebContainer password hasher into auth-manager-password-hasher.ts (#25224 cut 11) (#25524)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuMjbPDrsigsyqoKsV4WZQMz)_ Part of #25224 Clause-②: no Metadata-protocol: none Ruling-ref: 6348891160, 6364006498 The basis for the `Clause-②: no` line: this is a move plus a comment-and-anchor reader fix. No accept set, route, refusal, code or answer changes, and the published surface is measured unchanged: - `@objectstack/plugin-auth`'s `dist/index.d.ts` and `dist/index.d.mts` are raw byte-identical to base after each commit (0 diff lines, so the union-order normalisation has nothing to do). The positive control is the one ruling `6348891160` asks for. - Its ESM and CJS entries export the same 159 names at base, after commit 1, and at head. The `.d.ts` bar is ruling `6348891160` item 1 (on #25222, covering this card). Ruling `6364006498` is Sales's ruling A on this card, for bodies that read shared unexported helpers. This is the third cut it applies to. Cut 11 of the `auth-manager.ts` split: **the WebContainer password hasher**, after **a declared reader fix for cut 8's anchor miss**. #25224 stays open for the later cuts. This follows the amended claim `6372234293`, which amends `6369962162` on the file cut 10 left (#25508, landed note `6371356601`). The shape is cuts 1 to 10's, which is #23050's. Branch: `claude/project-thread-lg3wrj`, the harness-pinned branch for this session, restarted from `origin/main` `7d3c0139fb` after #25508 merged. It names no issue, so the card relation is this body's first line. `auth-manager.ts` goes from 8,521 to 8,496 lines. The cut is +96 / −33 over 4 files: 129 changed lines, under the 3,000-line line. It is not a governed surface (`check-governed-merges.mjs --branch HEAD`: NOT governed). Two commits, in the order the seat set: 1. `e2b7669134` `chore(plugin-auth): anchor auth-manager-membership-admission.ts to ADR-0093` 2. `08e7407930` `refactor(plugin-auth): move AuthManager's WebContainer password hasher into auth-manager-password-hasher.ts` ## Commit 1: the reader fix (comment and anchor entry only) Cut 8 (#25423) moved `settleMembership`'s body into `auth-manager-membership-admission.ts`. The ADR-0093 D2 invariant that governs it stayed anchored only on `auth-manager.ts` and `auth-manager-database-hooks.ts`. Cut 10's report named this as an out-of-scope finding, and the seat put the fix in this PR, as its own commit. - **A new anchor entry**, `scripts/adr-anchors/packages__plugins__plugin-auth__src__auth-manager-membership-admission.ts.json`. `adrs` is `["ADR-0093"]`, and `invariant` is the D2 paragraph verbatim. That is the same text as the database-hooks entry's invariant and the second paragraph of the `auth-manager.ts` entry, in the database-hooks entry's JSON layout. - **One header paragraph** in the module, just above "Nothing re-exports this module", worded as the seat approved. It says `settleMembership` is governed by ADR-0093 D2, and that `scripts/adr-anchors/` carries the invariant for this file as it does for `auth-manager.ts`. The presence check needs the citation: an entry whose file never names its ADR fails `check-adr-anchors`. - **No code changes.** The module's tokens are 1,055 before and after, and its comment-free print is identical. ## Commit 2: the move | moved | base line | proof lines | members it reads | |---|---:|---:|---| | `resolvePasswordHasher` (private) | `:3160` | 33 | none | The line is base `7d3c0139fb`. The 33 proof lines are the two return-type continuation lines and the 31 body lines (`:3161`–`:3193`). The body has no `this` at all. - **What it is:** the pure-JS scrypt hasher that `createAuthInstance` (`:1696`, call at `:1700`) hands better-auth inside StackBlitz's WebContainer, whose `node:crypto.scrypt` polyfill fails sign-up. Outside WebContainer it returns `undefined`, so the native hasher stays. - **What moves with it, text unchanged:** its lazy `await import('@noble/hashes/scrypt.js')` (still loaded only inside WebContainer), the scrypt parameters, the `{saltHex}:{keyHex}` storage format, and its `console.warn` (`[AuthManager] WebContainer detected but pure-JS scrypt unavailable: …`). - **The shape:** the class keeps the method at its place, with its name, signature and docblock, because the published `.d.ts` lists it. The body reads no member of the class, so it becomes a plain call, `return resolvePasswordHasher();`, with the comment "it reads no member of this class". There is no host type. - **The stub is no longer `async`.** It returns the moved async function's own promise, and the one caller awaits it, so the caller settles on the same promise. Calling an async function cannot throw synchronously, so nothing now throws where it used to reject. **`isWebContainerRuntime` is exported in place** (ruling `6364006498`, parameter 2): - `:262` becomes `export function isWebContainerRuntime()`. That is the only edit to code that stays. - It does not move, because `installWebContainerRequestStatePolyfill` (`:342`) reads it too. The constructor installs that polyfill (`:1641`). - The new module imports it by value from `./auth-manager.js`. The one read is inside the moved body, when the better-auth instance is built. The two modules import each other. That is safe because the new module's top level is one import and one function declaration, so nothing reads the binding while the two modules are first evaluated. - `index.ts` lists `auth-manager.ts`'s published names one by one (cut 9), and it lists neither this function nor the new module. The head's ESM and CJS entries do not export either name, and `dist/index.d.ts` names neither. **Imports.** No import leaves `auth-manager.ts`: the body's only reads are its own dynamic import and `isWebContainerRuntime`. One import line is added after cut 10's block, with a comment in the same form. ## The decision-letter layer: +2, as agreed with the seat `check-adr-anchors`, measured at each point. The gate exits 0 at all three. | point | anchored files | citations | files | decision letters | |---|---:|---:|---:|---:| | base `7d3c0139fb` | 82 | 52,244 | 6,661 | 1,978 | | after commit 1 `e2b7669134` | 83 | 52,247 | 6,662 | 1,980 | | head `08e7407930` | 83 | 52,247 | 6,662 | 1,980 | - The three new citations are the header's `ADR-0093 D2`, the entry's `adrs` id, and its invariant's `ADR-0093 D2`. The new file is the entry JSON. - The two new letters are the header's D2 and the invariant's D2, both now on the anchored surface. - Commit 2 moves nothing in this layer. The moved body cites no ADR, and the new module's header cites no `ADR-NNNN`. - The anchored-file count was 81 at cut 10's base `05cc3ee953` and is 82 at this base. This entry makes it 83. ## Path readers No path reader needs migrating: none reads a moved line. - **The tenant-audit census.** No census site moves: the moved text has no engine write, no `this.X.delete()` and no elevation read. `check-tenant-audit-census` exits 0 at head with no `--write`. There are 238 sites at base and at head, compared per site with line numbers ignored: 0 differences, 2 in `auth-manager.ts` at both, and 0 in the new module. So neither census document is edited. - **`check-dispatcher-error-vocabulary.mjs`** and **`api-error-code.pin.test.ts`'s `authManagerFiles` floor**: no `new APIError(` sits in the moved text. The gate reads 56 sites found and 56 classified at base and at head. - **`check-auth-mount-ledger.mjs`**: no base-path literal or declaration sits in the moved text. - **The plugin-factory scans** in `better-auth-schema-parity.test.ts` and `managed-extension-fields.test.ts` match `better-auth/plugins…` and `@better-auth/…` dynamic imports. The moved `@noble/hashes/scrypt.js` import is neither, and both suites pass at head. - **`rate-limit-storage-isolation.test.ts`** walks the import graph from `rate-limit-storage.ts`, which reaches neither file. It passes at head. - **The system-context census** reads 149 elevation read sites at base and at head. - **Prose that names `isWebContainerRuntime()` in `auth-manager.ts`** (`jwt-key-algorithm.ts:24`) still holds: the function stays there. - **Every symbol anchor into the file.** Before the cut, every `auth-manager.ts#symbol` anchor in the tracked tree outside changelogs was mapped with `symbolSegmentResolution` from `scripts/symbol-anchors.mjs`. There are 29, and all 29 are `declaration`-class at base, with the moved body blanked, and at head. None names `resolvePasswordHasher` or `isWebContainerRuntime`. One names a token the moved text carries, `#AuthManager` (ADR-0135 `:104`; the moved warning spells `[AuthManager]`), and it resolves on the class declaration, which stays. ## Proof Builds and suites ran through `os-verify-lock`, and every exit code was captured before any pipe. Base `7d3c0139fb`. A full `turbo run build` ran first. Then `plugin-auth` was built and its suite run at base, built after commit 1, and built and run again at head, on the same dependency dists. **1. The moved text.** - The base class body and the new module's function body are the same TypeScript token sequence (scanner tokens, kind and text, trivia skipped, each template literal re-scanned at its substitutions as the parser does): 33 lines and 301 tokens. - Every line is the base line minus exactly two leading spaces, and the return type is the same text. - The class method keeps its docblock and signature, with `async` dropped. - **Negative control:** one token changed in the module (`p: 1` → `p: 2` in the scrypt parameters) makes the method fail (tokens and lines DIFFER), and the run exits 1. The module was restored afterwards, and `cmp` against the saved copy was clean. - **An independent reading:** `git diff --color-moved=plain --color-moved-ws=allow-indentation-change e2b7669 08e7407` over the source files marks 31 of the 33 deleted lines and 32 of the 83 added lines as moved. - The other 2 deleted lines are `function isWebContainerRuntime()` (now `export function`) and the `private async resolvePasswordHasher(): Promise<` signature line (now without `async`). - The other added lines are the module header, its import, the pointer docblock, the function signature, the stub's two lines and the new import block. - A one-word comment correction in the new header was amended into commit 2 after the full run ("nine" → "ten", the count of earlier `auth-manager-*.ts` modules). The rest of this section, the suites, the typechecks and the gates were measured at the pre-amend head `e66a6f4a8d`. Measured again after the amend: the move proof is still 1 of 1, the module's comment-free token print is identical to the pre-amend one (355 tokens), a rebuilt `dist/` matches the head readings byte for byte (only the two `.build-input-hash` files differ), `eslint` exits 0, and `check-adr-anchors` reads as in the table. **2. `.d.ts` and exports (ruling `6348891160` items 1 and 2).** | file | base, after commit 1, head | raw diff lines | |---|---|---:| | `dist/index.d.ts` raw | `b85cb81c24e981304d02184a9ef1294be702aa7aa9df1d327f51e66be9dcfd33` | 0 | | `dist/index.d.ts` normalised | `c86ed39db60c3929cf287b59d6cc9aa1a2505ccbfbaa23a624af35a3fcd11abb` | | | `dist/index.d.mts` raw | `3561220e444c82fbf102db534ec2a274041db507ed9e38785c0993ca92b84f70` | 0 | | `dist/index.d.mts` normalised | `6165644e3dfd1d779991e9bd63ceac3b9ab3dc47d910776ac5a88b4293ef6de5` | | - These are the same readings as cut 10's head, because neither cut changes the published declarations. - The normaliser, verbatim (the ruling's one-liner): `node -e 'const s=require("fs").readFileSync(process.argv[1],"utf8");process.stdout.write(s.replace(/"[^"\n]*"(?:\s*\|\s*"[^"\n]*")+/g,r=>r.split(/\s*\|\s*/).sort().join(" | ")))' dist/index.d.ts | sha256sum`. - The raw files are byte-identical, so no union-order swap occurs: items 1(b) and 1(d) hold with nothing to account for. - **Positive control:** the head `dist/index.d.ts` with one private member made public (`private resolvePasswordHasher;` → `resolvePasswordHasher;`, line 1732, a one-line change) hashes raw `aadc3e91ed586bd9ff14509f08529a4b9df5b0d5ee3ee9c3e9d914e939b6a3bd` and normalised `a31ac6fb32392d426d5480769cd97133ed0895014104c731f80316d2b7e0b3c9`. Both differ from the readings above. - **Export lists:** ESM `dist/index.mjs` and CJS `dist/index.js` each have 159 names at all three points, the same list (the sha256 of the sorted names is `3abb126f5babb701…` on all six). `isWebContainerRuntime` and `resolvePasswordHasher` are not among them. - **The bundle:** 18 files in `dist/` at all three points, the same list. After commit 1 only the two `.map` files differ, because a comment line moved code positions in the source. At head `index.js`, `index.mjs` and their two `.map` files differ, as a move of code inside the bundle must. `tsup` bundles the new module into the root entry (`splitting: false`). **3. Tests and lint.** - `pnpm exec vitest run --maxWorkers=3` in `packages/plugins/plugin-auth`: base `Test Files 189 passed (189)`, `Tests 3580 passed | 10 skipped (3590)`, exit 0. Head: the same counts, exit 0. - `pnpm --filter @objectstack/plugin-auth typecheck` (both `tsc` projects and `check:test-typecheck`): exit 0. `pnpm --filter @objectstack/runtime typecheck`: exit 0. - `eslint --no-inline-config` (the root `lint` script's binary and flags) over the 3 source files: exit 0. **4. The censuses, the measurement and the anchors, at base and at head.** - The tenant-audit census, as above: 238 sites at both, 0 per-site differences, 0 unresolved and 0 unledgered at both, and every total the same. Only `scannedSources` (734 → 735, the new module) and `measuredAt` differ. - `scripts/measure-durability-swallow-family.mjs` (a measurement, not a gate): 77 members and 84 quiet rows at both, the same with line numbers ignored. Only the scanned file count moves (3,459 → 3,460, the new module). `tryStatements` (3,633), `droppedNotAwaited` (242) and every other statistic are the same: the moved `try` calls no same-file helper, so its walk does not change. - These checkers were run by hand at base and at head, because `dispatch-gates` does not derive the ADR one. All six exit 0 at both (and the two ADR ones after commit 1 too): - `check-adr-symbol-anchors`: 2,384 anchors across 142 records, 466 symbol (440 declaration, 26 literal), at both; - `check-adr-anchors`: as in the table above; - `check-scripts-symbol-anchors`: 4,070 anchors at both; - `check-spec-docblock-symbol-anchors`: 5,075 anchors at both; - `check-system-context-census`: 149 elevation read sites at both; - `check-platform-checklist`: 16 areas, 329 items at both. **5. Gates.** - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 63 commands for the 4 paths. All 63 ran at head after the full build, and all 63 exited 0. - `--ran` over the record with each exit code: 63 derived, 63 run, 0 NOT-MEASURED, a derived zero. - `check-governed-merges.mjs --branch HEAD`: NOT governed, 129 changed lines. - **Docs drift** (`node scripts/docs-audit/affected-docs.mjs --json 7d3c013`, exit 0): 3 pages, `content/docs/kernel/contracts/auth-service.mdx`, `content/docs/kernel/services-checklist.mdx` and `content/docs/permissions/authentication.mdx`. All three are reached only through the `AuthManager` class name, which stays. So no doc edit is owed. 0 release-owned pages. - **Serial constraints** (in the claim): 8 PRs are open. One touches `auth-manager.ts`: #25515, in four hunks (one name added to an import and two new import lines at the top, a hook arm of `createAuthInstance`, the custom organization roles in `buildPluginList`, and a new method above `resolveActor`). None reaches this cut's regions. A scratch `git merge-tree` of `origin/main` `7a70be4365` with #25515, then this branch, has no conflict at either step. `origin/main` has moved 7 commits past this base. None of them touches `plugin-auth` or `scripts/adr-anchors/`. Re-read just before this PR was opened: #25519 has merged since (`aa2dbab7b6`, `plugin-email` only), #25523 has opened (`.github/workflows/ci.yml` and one `scripts/ci/` file only), and #25515's head is unchanged. ## Changeset None: `skip-changeset`, per ruling `6348891160` item 4. The published declarations and export lists are unchanged, and the bundle's bytes move only because code moved inside it. --- _Generated by [Claude Code](https://claude.ai/code/session_01LSXBc2GCBwiAL7vV4kWkyr)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LSXBc2GCBwiAL7vV4kWkyr --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f38832b commit 794b769

4 files changed

Lines changed: 96 additions & 33 deletions

File tree

‎packages/plugins/plugin-auth/src/auth-manager-membership-admission.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@
4545
* while they are first evaluated: this module's top level is imports, an
4646
* interface and function declarations.
4747
*
48+
* `settleMembership` below is governed by ADR-0093 D2: the membership
49+
* reconciler is the single owner of the "every user gets a membership"
50+
* invariant, and this is the manager's single assembly of its inputs, which
51+
* both creation seams call. `scripts/adr-anchors/` carries the invariant for
52+
* this file, as it does for `auth-manager.ts`.
53+
*
4854
* Nothing re-exports this module, so the published surface does not move.
4955
*/
5056

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25224 cut 11] The WebContainer password hasher of `AuthManager` — the
5+
* pure-JS scrypt hasher handed to better-auth when the runtime is StackBlitz's
6+
* WebContainer, whose `node:crypto.scrypt` polyfill fails sign-up — moved out
7+
* of `auth-manager.ts` whole.
8+
*
9+
* A MOVE, not a redesign. The function below is the body of the
10+
* `AuthManager` method of the same name as it stood, re-indented one level
11+
* and otherwise unedited: the same runtime test, the same scrypt parameters
12+
* and `{saltHex}:{keyHex}` storage format, the same lazy load of
13+
* `@noble/hashes` (only inside WebContainer), and the same warning and
14+
* fallback when that load fails.
15+
*
16+
* ## How the moved body reaches the class
17+
*
18+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
19+
* header carries the full reasoning), which the ten `auth-manager-*.ts`
20+
* modules split out before this one follow. The class keeps the method — the
21+
* published `dist/*.d.ts` lists it — and its body is a plain call: it reads
22+
* no member of the class, so it is handed none, and there is no host type.
23+
* It is no longer `async`: it returns the moved function's own promise, so a
24+
* caller awaits the same promise the body settles, with no extra wrapper.
25+
*
26+
* `isWebContainerRuntime` stays: the request-state polyfill in
27+
* `auth-manager.ts`, which the constructor installs, reads it too. So that
28+
* file exports it and this module imports it by VALUE, for one read inside
29+
* `resolvePasswordHasher`, when the better-auth instance is built.
30+
* `index.ts` lists `auth-manager.ts`'s published names one by one, so that
31+
* export is not published. The two modules import each other, and that is
32+
* safe because nothing reads the binding while they are first evaluated: this
33+
* module's top level is one import and one function declaration.
34+
*
35+
* Nothing re-exports this module, so the published surface does not move.
36+
*/
37+
38+
import { isWebContainerRuntime } from './auth-manager.js';
39+
40+
/** The body of `AuthManager#resolvePasswordHasher`; the method's docblock there is its contract. */
41+
export async function resolvePasswordHasher(): Promise<
42+
{ hash: (password: string) => Promise<string>; verify: (args: { hash: string; password: string }) => Promise<boolean> } | undefined
43+
> {
44+
if (!isWebContainerRuntime()) return undefined;
45+
try {
46+
const { scryptAsync } = await import('@noble/hashes/scrypt.js');
47+
const PARAMS = { N: 16384, r: 16, p: 1, dkLen: 64, maxmem: 128 * 16384 * 16 * 2 } as const;
48+
const toHex = (b: Uint8Array): string => {
49+
let s = '';
50+
for (let i = 0; i < b.length; i++) s += b[i]!.toString(16).padStart(2, '0');
51+
return s;
52+
};
53+
const generateKey = (password: string, saltHex: string): Promise<Uint8Array> =>
54+
scryptAsync(password.normalize('NFKC'), saltHex, PARAMS);
55+
return {
56+
hash: async (password: string) => {
57+
const saltBytes = (globalThis as any).crypto.getRandomValues(new Uint8Array(16));
58+
const saltHex = toHex(saltBytes);
59+
const key = await generateKey(password, saltHex);
60+
return `${saltHex}:${toHex(key)}`;
61+
},
62+
verify: async ({ hash, password }) => {
63+
const [saltHex, keyHex] = hash.split(':');
64+
if (!saltHex || !keyHex) throw new Error('Invalid password hash');
65+
const target = await generateKey(password, saltHex);
66+
return toHex(target) === keyHex;
67+
},
68+
};
69+
} catch (err: any) {
70+
console.warn(
71+
`[AuthManager] WebContainer detected but pure-JS scrypt unavailable: ${err?.message ?? err}. Falling back to default.`,
72+
);
73+
return undefined;
74+
}
75+
}

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

Lines changed: 8 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,10 @@ import {
252252
ownsRoute,
253253
type RequestRoutingHost,
254254
} from './auth-manager-request-routing.js';
255+
// [#25224 cut 11] The WebContainer password hasher; the method of the same
256+
// name below calls it, and hands it nothing, because it reads no member of
257+
// this class.
258+
import { resolvePasswordHasher } from './auth-manager-password-hasher.js';
255259

256260
/**
257261
* Detect WebContainer (StackBlitz) environment.
@@ -262,7 +266,7 @@ import {
262266
* propagate the store across `await` boundaries the way Node's native
263267
* implementation does.
264268
*/
265-
function isWebContainerRuntime(): boolean {
269+
export function isWebContainerRuntime(): boolean {
266270
if (typeof globalThis === 'undefined') return false;
267271
const proc = (globalThis as any).process;
268272
return (
@@ -3166,40 +3170,11 @@ export class AuthManager {
31663170
* Returns `undefined` outside WebContainer so production deployments keep
31673171
* the native (fast) hasher and never load `@noble/hashes`.
31683172
*/
3169-
private async resolvePasswordHasher(): Promise<
3173+
private resolvePasswordHasher(): Promise<
31703174
{ hash: (password: string) => Promise<string>; verify: (args: { hash: string; password: string }) => Promise<boolean> } | undefined
31713175
> {
3172-
if (!isWebContainerRuntime()) return undefined;
3173-
try {
3174-
const { scryptAsync } = await import('@noble/hashes/scrypt.js');
3175-
const PARAMS = { N: 16384, r: 16, p: 1, dkLen: 64, maxmem: 128 * 16384 * 16 * 2 } as const;
3176-
const toHex = (b: Uint8Array): string => {
3177-
let s = '';
3178-
for (let i = 0; i < b.length; i++) s += b[i]!.toString(16).padStart(2, '0');
3179-
return s;
3180-
};
3181-
const generateKey = (password: string, saltHex: string): Promise<Uint8Array> =>
3182-
scryptAsync(password.normalize('NFKC'), saltHex, PARAMS);
3183-
return {
3184-
hash: async (password: string) => {
3185-
const saltBytes = (globalThis as any).crypto.getRandomValues(new Uint8Array(16));
3186-
const saltHex = toHex(saltBytes);
3187-
const key = await generateKey(password, saltHex);
3188-
return `${saltHex}:${toHex(key)}`;
3189-
},
3190-
verify: async ({ hash, password }) => {
3191-
const [saltHex, keyHex] = hash.split(':');
3192-
if (!saltHex || !keyHex) throw new Error('Invalid password hash');
3193-
const target = await generateKey(password, saltHex);
3194-
return toHex(target) === keyHex;
3195-
},
3196-
};
3197-
} catch (err: any) {
3198-
console.warn(
3199-
`[AuthManager] WebContainer detected but pure-JS scrypt unavailable: ${err?.message ?? err}. Falling back to default.`,
3200-
);
3201-
return undefined;
3202-
}
3176+
// [#25224 cut 11] The body moved to `auth-manager-password-hasher.ts`; it reads no member of this class.
3177+
return resolvePasswordHasher();
32033178
}
32043179

32053180
/**
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"file": "packages/plugins/plugin-auth/src/auth-manager-membership-admission.ts",
3+
"adrs": [
4+
"ADR-0093"
5+
],
6+
"invariant": "ADR-0093 D2 — the membership reconciler is the SINGLE owner of the \"every user gets a membership\" invariant, and `settleMembership` is this manager's single assembly of its inputs. TWO seams call it and must never disagree: `user.create.after` (the creation seam every path flows through — email signup, admin create-user, bulk import, SSO JIT, none of which need a session) and `session.create.before` (which settles BEFORE resolving the session's active organization, so a user's first session is not minted tenant-less — #8245). Assembling the deps per call site would let them drift on the policy, which is a live platform setting read through `getMembershipPolicy()` (#5152): a captured option leaves one seam auto-binding after an admin switched the deployment to `invite-only`. The session-seam call must also stay a pure ORDERING change — same reconciler, same policy, same target-org resolution — so `invite-only` and multi-org still bind nobody and those sessions still mint with no active organization, which is the legal state the #8247 ruling declares."
7+
}

0 commit comments

Comments
 (0)