Skip to content

Latest commit

 

History

History
1429 lines (974 loc) · 255 KB

HISTORY.md

File metadata and controls

1429 lines (974 loc) · 255 KB

HISTORY

Shipped phases: full architectural log. The roadmap stays forward-looking; everything completed lives here.

Each section preserves the why and the non-obvious decisions baked into the codebase. New contributors read this to understand why the code looks like it does.

Note on paths: file paths in entries below reflect the layout at the time of shipping. The codebase has since migrated to vertical-slice on both sides (front: features/<x>/<x>.route.tsx + shared/, file-based routing via apps/app/routes.ts + generated src/routeTree.gen.ts; api: modules/<context>/{application,infrastructure,routes.ts,module.ts} + shared/, inwire defineModule() per context). For the current canonical layout see CLAUDE.md ## Layout. The decisions and rationales below stay accurate: only the directory containers moved.


Accessibility gate: axe in CI ✅ Phase A.6 · Aug 2026

Why: /legal/accessibility publishes a WCAG 2.1 AA conformance claim, and the EAA has been in force since 28 June 2025. A11y regressions ship invisibly: nobody files a bug, the complaint arrives through a different channel. A published claim with nothing enforcing it is the worst of both worlds.

What this phase delivered:

  • apps/app/a11y/: Playwright as an axe driver, not an E2E suite. pages.ts lists 4 public + 3 authenticated pages, audited in both colour schemes (a test.use({ colorScheme }) loop; next-themes defaults to system, so emulating the preference is enough). Adding a page is one array entry. auth.setup.ts produces the storageState the authenticated tests reuse.
  • .github/workflows/ci.yml: the repo's first PR-triggered CI. Until this phase every gate (ci:check, type-check, test, knip, jscpd) ran only in the local pre-push hook, so a PR opened from anywhere else was never checked by a machine. The job stands up postgres, bootstraps the env files, pushes the schema, seeds, starts the API, and runs turbo run check:a11y --filter=app (which builds first through dependsOn).
  • Blocking checks: zero serious/critical WCAG 2.1 A/AA violations in light and dark; exactly one <main> and one <h1> per page; tab order across every /sign-in control; command-palette focus trap; reduced-motion skipping the theme view transition.

Key decisions:

  • Lighthouse dropped, against the original spec. Its accessibility category runs a subset of axe-core, so an a11y = 100 budget restates what a zero-violation axe run already proves: at the cost of ~2 min of CI and one more flake source. Perf/SEO budgets remain a separate, unmade decision.
  • audit() asserts the final URL. A gate redirect (no session, stale policies) renders a page with a <main>, an <h1> and no violations: it passes every other assertion. This was not hypothetical: the authenticated tests were green while auditing /legal/accept three times, because the seeded account had never accepted the policies. Without a URL assertion the whole authenticated half of the suite is theatre.
  • One sign-in per run, by construction. /sign-in allows 5 attempts per 15 min per IP, and RateLimiterFlexibleAdapter sets inMemoryBlockDuration, so the block lives in the API process: clearing the rate_limit table does not lift it. Two sign-ins per run means three consecutive local runs lock the developer out. The setup therefore signs in with the keyboard (covering WCAG 2.1.1 operability) and the interaction spec asserts tab order without submitting.
  • Reduced motion is observed, not inferred. theme-transitioning is added and removed within one transition, so polling for it is a race. An addInitScript MutationObserver records whether the class ever appeared; the assertion is on that record.
  • a11y/ excluded from vitest. Its default glob collects *.spec.ts, and a Playwright test.describe() outside the Playwright runner throws. It also carries its own tsconfig.json (types: ["node"] over the react base) and a second tsc invocation in type-check: the app's include is src/** only, so the specs would otherwise never be type-checked.

Seven real defects:

  1. Every password field in the app had no accessible label (label, critical). FormTextField wrapped <FormControl> around the <div class="relative"> holding the input and the reveal button. FormControl is a Slot: it forwards the generated id to its single child, so <label for> pointed at the div. Fixed by wrapping the <Input> itself.
  2. /sign-in and /sign-up had no <h1>. AuthShell passes its title to CardTitle, which renders a <div>. CardTitle now accepts asChild (the shadcn Slot pattern already used by Button and NavLink), so the visible title is the heading, rather than bolting on an sr-only duplicate the way pages with no visible title do.
  3. --accent-foreground on --accent measured 4.27:1 in light (active nav pill). L 0.5445 → 0.52 (4.75:1). Dark measured 7.5:1, untouched.
  4. --destructive measured 3.85:1 both as text on a card and behind white on a button, and the destructive Alert compounded it with /90 opacity on the description → 3.36:1. The opacity is gone (no red dark enough to absorb ~0.9 of ratio stays readable) and the token went L 0.629 → 0.58 (4.72:1).
  5. The avatar input[type=file] was sr-only, tabIndex={-1} and unlabelled. Given an aria-label.
  6. Dark only: --primary measured 4.12:1 under white, i.e. every default button in the app. L 0.6132 → 0.58 (4.75:1). Light was already 5.82:1.
  7. Dark only: the impersonation banner measured 2.76:1. Alert variant="banner" used bg-destructive at full opacity; Button and Badge had long carried dark:bg-destructive/60 for exactly this reason, and the banner now does too.

Why the arithmetic was not enough. The first pass computed contrast by hand (oklch → linear sRGB → relative luminance) and concluded the opposite of the truth: that every destructive button was broken in dark, and that --primary was fine. Tailwind mixes opacity in oklab, so bg-destructive/60 lands far darker than a linear-sRGB blend predicts. Hand calculation is useful to propose a candidate L; only axe against the rendered page decides. Every number above is a measurement.

A defect the gate cannot guard. The impersonation banner only renders inside an impersonated session, so no audited page contains it. It was measured by injecting the markup into a live page and running axe on that node: enough to find and fix it, not enough to keep it fixed. Same blind spot for any surface behind a specific session state.

Two clone-blocking db:seed bugs, surfaced by needing a real account to audit authenticated pages: pnpm --filter api db:seed failed on a fresh clone:

  • dev@clean-stack.test is rejected by the S5a disposable-email guard: .test is IANA-reserved and can never carry an MX record, which DisposableEmailService reads as throwaway. Default now dev@example.com.
  • The default password DevSeed!2026-clean contains the email local part (dev), which findPasswordViolation rejects. Default now Nimbus-Harbor-42-Quartz.
  • The script also now records the initial policy acceptance. Forcing emailVerified in SQL bypasses the /verify-email hook that normally writes it, so every seeded account landed on /legal/accept at first sign-in.

Deliberately not covered: Lighthouse perf/SEO budgets, and screen-reader behaviour (no automated tool covers it).


In-app notification center ✅ Phase D.3 · Aug 2026

Why: transactional email is asynchronous and users miss it. An in-app inbox is the SaaS default (Linear, GitHub, Stripe) and is the last piece F.1 (Capacitor) depends on. The interesting part is not the inbox: it is that the event backbone already resolves what happened; notifications only add who should hear about it.

Why a typed catalog projection over a workflow DSL: Knock / Novu / Courier all ship a workflow engine because they do not own the emitting system. Here the outbox already resolves fan-out, so the entire "engine" collapses into Partial<Record<EventType, NotificationConfig>>: the third projection of the catalog after visibility-map (webhooks) and retention-map (purge). 21 of 67 events are notifiable; the rest stay audit-only by default.

What this phase delivered:

  • Audience by capability, never by role tuple: "self" | "actor" | "org:all" | { can: OrgPermissions }. audience: "org:admins" is exactly the hardcoded tuple org-scoping rule §6 forbids. Costs nothing at runtime: roles are static, so ORG_ROLES.filter(authorizeRole) resolves at boot into WHERE member.role = ANY($1). Trap: billing:["manage"] is owner-only: a notification asks who needs to know, not who may act, so read is nearly always the right level.
  • Fan-out inside the dispatch TX (NotificationFanoutSubscriber implements OutboxSubscriber), beside audit and webhook fan-out, not the onEvent post-commit handler the design first suggested. onEvent is best-effort and isolated, so a lost notification fails silently.
  • Preference cascade resolved in the insert statement: org locked > user > org default > enabled, one LEFT JOIN notification_preference per scope per channel on a single INSERT ... SELECT. In-app decides the WHERE; email decides a CASE filling emailPendingAt. forced: true short-circuits all four and emits no joins.
  • SSE carries a signal, never data: pg_notify('notification_created', user_id) + one LISTEN connection per instance (never per client, which exhausts the pool at a few hundred users). The client's only reaction is invalidateQueries, which makes reconnection self-healing and deletes Last-Event-ID, replay and merge logic outright. Client uses fetch + ReadableStream, not EventSource: it cannot carry an Authorization header, which would break F.1's bearer.
  • Throttling by partial unique index (userId, dedupKey) WHERE dedup_key IS NOT NULL, window baked into the key. Dedup at insert inside the TX is concurrency-correct; a counter would need a lock for the same guarantee.
  • Front: bell + inbox in the shell, /settings/notifications matrix, org defaults card in /settings/organization (a route under orgScopeLayout would collide, it flattens children under settings/). Cross-tab read propagation over BroadcastChannel, applied to the cache without refetching. Polling only as fallback, with refetchIntervalInBackground: false so a forgotten tab whose stream died stops asking.
  • No new event type. Notification creation emits nothing: it would loop with its own subscriber. Only the two preference mutations emit (notification.preference.updated, notification.org_preference.updated). Catalog stays 67 / 28 public / 39 internal.

Structural decisions:

  1. The preference cascade shipped inert, and was found only when building the UI on top of it. The fan-out never read the preference table, forced was read nowhere, and NotificationPreferenceService.resolve was registered in inwire with zero callers: the backend phase had been marked complete with a green suite. The settings page would have written rows that changed nothing, while telling users security alerts could not be disabled (nothing was ever disabled). Branched into the insert statement, and the dead service deleted: two implementations of one cascade, only one of them wired, diverge by construction, and this one already had, since resolve() ignored unlocked org rows.
  2. org default is a fourth cascade level, added deliberately. An unlocked org row now applies when the member expressed no choice. Without it, the "organization defaults" card would set values that never take effect: a default that never defaults.
  3. forcedLevelOf returns all | some | none, not a boolean: security is fully forced, billing only partly (payment failed and cancellation are, subscription created is not). A boolean would have to lie about billing. The UI disables a fully-forced row with its reason rather than hiding the switch: a hidden option is one the user hunts for forever.
  4. SQL logic cannot be covered by this repo's unit tests, and pretending otherwise is the real risk. A mocked tx evaluates no WHERE; there is no DB integration harness. Verification lives in apps/api/scripts/check-fanout-preferences.ts (pnpm --filter api check:fanout, 8 cases against Postgres): re-run it after touching the fan-out. Two tests asserting on generated SQL text were written and removed: they depend on the real sql, which another file's mock.module replaces process-wide, so they passed or failed on execution order. That script is also what caught CASE WHEN … THEN $date breaking Postgres type inference (cast ::timestamp required).
  5. Everything cross-cutting lives in apps/app/src/shared/notifications/, not in the feature. shared/ may not import features/ (the bell mounts in app-shell), and two route-owning features may not import each other (the matrix serves both features/notifications and features/organization). The import the feature layout would need does not exist. Promoted to a placement decisor in apps/app/CLAUDE.md.
  6. Row labels reuse EVENT_DESCRIPTIONS rather than restating 21 strings in the front. Payloads carry ids, not names, so there is no human detail to render beyond the sentence the catalog already owns.

Out of scope (explicit):

  • Native push (mobile / browser): Phase F, needs a device-token registry.
  • Frequency-driven digest windows: the column and the UI exist, the flush cron still treats every pending row the same. Closed. The window is resolved at fan-out and stamped into email_pending_at, which turned out to need no cron change at all: the flush already selected on that column. See docs/CRON.md § Digest windows.
  • Generalized real-time bus. The stream is notification-shaped on purpose; a generic bus is a different product.
  • Resend Broadcasts and Audiences: rejected, they are marketing one-to-many and orthogonal to event-driven one-to-one. Resend Topics (vendor-side email preferences) also rejected: it would hand a product decision to the vendor and covers only the email channel, when D.3 exists to arbitrate between channels. Revisit Broadcasts only if a clone ships a newsletter.

API tokens / Personal Access Tokens ✅ Phase C.4 · Aug 2026

Why: any B2B SaaS needs a machine-to-machine auth primitive that can't be session-stolen. Session cookies work for browsers; PATs work for CI, CLI, and customer integrations. Without them, customers resort to screen-scraping or long-lived session abuse: both unauditable. PATs also unblock C.4's opt-in public API surface, which is the prerequisite for D.2 (OpenAPI docs) and F.1 (Capacitor bearer).

Why hand-rolled over @better-auth/api-key: the plugin covers table + SHA-256 + expiry + CRUD but has no lifecycle hooks: rule §6 requires the event to commit in the same TX as the write. Wrapping every endpoint to add event emission would leave only a table with 8 dead columns. ScopedRepository<ApiToken, RepoScope> + emitEvent in-TX cost fewer LOC than the wrapper, and gives native §8 instrumentation. Scopes format and column names borrowed from the plugin.

What this phase delivered:

  • Token format: clean_ prefix (configurable via API_TOKEN_PREFIX) + 32 bytes CSPRNG as 44-char base58 + 6-char CRC32 checksum. The prefix makes tokens greppable in logs; the checksum rejects typos/truncations before the DB hit and sharpens the GitHub secret-scanner regex.
  • Storage: HMAC-SHA256(pepper, token) in a unique-indexed token_hmac column. No per-row salt: a 256-bit HMAC key makes rainbow tables inconceivable, and per-row salt destroys the O(1) lookup. The pepper (API_TOKEN_PEPPER) is the SOC2 argument: a DB dump alone yields no usable token. pepper_version column enables zero-downtime rotation via API_TOKEN_PEPPER_PREVIOUS.
  • Schema: api_token(id, userId, organizationId, name, tokenHmac, pepperVersion, tokenStart, scopes, lastUsedAt, expiresAt, createdAt, revokedAt, revokedReason). userId = creator + owner. ScopedRepository<ApiToken, RepoScope>: wrong-owner returns Option.none() / NOT_FOUND, never 403.
  • Scopes: typed const API_SCOPES = ["read:profile", "write:profile", "read:organizations"]. Per-token subset. No global *. No admin scope: platform-admin surface is token-unreachable by construction (token management lives outside /api/v1; a token cannot mint a token).
  • requireApiToken middleware: accepts Authorization: Bearer clean_<…>, verifies checksum, HMACs both peppers, sets c.var.user + c.var.tokenScopes. lastUsedAt updated via time-bucket write (WHERE last_used_at < now() - interval '15 min') to avoid hot-row contention.
  • /api/v1 sub-app: mounted in index.ts outside the const routes = ... chain, deliberately outside AppType. Token-auth only; no sessionMiddleware. Dual rate-limit: per-token (detect compromised-key abuse) + per-IP (detect distributed rotation). See apps/api/CLAUDE.md for the AppType exception note.
  • /settings/tokens CRUD: create (name + scope picker + optional expiry, token shown once in <SecretRevealDialog>), list (last-used timestamps), revoke. denyImpersonated on create + revoke: the admin blocklist grows from 11 to 13 routes.
  • Cascade revocation: onEvent(ORG_MEMBER_REMOVED) handler revokes all org-scoped tokens for the departed member in a single pass. Emits one api_token.revoked per token.
  • POST /api/token-scanning/github: ECDSA P-256 signature verification (GITHUB-PUBLIC-KEY-SIGNATURE + GITHUB-PUBLIC-KEY-IDENTIFIER against api.github.com/meta/public_keys/secret_scanning). Matching tokens are revoked + owner notified by email.
  • Event catalog 62 → 65: three new events: api_token.created (public), api_token.revoked (public), api_token.used (internal: sampled, high-volume). visibility-map.ts introduced as the explicit public/internal allowlist (28 public, 37 internal). Publishing an event is now a deliberate PR-reviewed decision, not the default.

Structural decisions:

  1. /api/v1 outside AppType: chaining it into routes would impose AuthVariables on routes that carry ApiTokenVariables, and expose every public-API route to hcWithType which the internal RPC client never needs. The boundary is structural, not config: a route under /api/v1 is unreachable by session auth.

  2. api_token.used is internal: sampled at ~1 event per 15-min bucket per token. Making it public would create a contract on a high-volume, lossy signal: customers could never build a reliable audit trail from it anyway, and subscribing would flood their endpoints. audit_log is the reliable trail.

  3. All webhook.* events reclassified to internal during C.5 curation: webhook.endpoint.created/updated/deleted are operational plumbing, not customer-observable state. This means the subscription picker and /developers/events show no "webhook" group. Test webhooks-schema.test.ts documents this decision assertively.

  4. visibility-map.ts as the gate, not a naming convention: a webhook. prefix doesn't make an event internal; only an explicit "internal" entry in the map does. New events default to nothing: they must be added to the map (TypeScript satisfies Record<EventType, Visibility> enforces this).

  5. The public API surface is opt-in, route by route. A PAT is never accepted by requireAuth: only a route that explicitly mounts requireApiToken({ scopes }) is reachable by token, and every other route stays session-only by construction rather than by config. "Authenticated" is not "public API": a token that inherits the whole session surface turns every internal route (admin, billing mutations, impersonation) into a public endpoint the day it ships, and the blast radius of a leaked token becomes the entire app. The opt-in registry also serves as the source for the API documentation: only token-reachable routes are documented. Design decisions were taken on 2026-08-06 (org-scoped, HMAC-SHA256 plus server pepper, CRC32 checksum).

Out of scope (explicit):

  • OAuth app flow (next natural step after PATs, requires a separate spec).
  • read:uploads scope: the uploads module has no listing route and storage is opt-in; the scope would guard a route that can't exist without out-of-scope work.
  • GitHub Secret Scanning Partner Program registration: requires a public service; each clone registers independently. The boilerplate ships the verifier + endpoint + prefix convention so registration is a 30-minute job.

Option / Result convention back-fill ✅ Aug 2026

Why: an audit of ports, services and the HTTP boundary found Result respected everywhere and Option respected only in recently-written code. The convention had been applied going forward and never back-applied, so modules/webhooks (the module the codebase treats as the reference) was inconsistent with itself: WebhookDeliveryRecord used Option<T> directly above two record types using raw | null.

How it was found: not by review. The same defect had just shipped in the D.5 email queue (Date | null in a port whose sibling used Option) and survived three separate code reviews, each of which had the rule quoted verbatim in its prompt. Reviewers verify what they are asked to trace concretely (index mapping, claim concurrency, attempt accounting) and skip what is stated as a general principle. The lesson, now applied when auditing a cross-cutting convention: give the reviewer the conformant sibling file and ask for a field-by-field comparison.

Decisions:

  1. Result.ok overloaded. static ok<T, E>(value?: T) made the value optional for every T, so Result.ok<string>() compiled and getValue() returned undefined typed string, with a biome-ignore comment claiming the non-null assertion was safe after the isSuccess check. It was not: the result is a success whose value is absent. Two overloads ((): Result<void, E> and (value: T): Result<T, E>) close it. The hole was genuinely exploited: 18 test mocks called Result.ok<void, E>() with no argument.
  2. null stops at the store. Ports express absence as Option<T>; the repository converts with Option.fromNullable on read and unwraps on write; routes unwrap back to null only when serialising, so no wire format changed.
  3. Three residuals kept deliberately: OutboxEnqueueScope.organizationId (input DTO whose callers hold raw nullables from BetterAuth), AuditFilters.organizationId (three-state SQL filter: undefined don't-filter / null IS NULL / value equality; Option cannot express it), and AuditEventSubscriber writing audit_log directly rather than through IAuditPort (infra subscriber, parallel write path by design).
  4. The audit hash chain was never at risk: audit-hash.ts was not modified, so the hashed bytes are identical on existing production rows and GET /admin/audit-log/verify still validates them.

Scope: @packages/ddd-kit (the overloads), then consents, billing, the rate-limiter adapter, webhooks, and the shared outbox + audit ports. An adversarial closure re-audit confirmed no unguarded unwrap, no dropped isFailure check and no paper-over cast.

Trap for later: converting a record type to Option tempts the implementer to satisfy the type-checker with arr[0]! in tests. Three such assertions were introduced and reported back as "pre-existing": they were not. Destructure and guard instead.


Auth: BetterAuth (end-to-end) ✅ Phase 1 · Phase 2 (organization)

Why: own the token, multi-provider, typed plugins (Stripe, organizations, 2FA, passkeys, magic-link), DB-backed sessions, first lib that runs natively on Bun + Hono with no hacks.

Plugins shipped: twoFactor, passkey, magicLink, bearer (Capacitor-ready). organization shipped in Phase 2; stripe deferred.

  • Performance: session.cookieCache (5 min) on the server: auth check is signature-only between refreshes (no DB hit). DB stays source of truth at expiry → instant revoke on sign-out/ban.
  • Native readiness: bearer() plugin enables Authorization: Bearer <token> alongside cookies. Web stays cookie-based (httpOnly, XSS-safe), Capacitor/mobile uses bearer with secure storage. Same session row, transport differs.
  • Email URLs route through the app, not the API: every email link points to ${APP_URL}/<route>?token=.... The frontend page consumes the token via the typed client. No more callbackURL mangling by Outlook & co.
  • Session as TanStack Query, not React state: sessionQueryOptions (staleTime 5 min aligned with cookieCache). Router context only exposes queryClient; gates do await context.queryClient.ensureQueryData(sessionQueryOptions) in beforeLoad. No useSession() React bridge, zero race between nanostores and beforeLoad.
  • Realtime cross-tab session sync: native BroadcastChannel('clean-stack-auth') (~15 LoC, no experimental dep). Auth mutations call broadcastAuthChange() after refetching the session query; app-providers.tsx listens once and on receive does refetchQueries(['session']) + router.invalidate(). Tab A signs out → tab B instantly transitions to /sign-in without polling or hard reload.
  • Strong password schema split: passwordSchema (loose: min(1), used by sign-in to capture; server validates) and strongPasswordSchema (strict: min(12).max(128) + lowercase/uppercase/digit, used by sign-up + reset). NIST-aligned: no required special character.
  • StrictMode-safe token consumption: useRef(false) guard in verify-email and magic-link pages prevents the dev-only double-fire of single-use tokens.
  • Pathless layouts: _protected.tsx (block when no session) and _guest.tsx (block when already logged in): single beforeLoad shared by all children, URLs unchanged.

Multi-tenant: BetterAuth organization plugin ✅ Phase 2

Why from day one: migrating single-user → multi-tenant after the fact is hell (backfill organizationId everywhere, orphaned owners, rewrite every query). The reverse is free: if it ends up B2C, every user gets an invisible auto-created "personal org".

  • Auto-create a personal org on signup (databaseHooks.user.create.after, slug personal-${orgId}: UUID v4, never user-visible).
  • Session enriched with activeOrganizationId → Hono middleware pushes it into c.var.orgId.
  • Every business table has an organizationId FK from the very first migration (never added later).
  • Slug auto-generated, never user-input: create-org form only asks for name; mutation generates org-${crypto.randomUUID()}. Slug is a DB uniqueness constraint, not a UX surface.
  • Stripe customer = per organization, not per user (the Stripe plugin supports it natively: wired in Phase 3).

Capability-based authorization layer (post-merge hardening)

The plugin ships built-in role checks for plugin endpoints, but business routes + UI need the same predicate without re-implementing it. Solution: @packages/access-control: wraps createAccessControl with extended STATEMENTS, exports ac, roles, authorizeRole(role, permissions, connector?) predicate. The as unknown as AccessControl cast required by BetterAuth's generic plugin signature is hidden inside the package: call sites stay strict-typed.

Three layers, one contract: server requireOrgPermission(permissions) middleware; front route gate ensureOrgPermission(permissions) in beforeLoad; UI gate <Can requires={...}> backed by useAuthorization(). Same predicate everywhere; renaming an action requires touching the package only.

  • Capability-based, never role-based, in feature code: describe { organization: ["update"] }, not ["owner", "admin"]. Children call useAuthorization themselves rather than receiving boolean canEdit props.
  • Flat _org-scope route layout: one pathless gate ensures active-org-required; capabilities live per-route via beforeLoad: ensureOrgPermission({...}). Avoids stacking _org-admin / _org-owner / _can-manage-billing pathless tiers as new resources land.
  • Navigation declares requires: OrgPermissions: SETTINGS_TABS and NAVIGATION_ROUTES filter via useAuthorization().can(requires). The visible tab set matches what the gate accepts; no drift between "I see the tab" and "the gate lets me in".
  • AuthorizationDevTool: dev-only floating panel (tree-shaken via import.meta.env.DEV) visualising the active session's role and the full capability matrix derived from STATEMENTS × roles.

Lifecycle hooks: self-heal + auto-cleanup

Personal org is structurally identical to a team org for every operation except delete/leave. The lifecycle exception is encoded in isPersonalOrg(slug) + the hooks below.

  • ensurePersonalOrgFor(userId): idempotent self-heal in auth.ts. Runs in databaseHooks.user.create.after (signup) AND databaseHooks.session.create.before (sign-in: back-fills legacy users with activeOrganizationId: null). Never duplicate the create-personal-org logic inline.
  • afterRemoveMember: non-Personal orgs auto-collapse when the last member leaves. Skipped for Personal orgs (user must delete their account). Empty orgs = zombies; auto-cleanup keeps it truthful.
  • beforeDeleteOrganization: rejects Personal org deletion outright. The front mirrors this by hiding the Leave button on Personal and rendering a hint on Delete.
  • Owner-leave flow: sole member → org auto-deletes; sole owner with other members → must transfer first. transferAndLeaveMutationOptions is the multi-step factory: updateMemberRole then leave. Post-leave both flows call switchToFirstRemainingOrg(queryClient) + broadcastAuthChange().
  • NO_ACTIVE_ORGANIZATION translated to null: currentMembershipQueryOptions and activeOrgQueryOptions catch BetterAuth's error code and return null. "No active org" is a valid transient state; letting it bubble crashes consumers.
  • broadcastAuthChange() extended to org events: setActive, create-org, delete-org, leave-org, transfer-and-leave, accept-invitation, remove-member all call the broadcast. Cross-tab consistency under the 5-min cookieCache.maxAge window.

May 2026 cleanup: dropped the teams sub-plugin

Removed the BetterAuth teams sub-plugin. Grouping-only (no team-scoped roles or statements) added UX surface for ~zero value at this stage. Re-enables in two lines if a clear use-case emerges. Settings collapsed from a General/Members/Teams split to a single Organization tab with section-level <Can> gates per role.


Email: Resend (dashboard templates) ✅ Phase 1

Why: templates managed from the Resend dashboard (no code, no rebuild to change wording), built-in versioning, native A/B test.

  • Type-safe variables per template: EmailTemplates maps each template name to its required variables. Renaming a variable in the dashboard without updating code = TS red, no silent break.
  • Result<void, EmailError>: sendTemplate never throws, returns a discriminated EmailError (EMAIL_TRANSPORT_NOT_CONFIGURED | EMAIL_PROVIDER_FAILURE). Integration adapters (auth.ts) translate to throw only at the BetterAuth-hook frontier.
  • Retry with exponential backoff: 3 attempts (1s/2s/4s), retry only on 429 and 5xx + network errors (status === 0). 4xx non-rate-limit fail fast (validation = retry futile). Distinct STATUS_HINTS log per 401 / 403 / 409 / 422 so prod debug isn't blind.
  • Idempotency-Key: ${event-type}/${sha256(token)[:32]} (Resend pattern, 24h window). Hash via Bun.CryptoHasher. Safe under retries: same payload returns original response, different payload returns 409.
  • SendTemplateOptions.from?: per-tenant from override slot for future per-org sending domain. Defaults to env.RESEND_FROM. Adding it now = zero breaking change in phase 2.
  • SendTemplateOptions.locale?: slot reserved for the i18n phase. Adapter logs a warn ("not yet implemented") if passed; resolution will switch to ${template}_${locale} env lookup when locale-prefixed templates land. Note: removed rather than activated by the i18n foundation (Phase E.1a): a batch-level locale would force one language on every recipient of a batch, so locale moved onto EmailRecipient<K>.locale instead. See that entry.
  • Boot-time fail-hard in production: constructor throws if NODE_ENV === "production" and RESEND_API_KEY or any template ID is missing. Dev mode keeps the warn-only fallback. Note: reversed by prod-validation closeout (Phase 0.7) to warn-and-degrade for the boilerplate: see that entry.
  • IP reputation guarded by Resend, not by us: Resend ships a domain-scoped suppression list since 2025: hard bounces and spam complaints auto-add the address. No own suppression table needed until a product feature actually consumes it. Building it earlier is the OpenUp anti-pattern. Promote on second occurrence.
  • DNS mandatory before any production send: Gmail (Feb 2024), Yahoo (Feb 2024), Microsoft Outlook (May 2025) all reject unauthenticated bulk senders with 550 5.7.515. SPF + DKIM CNAMEs + DMARC TXT (p=none → p=quarantine once stable, target p=reject).

Storage: Cloudflare R2 (prod) + SeaweedFS (dev, opt-in) ✅ Phase 1

Why: R2 = no egress fees, S3-compatible, SigV4 only. SeaweedFS local = same S3 API → one codebase, switched via env. R2 drives the design (SeaweedFS is for dev convenience, not a target). Originally MinIO; swapped to SeaweedFS in May 2026 after MinIO was archived (April 25, 2026, features moved behind enterprise license).

R2 quirks that shape the design (verified 2026): R2 does not support Presigned POST policies: only PUT/GET/HEAD/DELETE. There is no native content-length-range condition. ContentLength and ContentType passed to a presigned PUT are signed (the client must send those exact headers or 403 SignatureDoesNotMatch), but R2 does not verify the actual body size against them. Real enforcement requires a post-upload HeadObject + DeleteObject on mismatch step, which is what the confirm route does. Object Lock and Bucket Policies are not implemented on R2; do not depend on them.

Three-step flow: presign → client PUT direct to R2 → confirm (server HeadObject, deletes on size/content-type mismatch).

  • Pure transport port (IStorageService): presignUpload / presignDownload / headObject / deleteObject / publicUrlFor. Zero business rules; the adapter just signs S3 requests and forwards SDK calls.
  • S3 adapter: S3Client with region: "auto" (R2's only accepted value), forcePathStyle (kept on for SeaweedFS compat: harmless on R2). Boot-time fail-hard in production if S3_ENDPOINT is localhost or creds are dev defaults. Presigned PUT signs content-type + content-length headers (signableHeaders) so the client can't drop them.
  • Owner-scoping enforced in use-cases: every key is <userId>/<scope>/<uuid>-<filename>; download + confirm reject any key whose prefix is not <requestingUserId>/ (STORAGE_FORBIDDEN). confirm-upload performs HeadObject, deletes on size/content-type mismatch, returns STORAGE_INTEGRITY_FAILED.
  • Validation lives at the controller boundary: Zod schemas enforce filename regex (^[\w\-. ]+$), scope regex (^[a-z][a-z0-9-]{0,31}$), max size (STORAGE_MAX_UPLOAD_BYTES, default 50 MB). Zod failures return 400.
  • R2 region is permanent: once chosen (https://<ACCOUNT_ID>.r2.cloudflarestorage.com or …eu.r2.cloudflarestorage.com for EU jurisdiction), R2 cannot move the bucket.
  • Flat DI container: inwire infers everything. Use-cases registered next to their infra ports, type-checked by inference (reorder a .add() to put a use-case before its port → tsc rouge). Promote a section to modules/<context>.module.ts only when a bounded context grows large enough to bloat container.ts.
  • createUploadMutationOptions: TanStack Query mutation factory chaining presign → PUT direct to R2 (with explicit Content-Length) → confirm. Returns { key, publicUrl, size, contentType } only after server-verified integrity.

Dev: opt-in via docker compose --profile storage up -d. SeaweedFS has no auth by default (any creds accepted). No web console.


RGPD / CCPA: data deletion (Art. 17) + export (Art. 20) ✅ Phase 1

Why: clean-stack is a boilerplate cloned to start any SaaS. A clone deployed to EU users without Art. 17 + Art. 20 is illegal day one: fines up to 4% of revenue. The cascade was built before Billing / Audit-log / Admin landed so every future feature inherits the deletion contract rather than retrofitting it.

  • Export endpoint POST /me/export: sync (walks user's tables in-request, uploads JSON bundle to R2, emails a signed 7-day URL via Resend). Rate-limited 1/24h. The presigned URL is never put in an event payload: events carry only storageKey (security).
  • Pre-flight ownership gate GET /me/delete/preflight: returns the sole-owner non-personal orgs that block deletion. UI renders the blocking list; the Delete account button stays disabled while non-empty. Auto-transfer rejected on principle, no implicit refiling of legal/billing responsibility onto a member without consent.
  • Delete endpoint POST /me/delete: auth + 2FA-required + server-side preflight re-check (409 ACCOUNT_DELETION_BLOCKED if a sole-owner org appeared between read and submit) + 7-day soft-delete grace. Cron sweeps expired requests, wipes personal data and anonymizes member rows (userId → null, email → deleted-<uuid>@anonymized.local).
  • Cancel-deletion UX: signing in during the grace window prompts a cancel/continue dialog.
  • Soft-delete confined to RGPD: user.deletedAt + user.pendingDeletionUntil are the only soft-delete columns in the codebase (no creep elsewhere; everything else is hard-delete).

Decisions (non-obvious, locked-in by code):

  1. Soft-delete grace, not immediate wipe: Art. 17 allows a reasonable processing window. The 7-day grace doubles as a self-service "I changed my mind" path and a safety net against account-takeover-then-delete attacks.
  2. Anonymize member, don't cascade-delete it: deleting the member row would corrupt org audit trails ("who invited whom"). Setting userId → null + a tombstone email keeps referential history intact while removing the PII link.
  3. Sole-owner preflight is server-authoritative, re-checked at submit: the UI gate is UX; POST /me/delete handler re-runs the preflight so a race (org ownership changing between page-load and click) can't orphan an org.
  4. 2FA gate on a destructive irreversible action: reuses BetterAuth twoFactor challenge, consistent with "step-up auth on irreversible ops" posture.

Remaining (tracked in dependent phases): E2E deletion-cascade gate (A.6), admin export-on-behalf + deletion overrides (C.3), Stripe customer cleanup during wipe (B.1).


App shell: top-nav + ⌘K command palette ✅

Why: sidebar SaaS shells are the 2010-2024 standard, but the SOTA 2026 wave (Vercel, Linear, Resend, Trigger.dev) consolidated on top-nav + global ⌘K palette. Less chrome, better mobile, keyboard-first power-users.

Top-nav header (sticky, blurred bg); contextual sub-nav (second header line, appears only on /settings/*); global ⌘K palette with Navigate, Switch organization, and Actions groups; org switcher as Command-powered popover. /settings hub with six sub-pages; /settings index redirects to /settings/general.

Custom inline-SVG LogoMark: two offset rounded squares (front solid, back at 18% opacity), theme-aware via currentColor + var(--background). No asset file.


Event-driven foundation: outbox + dispatcher + audit-log + webhooks ✅ May 2026

Why: every cloned SaaS needs the same event plumbing: outbox for at-least-once delivery, audit log for compliance (SOC2 §CC7.2 + ISO 27001), outbound webhooks for customer integrations, in-process handlers for side-effects. Building that rail once-for-all unlocks Phases A.4 (consent handlers), C.2 (audit), C.5 (webhooks), C.7 (SSO audit), D.3 (in-app notifs), 0.4 (observability subscribers): each becomes a 1-line onEvent(...) declaration instead of a per-feature plumbing chunk.

DX contract: zero plumbing post-clone: a dev writes (1) a 1-line entry in packages/events/src/event-types.ts, (2) aggregate.addEvent(new XEvent(...)) in their domain method, (3) this.uow.run(async tx => repo.save(agg, tx)) in their use-case. The outbox enqueue happens transparently via AsyncLocalStorage event collector + IUnitOfWork.run() flush pre-commit. Audit + webhook fan-out automatic if the event is in the retention map. In-process handlers via onEvent(type, factory) + 1 inwire b.add(...) auto-discovered at boot. See docs/EVENTS.md for the full DX guide.

Catalog @packages/events

29 events at foundation (grown to 35 as later phases added webhook-endpoint ×3, profile-updated + email-change-requested ×2, policy-accepted ×1) covering BetterAuth (user/session/account, organization/member/invitation, MFA/passkey), RGPD, uploads. Zod payload schemas: typed discriminated union via PayloadByEventType. RETENTION_MAP: per-event operational (90d) / compliance (7y) / none.

@packages/ddd-kit extensions

  • Aggregate.pullDomainEvents(): atomic pull-and-clear.
  • EventCollector (AsyncLocalStorage): per-uow context isolation. Verified via concurrent Promise.all test.
  • IUnitOfWork.run(cb) standardized: wraps Drizzle db.transaction(...) + opens ALS context, drains events pre-COMMIT via injected flushHandler. Nested run() forbidden: impl throws Error("nested IUnitOfWork.run() is not supported") because Drizzle nested transactions are independent (not savepoints) → events would orphan.
  • onEvent(type, factory) + EVENT_HANDLER_SYMBOL (cross-realm via Symbol.for("clean-stack/event-handler")).
  • UUID v7 inline impl (RFC 9562, no external dep): replaces v4 in UUID.create(). Time-ordered → B-tree locality on insert.

Shared infra

  • OutboxDispatcher: in-process Bun worker. Dedicated pg.Client for LISTEN outbox_event + reconnect with exponential backoff + 30s poll fallback. SELECT ... FOR UPDATE SKIP LOCKED LIMIT 50 drain (multi-instance ready). Built-in subscribers (audit + webhook fanout) run inside the dispatch TX (atomic with markDispatched); user onEvent(...) handlers run post-commit in a separate loop (best-effort, isolated). pg_notify trigger ensured idempotently at boot. Container introspection auto-wires user handlers via Object.entries(di) + EVENT_HANDLER_SYMBOL filter.
  • Built-in subscribers: AuditEventSubscriber writes audit rows idempotently via deterministic ID audit-${event.id} (ON CONFLICT DO NOTHING). WebhookFanoutSubscriber enqueues webhook_delivery rows with org-scoping. Multi-tenant safety: events with organizationId = null (platform-level) skip the webhook fanout entirely, never broadcast across tenants.
  • AEAD secret crypto: @noble/ciphers v2 XChaCha20-Poly1305 + HKDF-SHA256 per-org sub-key from WEBHOOK_MASTER_KEY (32-byte hex). Webhook secrets encrypted at rest, plaintext returned once at endpoint creation (Stripe-style).
  • Decorrelated jitter: AWS Architecture Blog formula: min(cap, random(base, lastDelay × 3)). BASE = 1000ms, CAP = 12h, MAX_ATTEMPTS = 5. Retry paliers ~1m / 5m / 30m / 2h / 12h, dead-letter after 5 attempts.
  • Request correlation via AsyncLocalStorage: a requestId middleware wraps each request in an ALS carrying X-Request-Id; DrizzleOutboxRepository.enqueue reads it at the single write choke-point and stamps outbox_event.metadata.requestId. Chosen over threading c.get("requestId") through ~30 call sites because BetterAuth hooks (which emit the majority of events) have no Hono c in scope: ALS is the only source that covers Hono routes, BetterAuth lifecycle hooks, and the internal-route cron alike. The actor stays explicit per rule §7 (ALS is for observability only, never authz).

BetterAuth bridge: 21 unique events emitted automatically (23 emit sites)

3 voies SOTA combinées :

  • databaseHooks (TX-bound, captures all flows including non-HTTP): USER_CREATED, USER_SIGNED_IN, USER_SIGNED_OUT, USER_ACCOUNT_UNLINKED.
  • hooks.after + createAuthMiddleware (path-based, plugin events not exposed in databaseHooks): filter if (ctx.context.returned instanceof APIError) return to skip on 4xx/5xx (plugin events fire even on errors otherwise). Paths: /two-factor/{enable,disable}, /passkey/verify-registration, /passkey/delete-passkey, /verify-email, /change-password, /link-social.
  • Native callbacks: emailAndPassword.{sendResetPassword,onPasswordReset}, magicLink.sendMagicLink.
  • organizationHooks: afterCreateOrganization, afterUpdateOrganization, afterDeleteOrganization, afterAddMember + afterAcceptInvitation (both emit ORG_MEMBER_JOINED: the two lifecycles are independent in BetterAuth, missing the second drops every member who joins via invite), afterRemoveMember, afterUpdateMemberRole, afterCreateInvitation, afterCancelInvitation.
  • Race window BetterAuth COMMIT ↔ outbox enqueue documented as accepted (no 2PC available).

Built-in modules

  • modules/audit-log/: AuditQueryService.listForOrg(orgId, filters) (orgId always from session, never query string); GET /admin/audit-log (gated requireOrgPermission({ auditLog: ["read"] })); POST /internal/audit-log-purge.
  • modules/webhooks/: full CRUD /settings/webhooks (role-gated), WebhookDeliveryWorker with claim window pattern (claim batch with next_attempt_at = now() + (BATCH_SIZE × FETCH_TIMEOUT + 30s), fetch HTTP outside TX, update status in fresh TX): prevents lock starvation under sustained load. HMAC signing format t=<unix>,v1=<hex-sha256> (Stripe-style). Idempotency-key <eventId>:<endpointId> (UNIQUE).

Lifecycle

Boot: OutboxDispatcher.start(di) + WebhookDeliveryWorker.start() in index.ts. SIGTERM/SIGINT: Promise.all([stopWithTimeout(webhookWorker), stopWithTimeout(outboxDispatcher)]) parallel shutdown (each 25s timeout, fits K8s 30s terminationGracePeriodSeconds).

Decisions clés (non-obvious, locked-in by code)

  1. databaseHooks for core models, hooks.after for plugin events: confirmed by reading BetterAuth v1.6.9 source. Plugin tables (twoFactor, passkey) not exposed in databaseHooks. hooks.after requires APIError instance check (not "error" in returned: that pattern misses APIError instances thrown by handlers).
  2. Built-in subscribers run inside dispatch TX, user handlers run post-commit: atomic for the rail, best-effort for handlers. A user handler throwing doesn't fail markDispatched. A built-in subscriber throwing rolls back the entire batch (retried at next drain).
  3. No nested IUnitOfWork.run: Drizzle nested db.transaction() opens independent TXs (not savepoints). Events from inner run would persist in outbox even if outer rolls back (orphan). Hard guard via EventCollector.hasContext() throw.
  4. organizationId = null skips webhook fanout: platform-level events (USER_CREATED, USER_SIGNED_IN) emit without an org context. Without this skip, cross-tenant data leak (every tenant's webhook receives every signup of every other tenant).
  5. AEAD secret stored, plaintext returned once: Stripe pattern. WEBHOOK_MASTER_KEY (32 hex bytes) required at boot in production. Per-org sub-key via HKDF-SHA256(masterKey, info: "webhook-secret:${orgId}").
  6. Claim window pattern in delivery worker: fetch HTTP outside TX (otherwise 50 deliveries × 30s timeout = 25min TX, kills connection pool). Claim window = BATCH_SIZE × FETCH_TIMEOUT + 30s buffer. Idempotency-key on receiver side prevents double-POST if worker crashes mid-fetch.
  7. hashKey(rawKey) in UPLOAD events: sha256-truncated to 16 chars. Raw filename stays only in S3 + the user's session (never in audit_log/webhooks). PII compliance.
  8. onPasswordReset + /change-password both emit USER_PASSWORD_CHANGED: different flows (reset via email vs. logged-in change), single event type.
  9. Tamper-evidence deferred: prev_hash/hash columns posed in audit_log, calc gated by AUDIT_TAMPER_EVIDENCE env flag (off). Implementation choice (Merkle batch vs. row-lock hash chain) parked until SOC2 audit demands.
  10. 3 rounds of multi-agent review: round 1: 20 issues fixed; round 2: 13 issues (3 invalidated round 1 fixes); round 3: 1 HIGH (stopWithTimeout sequential → Promise.all) + 1 MEDIUM (break on stopping in post-commit loop) fixed.
  11. End-to-end QA pass found 2 real bugs + drove the ORM-first rule. A 36-test harness catching: (a) ORG_MEMBER_JOINED silently missing on acceptInvitation: BetterAuth routes it through organizationHooks.afterAcceptInvitation, a separate lifecycle from afterAddMember (which only fires for direct adds, not invites); (b) UPLOAD_DELETED declared in the catalog but never emitted. Both fixed. Same QA pass exposed sql\...`` raw fragments where Drizzle has typed helpers: codified as cross-cutting rule #5 (ORM-first).
  12. emitEvent tx-aware + drop swallowing catch (post-merge hardening, May 2026). Original service-level emitEvent opened its own autonomous TX and wrapped in catch + logger.error. Two gaps: (a) state change and event emission were not atomic when the caller already had a TX: silent audit/webhook gap; (b) silently swallowed enqueue failures. Fix: optional tx?: Transaction arg propagated to outbox.enqueue, catch removed. RGPD writes that ship state-change events now wrap in uow.run. Upload service and auth.ts bridge stay autonomous (no local DB write, hooks don't expose a Transaction): documented limitation.
  13. docs/EVENT_PIPELINE.md: pedagogical complement to EVENTS.md. Visual walkthrough (dual-write problem, 4-phase flow diagram, LISTEN/NOTIFY, two-tier delivery, failure modes, SKIP LOCKED concurrency).
  14. Retention sweeps (Phase 0.6, May 2026). Three HMAC-gated /internal/sweep-* routes purge derived pipeline tables. SOTA 2026 defaults: OUTBOX_RETENTION_DAYS=7, AUDIT_LOG_OPERATIONAL_RETENTION_DAYS=90, AUDIT_LOG_COMPLIANCE_RETENTION_DAYS=365, WEBHOOK_DELIVERY_RETENTION_DAYS=30. Batch pattern DELETE WHERE id IN (SELECT … LIMIT 5000 FOR UPDATE SKIP LOCKED). Cron order matters (FK ON DELETE RESTRICT): webhook → audit → outbox. Legacy /internal/audit-log-purge retired: sweep-audit-log is a strict superset. Rule §6 gained an explicit exception for infra retention sweeps.

Observability: Sentry with IInstrumentation port ✅ Phase 0.4 · May 2026

Why: errors silently swallowed in catch + Result.fail(...) blocks were the #1 source of "no idea why it broke in prod". SOC2 §CC7.3 + ISO 27001 A.16.1 require monitored incident detection. The SOTA-2026 audit landed on Sentry only: OTel sub-Bun 1.3+ requires manual Bun.serve() instrumentation and Prometheus /metrics is dead code until a Grafana consumer exists.

Pattern (Lazar-inspired, port-first): single IInstrumentation port (startSpan + capture + addBreadcrumb) injected via inwire DI. NoOpInstrumentation by default, SentryInstrumentation swaps in when SENTRY_DSN is set: single binding flip in container.ts, zero refactor at call sites. Every I/O class receives the port via constructor: outer span per public method, inner span per query.execute() / fetch() / client.send() (OTel SemConv 1.27+ attributes db.system.name: "postgresql" + op: db.query / db.transaction / http.client / function), catch + capture + return-or-rethrow.

Front (@sentry/react): Sentry.ErrorBoundary wraps the router with shadcn AppErrorFallback, React 19 createRoot receives onUncaughtError / onCaughtError / onRecoverableError handlers from Sentry.reactErrorHandler(). Sentry Vite plugin gated on CI secrets; sourcemap: "hidden". beforeSend scrub whitelist drops email, username, ip_address, cookie/Cookie, authorization/Authorization, x-csrf-token. sendDefaultPii: false.

Decisions (Phase 0.4)

  1. Single merged port (vs Lazar's two separate IInstrumentationService + ICrashReporterService). Both surfaces consume the same Sentry global; splitting forces double-wiring at every call site for zero portability gain.
  2. Sentry-only, OTel + Prometheus deferred to D.1. Bun OTel auto-instrumentation requires manual Bun.serve() wiring (not stable until Bun 1.4); prom-client without Grafana scrape = dead code. Ship infra cross-cutting only when a consumer exists.
  3. SentryInstrumentation constructor-injected, NOT module-level singleton. Mirrors IEmailService / IStorageService pattern. Sentry SDK init remains a side-effect import (the SDK detains global state: wrapping that init would just recopy Sentry.* and lose typings).
  4. createErrorHandler(instrumentation) factory pattern for error.middleware. Middlewares importing di directly would risk a runtime cycle if any module imported back into shared/middleware/. Factory takes the dep as a parameter, called once in index.ts after di.build(): cycle-immune.
  5. NoOp + Sentry surfaces stay symmetric. Front noop.ts + sentry.ts export identical signatures. Swap = single import path change; no runtime alias gymnastics.

Disaster recovery: PITR-first, doc-only deliverable ✅ Phase 0.3 · May 2026

Why: SOC2 §A.1 + ISO 27001 A.12.3 require a tested backup/restore policy. The original roadmap expected a pg_dump cron route in the API; SOTA 2026 audit reversed that decision.

Superseded by Phase L: the Coolify reference deploy now ships pgBackRest PITR to R2 with restore drills, see DISASTER-RECOVERY.md.

Why no code shipped: Every managed Postgres provider (Railway, Neon, Supabase, AWS RDS, Fly) ships PITR one-click with sub-minute RPO and 7 to 35 d retention. pgBackRest lost its maintainer in 2026: building a clean-stack route on top would have duplicated what the platform already does, forced postgresql-client into the Docker image, and introduced streaming/OOM/timeout failure modes the cloneur inherits for zero value. clean-stack ships cross-cutting multi-tenant infrastructure: backup is infra DB, owned by the provider.

Deliverables: docs/DISASTER-RECOVERY.md covering RPO/RTO targets, 3-2-1 rule applied, restore runbook, PITR pointers per provider (Railway, Neon, Supabase, AWS RDS, Fly, self-hosted WAL-G: pgBackRest flagged unmaintained), weekly portable pg_dump export recipes (GitHub Actions, Railway Cron, K8s CronJob), monthly automated restore-test recipe.

Decisions (Phase 0.3)

  1. Doc-only. No route, no script, no Docker image change. SOTA-2026 made the boilerplate-side code obsolete before it was written. Encoded as the anti-NIH default for infra layers a provider already owns: applies to Phase 0.4 candidates too.
  2. backups/postgres/ prefix in the existing S3 bucket, not a dedicated bucket. Trade-off accepted: lifecycle policy applies to the whole bucket, slightly less SOC2-friendly. Simpler ops; the lifecycle filter Prefix: "backups/postgres/" still isolates the expiry rule.
  3. Read-only Postgres role for the export job, not the API role. pg_dump only needs GRANT CONNECT, USAGE, SELECT. CI-secret-leak best practice.
  4. No pnpm db:smoke script committed. The verification step is a 15-line inline snippet in the doc; adding it to the boilerplate would couple to a specific schema enumeration that drifts on every domain change.
  5. No GitHub Actions workflow committed. Committing .github/workflows/postgres-export.yml would silently fire on every fork without secrets: bad UX. Documented YAML stays inert until copied.

Health probes: /livez · /readyz · /startupz ✅ Phase 0.2

Why: K8s / Railway / Fly / Render all probe liveness/readiness/startup; absence = restart loops + 502s during deploys. SOTA 2026 = three probes, IETF draft-inadarei response format, graceful shutdown wired to /readyz. Full per-PaaS recipes in docs/HEALTH-PROBES.md.

  • Each infra-owning module ships an XxxHealthProbe implements OnInit that self-registers at di.preload(): trash the module removes its probe in one shot, no orphan.
  • /livez liveness, no dependency hit (a DB outage must not restart pods → thundering herd). /readyz aggregates checks (db SELECT 1 critical, storage HeadBucket non-critical), tri-state pass/warn/fail → 200 unless critical fails (503).
  • Asymmetric cache (positive 30s / negative 5s) + self-cancelling 5s timeout on /readyz. Mounted outside requestId/httpLogger/cors/session middleware (probes carry no cookies; ~17k hits/day/pod would drown logs).
  • Graceful shutdown: SIGTERM flips lifecycleState → /readyz returns 503 within one probe interval (LB drains), waits SHUTDOWN_GRACE_PERIOD_MS (15s default), then stops the workers. Without it: intermittent 502s on every deploy.
  • Prod-validation hardening (Jun 2026): /livez + /startupz payloads trimmed to {status, uptimeMs}; build metadata moved behind /internal/build-info (HMAC-gated). Public probes were an info-disclosure vector (version fingerprinting + exact-source mapping for private clones).

Removability dry-run: first leaf removed end-to-end ✅ Phase 0.5 · May 2026

Why: the vertical-slice contract claims "a leaf feature is removable in 5 minutes". Until one was actually removed end-to-end, that was theory. Runbook + worked example + edge cases in docs/REMOVABILITY.md.

Removed modules/rgpd end-to-end in a throwaway worktree: 46 files touched, −2980 LOC net, 3 DROP COLUMN migration. All 6 gates green (type-check, ci:check, check:unused, check:duplication, build, test).

4 surprises captured: a 3rd RGPD column missed by the initial cartography, a transitively-dead throwApiError helper, a dangling knip pattern, pre-existing test fails unrelated to the removal. Contract holds: TS error-points the rest.

6-axis checklist codified (schema barrel, DI .addModule() + app.route(), access-control statements, front nav, email templates): the canonical "how to remove a feature".


Railway reference deploy: config-as-code SOTA 2026 ✅ Phase 0.7 · May 2026

Why: clean-stack mentioned Railway in 3 docs but no cloneur had ever validated the boilerplate end-to-end. The existing Railway project fell back to Nixpacks (no railway.toml) and couldn't drive the pnpm + Bun monorepo. Phase 0 couldn't be closed with this gap.

SOTA 2026 pattern: monorepo "shared root": all services point rootDirectory = / (build context = repo root to resolve packages/), each has a custom config file path (infra/railway/<service>.toml). cronSchedule native in deploy block. Reference Variables (${{shared.NAME}}, ${{Postgres.DATABASE_URL}}) for cross-service secrets, never duplicated.

apps/api/src/cron/sweep.ts: chains the 3 sweeps in FK order (webhook → audit → outbox) via signedInternalFetch. Reads API_URL and INTERNAL_SIGNING_KEY from env, process.exit(1) on first non-2xx. Bundle entrypoint added to bun build alongside index.ts + migrate.ts. The cron Railway service reuses the api image (single Dockerfile, single source of truth), no separate cron.Dockerfile.

Decisions (Phase 0.7)

  1. Single-source cron: Railway Cron only, no GH Actions fallback. User explicit decision after the first plan proposed both. Multiple cron paths = noise + risk of desync. If a cloneur wants another scheduler, the runbook documents the swap.
  2. Storage: Cloudflare R2 by default, Railway Bucket as documented alternative. R2 free tier 10 GB + zero egress > Railway Bucket. The trap: Railway Bucket egress (service→bucket) is billed at public network rates: invisible in the Railway pricing page. Calculated inline in the runbook.
  3. Cron service reuses the api image, no dedicated Dockerfile. Single source of truth for the binary. startCommand override + cronSchedule in the service dashboard suffice.
  4. Custom config file path in dashboard, not multiple railway.toml at root. Railway 2026 supports only one railway.toml per service-root. For shared build context (= repo root, required for packages/), only option = rootDirectory = / for all 3 services + 3 different config paths in the dashboard. Not reproducible via file alone: documented as manual step 1 in the runbook.
  5. No preDeployCommand for migration. Keeps the pattern CMD migrate && start portable (Fly, K8s, Cloud Run). preDeployCommand is a Railway optimization for > 5 replicas; defer to operational phase.
  6. No numReplicas = 1 explicit. First iteration had it: invalid: this field only exists under [deploy.multiRegionConfig.<region>]. Default = 1 implicitly.
  7. No GH Actions deploy workflow. Railway watch main natively + watchPatterns per service = zero glue needed. First iteration had a matrix workflow hitting Railway Deploy Webhooks: removed (double-deploy with native watch, unnecessary GH secrets, violates single-source).

Prod-validation closeout (Jun 2026): live on main, release 1.19.2

The config-as-code shipped but no one had run it end-to-end. Bringing the reference deploy green on Railway surfaced a stack of prod-boot traps, each masking the next. All fixed:

  • NODE_ENV override trap. A Railway service var NODE_ENV=development overrode the Dockerfile ENV NODE_ENV=production (service vars beat Dockerfile ENV at runtime). In dev mode the logger loads pino-pretty: a devDependency absent from the --prod install → instant boot crash (unable to determine transport target for "pino-pretty"). Never set NODE_ENV=development in prod.
  • @packages/access-control declared better-auth as peer/devDependency, not a dependency. A workspace package that imports a lib in its runtime source must declare it under dependencies, else pnpm install --prod skips it → Cannot find module 'better-auth/plugins/access' in the pruned prod image. Compiles in dev (hoisting + devDeps present), breaks only in the pruned prod image.
  • Email + storage threw at boot. di.preload() is eager, so ResendEmailService and S3StorageService fail-hard'd when unconfigured. Changed to warn-and-degrade: email logs-not-delivers, storage swaps to NoOpStorageService. Reverses the Phase 1 "boot-time fail-hard in production" email decision: right for a configured SaaS, wrong for a clonable boilerplate that must boot before the cloner wires Resend.
  • app service Start Command override. A leftover Railway Start Command pnpm --filter app start replaced the Dockerfile Caddy CMD in the caddy:2.11-alpine runner (no Node/pnpm) → container exits instantly with zero logs, healthcheck never passes, deploy fails silently. Cleared the override so the Dockerfile caddy run CMD applies.
  • Cross-site cookies. app and api on different *.up.railway.app hosts = different sites. BetterAuth session cookie set to sameSite: isProd ? "none" : "lax" so it survives the cross-site credentialed fetch. Custom domain under one shared parent (api.x.com + app.x.com) → lax works and is preferable.

Lessons (locked in):

  1. railway up deploys local working-tree code; a variable change / Redeploy / git push rebuilds from the connected branch. During the fix loop railway up was the only way to test uncommitted code; a mid-fix variable change rebuilt from main (no fixes yet) and re-crashed, confirming the model.
  2. RAILWAY_GIT_COMMIT_SHA only populates on branch deploys, not railway up. GIT_SHA/BUILD_TIME show unknown under railway up.
  3. A multi-service deploy fails one layer at a time. Each ~3-minute build only reveals the next boot trap. Pre-scan for the whole class (eager-preloaded constructors that throw, peer-deps imported at runtime, dashboard overrides).

Right to rectification (Art. 16) + NIST 800-63B-4 ✅ Phase A.1 · Jun 2026

Why: two non-negotiables bundled in one push. Art. 16 GDPR requires a working edit-profile surface. NIST SP 800-63B-4 final (August 2025) is the SOTA password baseline: minimum length 15, HIBP breach screening, no complexity rules. Both touch the same surface (/settings/account + auth flows).

  • IPasswordBreachService port + HibpPasswordBreachService: HIBP k-anonymity (api.pwnedpasswords.com/range/<sha1[:5]>, Add-Padding header, timeout HIBP_TIMEOUT_MS default 3000 ms). Fail-open on network error: breach check failure is captured and logged but never blocks the user.
  • findPasswordViolation() + validatePassword() helpers (shared/password-policy.ts): pure, unit-testable. findPasswordViolation bans email-local-part, display name, and app name. The inline ~20 common-password list was dropped: HIBP already covers every common password, so it was dead weight.
  • pendingEmail field: nullable pending_email column exposed as a BetterAuth additionalField. Set in sendChangeEmailConfirmation, cleared on effective change: drives the front "pending change" badge.
  • Password field UX (NIST-aligned): show/hide reveal toggle + optional description slot. Policy rejections (HIBP breach, contextual ban, wrong current password) surface inline on the offending field via form.setError instead of a transient toast.

Decisions

  1. 15 chars everywhere, no MFA exception: the 8-with-MFA floor is a NIST permission, not an obligation. Implementing the two-tier would add session-state coupling to the password validator with zero security benefit at this scale.
  2. HIBP fail-open: a transient HIBP outage must not prevent users from signing up or resetting. The risk of accepting one pwned password during a 3-second HIBP blip is lower than locking out all sign-ups.
  3. Validation via hooks.before, not password.hash override: password.hash intercepts only hashing; hooks.before intercepts at the route level before any BetterAuth processing. Cleanly separates validation (policy) from hashing (crypto).
  4. user.changeEmail confirmation to the current address: confirms the current owner is aware of the change before the new address takes effect. BetterAuth auto-handles the verification challenge to the new address.
  5. No /settings/profile page: rectification fields live in /settings/account. A dedicated tab is reserved for Phase A.5 (Privacy dashboard).
  6. Password policy extracted to a pure validatePassword(): the security-critical logic can't be unit-tested inside a BetterAuth hook (hooks aren't testable in isolation), so it lives in password-policy.ts and the hook is a one-line caller.

Change-email flow (2-step, BetterAuth)

user.changeEmail runs a two-confirmation flow: neither the request nor the first click mutates user.email:

  1. Request: authClient.changeEmail({ newEmail, callbackURL }) → our sendChangeEmailConfirmation hook sets pendingEmail = newEmail, emits user.email.change_requested, and mails the current address a confirmation link.
  2. Confirm (current address): the link hits /api/auth/verify-email?token=…. BetterAuth mints a second token and mails the new address a verification link. Email still unchanged.
  3. Verify (new address): this flips user.email to the new value. databaseHooks.user.update.after clears pendingEmail and emits user.profile.updated.

Mail links point at the API (not the app front): for this one auth mail the click is the state transition (BetterAuth applies it server-side), then redirects to callbackURL. The other auth mails (reset, magic-link, verify) route through the app because the front consumes their token.


Prod-validation closeout (Jun 2026): live on main, release 1.19.2

(See Railway Phase 0.7 above for the full prod-boot trap log: this closeout was part of the same merge sequence.)


Privacy / Terms versioning ✅ Phase A.2 · Jun 2026

Why: Art. 7 §1 RGPD: "the controller shall be able to demonstrate that the data subject has consented". The boilerplate had zero versioning: a cloner shipping a policy update had no way to re-prompt users or produce compliance evidence. This phase closes that gap.

  • @packages/policies: source-only package. Exports POLICY_TYPES, POLICY_VERSIONS (Record<PolicyType,string>, both "2026-01-15"), POLICY_CHANGELOG. Imported by api (gate + service), app (sign-up form + page render), and @packages/drizzle (schema enum). Single place to bump a version: every consumer sees the change at compile time.
  • Append-only policy_acceptance table: (userId, policyType, policyVersion, ipAddress, acceptedAt) + composite index (userId, policyType, acceptedAt DESC). Two-layer compliance trail: this table is the live gate evidence; the durable 7-year compliance trail lives in audit_log via user.policy.accepted event.
  • requireCurrentPolicies middleware: throws HTTPException(409) when any policy is stale. Composable, not mounted globally: the front _shell beforeLoad redirect is the primary UX enforcement today; the middleware is opt-in for business routes needing hard server-side gating.
  • Acceptance gate /legal/accept: adapts to context: new user sees "Before you get started" + links to policies; returning user with stale version sees changelog diff. One Accept button either way; on success navigates to the originally intended route.

As-built deviation: acceptance recorded at /verify-email, not /sign-up/email

The original plan proposed recording acceptance at /sign-up/email. Changed during implementation for two reasons:

  1. No session at sign-up. With requireEmailVerification: true, /sign-up/email has no session yet: reading a reliable userId from the response is unsafe because BetterAuth returns a synthetic user on duplicate-email attempts (anti-enumeration).
  2. /verify-email is the natural idempotent boundary. This route fires exactly when the user proves ownership of their email address. getStaleTypes(userId) makes the call naturally idempotent: a user who re-verifies after an email change is a no-op.

Safety net: the front _shell beforeLoad gate redirects any authenticated user with stale policies to /legal/accept regardless of auth path.

Decisions

  1. @packages/policies as SSOT, not a front-only config. The version string must be the same on the API, the front, and the DB schema. A front-only config means the API has a hardcoded constant elsewhere: drift. The shared package eliminates the sync requirement entirely.
  2. Append-only policy_acceptance, not an upsert. Compliance requires the full history of when each version was accepted. An upsert destroys past evidence.
  3. Not DDD. The acceptance rule is latestAcceptedVersion === currentVersion: a comparison, not an invariant requiring aggregate lifecycle protection. If the rule fits in a comparison, it's infra.
  4. /verify-email hook, not /sign-up/email: see deviation note above. The gate-predicate (front _shell) is the primary enforcement; the hook is best-effort defense-in-depth.
  5. requireCurrentPolicies composable, not global default. Mounting it globally would make every current API call return 409 for a stale user: too aggressive.

Security perimeter: rate-limit + CSP + CSRF ✅ Phase C.1 · Jun 2026

Why: a boilerplate that ships auth, multi-tenant, and billing without a security perimeter is a liability for every cloner. Phase C.1 closes the four cheapest attack vectors: brute-force/credential-stuffing (rate-limit), XSS script injection (CSP), CSRF, and CSP telemetry.

  • requireRateLimit(deps, policy) factory: on blocked: sets Retry-After, throws AppErrorException({ code: "SECURITY_RATE_LIMITED" }) → 429. On first block: emits security.rate_limit.exceeded event if policy.emitSecurityEvent. On store error: either 503 RATE_LIMITER_UNAVAILABLE (fail-closed) or warn + pass-through (fail-open): controlled per policy.
  • 8 auth-burst policies (all IP-keyed, failClosed: true, emitSecurityEvent: true). GLOBAL_POLICY and CSP_REPORT_POLICY remain fail-open.
  • BetterAuth built-in rateLimit disabled: the Hono middleware is the single 429 path. One envelope, one store, one set of headers. BetterAuth's built-in has its own 429 shape and its own in-memory store.
  • Durable Postgres store (rate_limit table) with dedicated pool (max: 3, connectionTimeoutMillis: 500 ms): separate from the app pool (max: 20). Pool exhaustion under a DDoS flood cannot spill into application query capacity.
  • RATE_LIMIT_STORE env: z.enum(["memory", "postgres"]).default("memory"). Default is memory (zero-config dev); set to postgres before horizontal scaling.
  • Per-request nonce via Caddy templates: Caddy's native {http.request.uuid} provides the per-request nonce. Vite html.cspNonce stamps nonce= attributes at build time using the placeholder string; Caddy's templates replaces it at request time.
  • POST /csp-report endpoint: mounted before the global restrictive CORS (browsers post unauthenticated cross-origin). Handles both application/csp-report (legacy) and application/reports+json (Reporting API v1). Sets Cross-Origin-Resource-Policy: cross-origin. Filters reports whose document-uri origin doesn't match APP_URL (third-party extension noise).
  • requireCsrf(deps) middleware: Origin-allowlist strategy: safe methods pass through; for all other methods, Origin must be present and in deps.allowedOrigins. The rejection reason is included in the emitted event but absent from the client response (no security-decision leak). allowedOrigins reuses env.CORS_ORIGIN: single source of truth for "who is our front".
  • TRUSTED_PROXIES CIDR + private keyword: resolveClientIp uses node:net BlockList to check trust. private expands to all RFC1918 + loopback + CGNAT ranges, allowing Railway/PaaS deploys to set TRUSTED_PROXIES=private without pinning a non-stable internal IP.
  • CORS_ORIGIN fail-hard in production: env.ts throws at boot if unset. Without it the API falls back to localhost, silently rejecting the real front.

As-built deviation: CSRF is Origin-allowlist, not double-submit cookie

The ROADMAP originally specified a __Host-csrf cookie + X-CSRF-Token double-submit pattern. Dropped for two reasons:

  1. Cross-origin deploy makes double-submit physically impossible. App and API are on different eTLD+1 origins (*.up.railway.app). document.cookie is per-origin; __Host- forbids Domain=; the front can never read a cookie set by the API origin to echo it back as a header.
  2. Origin-header validation is the 2026 SOTA. Next.js Server Actions, SvelteKit, and Remix all use it. The Origin header is unforgeable by the browser (forbidden header), stateless, zero front-end code.

Bearer-authed requests (Capacitor mobile) skip requireCsrf entirely: no ambient cookie means no CSRF surface, and a forged cross-origin request cannot set Authorization without a CORS preflight that the cors() allowlist blocks.

As-built deviation: CSP nonce in Caddy, not a Hono middleware

The ROADMAP assumed a csp.middleware.ts injecting the nonce server-side. That model requires the app server to intercept and modify HTML responses: impossible when the SPA is a pre-built static bundle served directly by Caddy. Caddy's templates directive with {http.request.uuid} is the correct per-request nonce mechanism for static SPAs: no app-server roundtrip, zero Bun involvement, cryptographically unique per request.

/csp-report is public (browsers post unauthenticated: HMAC verification is impossible from a browser context); defended instead by rate-limit + Cross-Origin-Resource-Policy: cross-origin + document-uri origin filter.

As-built deviation: Trusted Types deferred

In report-only mode on a non-migrated React app, every React DOM call produces a violation and floods audit_log with noise. Browser baseline is also partial: Firefox support is not stable, Safari only reached partial support in 26.1 (2026). The nonce-based CSP ships first; Trusted Types lands once React's DOM abstraction is Trusted-Types-compatible in the project's baseline.

S5a: Abuse-prevention quick-wins (Jul 2026)

Three OSS signals wired into BetterAuth hooks.before, each emitting a security.* event. BetterAuth's Sentinel plugin (@better-auth/infra) covers all of this (credential-stuffing, HIBP, impossible-travel, geo/bot blocking, free-trial abuse) but it is a paid, API-key-bound cloud SaaS, which fails the zero-mandatory-SaaS rule. We mined its threat model and shipped the cheap, calibration-free subset ourselves.

  • HIBP breached-password telemetry. validatePassword already rejected Pwned-Passwords hits (A.1); S5a makes the reject observable: emits security.password.breached only on a genuine breach.
  • Per-account credential-stuffing counter. A second IRateLimiter.consume("auth-sign-in:account:<email>", …) axis on /sign-in (5/15min, fail-closed). IP-only is dead against distributed botnets rotating source IPs against one account; the account axis catches exactly that.
  • Disposable-email block. IDisposableEmailService (embedded disposable-email-domains ~90k-domain Set, O(1)) + DNS MX lookup. Fail-open: a DNS error/timeout warns and lets the sign-up through.

As-built gotcha (cost the demo, not the unit tests): ctx.context.* is undefined in a BetterAuth hooks.before. The global before-hook runs before the session middleware, so ctx.context.request and ctx.context.session are both undefined there (they are populated in hooks.after). The initial implementation read the client IP via ctx.context.request.headers → a TypeError that (a) escaped uncaught on /sign-in → 500 on every login, and (b) was swallowed by the emit's own try/catch on the other paths → the three events never persisted despite the 422/429 firing. Unit tests don't mount the BetterAuth hooks, so only an end-to-end pass (curl + outbox_event query) surfaced it. Fix: read the IP from ctx.headers, and load the change-password actor via auth.api.getSession({ headers: ctx.headers }). Lesson: any story wiring a library's lifecycle hooks must be exercised end-to-end: a green unit suite proves nothing about the hook boundary.

Decisions

  1. Single unified Hono rate-limit middleware; BetterAuth built-in disabled. One 429 error envelope, one §8-instrumented store, one set of IETF headers. BetterAuth's built-in creates a second 429 shape and a second in-memory store.
  2. Fail-closed on auth, fail-open on global traffic. A store outage must not silently disable brute-force protection (OWASP A10:2025). Noted v-next: a circuit-breaker / in-memory fallback would avoid a transient store glitch turning prolonged login into 503.
  3. env.CORS_ORIGIN as single source of truth for "who is our front". The same list feeds cors(), requireCsrf({ allowedOrigins }), and BetterAuth trustedOrigins. Misalignment between cors and csrf would be an open CSRF hole.
  4. TRUSTED_PROXIES=private is the correct Railway value. Trusting private ranges is safe on Railway (reachable only via the platform's edge proxy over the private network) and avoids pinning a non-stable internal IP.
  5. Cookie sameSite: none in prod is required; requireCsrf is the replacement CSRF layer. The cross-origin Railway deploy means cookies must be none to survive credentialed fetch. SameSite no longer provides CSRF protection in that topology; requireCsrf (Origin allowlist) is the explicit in-app replacement. Cloners who deploy under a single parent domain (api.x.com + app.x.com) should switch to sameSite: "lax".

Deployment debt

  • RATE_LIMIT_STORE=memory is per-replica: state lost on restart, not shared across instances. Switch to postgres before horizontal scaling; a second replica with memory store effectively halves the rate-limit budget.
  • TRUSTED_PROXIES must be set (private on Railway) before going to production. If unset, all requests appear to originate from the load-balancer IP: rate-limit keys collide and a small burst from one user can trigger a collective lockout.

Compliance docs bundle ✅ Phase A.3 · Jul 2026

Why: two legal obligations were shipping as missing pages: GDPR Art. 28 (sub-processor disclosure is mandatory when acting as a data processor for any EU client) and EAA Art. 14 (accessibility statement mandatory since June 28 2025). Bundled with two contractual templates (DPA + DORA annex) because they share the same context window and are all pure Markdown / static config: no DB, no backend, no event. A missing sub-processor page or accessibility statement is a legal violation the moment a clone ships to EU users; a missing DPA template blocks every EU enterprise deal.

  • Public pages /legal/sub-processors and /legal/accessibility (no auth gate). Both linked from the command palette (LEGAL_ROUTES group) and data-rights.page.tsx.
  • Contract templates: docs/legal/DPA-template.md (12-clause GDPR Art. 28) + docs/legal/DORA-annex-template.md (11-provision DORA Art. 30, mandatory for EU fintech/insurance since Jan 17 2025).
  • VITE_SENTRY_DSN empty-string fix: schema was z.url().optional(). z.url().parse("") throws (empty string ≠ undefined). Fix: z.preprocess((v) => (v === "" ? undefined : v), z.url().optional()). The boilerplate now boots clean on a fresh pnpm bootstrap without requiring the cloner to manually delete the empty DSN line.

As-built deviations

  1. No footer links → command-palette + data-rights cross-links. No global footer component exists in the app shell (top-nav only: SOTA 2026 pattern). Footer links can be added when a global footer is introduced (Phase E.2 marketing site).
  2. CardTitle heading tree → <TypographyH2>. shadcn <CardTitle> renders as <div>, not an <h2>: the <h1> page title had no <h2> children, a WCAG 1.3.1 heading-structure violation. Fixed so the accessibility statement is itself accessible.

Decisions

  1. 0 domain events. Pages are 100% static reads, no aggregate, no write path, no compliance state change.
  2. status: "active" | "planned" split. Active = contractually engaged today (require DPA coverage). Planned = will require a DPA update before going live (Art. 28 §2 30-day advance notice). This makes the contractual obligation visible.
  3. url? + dpaUrl? both optional. Not every sub-processor publishes a DPA URL directly.
  4. Accessibility statement written before A.6 CI gate. The statement must exist (obligation since Jun 2025); its accuracy improves as A.6 lands. A statement with a complaint contact satisfies the obligation; a blank page does not.

Cookie consent + Consent management ✅ Phase A.4 · Jul 2026

Why: la directive ePrivacy + RGPD Art. 7 exigent un consentement valide avant tout dépôt de cookie non-nécessaire. Sans banner conforme, un clone qui ajoute Umami, Plausible, Stripe pixel ou n'importe quel tracker est illégal en EU dès le premier déploiement. A.4 fournit la mécanique de réconciliation guest→user et expose les primitifs (<ConsentGate>, <AnalyticsScripts>) pour que les cloners branchent leurs outils sans réécrire la couche.

Pourquoi infra, pas DDD : isActive = withdrawnAt IS NULL AND expiresAt > now AND policyVersion = current est une WHERE clause ; la catégorie = categories.includes(cat) ; la validité = comparaison de dates. Même classe qu'A.2 (modules/policies/).

  • @packages/cookie-consent: source-only. Exports CONSENT_CATEGORIES, CONSENT_COOKIE_NAME = "cc_sid", CONSENT_GRANT_TTL_DAYS = 180, COOKIE_CONSENT_VERSION. Bump COOKIE_CONSENT_VERSION → re-prompt automatique de tous les users.
  • Append-only consent_record table: subjectId NOT NULL, userId nullable FK. subjectId = UUID généré serveur, stocké dans le cookie cc_sid httpOnly. Découple le consentement du compte (un guest peut consentir avant de créer un compte).
  • Routes /consents (optionalAuth: guests ET utilisateurs connectés). Cookie cc_sid : httpOnly: true, secure: isProd, sameSite: isProd ? "none" : "lax". Pas de prefix __Host-: le déploiement cross-origin rend __Host- inutilisable.
  • Rate-limit CONSENT_POST_POLICY sur POST/DELETE uniquement: un GET rate-limité saturait la fenêtre et bloquait l'affichage du banner (bug découvert en test : après plusieurs reloads, le GET /consents retournait 429 et la bannière flashait en boucle).
  • <AnalyticsScripts>: exemple d'usage : charge le script VITE_ANALYTICS_SRC seulement si analytics consenti via <ConsentGate>, cleanup React au retrait. VITE_ANALYTICS_SRC vide = composant no-op.
  • Toast 429 consolidé (observability/query-error-handler.ts): notifyIfRateLimited centralise le toast 429 pour toutes les queries ET mutations, dédup par id. L'ancien rate-limit-toast.ts (countdown seconde-par-seconde) supprimé: inadapté aux durées CONSENT_REFUSAL_TTL_DAYS (heures/jours, pas secondes).

Réconciliation au login: hooks.after + ctx.context.newSession

La réconciliation guest→user se fait entièrement côté serveur, sans round-trip client, via le hook BetterAuth hooks.after. Dans auth.ts, si ctx.context.newSession est non-null (= login vient d'avoir lieu: signal couvre TOUS les flux : password/passkey/magic-link/2FA/email-verify/OAuth), lit cc_sid depuis les headers, appelle ConsentService.reconcile(subjectId, userId).

Pourquoi hooks.after + ctx.context.newSession et PAS databaseHooks.session.create :

  1. databaseHooks.session.create.after n'a pas accès aux cookies de la requête HTTP: confirmé par la source BetterAuth (le hook reçoit le model session, pas les Request headers). Lire le cookie cc_sid y est impossible.
  2. hooks.after + createAuthMiddleware donne accès aux ctx.headers. ctx.context.newSession est le signal canonique "un login vient d'avoir lieu sur cette requête, tous flux confondus".
  3. Un seul point d'entrée = pas de drift si BetterAuth ajoute un nouveau flux d'authentification.

Règle générale extraite : pour exécuter du code à chaque login (tous flux confondus) avec accès aux cookies de requête, utiliser hooks.after + createAuthMiddleware + vérifier ctx.context.newSession. databaseHooks.session.create est TX-bound mais n'a pas les headers.

Décisions SOTA (recherche 2026 vérifiée)

  1. Device-scoped vs user-scoped: userId NOT NULL sur consent_record changé en subjectId NOT NULL, userId nullable: (a) un guest doit pouvoir consentir avant de créer un compte ; (b) RGPD Art.7§1 requiert une preuve horodatée, pas la session. La réconciliation au login lie le guest au compte sans perdre l'historique.
  2. Réconciliation via hooks.after+newSession, pas databaseHooks: la contrainte technique (pas d'accès aux cookies dans databaseHooks) a forcé ce choix ; la solution couvre tous les flux en un point.
  3. Rate-limit GET exclu: le GET /consents est appelé à chaque rendu initial. Un rate-limit sur GET saturait la fenêtre en quelques reloads normaux et bloquait l'affichage du banner (bug reproduit en test manuel).
  4. Append-only, pas d'idempotence sur record: la trace complète des changements de consentement est une exigence de compliance. L'auditeur veut voir "l'user avait accepté marketing le 3 juillet, puis l'a retiré le 5" ; une upsert détruirait cet historique.
  5. GPC requalifié hors scope EU: après SOTA review 2026 : l'EDPB ne l'a pas reconnu comme signal de retrait valide au sens RGPD ; seule la CCPA (California) lui donne force légale. Le modèle opt-in du boilerplate satisfait intrinsèquement la conformité RGPD.
  6. DNT mort: le W3C a officiellement abandonné la spécification DNT en 2024. Ignorer DNT: 1 est la posture correcte en 2026.
  7. Google Consent Mode v2 hors scope: aucun produit Google dans le stack. IAB TCF v2.2 pareillement (heavy, vendor-specific).
  8. <AnalyticsScripts> comme exemple, pas comme primitif figé: le cloner le remplace ou le réutilise. Aucune dépendance runtime sur un outil spécifique.
  9. Toast 429 consolidé: CONSENT_REFUSAL_TTL_DAYS=180 produirait des Retry-After de plusieurs heures, que l'ancien countdown seconde-par-seconde ne gérait pas.

Billing: Stripe subscriptions + feature/seat gating ✅ Phase B.1 · Jul 2026

Why: every SaaS needs billing. B.1 ships the plumbing as permanent infra following the same infra-not-DDD principle as policies (A.2) and consent (A.4). Leaving billing to the cloner means every clone rebuilds the same Stripe webhook idempotency, subscription SSOT decision, seat-gating hooks, and free-tier fallback independently: that is the OpenUp anti-pattern at scale.

Posture: zero billing backoffice. Stripe Checkout handles upgrades; Stripe Billing Portal handles subscription management. The app surfaces only the current plan, seat usage, Upgrade button (→ Checkout), and Manage button (→ Portal). Copy and prices live in Stripe; no redeploy to change them.

  • @better-auth/stripe: Stripe customer = per organization (honoring the Phase 2 multi-tenant decision). Plugin manages the subscription table + webhook ingestion + createCheckoutSession / createPortalSession.
  • Subscription state SSOT = subscription table (plugin-managed, webhook-synced). organization.metadata carries no plan data. No poll-to-Stripe in the request path.
  • Hybrid catalog: price + display copy in Stripe Products. Feature entitlements, tier rank, and maxMembers in typed code at modules/billing/config.ts (ENTITLEMENTS map). metadata.tier on the Stripe Product is the sole join key. Editing a gate = a reviewable code change, never a silent dashboard edit.
  • Unlimited seats = null (JSON-safe). Infinity is not JSON-serializable (JSON.stringify(Infinity) === "null": a silent wrong value). null is explicit; the nil-check is if (maxMembers !== null && current >= maxMembers).
  • Seat gates in all three org hooks: beforeAddMember + beforeAcceptInvitation + beforeCreateInvitation all check ENTITLEMENTS[tier].maxMembers. Gating only beforeAddMember leaves a window where an admin can issue more invitations than the seat cap allows; the acceptances then fail with an unhelpful error.
  • Three gate axes (transferable pattern): (1) Role gate: billing:["read","manage"] in @packages/access-control. (2) Seat quota: ENTITLEMENTS[tier].maxMembers, returns 403 BILLING_SEAT_LIMIT_REACHED. (3) Tier/feature gate: requireFeature(flag) / requirePlan(minTier) (back, returning 402 BILLING_PAYMENT_REQUIRED); useEntitlements() / <FeatureGate> / <PlanGate> (front). 402 = "available on a higher plan": semantically distinct from 403 (wrong permission) or 401 (unauthenticated).
  • STRIPE_SECRET_KEY unset → free-only degradation, no boot failure.
  • RgpdService.executeAccountWipe: closes the "Stripe customer cleanup during wipe" deferred from Phase 1. Failure is captured and logged (non-fatal: account deletion must never be blocked by a Stripe API error).

Decisions (B.1)

  1. State SSOT = plugin subscription table, not organization.metadata. Metadata is an opaque blob: not queryable by column, not typed at compile time, subject to divergence when a webhook arrives out of order.
  2. Hybrid catalog (Stripe + typed code, metadata.tier as join key). Pure-Stripe means a feature gate change is a dashboard edit: unreviewed, unversioned. Pure-code means a price change requires a deploy. The hybrid: prices in Stripe (the natural editor), capabilities in code (must pass code review, version-controlled).
  3. null for unlimited, not Infinity. JSON.stringify(Infinity) === "null": a silent wrong value. The sentinel 9999 was also ruled out: it silently becomes a real cap if a customer ever reaches it.
  4. Standard unlimited-orgs model. Capping org count per account requires a cross-org aggregate query on every org-create path: avoidable complexity. The dominant SaaS precedent (Vercel, Linear, Resend) limits seats and features within an org, not org count.
  5. 402 BILLING_PAYMENT_REQUIRED code suffix required in the response body. A bare HTTPException(402) produces no code field in the error envelope: the client-side branch is unreliable.
  6. Whole billing.* family = compliance retention (no divergence). operational was considered for billing.payment.failed (a transient dunning signal), but keeping the family on one retention lifetime avoids a split audit trail where a failed-payment record is purged before the subscription events that reference it.
  7. Loose-typed authClient.billing.* (documented debt). @better-auth/stripe v1.6.23 client extensions return any in client types. The app confines them behind typed adapters in features/billing/_api/; the untyped surface is a single file.

Review catches (pre-merge whole-branch pass)

  1. Portal route missing billing:manage gate: any authenticated member could redirect to the org's Stripe portal and change the payment method or cancel the subscription.
  2. 402 response body missing code suffix: bare throw new HTTPException(402) produced no code field; error handler couldn't distinguish it from other 402s.
  3. beforeCreateInvitation not seat-gated: an admin could create 10 invitations for a 3-seat free org; acceptances fail at acceptance time with a generic error and no clear recovery path.
  4. Double billing.subscription.cancelled emission: subscription.updated (when cancelAtPeriodEnd flips, a scheduled cancellation) and subscription.deleted (actual termination) are distinct business facts. Fix: only subscription.deleted emits billing.subscription.cancelled.

Quota gating ✅ Phase B.2

Why: the third gate axis in the billing design (Role, Seats, Tier/Feature from B.1) was always quotas: quantitative limits per org per billing period. Shipped as a dormant, complete skeleton: no code path calls requireQuota or reserveQuota today, but the plumbing is wired and knip-whitelisted.

Two-layer design (anti-TOCTOU):

  • Pre-check (requireQuota middleware): UX-layer, runs before the write, returns 429 BILLING_QUOTA_EXCEEDED early. Uses countScopedRows (a COUNT(*)) or the quota_usage denormalized counter.
  • Authoritative reserve (reserveQuota): inside uow.run(), acquires a Postgres advisory lock (pg_advisory_xact_lock(orgId hash, quotaKey hash)) then recomputes the count within the same TX as the insert. No TOCTOU: the count and the write are atomic.

Two counting strategies: Live COUNT(*) (default, zero drift, for uploads/projects/seats). Denormalized quota_usage (high-volume, increment(orgId, resource, period, tx) in same TX as business write, window-aligned on Stripe billing period).

Atomic reserve decision: advisory locks chosen over SELECT … FOR UPDATE because: (a) the quota_usage row may not exist yet (first use in a period), requiring an upsert + lock sequence that advisory locks short-circuit; (b) advisory locks are XACT-scoped (auto-release at TX end); (c) they add zero table contention on the resource table itself.

SOTA rejections (2026):

  1. Stripe Entitlements API: boolean-only (feature flags, not counts). No quantitative quota, no runtime blocking.
  2. Stripe Billing Meters: async metering-to-bill, not synchronous gating-to-block. A metered event reaching Stripe does not prevent the next write.
  3. @better-auth/stripe native limits: would create a 2nd SSOT (entitlements in code + limits in plugin config). Kept unified in ENTITLEMENTS[tier].quotas.

Why not DDD: count(rows) >= limit is a WHERE clause and a comparison. No aggregate invariant, no domain event. Every quota rule collapses to config lookup + arithmetic. Applying DDD here matches the OpenUp anti-pattern.


Operator audit log: cross-org read + tamper-evident hash chain + /admin zone ✅ Phase C.2 · Jul 2026

Why: the audit write-path shipped with the event-driven foundation but rows were invisible, no read surface, no integrity proof. C.2 ships the read side as an operator (platform-admin) surface: cross-org filtering, a tamper-evidence hash chain, and the first /admin front zone (foundation for C.3 admin & impersonation).

  • GET /admin/audit-log: cursor-paginated (limit 1 to 500, default 50), filters actorId / organizationId / targetType / targetId / actionPrefix / occurredFrom / occurredTo. CQRS read side, no use case.
  • Gate: requirePlatformAdmin: operator = env.PLATFORM_ADMIN_IDS allowlist OR user.role === "admin", optional MFA via PLATFORM_ADMIN_REQUIRE_MFA. NOT requireOrgPermission({ auditLog: ["read"] }): the surface is cross-org by design; the auditLog statement stays in @packages/access-control for a future per-tenant view.
  • Tamper-evidence hash chain (env-gated AUDIT_TAMPER_EVIDENCE, default off): audit_log.{sequence,prev_hash,hash}: SHA-256 over canonical row content + prevHash, chain writes serialized via pg_advisory_xact_lock(hashtext('audit_log_chain')). GET /admin/audit-log/verify recomputes the full chain → { verified, rowCount, brokenAtId, brokenAtSequence }.
  • Meta-audit: reading the audit log is itself audited: security.operator.audit_accessed emitted only on the first page (cursor absent), not per pagination step.

Decisions (C.2):

  • Operator surface, not tenant surface: a per-org audit page would either leak platform-level rows (organizationId = null) or hide them. A future tenant view (feature-gated audit_log in ENTITLEMENTS) stays a separate deliverable.
  • Hash chain global, not per-org: one chain, one advisory lock; per-org chains would multiply genesis edge cases without adding forensic value.
  • Genesis-at-activation: rows written before AUDIT_TAMPER_EVIDENCE=true keep hash = null; the chain starts at the first hashed row, so enabling the flag never requires a backfill migration.

Outbound webhooks: SOTA hardening (Plans 1 and 2) + front UI + public catalog (Plan 3) ✅ Phase C.5 · Jul 2026

Why (hardening): the event-driven foundation shipped the webhook worker with HMAC signing and jitter retry, but left several attack surfaces open: (1) SSRF: org admins can register arbitrary URLs; (2) secret rotation: hard-cutting a secret during rotation breaks all in-flight verifications; (3) delivery forensics: debugging a failing endpoint was guesswork; (4) dead endpoints accumulate retry backpressure.

Why (front UI + catalog): the CRUD API was live but operator access required raw HTTP calls. The public /developers/events catalog solves integration onboarding friction: customers need to know what events fire, what their payloads look like, and how to verify signatures.

  • SSRF guard: DNS-resolves the URL at create/update AND at delivery time. Re-resolving at delivery closes the DNS-rebinding window (register a public domain → TTL-0 rebind to 169.254.169.254 by delivery time). Checks: loopback, RFC1918, link-local, ULA, CGNAT, cloud-metadata hosts (169.254.169.254, metadata.google.internal, 100.100.100.200, etc.).
  • Dual-secret rotation (POST /settings/webhooks/:id/rotate-secret): stores new secret alongside old, sets rotatedAt = now(). During grace period (WEBHOOK_SECRET_GRACE_HOURS, default 24), worker signs with both and emits t=<ts>,v1=<hex_old>,v1=<hex_new> in a single header. Consumers accept if any v1= verifies.
  • Per-attempt delivery timeline: webhook_delivery_attempt table: attemptNumber, requestHeaders, requestBody, responseStatus, responseHeaders, responseBody (capped at WEBHOOK_RESPONSE_CAPTURE_BYTES, default 4096 bytes: prevents large HTML error pages from filling the table), durationMs, error, attemptedAt.
  • Auto-disable failing endpoints: consecutiveFailures >= WEBHOOK_AUTO_DISABLE_MIN_FAILURES (default 2) AND first failure was more than WEBHOOK_AUTO_DISABLE_AFTER_DAYS (default 5) days ago: status flips to auto_disabled. Time-based + count-based: count-only would auto-disable within minutes of a brief server restart: too aggressive.
  • Wildcard subscriptions: eventTypes accepts "*", "<group>.*", or exact names. Internal events (webhook.test, webhook.endpoint.*, webhook.delivery.*) are never in the expandable set, no infinite-fanout loop.
  • Test event (POST /settings/webhooks/:id/test): bypasses the fanout subscriber (direct delivery-row insertion). Also auto-inserted on endpoint creation (immediate reachability feedback).
  • 4 new internal events (webhook.test, webhook.endpoint.secret_rotated, webhook.endpoint.disabled, webhook.delivery.exhausted): operational retention, non-subscribable, never fanout. Catalog after C.5: 52 total events, 48 subscribable, 4 internal.
  • Public developer catalog (/developers/events, no auth): 48 subscribable events with group, retention label, description, expandable JSON schema (from Zod 4 native z.toJSONSchema({ unrepresentable: "any" })). Node.js signature-verification snippet (cross-checked against hmac-signer.ts).

Decisions (C.5)

  1. SSRF guard at both create and delivery time (anti-rebinding): validating only at create allows DNS-rebinding: register a safe IP, rebind to 169.254.169.254 by delivery time via TTL-0. One extra DNS lookup per delivery attempt is the accepted cost.
  2. Multiple v1= values for secret rotation (Stripe-compatible): simpler than a x-webhook-signature-old header. Receivers already split on , then =; iterating pairs and accepting on first match requires ~3 extra lines.
  3. webhook.test and webhook.endpoint.* as internal events (non-subscribable, non-fanout): the test delivery must travel through the delivery worker for realistic integration testing, but must never fan out to other endpoints' subscriptions.
  4. Zod 4 native z.toJSONSchema for the public catalog: Zod 4 ships this natively, no external dependency, no walker, no drift.
  5. Separate EventTypePicker and EventTypesTable components (shared SSOT): the SSOT is SUBSCRIBABLE_EVENT_TYPES + descriptionFor + jsonSchemaForEvent in @packages/events. The two components are separate because their contracts are incompatible (interactive checkboxes + RHF integration vs. static reference table). Both already depend on the shared SSOT, so description and schema changes propagate to both automatically.
  6. Response body capped at WEBHOOK_RESPONSE_CAPTURE_BYTES (default 4096): 4096 bytes is enough to see the error type and message for every real-world error format. Configurable for operators who need more context.
  7. Auto-disable threshold is time-based + count-based: count-only auto-disables within minutes of a brief server restart: too aggressive. Time-based component (WEBHOOK_AUTO_DISABLE_AFTER_DAYS) distinguishes "transient outage" from "dead endpoint".

Account recovery codes UI ✅ Phase C.6 · Jul 2026

Why: NIST 800-63B-4 §5.1.9 (look-up secrets) mandates that backup codes be stored so the verifier can look up the exact value. BetterAuth encrypts the code set (XChaCha20-Poly1305, symmetric key derived from BETTER_AUTH_SECRET), satisfying this posture. The feature completes the 2FA story: users had TOTP enable/disable but no UI to manage recovery codes or use one as a fallback.

  • RecoveryCodesCard on /settings/account: regenerate-only (no re-view of existing codes, matching GitHub model). Password gate on regeneration. Codes pre-formatted xxxxx-xxxxx by BetterAuth; BackupCodeList renders them as-is.
  • Backup-code fallback on /two-factor: "Use a recovery code instead" toggle. Input normalization: whitespace stripped; if result is exactly 10 alphanumeric characters, canonical dash is auto-inserted (xxxxx-xxxxx); otherwise passes through as-is. BetterAuth's verifyBackupCode does an exact includes() comparison on stored xxxxx-xxxxx codes, so the canonical dashed form must reach the API.
  • 2 new compliance events: user.mfa.backup_codes_regenerated and user.mfa.backup_code_used (retention compliance). Emission placement trap: /two-factor/verify-backup-code is a login endpoint; ctx.context.session is null. The actor is read from ctx.context.newSession (set by BetterAuth after a successful verify), placed before the userId early-return guard. Unit tests do not mount BetterAuth hooks; only the runtime QA catches this trap.
  • BackupCodeUsedNotifier: first onEvent handler in the project. Sends template backup_code_used via email; in dev without RESEND_API_KEY logs the variables. SOTA ref: Bitwarden and NetSuite both send on-use email for backup code consumption (breach signal).
  • formatBackupCode removed: BetterAuth's generateBackupCodesFn already returns codes pre-formatted as xxxxx-xxxxx; a client-side re-formatter produced double-dashes (abcde-fghij → abcde--fghij).

Decisions (C.6)

  1. Regenerate-only (no re-view): showing codes again widens the exposure window. GitHub model: show once at generation, force re-generate to see new ones.
  2. Encrypted-not-hashed (BetterAuth default kept): NIST 800-63B-4 §5.1.9 requires "the verifier can look up the exact value." Hashing would require verifying each hash individually. BetterAuth's symmetric encryption (XChaCha20-Poly1305) satisfies the look-up requirement. No storeBackupCodes override was applied.
  3. No remaining-count UI: showing "8 of 10 codes remaining" encourages users to think of codes as a finite budget and delay regeneration. If a consumer needs it, backup_code_used events in the audit log provide the count.
  4. Input tolerance: whitespace stripped, dash auto-inserted on 10-char input: server comparison is exact, so the canonical dashed form must reach the API. The schema transform handles three input shapes: code pasted with dash (pass-through), code typed without dash (dash re-inserted on exact 10-alphanum match), anything else (passed as-is, fails server-side if wrong).

Privacy dashboard ✅ Phase A.5 · Jul 2026

Why: M3 hub consolidating the privacy/compliance surfaces that were scattered across /settings/account and missing. Refactor-only: composes A.2 (policy acceptance), A.3 (sub-processors), A.4 (consent), RGPD core (export + deletion), and security (sessions). 0 new domain events, 0 back-end changes, 0 migrations.

  • Page composition: <PolicyAcceptanceCard /> (acceptance status + up-to-date badge) + <ConsentSettings /> (A.4) + <DataSourcesCard /> (static list of active SUB_PROCESSORS) + <DataExportCard /> (rgpd, relocated) + <SessionsCard /> (security, relocated from account).
  • <RgpdDeletionCard /> relocated to bottom of account.page.tsx (contextual danger zone). Deviation from the ROADMAP spec which placed it on the Privacy page.
  • Danger tab dissolved: org-danger-card.tsx + transfer-leave-dialog.tsx moved to features/organization/components/, rendered at bottom of organization.page.tsx.
  • sub-processors.config.ts promoted to apps/app/src/shared/: features/legal/ and features/privacy/ are both route-owning features and cannot import from each other (import-direction rule); the 2-consumer config belongs in shared/.

Decisions (A.5)

  1. GitHub-style contextual danger zones: no separate Danger tab. Delete-account at the bottom of the Account page; org leave/delete at the bottom of the Organization page. SOTA convention (GitHub/Linear/Vercel).
  2. PolicyAcceptanceCard = status only: dated/IP acceptance history deferred: policy_acceptance has the data but exposing it front-side would require a new back-end route; avoided under "refactor-only".
  3. Direct RGPD download link dropped: the presigned R2 export key is discarded after the email is sent. A direct link would require a new user column + route + on-demand presign.
  4. DataSourcesCard = static list of active SUB_PROCESSORS: no per-user "last-sync timestamp": it is config, not per-user sync state.
  5. sub-processors.config.ts promoted to shared/: two route-owning features cannot import from each other → belongs in shared/.
  6. ROADMAP deviation acknowledged: the ROADMAP placed <RgpdDeletionCard /> in Privacy; it shipped in Account (danger zone). Contextual danger zones are the SOTA convention: the deviation is intentional.

Email delivery queue: durable outbox + batch worker + in-repo templates ✅ Phase D.5 · Aug 2026

Why: IEmailService.sendTemplate was a direct Resend HTTP call: 1 email = 1 HTTP request. Any fan-out (RGPD wipe notifying N members, D.3 digests) serialized into 429s against the 10 req/s per team ceiling. The fix mirrors what the repo already does for webhooks: a durable queue table + a background worker that batches and retries, decoupled from request paths.

  • email_message table: durable outbox for all outgoing emails. Columns: UUID v7 PK, to_address, kind, template, subject, html_body / text_body (rendered at enqueue), variables jsonb, idempotency_key, status (pending / claimed / sent / failed), claimed_until, attempts, provider_message_id, last_error.
  • QueuedEmailService: binds to IEmailService. Enqueues rows inside the caller's transaction when options.tx is passed (atomicity with the write that triggered the email). Renders React Email templates at enqueue time via @packages/emails.
  • @packages/emails: one React Email component + subject() per template key. TEMPLATE_IDS in the worker is now an override map: empty string → render in-repo template; non-empty → use Resend dashboard ID. A fresh clone sends real emails with zero dashboard setup.
  • EmailDeliveryWorker: polls every 2 s, claims up to 300 rows (FOR UPDATE SKIP LOCKED, 120 s claim window), groups by (kind, template), chunks to 100 per resend.batch.send call. batchValidation: "permissive" isolates bad entries into errors[] without aborting the chunk. Decorrelated jitter retry via shared/jitter.ts. After the ceiling the row moves to status = 'failed' and email.delivery.exhausted is emitted.
  • Retention sweep: POST /internal/sweep-email-messages (EMAIL_MESSAGE_RETENTION_DAYS=7). Purges only status = 'sent'; failed rows are kept deliberately as the operator's only trace of a dropped email.
  • RGPD wipe sweep refactor: sendTemplateBatch replaces up to 50 serial sendTemplate calls, removing per-member latency fan-out in the wipe path.

Decisions (D.5)

  1. email_message table, not the outbox: the outbox dispatcher's built-in subscribers run inside the dispatch transaction (with idle_in_transaction_session_timeout = '30s'), so an HTTP call there holds a Postgres TX open across network I/O. User handlers run post-commit with no retry. Neither slot can hold a retryable external call. email_message is the same pattern as webhook_delivery, for the same reason.
  2. batchValidation: "permissive": the original spec proposed replaying the entire rejected chunk one recipient at a time. "permissive" is strictly better: the provider isolates invalid entries into errors[] and still sends the valid ones: 1 request, no replay. Invalid recipients are marked failed individually and kept for operator review.
  3. No retry-after parsing: the Resend SDK returns { data, error } and does not expose response headers. Decorrelated jitter provides the backoff substitute.
  4. Polling at 2 s, not LISTEN/NOTIFY: a persistent pg.Client listener costs a dedicated connection plus reconnect handling. The gain is at most 2 s of latency reduction on the first delivery attempt: acceptable for email. LISTEN/NOTIFY would be the right call if latency SLA were < 1 s.
  5. provider_message_id attached only when data.length === chunk.length: the Resend batch API does not document positional alignment when some entries fail. Attaching ids[i] to row i when array lengths differ would silently associate wrong IDs. Correctness never depends on provider_message_id (debugging only), so conservatism wins.
  6. @react-email/components deprecated mid-task: React Email 6.0 unified everything into the react-email package. Migrated to react-email@6.9.1.
  7. Bounce suppression is provider-side: Resend's domain-scoped suppression list automatically blocks future sends to hard-bounced addresses. No local email_suppression table needed: build it only when a product feature needs suppression-list reads.

As-built deviations (D.5)

  1. TEMPLATE_IDS as override, not mandatory: the ROADMAP framed template IDs as required config (boot fails if empty). Post-D.5, an empty string means render the in-repo template. Boot no longer fails on empty TEMPLATE_IDS.
  2. @packages/emails introduced: the original ROADMAP spec did not include an in-repo template package. Added to remove the "requires Resend dashboard setup before first email" friction on a fresh clone.
  3. sendRaw / sendRawBatch on IEmailService: not in the original spec. Added to support future transactional emails needing full HTML control without a named template. No call sites exist today; public port surface.

Sweep rail bounds (closeout review, Aug 2026)

The sweep routes and the cron client had no explicit timeout (internalLayers set none, Bun.serve had no idleTimeout, signedInternalFetch issued a bare fetch), which doubling the sweep passes made more likely to matter. Closed as follows.

  • The internal sweep rail is bounded in time and against itself. Three nested deadlines (SWEEP_DEADLINE_MS 90 s < SERVER_IDLE_TIMEOUT_SECONDS 120 s < INTERNAL_FETCH_TIMEOUT_MS 150 s), with env parsing refusing a configuration that inverts them, plus a time-boxed lease in sweep_lock that makes each label single-flight. Timeouts alone were never enough: bounding a run does not stop the next tick from starting a second one, and two concurrent sweeps just split the SKIP LOCKED rows at double the pool cost. The lease expires on its own, so a crashed run does not wedge the label the way a pooled advisory lock would. It is fenced by an owner token, not just the label, because the deadline is only checked between batches: a run can outlive its own TTL, and without the token its late release() could steal back a lease a legitimate successor had already acquired.
  • Two live bugs found by measuring: Bun.serve defaults idleTimeout to 10 s and applies it while the handler runs, so every sweep past 10 s had its socket closed under it while the work continued server-side, and GET /notifications/stream, whose heartbeat is 25 s, was being dropped and reconnected every 10 s in production. A third was found by reading: sweep-notifications was mounted but missing from the bundled cron, so read notifications were never purged.
  • Truncation is reported, not hidden. A sweep out of budget answers truncated: true (derived per pass from a stopReasons map: exhausted, budget, batch-cap, batch-error) and resumes next tick. stopReasons keeps a recurring batch-error from hiding behind that, since one is a healthy backlog and the other is data that will fail identically forever, and the bundled cron exits non-zero on it.
  • rgpd-sweep gets the budget between account wipes, never inside one: a half-erased account is the one Art. 17 outcome that must not happen. It deliberately has no lease and no SKIP LOCKED on selection, so two overlapping runs can select the same account. The anonymizing update is guarded by deletedAt IS NULL, which makes the wipe itself idempotent and lets the losing run short-circuit before emitting USER_DELETED again. Its processed field now reports accounts actually attempted rather than the batch size.

Admin & impersonation: BetterAuth admin plugin ✅ Phase C.3 · Aug 2026

Why: every paid SaaS needs staff-level tooling to debug a paying user's issue without asking for their credentials, and a one-click ban path for abuse without DB surgery. BetterAuth ships an official admin plugin covering these primitives, no rolling our own. The phase is infra (no DDD), gated by the platform role enforced since C.2.

What was already in place before this phase: the admin plugin wired in auth.ts, the Drizzle schema ban columns and platform role column on user, the requirePlatformAdmin gate, and the _admin layout zone in the front. The phase delivered the rest.

What this phase delivered:

  • Read-only user and org back-office: GET /admin/users, GET /admin/users/:id, GET /admin/orgs, GET /admin/orgs/:id (paginated, search-filtered), backed by DrizzleAdminUserStore, DrizzleAdminOrgStore, AdminQueryService. Front: features/admin-users/ + features/admin-orgs/.
  • Five audited admin actions (AdminActionService): ban (with reason + optional expiry), unban, platform role change, force password reset (sends reset email via BetterAuth), revoke all sessions: each emits an admin.* event via emitEvent.
  • Justified impersonation: POST /admin/impersonation/:id/start requires reason (min 1 char) and accepts optional ticketRef. POST /admin/impersonation/stop validates the BetterAuth response before emitting the audit event (no false audit trail on BetterAuth failure).
  • Server-side blocklist: two layers: a BetterAuth beforeHook in auth.ts (impersonation-blocklist.ts) blocking the most sensitive auth endpoints, and per-mutation denyImpersonated middleware on 11 business mutation routes. Six read-only routes left open. Exit route POST /admin/impersonation/stop always preserved.
  • Non-dismissable impersonation banner: ImpersonationBanner component mounted in _shell, live countdown from session expiry (setInterval, recalculated every second), variant="banner" added to the Alert primitive in @packages/ui.
  • Transparency email: NotifyImpersonatedUserHandler (onEvent(ADMIN_IMPERSONATION_STARTED)) sends the impersonated user a "someone accessed your account" email. Failure captured to telemetry, does not abort impersonation.
  • Session payload extended: buildSessionPayload (auth-session-payload.ts) enriches BetterAuth's own session response with impersonatedBy (normalized from undefined to null) and isPlatformAdmin. The front reads these fields from the existing session query; no dedicated admin session endpoint exists.
  • Event catalog 55 → 62: seven new admin.* events added to @packages/events, all compliance retention.

Structural decision: AdminActionService outside inwire:

AdminActionService is instantiated in routes.ts rather than registered in the inwire container. The reason is a real import cycle (service → auth.ts → container.ts → module.ts) that cannot be resolved without touching auth.ts, which is protected by its own module boundary. Its dependencies (IOutboxRepository, IInstrumentation) are still resolved from di. The same pattern applies to the impersonation routes. Validated and accepted in review.

Decisions:

  1. Write-enabled impersonation with server-side blocklist: read-only impersonation cannot reproduce write-path bugs. The blocklist specifically targets irreversible or identity-mutating operations: change-email, change-password, MFA enable/disable, link/unlink social account, revoke-sessions, billing portal. Read operations and the exit route stay open.
  2. Mandatory justification: reason is a required field on POST /admin/impersonation/:id/start. No reason = 400 before any impersonation session is issued. Optional ticketRef links to a support ticket.
  3. Transparency email: the impersonated user is notified at impersonation start. Failure is captured to telemetry and logged but does not block the action. supportUrl is derived from APP_URL.
  4. No support platform role: the original plan mentioned a read-only support role. BetterAuth's admin plugin supports a single platform role column. Adding a support role would require custom middleware with zero library support. Deferred out of scope.
  5. cookieCache.maxAge reduced to 60 s: makes a ban effective within approximately 60 s without adding a session revocation list lookup to every request. Updated in apps/api/CLAUDE.md. Near-instant ban (< 1 s) would require a revocation list checked in the session middleware: out of scope.
  6. Actions through custom API routes, not the BetterAuth admin client: wrapping auth.api.* in custom Hono routes lets the implementation inject actorUserId into every event, enforce reason on impersonation, and validate BetterAuth responses before emitting the audit event. Two API deviations found during implementation: auth.api.forgetPassword does not exist: the correct method is auth.api.requestPasswordReset; auth.api.setRole requires { requireHeaders: true } and a headers argument from c.req.raw.headers.
  7. /stop validates BetterAuth before emitting: if BetterAuth's stopImpersonating returns an error, the ADMIN_IMPERSONATION_STOPPED event is not emitted. This prevents a false audit trail entry when the stop actually failed.

Out of scope (explicit):

  • Dedicated admin subdomain (admin.<APP_DOMAIN>).
  • Platform support role (read-only).
  • Org mutations (member kick, org delete) from the admin back-office.
  • IP allowlist for admin access.
  • Session-length alerting.

QA-phase corrections (post-initial delivery)

  1. APP_URL promoted to required: Five call sites in auth.ts (auth email links) and the impersonation notification already assumed APP_URL was present and produced undefined/... links when it was absent. The optional declaration was the anomaly. Changed from z.url().optional() to z.url() in shared/env.ts. The task-15 deferred concern about supportUrl being inoperable is closed by this change.

  2. MFA gate on the front-side back-office entry: The server already required MFA (PLATFORM_ADMIN_REQUIRE_MFA), but the front-side gate checked only the platform role. canAccessPlatformAdmin (shared/auth/can-access-platform-admin.ts) was extracted to combine isPlatformAdmin (role) with twoFactorEnabled (MFA). A platform admin without 2FA sees neither the "Admin" nav entry nor the _admin route zone: ensure-platform-admin.ts redirects them to /settings/account rather than /, so the required action is explicit. isPlatformAdmin is kept as a separate predicate for call sites that only need the role check (e.g. event payloads).

  3. Policy acceptance blocked during impersonation: denyImpersonated was added to POST /me/policies/accept (defense-in-depth: an admin in impersonation session must not countersign legal terms on behalf of the user, which would write a legally false acceptance). The Decision 1 enumeration above used the collective term "the blocklist" for both layers; this item falls under the denyImpersonated Hono layer, not the BetterAuth hook. Mirrored on the front: shouldRedirectToLegalAccept (router/should-redirect-to-legal-accept.ts) returns false when isImpersonating(session) is true, so the legal acceptance gate does not trap the admin on a page whose only exit is to consent.

  4. Nav entry renamed "Operator" → "Admin", re-pointed to /admin/users: The shell nav entry linked to /admin/audit-log under the label "Operator". Post-QA it links to /admin/users (the accounts list) under the label "Admin", consistent with the product's English convention and with the primary use-case (looking up a specific user before impersonating). The pathname.startsWith("/admin") active-state check already covered all sub-pages. The term "operator" remains accurate in conceptual contexts (this log, the C.2 section header) where it describes the human role, not a UI label.

  5. Admin UI translated to English: The admin features were initially written in French, inconsistent with the rest of the product. All UI copy (column headers, button labels, form placeholders, error messages, date locales) was translated to English (commit 27ccbff).


Enterprise SSO (OIDC + SAML) + SCIM provisioning ✅ Phase C.7 · Aug 2026

Why: single biggest enterprise-tier price multiplier ($10-30k/deal, recurring): every B2B SaaS targeting >500-employee customers is blocked at procurement without SSO + SCIM. @better-auth/sso (OIDC + SAML 2.0) and @better-auth/scim (RFC 7644) are official BetterAuth plugins, no rolling our own protocol handling.

What this phase delivered: sso() + scim() mounted in auth.ts on their own schema file (packages/drizzle/src/schema/sso.ts); per-org OIDC/SAML provider registration + domain verification + a SCIM bearer-token per provider; JIT provisioning landing a member row on first SSO sign-in; domain-based SSO enforcement rejecting non-SSO sign-in on all four email/passkey paths; a full SCIM Users lifecycle; /settings/sso (provider forms, domain verification card, SCIM connection card, enforcement toggle); a "Sign in with SSO" entry on /sign-in; 13 new events; a local Keycloak profile + round-trip doc for development. Catalog 67 → 80 (28 → 34 public, 39 → 46 internal).

Decisions and deviations from the plan (chronological across the phase's tasks):

  1. Business-tier gate keyed on the request's target org, not session history: providersLimit (the SSO plugin's own anti-abuse ceiling) only ever receives user, never the org the registration targets, so it cannot enforce the business-tier sso entitlement. The gate lives instead in hooks.before on /sso/register, reading body.organizationId directly: the org actually named by the request, never inferred from session.activeOrganizationId (a stale or switched active-org would otherwise gate the wrong org). Provider registration also checked against impersonation (denyImpersonated-equivalent at the hook level).
  2. SAML hardened beyond the plugin's defaults: every SAML registration forces signatureAlgorithm: "sha256" and wantAssertionsSigned: true server-side; the client never gets to choose a weaker option. The reject list covers the whole sha1/md5 family (not just the bare algorithm string: rsa-sha1, hmac-sha1, etc. all match), closing a bypass the first pass missed.
  3. Domain casing normalized on both read and write paths: sso_provider.domain is persisted verbatim by the plugin (no casing normalization on its side), and every domain-based lookup used for enforcement (enforcedProviderForDomain) has to compare case-insensitively or Acme.com and acme.com silently diverge into two providers. Fixed on the write path (register and update-provider: the second one was a follow-up fix after the first pass missed the update path's own domain-change branch) and the read path, using eq(sql\lower(${column})`, domain)rather thanilike (iliketreats its argument as a pattern, not a literal: a domain containing_or%` would match unintended rows).
  4. SSO enforcement covers all four sign-in surfaces, not just the password form: /sign-in/email, /sign-up/email, /sign-in/magic-link are checked in hooks.before (isSsoEnforcedFor against the request's email, before the rate-limit branch, which returns for /sign-in/email, so anything placed after it would never run). Passkey sign-in carries no email, so it can't be checked path-based; it's closed separately in databaseHooks.session.create.before (note, superseded: this leg was originally keyed off whether the session's linked account already belongs to a registered SSO provider: that is exactly the defect fix round 2 item 2 below removes, since an SSO account is permanent and would wave through every later passkey sign-in. It now keys on the request's endpoint path instead). All four return 403 { message: "SSO_REQUIRED", providerId }: confirmed live that providerId survives JSON serialization, so the front never re-derives the provider from the email.
  5. No way to turn enforcement ON was in the original plan: the plan shipped ssoEnforced as a read/display flag with no mutation endpoint. Added POST /settings/organization/sso-enforcement (organization:["update"]), and separately extended it so a platform admin can also flip it on an org's behalf (admin.orgs back-office), since support needs to unblock an org that locked itself out.
  6. D3, no sso: [...] access-control statement exists. The original plan called for a dedicated sso: ["configure", "enforce"] statement in @packages/access-control. It was never built; the existing organization:["update"] permission already covers /settings/sso and the enforcement toggle, and adding a parallel statement for the same actors (owner) would have been a distinction without a difference. docs/FEATURES.md and ROADMAP.md are corrected to stop describing it as built.
  7. D9: SCIM DELETE is an org departure, not a grace-period deletion. The original plan's step 3 described deprovisioning as reusing the RGPD grace path (pendingDeletionUntil + revoke sessions). What's actually implemented: DELETE /scim/v2/Users/<id> removes only the member row for the owning org; the global user row (and any other org's membership) survives untouched. Verified live in this task: select * from member returns zero rows for the deprovisioned user in the target org while select * from "user" still returns the row. ROADMAP.md and docs/FEATURES.md are corrected.
  8. Every SCIM/SSO event payload carries actorUserId, including SCIM's bearer-token-only requests: SCIM endpoints authenticate with a token, not a session (ctx.context.session is empty), so the actor for scim.* events is resolved from the connection row (the user who generated the token) rather than from session state (note, superseded: this originally read the row with scimConnectionOwner, which trusts the provider id claimed by the bearer header without ever checking the token: see the post-PR review round below, where it becomes verifiedScimConnectionOwner). Pre-delete snapshots (ssoProviderDeleteSnapshots, scimConnectionDeleteSnapshots, keyed on providerId) supply organizationId for the *.deleted/*.connection.deleted events, since the row is already gone by the time hooks.after runs (note, superseded: these were bare global Maps until the post-PR round replaced all three with RequestSnapshots<T>).
  9. SSO_ENFORCEMENT_CHANGED is the one event of the 13 not routed through a plugin endpoint: it's emitted by AdminActionService.setSsoEnforcement, not a hooks.after path branch, because the enforcement toggle (deviation 5 above) is a bespoke route, not part of either plugin.
  10. R11: the local Keycloak Docker profile moved from this task (originally its own step) into Task 1. Task 6's live-IdP verification and this task's Step 4 round-trip both need a running Keycloak; rather than have Task 6 stub it or reorder the plan, the sso opt-in profile in docker-compose.yaml shipped with Task 1 and stayed untouched here.
  11. Two createSession call sites outside the enforcement hook's reach are a known, accepted gap: flagged and accepted rather than fixed in this phase; they don't participate in the four enforced sign-in paths and are out of scope for SSO enforcement specifically, but are noted here so a future security pass doesn't have to rediscover them.

Local development: docs/SSO-LOCAL.md documents the full Keycloak setup (realm, OIDC client, SAML client) and two traps discovered only by actually running the round-trip: (a) @better-auth/sso's samlConfig.issuer field doubles as the SP's own entity ID used both to sign the outgoing AuthnRequest and to validate it against the IdP's registered client: the IdP's real issuer has to go in samlConfig.idpMetadata.entityID instead, or every assertion fails ERR_UNMATCH_ISSUER; (b) the SSO plugin validates an OIDC discovery URL's origin against the auth server's own trustedOrigins (CORS_ORIGIN): a local, non-TLS Keycloak has to be added to that allowlist for local testing only, never in a deployed environment.

Verification performed for this task (Step 4), and what was not exercised live:

  • OIDC round-trip: full browser-equivalent flow against a local Keycloak realm: /sign-in/sso → authorization URL → Keycloak login → /sso/callback/<providerId> → session cookie issued, member row created in the target org (JIT provisioning, role: "member"). Confirmed live.
  • SAML round-trip: same, through the SP-initiated AuthnRequest → Keycloak SAML login → POST to /sso/saml2/sp/acs/<providerId> → session issued, member row created. Confirmed live, after working through the two traps above.
  • SCIM lifecycle: POST (create) → GET → PATCH (deactivate, active: false) → PUT (update, non-active fields) → DELETE (deprovision) all exercised against /scim/v2/Users, plus POST /scim/generate-token and POST /scim/delete-provider-connection. Confirmed live, including the D9 org-departure behavior (member row gone, user row intact).
  • All four enforced sign-in paths: /sign-in/email, /sign-up/email, and /sign-in/magic-link were exercised live against an SSO-enforced domain and each returned 403 { message: "SSO_REQUIRED", providerId }. The passkey leg was verified live too, through the shared hook rather than a WebAuthn ceremony: databaseHooks.session.create.before is the single hook every session-creation path funnels through (the three above never reach it, since they're already blocked earlier), so triggering it via /magic-link/verify for a user who was sent a link before enforcement turned on (then verified after) exercises the identical hook and the identical better-call serialization pipeline the passkey path uses. It returned the raw, unwrapped body {"message":"SSO_REQUIRED","providerId":"keycloak-test-com"}, confirming providerId reaches the client on that leg too (see the R30 fix below: it didn't, until this round). A real WebAuthn ceremony remains out of reach in this curl-only environment.
  • Event catalog: 12 of the 13 sso.*/scim.* event types were confirmed live via outbox_event rows. sso.domain.verified (1 of 13) was not exercised live: the real endpoint (/sso/verify-domain) performs a DNS TXT lookup, which cannot succeed against a domain this environment doesn't control; domainVerified was set directly in the database to unblock the OIDC/SAML round-trips instead. The event's emit site was confirmed present by code inspection (auth.ts, hooks.after on SSO_PATHS.verifyDomain) but not exercised end-to-end.

Fix round 1 (post-initial delivery)

  1. R30: the passkey leg's SSO_REQUIRED rejection was missing providerId. databaseHooks.session.create.before had already resolved enforced.unwrap() before throwing, but the throw itself only set message, unlike the three email-bearing paths in hooks.before. A client forced to special-case one path out of four to recover the provider was the actual defect: fixed by adding the field, not by documenting the inconsistency. Verified live via the /magic-link/verify route (see above), since it shares the exact hook and the exact serialization path the passkey leg uses.
  2. R31: the SSO-redirect-on-SSO_REQUIRED behavior only existed on use-sign-in.ts. use-sign-up.ts and use-magic-link.ts surfaced the raw "SSO_REQUIRED" string as an error toast: a dead end for two of the three server-enforced entry points a real user can hit before ever reaching a password field. The redirect logic (call authClient.signIn.sso({ providerId, callbackURL }), throw a sentinel so onError skips the toast) was extracted into redirectToSsoIfRequired in apps/app/src/features/auth/auth-error.ts (the file already shared by all three hooks for resolveAuthError) and all three call sites now use it. Unit-tested for the branch that never touches the network (no rejection, or a rejection missing providerId); the network-calling branch is integration-verified live the same way as the rest of Step 4.
  3. R32: the "dark › sso settings" a11y failure was a genuine, reproducible flake, not a rendering bug, and not a phantom. Root cause: every authenticated-page a11y test shares one seeded identity (auth.setup.ts), and the API's global rate limit is keyed per-user, not per browser context (GLOBAL_POLICY, 60 req/min), so parallel Playwright workers loading pages as the same identity turn into one shared request bucket. /settings/sso fires the most queries per load of any audited page (providers, domain verification token, SCIM connections, entitlements), making it the first to trip that ceiling. The failure's actual page snapshot showed a "Too many requests" toast and the app's global error boundary, confirming it, not a dark-theme defect. Reproduced against a genuinely fresh build (the a11y suite's real failure mode discovered along the way: pnpm --filter app check:a11y run directly serves a stale vite preview build, since only turbo run check:a11y honors the pipeline's dependsOn: ["build"]: the README already warned about this and it was missed once here), cleared entirely at --workers=1, and made deterministic by pinning workers: 4 in a11y/playwright.config.ts instead of leaving Playwright's host-core-count-dependent default in place. Confirmed clean across four consecutive fresh-build runs after the fix.
  4. [Minor] The keyboard tab-order test never reached the new SSO control. apps/app/a11y/interaction.spec.ts covered Email → Password → Remember me → Sign in only. A new test reaches the "Sign in with SSO" trigger, activates it with Space, and asserts the very next Tab stop is the field it revealed: the exact case a rule engine (axe) cannot check on its own.
  5. R33: two more "what ships" docs still described C.7 as unbuilt. docs/OVERVIEW.md (## What's next) and docs/MODULES.md (Roadmap modules table + subtotal) both still framed SSO/SCIM as the remaining feature. Both are named in the root CLAUDE.md inventory, so a reader following either would be misled about the phase this task closes. OVERVIEW.md gets its own shipped-feature section (matching every other shipped capability's treatment); MODULES.md's C.7 row moves from the Roadmap table to the Shipped table, with both subtotals recomputed (total unchanged: €51 900 to €86 800, only redistributed).

Note on the "pre-existing palette flake" recorded during fix round 1: it was neither pre-existing nor a separate defect. interaction.spec.ts's "traps focus inside the command palette" fails because /dashboard bounces to /sign-in when its session request is rate-limited, and Ctrl+K then has no palette to open. Same root cause as R32, wrongly diagnosed there: see fix round 2 item 4 below.

Fix round 2 (final whole-branch review, pre-merge)

  1. R35: SCIM provisioning bypassed the seat cap and emitted no membership event. @better-auth/scim writes the member row with a raw ctx.context.adapter.create({ model: "member" }), so neither beforeAddMember nor beforeAcceptInvitation (the two authoritative seat gates) nor afterAddMember ever fires on POST /scim/v2/Users. Combined with /scim/generate-token carrying no tier gate at all (only /sso/register had one), a Free-tier owner could mint a SCIM token and provision past maxMembers with no billing enforcement, no audit row and no webhook delivery. Three fixes, all in hooks.before/hooks.after: /scim/generate-token now goes through assertSsoEntitlementFor: the same helper /sso/register uses, extracted on this second occurrence rather than duplicated; POST /scim/v2/Users counts members against the plan cap before the endpoint runs (an after-hook cannot cap anything: the row is already written); and org.member.joined is emitted from the SCIM after-hook with the provisioned user as subject and the connection owner as actor. The removal half of the finding was wrong and was not implemented as briefed: @better-auth/scim does call afterRemoveMember after its own delete transaction (verified in @better-auth/scim@1.6.29 dist), so org.member.removed was already being emitted: adding a second emit would have produced duplicate audit rows and duplicate webhook deliveries. What was actually broken there is the actor: the organization plugin passes the removed user as user on every path, so the event named the deprovisioned user as its own actor. Fixed with a hooks.before snapshot (scimDeprovisionActors, same read-before-write pattern as the existing provider snapshots) that afterRemoveMember prefers over the plugin's argument.
  2. R34: the passkey enforcement leg was disabled for exactly the users it protects, and the proposed fix was rejected. The guard skipped enforcement whenever findLatestLinkedAccount returned an account belonging to a registered SSO provider. Since an SSO sign-in creates such an account permanently, a user who had signed in through the IdP once was waved through on every later path (passkey included) which is the deprovisioning guarantee SSO enforcement is sold on. The review proposed widening the lookup to "does ANY account match a registered sso_provider"; that is strictly more permissive and leaves the scenario failing identically, so it was not implemented. The discriminator has to be the request, never the user: BetterAuth passes the endpoint context as the second argument of databaseHooks.session.create.before (createWithHooks → getCurrentAuthContext()), and its path is the endpoint's own registered path, so the four SSO callback paths are identifiable and everything else is enforced. /admin/impersonate-user is exempt for a different reason: that session is minted for a platform admin who already passed the admin gate, not by the enforced user authenticating. Proven live by A/B: for a user who already owns an SSO account on an enforced domain, the pre-fix build returned 302 (session issued) on a non-SSO session-creation path and the post-fix build returns 403 {"message":"SSO_REQUIRED","providerId":"keycloak-test-com"}. That context.path is populated and carries the endpoint's registered form was confirmed live too (a probe read /sign-in/email out of the hook); the four exempt strings were matched against the plugin's own createAuthEndpoint registrations.
  3. The passkey leg's front-end redirect existed only in the docs. redirectToSsoIfRequired was wired into use-sign-in, use-sign-up and use-magic-link but not use-sign-in-passkey (raw SSO_REQUIRED toast) nor use-passkey-autofill (silent no-op), while docs/MODULES.md claimed all four paths redirect. Wired rather than downgrading the doc: the passkey leg is server-enforced like the other three, so it gets the same treatment; the autofill leg redirects without a toast, since conditional UI must stay silent on every expected failure but an enforced domain is not one.
  4. The check:a11y gate was reproducibly red, and the rationale shipped with it named the wrong bucket. playwright.config.ts and a11y/README.md both blamed the per-user rate-limit bucket and the worker count. Measured: the bucket that overflows is the IP-keyed one (global:60:global:::1 at 61 against a 60/min ceiling, twice in a row) while the per-user bucket sat at 39, because sessionMiddleware nulls the user for /api/auth/*, so a signed-in page's session and organization queries are counted against the IP alongside every unauthenticated page load. Worker count bounds neither: the ceiling is per minute over the whole run and the IP is the same from every worker. The real defect is the tuning: a signed-in page view fires up to 8 API calls (measured), so a 60/min burst window allowed ~7 navigations per minute before a legitimate user got a 429. GLOBAL_POLICY's minute window is now 300: the hour window (1800, the actual sustained anti-abuse ceiling) is unchanged, and every credential path keeps its own far tighter fail-closed policy. Gate green three consecutive --force runs, 21/21.
  5. sso.login.failure was public but undeliverable. It was emitted with no organizationId although the provider row carrying one had already been loaded one line above, and WebhookFanoutSubscriber drops any event whose organizationId is none before it even reads the visibility map, so the one signal telling a customer their IdP broke reached nobody and was absent from their audit view. One argument added at the emit; verified live (new outbox_event row carries the org id where the two pre-fix rows carry NULL).

Verification for this round: live: the SCIM entitlement gate (403 SSO_ORGANIZATION_REQUIRED / SSO_PLAN_REQUIRED / falls through to the endpoint on a Business org), the SCIM seat cap (402 Seat limit reached (3). on a Free-tier org at 5 members), org.member.joined and org.member.removed emitted with the connection owner as actor and the provisioned user as subject, the enforcement A/B above, sso.login.failure carrying its org id, and three consecutive green check:a11y runs. Established by reading, not exercised live: the SSO callback's own session creation still passing the guard (no IdP round-trip was run this round: the exempt path strings were matched against the plugin's endpoint registrations and context.path was confirmed populated with that exact form), and the passkey/autofill front-end redirect (a real WebAuthn ceremony remains out of reach here, same limitation as fix round 1).

Accepted, documented gaps left by this round:

  • The SCIM seat cap is a pre-endpoint count, not a transactional reservation. Two concurrent POST /scim/v2/Users can both pass the check and land one seat over. Same race the invitation-accept gate has, same accepted trade-off.
  • organizationHooks.afterAddMember/afterRemoveMember misattribute the actor on every non-SCIM path too. The organization plugin passes the added/removed user as user, so org.member.joined's actorUserId is always undefined (the user.id !== member.userId test can never be true) and org.member.removed names the removed member as its own actor. This pre-dates C.7 and affects the ordinary invite/kick flows; only the SCIM path is corrected here, because only there was an actor recoverable without a new mechanism. A proper fix needs an ambient actor at the hook level and deserves its own pass.
  • Every /api/auth/* request is rate-limited by IP, even for signed-in users, because sessionMiddleware deliberately skips the session lookup on those paths. Two signed-in users behind one NAT therefore share a bucket for their BetterAuth traffic (session + organization queries, ~4 calls per navigation). The burst-window retune above mitigates it; keying those requests per user would cost a session lookup on the hottest endpoint of the app and was judged out of scope for a fix round.

Post-PR security review (pre-merge)

A security-focused review of the whole branch was run after the PR was opened and blocked the merge with two Criticals. Both held, so the merge waited: the instruction standing at the time was "merge if everything is OK", and it wasn't.

  1. A stranded SCIM snapshot could forge the actor on an unrelated kick. scimDeprovisionActors was a global Map keyed on a bare userId, written unconditionally in hooks.before and consumed only by afterRemoveMember: a hook that never fires when the SCIM DELETE 404s or the target holds no member row. The stranded entry then poisoned the next removal of that user, by any admin, in any org, naming the SCIM connection owner as the actor: audit-trail forgery on org.member.removed. Fixed by introducing RequestSnapshots<T> (apps/api/src/shared/auth/request-snapshots.ts): a keyed store with a freshness TTL bounding how long a stranded entry stays pickable, plus a consumer-supplied accepts() predicate, and routing all three before/after snapshot maps through it. The deprovision snapshot now carries { actorUserId, organizationId } and its consumer requires an organization match; a non-matching entry is left in place rather than deleted, since it may belong to a request still in flight. Reproduced live before the fix and confirmed gone after. The same commit rewrote the seat-cap docblock (it claimed to reuse assertSeatAvailableFor when it inlined the check, because that helper throws AppErrorException and SCIM needs an APIError; seatCapFor() is now the shared predicate both callers use) and gave SCIM refusals an RFC 7644 error body (urn:ietf:params:scim:api:messages:2.0:Error), which is what Okta and Entra expect.
  2. /api/auth/send-verification-email had no dedicated rate-limit policy. It is a public POST taking an arbitrary email with no session: the same "make the server email a stranger" primitive as request-password-reset and sign-in/magic-link, both of which carry a fail-closed 3/15min policy. It only inherited the global window, which fix round 2 had just widened from 60 to 300/min for legitimate navigation traffic, so the retune quietly loosened it fivefold. Given the same policy as its siblings, mounted in the same guard block ahead of the global limiter.
  3. Critical: SAML hardening applied at registration but not on update. normalizeSamlConfig ran only on /sso/register. An org admin could register a compliant SAML provider, then PATCH /sso/update-provider with { wantAssertionsSigned: false, signatureAlgorithm: "sha1" }, and samlify derives wantMessageSigned from that flag, so assertions stopped requiring a signature at all. The normalizer is now applied on the update path too; it was already written spread-based and partial-update-safe (identity fields pass through only when sent, the four security fields are always forced strong), so the fix is the call site plus tests for the partial-update shape.
  4. Critical: the SCIM hooks.before branches trusted an unverified bearer token. They decoded the Authorization header, took the providerId out of it, and resolved the connection row with scimConnectionOwner, with no hash check. runBeforeHooks executes before an endpoint's own use: [sessionMiddleware] bearer auth, and providerId is a deterministic slug of the org's domain, so an unauthenticated caller with a guessed id got two primitives: a pre-auth cross-tenant billing oracle (a 402 leaking maxMembers versus a 401), and a same-org audit-forgery primitive: fix (1)'s organization-match guard had closed only the cross-org case. Fixed with verifiedScimConnectionOwner in auth-queries.ts: it hashes the decoded token with the exact algorithm the storeSCIMToken: "hashed" mount uses (SHA-256 → base64url, unpadded: the plugin exports no hasher, so it is reimplemented with a docblock tying it to that mount option) and constant-time-compares it against the stored value. Any hooks.before branch needing the connection owner must use it; on a missing, malformed, unknown or non-matching token it returns null and the branch falls through to the endpoint's own 401 rather than acting on unverified input. The seat cap simply isn't evaluated for such a request, which is correct: it 401s before any member row is written.

Merged after this round: PR #61 into dev as a merge commit, all gates green (718 api tests, 147 app tests, type-check 12/12, Biome clean, knip clean, jscpd 28 clones, a11y 21/21).

Gaps this phase never closed, carried forward: sso.domain.verified is still never exercised live (it needs DNS TXT control over a real domain); no real WebAuthn ceremony has been driven; the SSO-callback success path after the final enforcement change rests on construction plus a probe rather than a Keycloak round-trip; enforcedProviderForDomain and the auth.ts wiring have no automated coverage, since the repo has no real-DB test harness; the a11y suite stays coupled to an IP-keyed rate limit; SAML SLO is declared but unwired; and there is no operator runbook for recovering an org that locks itself out with enforcement.

Toolchain refresh: pnpm, Node, Bun, TypeScript 7, Postgres 18, file-based routing ✅ Phase G.1 · Aug 2026

Why: the version floor a boilerplate ships is a feature the cloner inherits silently. Starting point (repo baseline before this phase): Node 24.15.0, Bun 1.3.6, pnpm 11.0.9 (disagreeing with mise.toml's 10.33.2), TypeScript 6.0.3, Postgres 17-alpine. Everything below is what was actually measured landing, on 2026-08-28, not the versions the phase's own planning table guessed at ten days earlier, which had already drifted by the time the work started.

G.1a: Runtime and package manager

pnpm 11.24.0 (single source of truth now: mise.toml and package.json#packageManager agreed on the same string, corepack and mise no longer race), Node 24.20.0 LTS (.nvmrc moved with it, since CI resolves Node from that file), Bun 1.4.0. Bun held, no fallback was needed: the 718-test API suite and the Bun build both passed clean at 1.4.0.

G.1b: TypeScript 7

Landed at 7.0.2 across all 11 workspaces that declare typescript, @packages/ddd-kit included: that inclusion was not a given going in. The native (Go) compiler surfaced two real issues:

  • @packages/ddd-kit's tsup build crashed. Its .d.ts emit went through a bundled rollup-plugin-dts that isn't updated for TS7's rewritten compiler-host API. The fix was not to pin ddd-kit back to TS6 (which was the initial workaround, and which quietly took it off the TS7 type-check gate that every other workspace now runs): it was to stop tsup emitting declarations at all (dts: false, dropping the ignoreDeprecations: "6.0" compatibility shim that went with it) and let tsc -p tsconfig.build.json --emitDeclarationOnly do it instead. tsconfig.build.json already existed, unwired, left over from the package's publishing setup. The build script now chains tsup && tsc -p tsconfig.build.json, so a single pnpm build still produces dist/index.js, dist/index.cjs and dist/index.d.ts. Trap for the next person touching this config: incremental must stay false in tsconfig.build.json. tsup's clean: true wipes dist/ on every build; a stale .tsbuildinfo left behind by a prior tsc run convinces tsc the declarations are already up to date after tsup has just deleted them, so it emits nothing: silently. No error, no warning, just an absent dist/*.d.ts. Accepted trade-off: declarations are no longer aggregated into one file. tsc emits one .d.ts per source module (dist/domain/entity.d.ts, dist/primitives/result.d.ts, …), re-exported from dist/index.d.ts, instead of tsup's single flattened index.d.ts. Harmless today (nothing in-monorepo consumes dist/, internal packages import from src/ per the source-only rule) and only matters the day this package is actually published.
  • lib.dom.d.ts gained Response.textStream. Hono's ClientResponse doesn't implement it, which broke every RPC call site that fed a response into throwApiError(res: Response). Fixed by narrowing that function's parameter to the subset of Response it actually reads, rather than widening Hono's type.

G.1c: Postgres 18

Undocumented breaking change discovered while recreating the dev volume: the postgres:18-alpine image refuses to start against a bind mount at /var/lib/postgresql/data: the 18+ images store data one directory up, at /var/lib/postgresql/<major>/docker, to align with pg_ctlcluster-style layouts. docker-compose.yaml's postgres service now mounts the named volume at /var/lib/postgresql (was /var/lib/postgresql/data); without this the container loops in Restarting with Error: in 18+, these Docker images are configured to store database data in a format which is compatible with "pg_ctlcluster".... Neither the task brief nor the ROADMAP entry that scoped this work anticipated this: it's a genuine image-level change, not something to have caught by reading docker-compose.yaml alone.

Action required for anyone with a pre-existing postgres_data volume: the mount-path move above is silent, not loud, for a volume populated under the old layout. docker compose up won't crash the way a brand-new empty volume did during this task: Postgres 18 will find nothing at the new /var/lib/postgresql mount point (the old data sits one level down, at the now-unmounted /var/lib/postgresql/data) and will just initialize a fresh, empty database with no error. That's a silent dev-data loss, not a visible failure. Run docker volume rm clean-stack_postgres_data once before the first up on a checkout that already ran docker compose up postgres pre-bump, then rebuild with pnpm db:push && pnpm db:seed. Documented in README.md's Database section (the Volume row + the callout above the db:* script block) as the place a reader would hit this before losing data rather than after.

Primary keys stay text: every PK in packages/drizzle/src/schema/ is filled by BetterAuth, which generates its own ids, so uuidv7() is a BetterAuth question rather than a Postgres one. It's documented in docs/MODULES.md as the shape for new cloner-owned tables instead.

Before/after query plans

The payoff of 18 here is operational (async I/O, B-tree skip scan), so the two hot paths that touch the outbox were EXPLAIN (ANALYZE, BUFFERS)'d before the bump (Postgres 17.x, docker compose exec postgres psql) and again after (Postgres 18.6-alpine, same commands, dev DB recreated from scratch per the brief since a major version doesn't read the previous major's data directory).

1. Outbox drain: mirrors apps/api/src/shared/services/drizzle-outbox.service.ts:95-108, which reads outbox_event_pending_idx (packages/drizzle/src/schema/outbox.ts:30):

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM outbox_event
WHERE dispatched_at IS NULL
  AND (next_attempt_at IS NULL OR next_attempt_at <= now())
ORDER BY occurred_at
LIMIT 100
FOR UPDATE SKIP LOCKED;

Postgres 17 (0 pending rows at capture time: dev DB had already drained the seeded events):

Limit  (cost=8.15..8.17 rows=1 width=582) (actual time=0.024..0.025 rows=0 loops=1)
  Buffers: shared hit=4
  ->  LockRows  (cost=8.15..8.17 rows=1 width=582) (actual time=0.023..0.024 rows=0 loops=1)
        Buffers: shared hit=4
        ->  Sort  (cost=8.15..8.16 rows=1 width=582) (actual time=0.023..0.023 rows=0 loops=1)
              Sort Key: occurred_at
              Sort Method: quicksort  Memory: 25kB
              Buffers: shared hit=4
              ->  Index Scan using outbox_event_pending_idx on outbox_event  (cost=0.12..8.14 rows=1 width=582) (actual time=0.006..0.006 rows=0 loops=1)
                    Filter: ((dispatched_at IS NULL) AND ((next_attempt_at IS NULL) OR (next_attempt_at <= now())))
                    Buffers: shared hit=1
Planning:
  Buffers: shared hit=197
Planning Time: 0.662 ms
Execution Time: 0.075 ms

Postgres 18.6 (5 pending rows: fresh seed, dispatcher not yet run):

Limit  (cost=8.17..8.19 rows=1 width=290) (actual time=0.040..0.043 rows=5.00 loops=1)
  Buffers: shared hit=10
  ->  LockRows  (cost=8.17..8.19 rows=1 width=290) (actual time=0.039..0.041 rows=5.00 loops=1)
        Buffers: shared hit=10
        ->  Sort  (cost=8.17..8.18 rows=1 width=290) (actual time=0.028..0.029 rows=5.00 loops=1)
              Sort Key: occurred_at
              Sort Method: quicksort  Memory: 27kB
              Buffers: shared hit=5
              ->  Index Scan using outbox_event_pending_idx on outbox_event  (cost=0.14..8.16 rows=1 width=290) (actual time=0.009..0.011 rows=5.00 loops=1)
                    Filter: ((dispatched_at IS NULL) AND ((next_attempt_at IS NULL) OR (next_attempt_at <= now())))
                    Index Searches: 1
                    Buffers: shared hit=2
Planning:
  Buffers: shared hit=161
Planning Time: 0.553 ms
Execution Time: 0.079 ms

Access path unchanged: both plans drive the drain through Index Scan using outbox_event_pending_idx, wrapped in the same LockRows/Sort/Limit shape, no fallback to a sequential scan. The only textual diff is Postgres 18 printing a new Index Searches: 1 line under each index node (a genuine 18 addition to EXPLAIN's index-scan output, not a plan change) and the row counts differing because the two captures ran against different data volumes (see caveat below). This is the query that matters most for this bump: confirmed stable.

2. Notification fan-out: the INSERT … SELECT at apps/api/src/shared/services/notification-fanout-subscriber.ts:103-132, captured by instrumenting a CapturingInstrumentation that records the span name the code already builds for query.getQuery().sql (the same value the production Sentry span carries), driving it with a real org.member.invited event inside a rolled-back transaction against the seeded dev user/org, then re-running the captured SQL + params through EXPLAIN (ANALYZE, BUFFERS) in a second rolled-back transaction (pg.Pool, BEGIN/ROLLBACK bracketing, no data mutated in either DB).

Postgres 17 (org with 12 members across the accumulated 11h-old dev dataset):

Insert on notification  (cost=0.59..34.02 rows=0 width=0) (actual time=0.078..0.078 rows=0 loops=1)
  Conflict Resolution: NOTHING
  Conflict Arbiter Indexes: notification_dedup_uidx
  Tuples Inserted: 1
  Conflicting Tuples: 0
  Buffers: shared hit=20
  ->  Subquery Scan on "*SELECT*"  (cost=0.59..34.02 rows=1 width=283) (actual time=0.031..0.033 rows=1 loops=1)
        Buffers: shared hit=9
        ->  Nested Loop Left Join  (cost=0.59..34.00 rows=1 width=259) (actual time=0.030..0.031 rows=1 loops=1)
              Buffers: shared hit=9
              ->  Nested Loop Left Join  (cost=0.44..25.81 rows=1 width=28) (actual time=0.018..0.020 rows=1 loops=1)
                    Join Filter: (up_mail.scope_id = member.user_id)
                    Buffers: shared hit=7
                    ->  Nested Loop Left Join  (cost=0.29..17.62 rows=1 width=27) (actual time=0.015..0.016 rows=1 loops=1)
                          Filter: COALESCE(CASE WHEN op_app.locked THEN op_app.enabled ELSE NULL::boolean END, up_app.enabled, op_app.enabled, true)
                          Buffers: shared hit=5
                          ->  Nested Loop Left Join  (cost=0.15..9.44 rows=1 width=28) (actual time=0.011..0.012 rows=1 loops=1)
                                Join Filter: (up_app.scope_id = member.user_id)
                                Buffers: shared hit=3
                                ->  Seq Scan on member  (cost=0.00..1.24 rows=1 width=27) (actual time=0.006..0.007 rows=1 loops=1)
                                      Filter: ((role = ANY ('{owner,admin}'::text[])) AND (organization_id = 'cd51d944-2022-4038-b6ce-064af9a5be4e'::text))
                                      Rows Removed by Filter: 11
                                      Buffers: shared hit=1
                                ->  Index Scan using notification_preference_uidx on notification_preference up_app  (cost=0.15..8.18 rows=1 width=33) (actual time=0.004..0.004 rows=0 loops=1)
                                      Index Cond: ((scope = 'user'::text) AND (category = 'org'::text) AND (channel = 'in_app'::text))
                                      Buffers: shared hit=2
                          ->  Index Scan using notification_preference_uidx on notification_preference op_app  (cost=0.15..8.17 rows=1 width=2) (actual time=0.003..0.003 rows=0 loops=1)
                                Index Cond: ((scope = 'org'::text) AND (scope_id = 'cd51d944-2022-4038-b6ce-064af9a5be4e'::text) AND (category = 'org'::text) AND (channel = 'in_app'::text))
                                Buffers: shared hit=2
                    ->  Index Scan using notification_preference_uidx on notification_preference up_mail  (cost=0.15..8.18 rows=1 width=33) (actual time=0.003..0.003 rows=0 loops=1)
                          Index Cond: ((scope = 'user'::text) AND (category = 'org'::text) AND (channel = 'email'::text))
                          Buffers: shared hit=2
              ->  Index Scan using notification_preference_uidx on notification_preference op_mail  (cost=0.15..8.17 rows=1 width=2) (actual time=0.004..0.004 rows=0 loops=1)
                    Index Cond: ((scope = 'org'::text) AND (scope_id = 'cd51d944-2022-4038-b6ce-064af9a5be4e'::text) AND (category = 'org'::text) AND (channel = 'email'::text))
                    Buffers: shared hit=2
Planning:
  Buffers: shared hit=359
Planning Time: 0.895 ms
Trigger for constraint notification_user_id_user_id_fk: time=0.253 calls=1
Trigger for constraint notification_organization_id_organization_id_fk: time=0.194 calls=1
Trigger notification_notify_trigger: time=0.293 calls=1
Execution Time: 0.874 ms

Postgres 18.6 (org with 1 member: freshly reseeded dev volume, per Step 6):

Insert on notification  (cost=4.75..42.29 rows=0 width=0) (actual time=0.117..0.118 rows=0.00 loops=1)
  Conflict Resolution: NOTHING
  Conflict Arbiter Indexes: notification_dedup_uidx
  Tuples Inserted: 1
  Conflicting Tuples: 0
  Buffers: shared hit=22
  ->  Subquery Scan on "*SELECT*"  (cost=4.75..42.29 rows=1 width=288) (actual time=0.056..0.057 rows=1.00 loops=1)
        Buffers: shared hit=12
        ->  Nested Loop Left Join  (cost=4.75..42.27 rows=1 width=264) (actual time=0.051..0.052 rows=1.00 loops=1)
              Buffers: shared hit=12
              ->  Nested Loop Left Join  (cost=4.61..34.08 rows=1 width=33) (actual time=0.041..0.042 rows=1.00 loops=1)
                    Join Filter: (up_mail.scope_id = member.user_id)
                    Buffers: shared hit=10
                    ->  Nested Loop Left Join  (cost=4.46..25.89 rows=1 width=32) (actual time=0.035..0.036 rows=1.00 loops=1)
                          Filter: COALESCE(CASE WHEN op_app.locked THEN op_app.enabled ELSE NULL::boolean END, up_app.enabled, op_app.enabled, true)
                          Buffers: shared hit=8
                          ->  Nested Loop Left Join  (cost=4.31..17.70 rows=1 width=33) (actual time=0.029..0.030 rows=1.00 loops=1)
                                Join Filter: (up_app.scope_id = member.user_id)
                                Buffers: shared hit=6
                                ->  Bitmap Heap Scan on member  (cost=4.16..9.51 rows=1 width=32) (actual time=0.012..0.012 rows=1.00 loops=1)
                                      Recheck Cond: (organization_id = 'a1c087a7-9e0d-4f94-a509-6545adfcebdb'::text)
                                      Filter: (role = ANY ('{owner,admin}'::text[]))
                                      Heap Blocks: exact=1
                                      Buffers: shared hit=2
                                      ->  Bitmap Index Scan on "member_organizationId_idx"  (cost=0.00..4.16 rows=2 width=0) (actual time=0.005..0.006 rows=1.00 loops=1)
                                            Index Cond: (organization_id = 'a1c087a7-9e0d-4f94-a509-6545adfcebdb'::text)
                                            Index Searches: 1
                                            Buffers: shared hit=1
                                ->  Index Scan using notification_preference_uidx on notification_preference up_app  (cost=0.15..8.18 rows=1 width=33) (actual time=0.016..0.016 rows=0.00 loops=1)
                                      Index Cond: ((scope = 'user'::text) AND (category = 'org'::text) AND (channel = 'in_app'::text))
                                      Index Searches: 1
                                      Buffers: shared hit=4
                          ->  Index Scan using notification_preference_uidx on notification_preference op_app  (cost=0.15..8.17 rows=1 width=2) (actual time=0.005..0.005 rows=0.00 loops=1)
                                Index Cond: ((scope = 'org'::text) AND (scope_id = 'a1c087a7-9e0d-4f94-a509-6545adfcebdb'::text) AND (category = 'org'::text) AND (channel = 'in_app'::text))
                                Index Searches: 1
                                Buffers: shared hit=2
                    ->  Index Scan using notification_preference_uidx on notification_preference up_mail  (cost=0.15..8.18 rows=1 width=33) (actual time=0.006..0.006 rows=0.00 loops=1)
                          Index Cond: ((scope = 'user'::text) AND (category = 'org'::text) AND (channel = 'email'::text))
                          Index Searches: 1
                          Buffers: shared hit=2
              ->  Index Scan using notification_preference_uidx on notification_preference op_mail  (cost=0.15..8.17 rows=1 width=2) (actual time=0.004..0.004 rows=0.00 loops=1)
                    Index Cond: ((scope = 'org'::text) AND (scope_id = 'a1c087a7-9e0d-4f94-a509-6545adfcebdb'::text) AND (category = 'org'::text) AND (channel = 'email'::text))
                    Index Searches: 1
                    Buffers: shared hit=2
Planning:
  Buffers: shared hit=348
Planning Time: 1.250 ms
Trigger for constraint notification_user_id_user_id_fk: time=0.406 calls=1
Trigger for constraint notification_organization_id_organization_id_fk: time=0.248 calls=1
Execution Time: 0.855 ms

Access path partly changed, and it's not a clean read on Postgres 18 alone: the four notification_preference lookups (up_app/op_app/up_mail/op_mail) stayed Index Scan using notification_preference_uidx on both versions. The member lookup did change shape: Seq Scan on member (17) vs Bitmap Heap Scan through member_organizationId_idx (18), but the two captures ran against different data volumes: the 17 capture reused an 11-hour-old dev DB with 12 accumulated member rows across several orgs, while the 18 capture ran against the freshly reseeded, single-org, single-member DB the brief's Step 6 requires (docker volume rm + db:push && db:seed). A member table with 1 row choosing an index path over a seq scan is exactly the kind of small-table plan choice that's sensitive to ANALYZE timing right after a fresh seed, not evidence of a genuine Postgres 18 planner regression: the estimated costs (4.16..9.51 for the bitmap path) are still tiny in absolute terms. Re-running both captures against matched row counts, on the same major, is the only way to attribute this cleanly to the version bump rather than to dataset drift; it wasn't repeated here because the outbox-drain query is the one the brief calls the primary signal, and that one is unambiguous. Recorded here rather than silently dropped, per the instruction that an unexplained plan change is the thing this step exists to catch.

G.1d: Dependency floor

The three range-blocked bumps (typescript ^6.0.3 → ^7.0.2, @hono/zod-validator ^0.8.0 → ^0.9.0, @types/pg), plus a full pnpm up -r --latest lockfile refresh landing Biome 2.5.10 ($schema moved with it: it had pointed at 2.5.1 and was silently under-validating the config), Turbo, Vitest, Vite, @vitejs/plugin-react, Hono, pg, React/React-DOM, Tailwind, @types/node, @tanstack/react-router and @tanstack/react-query.

One open point, surfaced rather than hidden: pnpm up -r --latest also pulled the @better-auth/* family toward 1.7.2 via their caret ranges. @better-auth/scim@1.7.x turns out to be a full config-shape rewrite (requiredRole, providerOwnership, storeSCIMToken all removed), a real breaking change riding a semver-minor. Re-implementing the SCIM integration C.7 shipped against a different contract was out of scope for a toolchain refresh, so the whole better-auth family was rolled back and pinned to exact 1.6.30 (the newest 1.6.x patch, still ahead of the repo's starting point): better-auth, @better-auth/passkey and @better-auth/stripe moved from caret ranges to exact pins; @better-auth/scim and @better-auth/sso were already pinned and stayed that way. Migrating to @better-auth/scim@1.7.x was deferred to a dedicated task against the C.7 SCIM surface; it landed in Sep 2026 (see "Dependency refresh: better-auth 1.7" below).

Two behavioural side-effects worth a permanent record: @hono/zod-validator 0.8 → 0.9 wraps every input on the API surface through zV, and apps/api/src/shared/validator.ts was re-read after the bump to confirm it still throws HTTPException(400) instead of returning a Response inline: the RPC types depend on that. And the Stripe bump to 22.6.0 changed the API version the client actually calls: apiVersion in stripe-client.ts moved from "2026-06-24.dahlia" to "2026-08-26.dahlia", because 22.6's SDK types only the newest literal (LatestApiVersion = typeof ApiVersion): there is no way to bump the package and keep the old version string typed. The full surface consumed from Stripe was re-read against the 22.4→22.6 changelog with no shape change found, but this is a genuinely open point: no CI signal can exercise a real Stripe call, so nothing here proves the new API version behaves identically in production. Verify against a live Stripe account before deploying this change.

Eight deprecated z.string().email()/.url() call sites (@packages/events/payloads.ts and module DTOs) moved to the standalone Zod 4 form ahead of their removal in 5.

G.1e: File-based routing

apps/app/routes.ts now declares the entire route tree once via @tanstack/router-plugin/vite's virtualRouteConfig, wired in apps/app/vite.config.ts (routesDirectory: "./src", generatedRouteTree: "./src/routeTree.gen.ts", virtualRouteConfig: "./routes.ts", autoCodeSplitting: true). Deliberately not physical() and not a flattened src/routes/ directory: the 5 pathless layouts wrap routes drawn from 8 different features, which a directory mount can't express, and flattening would have destroyed the vertical slices: every features/<x>/<x>.route.tsx stays exactly where it was, routes.ts just points at it.

Path resolution: verified empirically, not assumed: virtualRouteConfig entries resolve relative to routesDirectory ("./src"), not to vite.config.ts's own location. This was confirmed by generating the tree from a single route before wiring the other 39, rather than trusting the docs and finding out wrong after 40 entries were written.

Route ids are unchanged: all 40 of them, which is what kept every getRouteApi(...) call site (12, at the time) valid through the migration: they're string literals checked through Register, so tsc was the gate that proved nothing broke. autoCodeSplitting stayed off in this first commit deliberately: the 32 lazyRouteComponent call sites were left untouched, merging the pages was treated as a separate, later step so the tree migration alone could be verified in isolation.

That separate step landed in the following commit: autoCodeSplitting: true only chunks a component that's local to its route file (a statically imported one silently stays in the main bundle) so all 32 <name>.page.tsx files were merged into their <name>.route.tsx, and the 32 lazyRouteComponent wrappers deleted along with the getRouteApi 2-file pattern (the 12 call sites collapse to Route.useSearch()/Route.useParams()/Route.useRouteContext() now that page and route share a module). This is also the point at which the 2-file rule in apps/app/CLAUDE.md was rewritten rather than amended, per the repo's cross-cutting rule #1: its justifying property (the bundler only reaching the page through a dynamic import()) no longer held. Main chunk 478,025 → 363,798 bytes (−23.9%), with 32 separate route chunks replacing the one lazyRouteComponent-driven bundle shape.

knip.json gained src/features/**/*.route.tsx as an apps/app entry point, alongside routes.ts and src/router/*.tsx. Necessary because routeTree.gen.ts (gitignored) is the only file that imports each Route export, and the vite.config.ts → routes.ts link goes through a string (virtualRouteConfig) knip can't follow statically, without the entry, every route file reads as dead code. The cost, worth stating plainly for whoever next touches routing: this is symmetric. A future route file created but never added to routes.ts is now itself an entry point, so knip no longer flags it as unreferenced dead code: the string-based indirection that made the entry necessary also makes the failure mode of "wrote a route, forgot to wire it" invisible to the dead-code gate.

Smaller consequences of the dependency floor (G.1d): Biome 2.5.10's stricter noUnsafeOptionalChaining caught three pre-existing unsafe casts in test files, fixed with explicit narrowing. The Postgres 18 notification fan-out EXPLAIN comparison is non-conclusive, because the two runs used different data volumes: it is recorded as such, neither a pass nor a regression.


i18n foundation: typed catalogs, locale outside the URL ✅ Phase E.1a · Aug 2026

Why: the ROADMAP originally prescribed Lingui and /en/...//fr/... locale routes. Both were re-decided on evidence before writing any code: see docs/superpowers/specs/2026-08-29-e1a-i18n-foundation-design.md for the full design record this section summarizes.

i18next replaces Lingui. Measured 2026-08-29: i18next at 21.7M and react-i18next at 15.7M weekly downloads against Lingui's 1.6M and Paraglide's 0.5M: a 31× adoption gap against the stated "known and recognized" requirement. Its key typing is also the strongest of the three, the opposite of the received wisdom: ParseKeys (i18next/typescript/t.d.ts) constrains t() across nested keys, namespaces, keyPrefix, context and the six plural categories; a plural key requires { count: number }; interpolation types from the format specifier. Lingui's Typed Message IDs only work in explicit-ID mode, not with its default macros, and react-intl generates no types at all. @vitejs/plugin-react no longer runs Babel for the JSX transform (oxc-transform-react does it now): Lingui's macro would have dragged Babel back into a build that just left it; i18next is pure runtime, no build-time extraction step. Costs accepted: ~23.4 kB gzip of runtime (i18next 13.4 + react-i18next 10.0) against Lingui's ~3.7 kB and Paraglide's ~0.5 kB, and catalogs are not tree-shaken (a dead key still ships). i18next itself has zero runtime dependencies since v26.0.7; MIT license on both packages keeps a fork available if ever needed.

Catalogs are .ts … as const, never .json. Probed directly against the repo's typescript@7.0.2: with resolveJsonModule on (already the case, packages/typescript-config/base.json), TypeScript widens every JSON string value to string: assigning an unrelated string to typeof catalog["greeting"] compiles with no error. The identical catalog written as a .ts module with as const correctly rejects it (TS2322). Since CustomTypeOptions.resources is bound to typeof enCatalog for the t() key-checking guarantee, a .json catalog would silently defeat the entire premise of this design: an unknown or mistyped key would type-check. Keys and plural suffixes (_one/_other) survive either format since they're property names, but only the .ts form protects the values, which is where interpolation placeholders ({{name}}) live. packages/i18n/src/load-catalog.ts's Resources type widens every string-literal leaf of the English catalog's shape (WidenStrings<T>) so a French catalog can share the same structure without being forced to the exact English literals: that widening is scoped to the runtime resources bag only; CustomTypeOptions.resources in types.ts stays bound to the literal English catalog, so the t() guarantee is untouched.

SendTemplateOptions.locale was removed, not activated. An early draft assumed the port's shared options bag was where locale belonged. It isn't: sendTemplateBatch(template, recipients[], options) takes N recipients under one options, so a batch-level locale would force a single language onto every recipient in the batch, and the two genuinely multi-recipient callers (flush-notification-emails.route.ts's one-digest-per-user job and rgpd.service.ts's export/deletion notices) are exactly the ones that must not be forced. Locale instead moved onto the recipient entry itself, EmailRecipient<K>.locale, defaulting to DEFAULT_LOCALE per recipient in QueuedEmailService.sendTemplateBatch and rendered through renderTemplate(template, variables, locale) before the row is written: the resolved language is frozen onto the email_message.locale column at enqueue time, so a queue retry days later still renders in the language the recipient had when the email was requested, not whatever locale they've since switched to.

Partial translation is a stated, accepted state, not an oversight. E.1a extracted auth, account and the app shell into catalogs; admin, webhooks, sso, billing, organization, the rest of settings, and the legal pages remain hardcoded English, tracked as E.1b in the ROADMAP. This is safe only because createI18n always registers the English catalog under DEFAULT_LOCALE in addition to the requested locale's resources: fallbackLng: DEFAULT_LOCALE is otherwise inert, since i18next can only fall back to a language whose resources are actually loaded into the instance. The result: a French user gets French navigation, sign-in/sign-up, account settings, error messages and email subjects, around English content on the unextracted deeper pages: a mixed-language UI, never a raw "settings.billing.title" key rendered to the screen. packages/i18n/src/__tests__/parity.test.ts enforces exact key parity between en and fr for every namespace that does exist, so the partial coverage is a scope decision, not a silently-drifting one.

Two decisions recorded but deliberately out of scope: EVENT_DESCRIPTIONS (packages/events/src/event-descriptions.ts) and the /developers/events page stay English permanently, consistent with the rest of the API documentation surface. And the pre-existing apps/app/CLAUDE.md claim that ThemeProvider's CSP nonce comes from a Caddy-injected meta[property="csp-nonce"] tag was found, while auditing app-providers.tsx, to reference a tag index.html doesn't actually contain: the nonce is always undefined in practice. Left unfixed here: out of this phase's scope, noted so it's a known debt rather than a silent discovery.


Email delivery queue: debt closeout ✅ Aug 2026

Why: Phase D.5 shipped with three known debts (failed rows never purged, markSent one UPDATE per row, RGPD deletion confirmation missing its idempotency key). Closing them surfaced two review findings that widened the phase beyond those three: a live production defect in the provider-side dedup key, and a second, more serious loss mode in the RGPD path that the original debt list hadn't named.

The design draft's central claim was wrong, and it matters: the draft justified skipping a dedicated fix by arguing the email_message.idempotency_key UNIQUE constraint already closed the duplicate-email window. It doesn't. The constraint protects an INSERT: two enqueue calls racing to create the same row. The actual failure mode is a re-send of an already-inserted row: the worker crashes after the provider accepts the send but before markSent commits, the row is re-claimed on the next poll, and sendChunk calls Resend again for a row that was never marked sent. No INSERT occurs on that second call, so the UNIQUE constraint never sees it. What actually closes that window is the provider-side Idempotency-Key header on the batch request: Resend recognizes the key and returns the cached response instead of sending twice. That header was itself broken (next paragraph), so for a period the window this design believed was closed, wasn't, by any mechanism.

Finding 1: silent mail loss via key truncation. chunkIdempotencyKey built the provider's Idempotency-Key by joining every recipient key in the chunk and truncating to 256 characters. Two structurally different chunks (different recipients, different content) can share the same first 256 characters of their joined keys, producing the same truncated header. Resend then returns the cached response of the first send for the second, structurally different chunk, and sendChunk reads that cached response as success and marks its own (different) recipients sent, even though nothing was ever sent to them. This was a live production defect, not a theoretical one: any two chunks colliding on the truncated prefix silently dropped mail while the queue reported success. Fixed by hashing the joined keys instead of truncating them, so no two distinct key-sets can collide onto the same header.

Finding 2: permanent loss of the RGPD deletion confirmation on a crash. The confirmation email was enqueued after the wipe transaction committed. A crash (or any failure) between the commit and the enqueue call permanently lost the confirmation: the wipe is one-shot and never replayed, so there was no second chance to send it. Fixed by moving the enqueue inside the wipe transaction itself, with throw new Error("rollback") on enqueue failure: a failed enqueue now rolls back the wipe rather than completing it silently without notifying the account holder.

The three originally-scoped debts, also closed:

  • enqueue idempotency at the queue layer: gained onConflictDoNothing targeted at the idempotency_key unique index, plus returning(): a warning is logged when fewer rows come back than were requested, surfacing silent conflict-driven drops instead of hiding them.
  • Per-recipient idempotency keys: EmailRecipient.idempotencyKey lets a caller set a key per recipient; the positional fallback (${options.idempotencyKey}#${index}) is namespaced with # and shared by both the template batch path and the raw batch path. This is a one-time cross-deploy dedup window, not a cosmetic rename: the separator moved from / to #, so every existing single-recipient caller's stored key changes shape (<key>/0 → <key>#0). A row enqueued before the deploy and retriggered after it will not match its old key and will not dedupe: one duplicate email per affected key, bounded by EMAIL_MESSAGE_RETENTION_DAYS=7 (old rows age out of the dedup window within a week). The digest flush route already keyed its send on a digest of the full row-id list (digestIdempotencyKey(rows.map(r => r.id))), unchanged by this pass, no per-call timestamp was ever involved there.
  • failed rows never purged: EMAIL_MESSAGE_FAILED_RETENTION_DAYS (default 90) plus a new partial index (email_message_failed_sweep_idx) backs a second sweep pass. sent rows still purge on the original EMAIL_MESSAGE_RETENTION_DAYS=7. The cutoff is measured from created_at (enqueue time), not from when the row transitioned to failed: since the retry ceiling is minutes, the two are close in practice, but a row enqueued 89 days ago that fails today is purged on the very next sweep.
  • markSent fan-out cost: collapsed from one UPDATE per row to a single UPDATE … WHERE id IN (...) (Drizzle inArray) with a CASE WHEN selecting each row's provider_message_id: a 100-message chunk now costs one round-trip instead of 100.

Supporting changes: runRetentionSweep was promoted from a single pass to N passes, and six route files were migrated onto the promoted signature: the five direct runRetentionSweep callers, plus sweep-audit-log, which had been bypassing the shared runner via runBatchedSweep on its own. All six now share the same runner as the two new email sweep passes (sent, failed), rather than growing a bespoke second code path.

Rule #6 amended, not exempted: closing the failed-row debt meant no new domain event fires when the sweep deletes a failed row, but rule #6's "infra retention sweeps" exception only named outbox_event, audit_log, webhook_delivery. email_message wasn't on that list, and invoking the exception as written anyway would have stretched a rule's scope silently, which rule #1 forbids. The exception list was rewritten to add email_message, with the justification spelled out: a row reaches status = 'failed' only when nextAttempt.isNone() (the exact point email.delivery.exhausted is already emitted) so every purged failed row corresponds 1:1 to an event already emitted and audited before the sweep ever runs.

No new event types. The catalog is consumed here, not extended: verified by importing packages/events/src/visibility-map.ts and counting Object.values(VISIBILITY): 81 total / 35 public / 46 internal, unchanged from Phase E.1a.

New debt found and deliberately left open: the sweep routes and the cron client (internalLayers, Bun.serve, signedInternalFetch) have no explicit request timeout anywhere in the chain, no AbortSignal on the outbound fetch, no idleTimeout on the server. Doubling the number of sweep passes (one more for email_message failed rows, on top of the existing runners) brings a hang closer to actually mattering, but wiring timeouts through the whole internal-call path was out of scope for a queue-correctness closeout. Tracked as D.5 in the ROADMAP's "What's left" table rather than folded in silently.


Sweep rail instrumentation ✅ Phase D.6 · Aug 2026

Why: the debt above closed a queue-correctness gap but left the whole /internal/sweep-* rail dark to tracing: a batch stuck on lock_timeout, a lease that never releases, or a pass silently truncating on MAX_BATCHES all looked identical from Sentry: nothing. The spec named three components that had to land together (the runner, the six purgeBatch bodies, sweep_lock) because instrumenting only one of them (the lease, say) produces a trace with a hole exactly where the run and its batches happened.

Façade over constructor injection: a deliberate rule #8 deviation. Rule #8 asks for IInstrumentation on the constructor; this rail has none to put it on: the runner, the lease helpers and the purge routine are plain functions, not a class. SweepSpans (apps/api/src/shared/internal-routes/sweep-span.ts) is built once per request from di.IInstrumentation and threaded down explicitly instead, which keeps both properties the rule protects: no module-level singleton (two sweep labels can run concurrently without sharing a span budget), and a unit test builds one over NoOpInstrumentation and spies on its calls the same way a constructor-injected mock would.

Span budget: MAX_INSTRUMENTED_BATCHES = 50 against MAX_BATCHES = 1000. Sentry truncates a transaction at roughly 1000 spans by dropping the overflow from the end: the exact spans most worth keeping (the last passes, the lease release) would be the first discarded if every batch opened one. sweepSpans() counts down a budget and, once exhausted, still executes the query: the trace loses resolution, never work. A full-backlog run therefore traces its first 50 batches and executes the remaining 950 unwrapped; verified by apps/api/src/shared/internal-routes/__TESTS__/sweep-span.test.ts (5/5) and by inspection, not by driving a 1000-batch backlog through a real database.

Double-capture avoidance. Two error paths in sweep-runner.ts were previously silent and are now instrumented, but not the same way: a batch failure under onBatchError: "break" and a failed lease release are both swallowed by design (a sweep that already did useful work must not fail the whole request over them) and are now the only two paths that call spans.capture(...) directly. A batch failure with no onBatchError still rethrows, and is deliberately left uncaptured at that point: app.onError already reports every uncaught exception, and capturing it twice would double-count it in Sentry.

purgeBatch promotion: justified by rule #2 and by what it enables, not by jscpd. Six routes carried byte-identical purgeBatch bodies; the plan expected promoting them to purgeBatchWithTimeout (apps/api/src/shared/internal-routes/sweep-purge.ts) to lower jscpd's duplication numbers. It does not. A worktree comparison with the same binary and config measured the repo before and after this branch: 484 duplicated lines / 3042 tokens / 35 clones before, 496 / 3107 / 35 after: duplication went slightly up, not down, and stayed well under the 3% gate (~1.24% both times, exit 0 either way). The reason is specific to jscpd's exact-token matcher: it never flagged the six purgeBatch bodies at all, because each route names different tables and columns: only the 3-line SET LOCAL boilerplate ever matched, and 3 lines is below the 10-line clone threshold. What jscpd flags, before and after, is the six routes' near-identical import blocks, which grew because all six now import di, purgeBatchWithTimeout and sweepSpans. The promotion is still correct: repo rule #2 (2nd occurrence promotes) applied at three occurrences already, and it is what turned "instrument the batched delete" into a one-file change instead of six.

Verification against a real database (the point of this task). Postgres up, schema pushed, dev user seeded, API started with bun --hot, then the bundled cron (apps/api/src/cron/sweep.ts) run against it with a real INTERNAL_SIGNING_KEY. All six sweeps (sweep-email-messages, sweep-webhook-delivery, sweep-audit-log, sweep-outbox, sweep-consents, sweep-notifications) answered OK with deleted: 0 (fresh database), every pass's stopReasons exhausted, no SKIPPED/FAIL/UNREACHABLE line, cron exit code 0. This is the only step in the whole plan that exercises purgeBatchWithTimeout's query against a real Postgres: every unit test in Tasks 1 to 6 runs against a mocked @packages/drizzle, which type-checks a query but cannot execute it.

Sentry: reasoned from code, not observed live. apps/api/.env carries a SENTRY_DSN key with an empty value; env.ts normalizes every empty-string env var to undefined before Zod sees it, so env.SENTRY_DSN is undefined and di.ts's env.SENTRY_DSN ? new SentryInstrumentation() : new NoOpInstrumentation() binds NoOpInstrumentation. No live Sentry project was available in this environment, so the trace shape described in docs/CRON.md's "What a sweep looks like in tracing": one run span (op: "function"), a db.query lease-acquire child, one pass span (op: "function") per pass carrying stop_reason, a db.query child per batch: is asserted from sweepSpans()'s implementation and its call sites in sweep-runner.ts and sweep-purge.ts, not from an opened Sentry trace. What was confirmed directly: with SENTRY_DSN unset, NoOpInstrumentation is what di.ts binds, so every sweep above ran with instrumentation fully disabled and still worked: Sentry off does not change sweep behavior.

Final whole-branch review, live under a real Sentry SDK (Aug 2026). A follow-up review ran the real runRetentionSweep under @sentry/bun with a stub transport instead of reasoning from code, and found two defects the unit suite could not see. First, the lease's two spans shared the same budget counter as the batch and dry-run-count spans; because the lease release runs last (in the runner's finally), any run past ~48 batches dropped delete from sweep_lock from the trace: reproduced live at budget 4, where the release span was simply absent. Fixed by giving the lease an unbudgeted spans.lease(...) path (sweep-span.ts's dbSpan helper, shared by both db and lease), on the reasoning that the budget exists to protect exactly this span, so spending it on the lease defeats its own purpose. Second, the run span itself carried no attributes: a run skipped for a held lease was indistinguishable in Sentry from a run that executed and found nothing. Fixed by writing sweep.skipped: true on the early return and sweep.deleted/sweep.batch_count/sweep.truncated on the completed path, both while the run span is still active. Both fixes are covered by tests: a lease span surviving an exhausted budget (sweep-span.test.ts), and the run-span attributes on both paths (sweep-runner.test.ts).

Sentry's ~1000-span truncation: now measured, not assumed. The original span-budget note above (and the matching claims in docs/CRON.md, docs/FEATURES.md, ROADMAP.md, and sweep-span.ts's docstring) asserted that Sentry truncates a transaction at ~1000 spans by dropping the overflow from the end. The plan that produced those docs had explicitly flagged this as unconfirmed in the installed SDK and said nothing should claim it as fact: that caveat did not survive into the shipped text. The same live-Sentry review closed the gap: emitting 1200 spans in one transaction retained exactly 1000 (b0..b999), with the tail dropped. The claim was true; it is now backed by a measurement instead of an assumption.

Two facts about earlier tasks worth recording here. First, Task 3 found a pre-existing bug already on dev: sweep-notifications.test.ts failed in isolation with Export named 'sweepSchema' not found (introduced in 88e3e92) but passed in the full suite, because bun's mock.module registry is process-global and another test file's factory happened to supply the export first. The shared drizzle-mock.ts factory this plan extracted removes that whole class of trap by giving every sweep test the same, complete mock. Second, the plan's Task 5 expected pnpm check:sweep-lock to pass at that point; it could not, because that script drives the real routes, which were deliberately left non-functional from Task 4 until Task 6 wired the runner, the purge helper and the lease together. The gate moved to Task 6, where it passes 23/23, and stayed 23/23 through Task 7.

No new event types. Nothing here mutates persistent business state beyond what the existing sweeps already purge: catalog stays 81 total / 35 public / 46 internal, unchanged from Phase E.1a and the email queue debt closeout.


i18n extraction: retiring the partial-translation carve-out ✅ Phase E.1b · Aug 2026

Why: E.1a shipped the typed rail and translated auth, account and the shell; the rest of the product (admin, webhooks, sso, billing, organization, the rest of settings, every /legal/* page) stayed hardcoded English, tracked as E.1b. This phase closed that list, then retired the apps/app/CLAUDE.md hedge that had named the state as deliberate.

Namespace split: admin gets its own catalog, not a home inside settings. packages/i18n/src/catalogs/en/settings.ts runs to 520 lines; admin.ts sits at 152. Folding the admin/audit-log/org-admin copy into settings.ts would have made one namespace carry two audiences the app already keeps structurally apart: apps/app/CLAUDE.md's import-direction rule forbids one route-owning feature importing another for exactly this reason, and it's what forced Task 13's role-labels.ts promotion to shared/auth rather than leaving it in features/organization. A separate admin namespace (useTranslation("admin")) means a customer-facing settings screen can never accidentally resolve platform-operator copy, and the reverse.

R2: i18next _one/_other plurals, no new ICU primitive. Ruled before dispatch: the repo already shipped 20 plural keys on this pattern, so introducing an ICU message-format primitive for this phase's own new counts (API-token expiry in days and years, webhook delivery-attempt counts) would have been a second plural mechanism solving a problem the first one already solved. A non-decision once the precedent was checked.

R3: legal prose is restructured per-locale, not translated, because translating placeholder text has negative value. features/legal/policies/{en,fr}.tsx gives every clone-owner one file to replace. The phase measured the placeholder legal corpus at roughly 2,000 words across legal/ (~560 of them in policies.config.tsx alone) against the spec's estimate of "~57 blocks": the disagreement didn't change the decision, it strengthened it: translating a boilerplate's fictional ToS/Privacy Policy is a legal act a clone-owner must redo in their own jurisdiction's language and wording, so doing it here would be work whose entire output gets discarded by every real deployment. policies/fr.tsx re-exports the English bodies verbatim.

Every legal page discloses its untranslated body, not only privacy/terms. Task 14's review found four more pages (accessibility, cookies, data-rights, sub-processors) shipping substantial English prose under already-translated French chrome, with no disclosure at all: 20 English strings on the RGPD walkthrough alone. R3's justification for leaving legal prose untranslated is the disclosure, not the omission: a French title over a silent English body is the "half a page is worse than none" defect this whole phase exists to prevent, even though the prose itself is legitimately in scope for R3. Fixed by extending the shared UntranslatedBodyBanner (already load-bearing on privacy/terms, since requireCurrentPolicies makes accepting the document mandatory) to all six legal pages, never by translating the ~560 words. The carve-out can now close honestly: every legal page either is translated, or tells the reader, in French, that it is not.

R5: an error formats to its final string immediately; a stale message already on screen does not re-translate if the user switches language mid-session. The alternative (carry an i18nKey and resolve it at display time) was considered and rejected: it would have touched roughly 40 call sites, including screens this phase had already finished, for a benefit that only matters in the narrow window between a language switch and the next re-render of a stale error. The frozen-language trade-off is accepted as the cheaper, correct-enough choice.

Three live defects the phase inherited, closed as a side effect of extraction rather than as its purpose (commit 8ae9485):

  • apps/app/src/features/api-tokens/api-tokens.schema.ts: the name and scopes fields carried inline Zod message: strings ("Name is required", "Select at least one scope") that silently outrank the global customError map, since Zod gives a per-issue message absolute precedence. Both fields already had a translated key in the map; the inline literal was quietly winning over it, invisible because the screen was English throughout anyway.
  • apps/app/src/features/sso/sso.schema.ts: domain, issuer and entryPoint validated through .regex()/.startsWith(), both of which raise Zod's invalid_format code, which can never carry params.i18nKey back to a specific catalog key: the global map can only answer it generically. Fixed via .refine(), whose custom issue code does carry params.i18nKey.
  • apps/app/src/features/admin-audit-log/components/audit-row.tsx and metadata-sheet.tsx: row.occurredAt as unknown as string rendered the timestamp in the browser's own locale, unconditionally, regardless of the active language. Fixed by routing both through useFormatDateTime (commit 31bf02a).

The currency-formatting fix: the sibling of E.1a's formatDate promotion. apps/app/src/shared/components/pricing-table.tsx built prices by hand (`${amount} ${CURRENCY}/${interval}`), which is wrong per locale by construction: English wants $12/month, French wants 12 €/mois, and no single template-literal shape produces both. formatPlanPrice now goes through Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0 }): the same "no date/currency library, Intl bound to an explicit locale argument" pattern E.1a established for formatDate/formatDateTime (commit 90b1302).

The parity gate grew by three assertions in Task 10 (17 → 20 tests), and they exist because a subagent volunteered a negative result about its own work. The implementer reported (and the reviewer independently reproduced) that collapsing the French expiryDays_one onto its _other left both the en/fr-comparison suite and the completeness suite green: the gate compares the two locales against each other and is structurally blind to a catalog that is wrong within one locale. Only a human reading "1 jours" caught it. Three assertions now close that hole (8377540): no French _one may equal its _other outside an explicit ALLOWED_SAME_PLURAL_FORM list (English is deliberately exempt: its two forms are legitimately identical wherever the noun doesn't inflect around {{count}}); that list is swept for stale entries the same way ALLOWED_IDENTICAL is; and every plural must be declared in both forms, in both locales, or i18next silently falls back out of the missing bucket at runtime. The suite reached 21 independently, in Task 12, when the new admin namespace added its own entry to the per-namespace parity loop. Worth recording plainly: every other gate this phase added came from a defect that had already shipped. This one came from a defect caught before it shipped, because the implementer said so instead of reporting green and moving on.

The inventory undercounted in five of the nine tasks where it was checked against the real file: always low, never high. Task 5 (organization): 71 strings, not 70. Task 9 (webhooks): 71, not 70. Task 12 (admin users): 80 call sites, not 78. Task 13 (admin orgs / audit log): a fifth undercount, same shape. In each case the miss was not a quoted string the grep-based inventory failed to notice: it was a bare enum variable interpolated raw into what became a translated sentence (invitation.role, d.status, user.role, item.role, member.role, row.actorType): invisible to a literal-string grep because there is no literal to find, only a value the surrounding sentence now depends on. The recipe's Step F grew a second pass for exactly this after Task 5 shipped the first instance of it, and that second pass paid for itself on every task afterward. The rule this leaves behind: an inventory is a hypothesis about what a screen contains, not an authority on it. Grep the file before trusting a count, every time: the question that matters is not "how many quoted strings are there" but "how many places does English still reach the screen."

Four follow-ups this phase deliberately did not close, recorded here so none reads later as an oversight:

  1. Business codes for the 61 HTTPException call sites in apps/api (verified: grep -rn "new HTTPException" apps/api/src still returns 61 at the end of this phase). Task 2 closed the user-visible half on the front: a byStatus layer in messages.ts covering all 61 sites by HTTP status; the API itself still answers HTTP_<status> where it should answer a domain code. Spec §4's "the API returns codes" is only half-held until the API side is done too.
  2. A French legal corpus. features/legal/policies/fr.tsx re-exports the English bodies by design (R3). requireCurrentPolicies makes accepting the document mandatory, so the UntranslatedBodyBanner is a disclosure, not a substitute: whoever ships this to real French users must replace that one file.
  3. EVENT_DESCRIPTIONS stays English, permanently (R4). It renders on /settings/webhooks via event-type-picker.tsx, directly beside the machine event names (org.member.added) that were never going to be translated either: that adjacency, decided in Task 9, is the justification: the row reads as technical data, not as untranslated prose.
  4. domain-verification-card.tsx's dead branch, found in Task 11 and deliberately not fixed there. The component tests err.message === "DOMAIN_VERIFIED" (domain-verification-card.tsx:43), but @better-auth/sso puts that string in .code, not .message, and sso.mutations.ts discards .code entirely. Re-verifying an already-verified domain therefore shows "DNS record not found" instead of the correct message: misleading, not crashing. Fixing it means threading .code through the mutation, a real behaviour change; burying that in a translation commit would have made the diff unreviewable.

Three permanent exclusions, not two: the sub-processor register earns its own line. Alongside the legal prose (R3) and EVENT_DESCRIPTIONS (R4), apps/app/src/shared/sub-processors.config.ts stays English because it restates the signed DPA: it is legal evidence, not display copy. But the two fields a user actually reads off it, purpose and region, are translated: they move through common.legal.subProcessors.*, named by shared/sub-processor-labels.ts, so /settings/privacy's DataSourcesCard and the public /legal/sub-processors page both render French purpose/region text around an English-titled register. The exclusion covers the source-of-truth file, not what the user sees.

Final gates, at 751d22c. pnpm ci:check (Biome): 881 files, 0 fixes. pnpm test: api 800/800, app 279/279 (19/19 turbo tasks, matching Task 14's own count). pnpm type-check: 13/13 turbo tasks. pnpm check:unused: exit 0, 4 configuration hints, none i18n-related (the POLICY_TYPES warning is gone, as Task 14 expected). pnpm check:duplication: 34 clones / 483 duplicated lines / 1.13%, against the measured baseline (dev @ 429d974, before Task 1) of 35 clones / 496 duplicated lines / 1.24%. The number moved down, not flat as expected. The phase's working hypothesis going in (that jscpd would stay flat because it flags no i18n catalog pair, en and fr differing by construction in every value) held: nothing in the new catalog pairs shows up as a clone. The actual movement came from the opposite direction: extraction promoted a few small duplicated blocks to shared helpers along the way (isHttpsUrl to shared/api/, role-labels.ts to shared/auth/), which removed pre-existing clones elsewhere in files this phase was already touching: a side effect of the extraction, not a target of it. a11y gate: docker compose up postgres -d → pnpm db:push && pnpm db:seed → pnpm --filter app build (main chunk 257.14 kB, matching Task 14's own post-fix measurement, no export leak regression) → pnpm --filter api dev → pnpm --filter app check:a11y: 22/22, matching Task 14's baseline.

No new event types. Nothing here mutates persistent business state: catalog stays 81 total / 35 public / 46 internal, unchanged since Phase E.1a.


Back ↔ front surface parity ✅ Phase H.1 · Sep 2026

Why: a boilerplate is judged on what the cloner can do, not on what the API can serve. A backend capability with no UI is invisible; a UI with no backend is a dead button. H.1 ran last on purpose: auditing the finished surface before E.1b would have meant translating screens twice.

The map is a checked-in TypeScript artifact, not a hand-written table. apps/api/src/shared/surface/back-routes.ts reads the route table off the live Hono app (71 keys) rather than being maintained by hand alongside it, which would drift the moment one of the two changed without the other. Reading app.routes from a test required splitting app construction from boot: apps/api/src/index.ts previously ran await di.preload() and started the outbox dispatcher and three workers at module scope, so importing it booted the whole server. apps/api/src/app.ts now exports app/routes; index.ts keeps the boot. front-consumers.ts extracts 52 call sites from apps/app's source. route-map.ts is the Record<"METHOD /path", Consumer | UiLessReason> over the 71 routes, with a runtime diff test (4 assertions) standing in for compiler exhaustiveness: routes have no hand-maintained union to satisfies against, since the true set only exists as app.routes at runtime. The template is the i18n parity test: flatten both sides, diff the sets, assert nothing is missing or stale.

19 routes have no front consumer, and every one is declared, not silently absent. 9 internal-cron, 3 infra-probe, 3 public-api, 2 provider-callback, 1 library-owned (the /api/auth/* wildcard: better-call's internal route table is not exported, so it is documented as one opaque row rather than a wrong enumeration), 1 dormant-by-design. Nothing was deleted for being unconsumed: Axel: "faut être futur proof, si demain j'ai besoin du flux ça doit rester implémenté, le but c'est d'avoir tout de prêt, c'est un boilerplate." POST /uploads/download stays, declared dormant in writing. Capabilities with no HTTP surface at all (quotas, no routes.ts, never mounted) can never appear in app.routes, so the map is structurally incapable of carrying them; they get a short prose section in docs/SURFACE.md instead, honestly labelled as a human claim rather than a machine-verified one.

Two gaps found, both confirmed and closed. GAP 1: PUT /admin/users/:id/role was live, guarded and audited on the backend with no front consumer: a platform admin could not change a role from the UI. Now wired. GAP 2: replacing an avatar leaked the previous storage object: createUpload then authClient.updateUser({ image }) never deleted the old one, and neither POST /uploads/download nor DELETE /uploads had a consumer. Fixed by deriving the object key server-side: keyFromPublicUrl(url): Option<string> on IStorageService, symmetric with publicUrlFor, and deleteUploadBodySchema now accepts { key } or { url }. The front must not learn the storage key format: apps/app has no VITE_STORAGE_PUBLIC_URL, and duplicating the public-URL base into the front is a config that drifts. A URL that isn't this storage's returns { deleted: false }, a legitimate no-op: a social-login avatar is an ordinary user.image and must not raise.

GAP 3: the inverse direction (a front call site targeting a route that doesn't exist) was INFIRMED. A sweep of disabled|TODO|FIXME|coming soon over apps/app/src found only legitimate form states (isPending, isFetchingNextPage, current-plan). Nothing to fix; recorded here so a future reader doesn't re-run the audit.

Behavioural parity: three refusal families didn't mirror, now two do. denyImpersonated (~29 call sites across 9 modules) had no proactive front mirror: every guarded action stayed enabled, and the refusal only arrived as a 403 after the click. Seven call sites rendered raw backend English via toast.error(err.message) instead of the localized formatApiError path. Fixed on both legs, per the ROADMAP's own wording ("the action is hidden or disabled where it would be refused"): localized refusal copy (28 sites converted in total) and proactive disabling with the reason exposed without a mouse. The impersonation refusal gained its own business code: deny-impersonated.middleware.ts now throws AppErrorException({ code: "IMPERSONATION_ACTION_FORBIDDEN" }) instead of a bare HTTPException(403, ...), which previously resolved to the generic "You don't have permission to do this": wrong, since the user does have permission, they're impersonating. Six of the nine refusal families already mirrored correctly and were left alone (auth/session, CSRF, rate-limit, requirePlatformAdmin + MFA, org capability, ownership scoping).

requireCurrentPolicies mounted for real: reversing Phase A.2 decision 5. A.2 shipped the middleware composable and unmounted, reasoning that a global mount "would make every current API call return 409 for a stale user: too aggressive." H.1 reverses that: a user who hasn't accepted the current policy version now gets POLICY_ACCEPTANCE_REQUIRED (409) on business routes, API tokens included. The refusal carries its own business code rather than a bare HTTPException(409, ...): the same treatment the impersonation refusal got, and for the same reason: HTTP_409 resolves to the generic "this action conflicts with the current state", which tells a user with stale terms nothing about what to do. The front handles the code globally (apps/app/src/shared/api/errors/policy-refusal.ts, wired where the router meets the query client): it refetches the policy status and invalidates the router, so _shell's existing shouldRedirectToLegalAccept issues the redirect. Without it, a terms version published mid-session left whoever stayed on a settings page clicking Save against a wall: beforeLoad does not re-run on the page you are already on. It is an allowlist, not a global mount with exclusions: a distinction the first write-up of this phase got wrong, claiming "four exclusion classes" while three whole surfaces (uploads, consents, /admin/*) sat ungated and undeclared. The gate is declared route by route on the 19 mutating business routes of profile, webhooks, api-token, organization, notifications, billing and uploads, plus PATCH /api/v1/me: 20 mount sites in all. What is not gated, and why, is now enumerated in full: POST /me/policies/accept and GET /me/policies (the gate would otherwise block the only route that clears it); RGPD Art. 17/20 routes (a data-subject right cannot be conditioned on accepting terms); sign-out and session reads (a user must always be able to leave); /consents, which has no requireAuth at all: the cookie banner records for anonymous visitors, so the gate is structurally unmountable there, and withdrawing consent is the same family of right as RGPD anyway; the whole /admin/* surface, deliberately, because an operator whose own acceptance went stale would be 409-walled out of the console: POST /admin/impersonation/stop included, which is exactly the route they would need; impersonated sessions (session.impersonatedBy set: C.3 already skips the legal gate on the front during impersonation; the server had to agree or an operator would get 409-walled out of the account they're inspecting); and every read-only route. uploads was the one genuine omission rather than a deliberate exclusion: uploading is product usage, not a data-subject right, and nothing on /legal/accept uploads, so it could not deadlock the unblocking route. It is now gated on all four of its routes. Mounting it on /api/v1 needed a typing change, not a second middleware: that surface runs on ApiTokenVariables, not AuthVariables, so requireCurrentPolicies was retyped against the minimal contract it actually reads: { user: SessionUser; session?: { impersonatedBy?: string | null } }: making it mountable on both surfaces. A token request has no session, so the impersonation bypass is simply never taken there, which is correct: a token cannot impersonate.

The E.1b plan's justification rested on a false premise: corrected, not the conclusion. docs/superpowers/plans/2026-08-31-e1b-i18n-extraction.md:67 claimed requireCurrentPolicies "blocks every protected route until the user accepts the current policy version," and used that claim to justify the UntranslatedBodyBanner. It was false when written (zero call sites) and would still be too broad today (RGPD rights, the acceptance route, and impersonated sessions are excluded). The line is rewritten to state the exclusion list, with a dated note pointing here. The plan's second reference (:863, "requireCurrentPolicies makes accepting the document mandatory") was equally false when written but is true as of this phase, and is left standing. The banner's justification survives either way (acceptance really is mandatory on the paths that matter) but a conclusion resting on a false premise is a defect even when the conclusion itself is right.

One new event type: the first catalog movement since E.1a. notification.read (internal, operational retention) is emitted when a user marks notifications read: the transition writes read_at, which is persistent state, and cross-cutting rule 6 admits no "small enough to skip" tier. Catalog now 82 total / 35 public / 47 internal, verified against packages/events/src/visibility-map.ts. The event pulls double duty: it is also the signal the unread badge converges on, so the count stops waiting for the next poll to become right.

Beyond the plan: the defects the audit surfaced

Auditing a finished surface finds what a plan written before the audit could not list. Everything below was found while executing the eleven planned tasks, or by Axel exercising the result in a browser, and every one of them was live on dev.

2FA was dead, and it took the whole /admin/* console with it. packages/drizzle/src/schema/auth.ts declared the two_factor table without two columns better-auth@1.6.30 writes: failed_verification_count and locked_until, its verification-lockout state. Every 2FA verification 500'd, and since requirePlatformAdmin demands a fresh MFA challenge, nothing under /admin/* was reachable on an account with 2FA enabled. The subagent that found it correctly refused to patch product code under a translation-scoped brief; that refusal was overruled deliberately: a boilerplate shipping a dead security feature is worth more than a scope line. The other nine BetterAuth plugins were then audited for the same class of drift and are clean. db:push fixing the dev box is not the fix: the first review caught that the schema change shipped without a migration, so a fresh production database would have recreated the bug on day one: packages/drizzle/migrations/0021_great_tana_nile.sql is what actually closes it.

Every modal could overflow, because no modal primitive declared a height. The reported symptom was one broken screen (the webhook creation dialog, whose event-type picker lists the whole catalog). The cause was one level down: Dialog, AlertDialog and Sheet in @packages/ui set a width bound and no height bound, so any content taller than the viewport simply ran off the top and bottom of the screen with no way to scroll to it: 16 modals one long list away from the same bug. Fixed at the primitive (max-h-[calc(100dvh-2rem)] + overflow-y-auto on dialog and alert-dialog content, the same on the sheet's top/bottom sides and overflow-y-auto on left/right), which then made delivery-sheet.tsx's own overflow-y-auto redundant and it was removed. A layout bound that every call site must remember is a bug waiting for the next call site: the same reason apps/app/CLAUDE.md keeps colors in the theme rather than at the point of use.

The signature-verification example we hand to integrators was replayable. verify-snippet.tsx is copy-paste code: it is what a customer's webhook endpoint will actually run. It parsed t=<ts> out of the header, folded the timestamp into the signed string, and never checked it. Since the signature covers the timestamp, a captured request stayed valid until the secret rotated. Now rejected outside a 300-second window. The Number.isFinite guard in front of the comparison is load-bearing, not defensive noise: Number("abc") is NaN, and NaN > 300 is false, so without it a garbage timestamp passed the check that exists to stop it.

/api/v1 had no reference document. The public surface is deliberately outside AppType (third parties do not get the Hono RPC client) which means it has no generated contract either, and its only description lived in docs/FEATURES.md as an inventory line. docs/PUBLIC-API.md is now the document a token holder reads: authentication, scopes, every route, the error-code table, and the webhook signature scheme. Every example in it was executed against a running instance rather than transcribed from the route handlers.

Seven org-scoped queries shared a single cache entry across organizations. The reported symptom was that a webhook created in Personal vanished when switching organization and needed a manual refresh to come back. The cause is that these endpoints are requireOrg: the server answers for the active organization, so a query key that does not name the organization asks TanStack Query to serve one organization's response for another's. With staleTime: 30_000 and refetchOnWindowFocus: false, the stale entry is not merely shown: it is shown and kept. Fixed across webhooks, api-tokens, current-membership, subscription, notifications' org preferences and the rest, with useActiveOrgId() as the single place the id is read. A cache key is a claim about what a response depends on; if the server scopes a response by something the key omits, the cache is not a cache, it is a bug with a TTL.

The notification rail carried two identical connection leaks, an inert selector, unreachable history, and a payload that shipped an invitation token to the browser. The leaks are the same defect written twice: notification-stream-hub.ts and outbox-dispatcher.service.ts both registered error and end handlers that each called scheduleReconnect(), and neither ever disposed the dying pg.Client: no removeAllListeners, no end(), the field just overwritten. One dropped connection therefore produced two reconnects, and every previous client stayed alive holding a LISTEN. Both now share one in-flight reconnect timer, an idempotent disposer that removes listeners before ending the client, a field cleared only if it still points at the client that died, and a backoff reset only on a real LISTEN: plus, on the hub, instrumentation.capture on the failure path, which is how anyone would have seen this in production. The front got the other half: a stream that goes silent without erroring is now detected and reconnected. NOTIFICATION_MAP gained a payloadFields allowlist per event, because the SSE frame was forwarding the raw event payload: org.member.invited carries invitationId, which is the acceptance token, to every connected client of the organization. The email-frequency selector offered immediate/hourly/daily and did nothing with the answer; it now drives a digest window in SQL, with a preference flipped after a digest was scheduled honoured at flush time rather than at schedule time. The panel showed the most recent page with no way to reach any earlier one; it now loads more. And a read targeting someone else's notification answered success: it answers not-found now, which is the right shape for the same reason ownership scoping never answers 403: 403 confirms the row exists.

Personal API tokens were invisible, and therefore unrevocable. routes.ts read session.activeOrganizationId ?? null, but this stack always has an active organization (Personal is one), so the ?? null branch was unreachable and the list only ever showed the active organization's tokens. A user's org-less tokens existed, authenticated, and could not be seen or revoked from the UI. TokenOwner is now a closed union ({kind:"personal"} | {kind:"orgAndPersonal"}) so the two cases cannot collapse into each other again, and visibleTokensFilter builds the WHERE from it. The filter is verified by check:api-token-visibility against a real database, not a mock, for the same reason as check:fanout: an and/or/eq/isNull tree is a value until Postgres evaluates it, and a mocked transaction asserts the shape of a query nobody ran.

Ten tests were not running, and which ten depended on file order. bun test shares one module registry across the whole run, so mock.module leaks process-wide: a stub registered in one file replaces the real module in every file that imports it afterwards. It bit three separate subagents in three unrelated disguises: hand-copied @packages/events catalogs blanking 17 event types in other files' assertions, an SQL-text assertion held hostage by another file's stubs, and a cross-suite SyntaxError that predated the branch. Measured rather than argued: bun test src/modules/admin src/modules/api-token ran 63 of 73 tests with 3 failures; the same command with --isolate runs 73 and passes. --isolate --parallel is now the default for every api test script, and apps/api/src/shared/__TESTS__/module-isolation* is a named canary over a module nothing else imports: one file replaces it, its sibling asserts it still sees the real one. Verified to fail without the flag. One consequence had to be paid for: pino ships pino-pretty to a worker thread, and a per-file registry spawns and tears one down per file, racing into "the worker thread exited", so the logger is silenced under test.

Final gates, at 834aaa9. pnpm ci:check (Biome): 912 files, 0 fixes. pnpm --filter api test: 867/867 across 105 files, and green on three --isolate --randomize seeds (3, 11, 99), which is the assertion that matters after an isolation fix: order-independence is not provable by one ordering. pnpm --filter app test: 328/328 across 62 files. @packages/i18n: 21/21. pnpm type-check: 13/13 turbo tasks. pnpm check:unused: exit 0, 4 configuration hints, none introduced here. pnpm check:duplication: 35 clones / 517 duplicated lines / 1.15%, against E.1b's 34 / 483 / 1.13%: one clone and 34 lines added across roughly 9,200 new lines, which is the shape you want: the percentage moved by two hundredths, not the direction.

Seven executable checks now run against a real Postgres in CI, not one: check:sweep-lock, check:fanout, check:digest, check:api-token-visibility, plus check:wipe-rollback, check:enqueue and check:marksent, which existed on this rail but had never been wired into the workflow: a check nothing runs is documentation with a shebang. They share a single rationale: each one's logic lives inside SQL (a lease's FOR UPDATE SKIP LOCKED, a preference cascade, a due-window filter, an ownership WHERE tree), and a mocked transaction can only assert that a query was built, never that Postgres agrees with what it means. check:sweep-lock refuses to run against a non-local database, because a check that acquires and releases leases is not something to point at production by accident.

Dependency refresh: better-auth 1.7 ✅ Sep 2026

Why: the G.1d refresh left the better-auth family pinned to 1.6.30 because @better-auth/scim 1.7 is a rewrite, not a bump. This pass moves the whole family to 1.7.6 and takes every other outdated package with it, except the three pins below, each kept for a measured reason.

What changed with better-auth 1.7:

  • SCIM moved to managed connections. The legacy scim_provider table and its generate-token/delete-provider-connection endpoints are gone (migrations 0022 drops the table, 0023 creates the ten tables the plugin now owns). The plugin knows no organizations: ScimConnectionService (shared/services/) creates one connection per organization, with the organization id as its provisioning domain, and GET/POST/DELETE /settings/organization/scim-connection (owner only, new scim:["manage"] statement, sso entitlement) read, issue or rotate, and decommission it. Membership is a projection the plugin calls inside its own transaction (shared/auth/scim-membership.ts): the seat cap now rolls the whole provisioning back instead of being checked in hooks.before, and an existing account is linked only when its email is verified on a verified SSO domain of the organization. Every connection issued before this change stops working: directories have to be reconnected with a new token.
  • The plugin requires native transactions, hence drizzleAdapter(..., { transaction: true }). Sign-up, the personal organization hook, org creation and every SCIM write were exercised live against Postgres with it on.
  • New secret SCIM_CREDENTIAL_HASH_SECRET (≥ 32 chars, required in production): the HMAC key the plugin stores directory tokens under. Rotating it invalidates every issued token.
  • SAML: samlConfig.idpMetadata.entityID is now how the IdP is identified, the top-level issuer is the SP entity ID, and the ACS is fixed at /sso/saml2/sp/acs/:providerId (the old /sso/saml2/callback/:providerId path is gone). The registration form asks for the IdP entity ID accordingly.
  • Two-factor enable returns a union on method; the front narrows on "totp" before reading totpURI.
  • A latent billing bug surfaced: 1.7 checks at boot that every model its plugins write is in the adapter schema, and subscription was not (it was exported by @packages/drizzle but missing from the schema db is built with), so every Stripe subscription write through the plugin would have failed at runtime. Fixed by adding the billing tables to that schema.
  • New event scim.connection.token_rotated (internal): catalog now 85 events, 35 public, 50 internal.

Review round (before merge), each fix proven by a check that failed first:

  • First SSO sign-in of a new user failed. The SSO plugin creates the user and its session in one transaction, and session.create.before bootstrapped the Personal org through db, on another connection: the member insert referenced a user not committed yet (foreign key violation). The adapter now receives db through withPublishedTransactions, which publishes each transaction it opens, and ensurePersonalOrgFor, emit and the other in-hook writes join it (check:auth-transaction).
  • Concurrent SCIM provisionings passed the seat cap (five at once on a 3-seat plan gave 6 members): the count ran on another connection without a lock. It now runs on the plugin transaction behind a per-organization advisory lock (check:scim-seats).
  • Concurrent token issues created one connection each (four at once gave four connections): ScimConnectionService writes are serialised per organization (check:scim-connection). A failed emit after the plugin committed no longer turns into an error, since the returned token is the only working copy.
  • A downgraded organization could not disconnect its directory (402 on DELETE): only issuing a token requires sso; provisioning refuses additions once the plan is gone, removals still go through.
  • Any organization could reserve an address it does not own: provisioning outside the organization's verified SSO domains is refused with a SCIM 403.

Live verification (local API on Postgres 18, curl): sign-up, sign-in, TOTP enable, passkey option generation; SCIM POST/GET/PATCH/PUT/DELETE with the expected member rows and outbox events (actor = connection creator), seat cap refusal as a SCIM 402 with nothing persisted, 409 on an unverified email match, token rotation (old token 401, new 200), disconnect removing provisioned members but not the owner; SAML registration, SP metadata and ACS URL. Not covered: a full browser round trip against an IdP. SAML sign-in without an SP private key is refused by the plugin because this codebase forces authnRequestsSigned (already the case on 1.6.30, docs/SSO-LOCAL.md documents the key pair).

What stays pinned, and why:

  • @better-auth/utils stays at 0.4.2: better-auth 1.7.6 depends on exactly that version and every plugin peers on it. 0.5.0 would install a second copy next to the one the TOTP verifier uses, and drizzle-rgpd.repository.ts must compute the same TOTP as twoFactor.
  • @simplewebauthn/server stays on 13: it is a type-only anchor for TS2883 (the inferred auth type names its types), and @better-auth/passkey 1.7.6 depends on ^13.3.1, no passkey release (beta included) moves to 14. Measured: removing the anchor or moving it to 14.0.3 both bring TS2883 back on auth.
  • conventional-changelog-conventionalcommits stays on 9: 10.x (latest 10.4.0) only renders with conventional-changelog-writer@9, while semantic-release 25.0.9's stable commit-analyzer 13.0.1 and release-notes-generator 14.1.1 still bundle writer 8 (only the 15.0.0-beta generator moved). Measured by running both plugins with .releaserc.json's own options: on 10.4.0 the analyzer still answers but generateNotes throws "requires conventional-changelog-writer@9"; on 9.3.1 notes render, a wrapped prose line starting with "BREAKING" stays a minor, and a real BREAKING CHANGE: footer is a major.

Rules review pass ✅ Sep 2026

Why: after the feature phases, every file was reviewed against the house rules (Option for absence, typed errors, one shared helper per repeated shape, no casts, spans on every I/O method) and the guides were refreshed. Delivered as PR #70 into dev: 149 commits, 626 files, mostly mechanical. This entry is reconstructed from that PR's commit log.

Behaviour fixes the pass surfaced (the rest was refactoring with no observable change):

  • Units of work return failed results instead of throwing, and a callback that returns a failed Result now rolls the transaction back. Aborted transactions, rejected SSO calls, SSO redirects and 4xx API failures stopped being reported to Sentry as unexpected errors.
  • A failing first drain no longer crashes the API: the outbox dispatcher, the email worker and the webhook worker report it and keep running.
  • Webhook responses stopped exposing the previous secret cipher.
  • Client IP: policy acceptance (sign-up included) and new sessions record the trusted-proxy client IP instead of the proxy address.
  • Audit gaps closed: profile name changes made through an API token are audited and committed with their event; an organization auto-deleted by SCIM deprovisioning is attributed to its owner; webhook delivery replays record who replayed (webhook.delivery.replayed); pre-login cookie consent attached to an account is recorded (user.cookie_consent.linked). Catalog 82 to 84 (85 with scim.connection.token_rotated from the better-auth 1.7 move).
  • Smaller defects: plan tiers no longer accept prototype names such as constructor; a failed lease release no longer fails a committed digest flush; a scan payload that is not a list answers 400; a stored API token expiry parses back into a date; TOTP secret decryption failures reach telemetry; copied recovery codes are confirmed only after the clipboard write succeeds.

Em and en dashes were removed from every guide, inventory and the roadmap in the same pass.


Dependency refresh: Oct 2026 ✅ Oct 2026

Why: a month after the better-auth 1.7 pass, Node 26 became the active LTS, pnpm 12 and Bun 1.4.4 shipped, and eight packages had a new major. Staying one LTS and several majors behind turns the next refresh into a migration. This pass moves the runtimes, takes every minor and patch, then every major in its own commit, so each one can be reverted alone. Drizzle 1.0 and Postgres 19 are out on purpose: both are release candidates, not stable releases.

Runtimes:

  • Node 26.13.0 LTS (.nvmrc, mise.toml, engines.node), Bun 1.4.4 (also pinned in ci.yml, which ran latest), pnpm 12.16.1 (packageManager).
  • Node 25+ ships without corepack, so corepack enable && corepack prepare would fail in a node:26 image. The api dev, app dev and app prod images now install pnpm with npm install -g pnpm@<version>, as the api prod image already did. npm 11 warns that the global install has its scripts blocked; pnpm runs correctly in every image (checked).
  • pnpm 12 lockfile: regenerated without a single resolution change. The only diff is a new leading document (packageManagerDependencies, the pinned pnpm and its platform binaries). Unknown workspace settings are now an error; pnpm-workspace.yaml has none.
  • The four Dockerfiles build on node:26.12.1-alpine until the 26.13.0-alpine tag is published: Docker Hub answered 404 for it on 28/10 (Node 26.13.0 shipped that day). 26.12.1 is the latest published Node 26 image, the same LTS line; move the FROM lines to 26.13.0 once the tag exists.
  • Keycloak (SSO profile) 26.0 → 26.8.1, the latest 26.x on quay.io.

Minors and patches (one commit): better-auth and every @better-auth/* plugin 1.7.6 → 1.7.14 (exact pins kept), hono, zod, drizzle-orm 0.45.4, the AWS and Sentry SDKs, React 19.3.1, TanStack, Vite 8.3.5, Vitest 5.0.5, Biome 2.6.3, Turbo 2.11.10, knip, jscpd and the rest of the outdated list. Two things came with it:

  • @better-auth/utils 0.4.2 → 0.5.1 in the same commit. better-auth 1.7.14 depends on exactly 0.5.1, so keeping 0.4.2 installed two copies and two peer variants of @better-auth/core, and the api scripts stopped type-checking (the DBAdapter of one variant is not assignable to the other). Folding the bump in keeps every commit of the series compiling; pnpm why now shows one copy. A pnpm dedupe in the same commit removes a stale @types/node that split stripe into two peer variants.
  • Biome 2.6 formats Markdown and YAML by default: ci:check went red on 35 files (every doc, ci.yml, the lockfile). biome.json disables both formatters, which restores the 2.5 behaviour; reformatting the docs and a generated lockfile does not belong in a dependency PR. lint-staged stops sending .md/.yml to biome format accordingly.

Fixes found on the way, each in its own commit:

  • Every Docker build was red on the base. The images pruned with npx turbo@2.9.6, which rejects the agentGuidance key of turbo.json (the repo resolves a newer turbo). The Dockerfiles now read the version from the root turbo devDependency and run that exact version, so the pruner follows each bump.
  • Account deletion rejected valid TOTP codes for imported secrets. better-auth 1.7.14 decodes a stored base32:<secret> (a secret imported from another provider) into bytes before computing the code; drizzle-rgpd.repository.ts used the raw string, so the deletion step-up would have refused a code twoFactor accepts. It now applies the same decoding. The new test, built on frozen bytes, fails on the old repository code (2 pass, 1 fail) and passes with the fix.
  • TOTP settings in one place. shared/auth/totp-options.ts is passed to twoFactor and read by the deletion step-up, which kept its own copy of the plugin defaults.
  • Account deletion failed under secret rotation (predates the refresh). better-auth encrypts the TOTP secret with the config built from BETTER_AUTH_SECRETS; the step-up decrypted with BETTER_AUTH_SECRET alone. env.ts validates BETTER_AUTH_SECRETS, auth.ts passes it to betterAuth, and authSecretKey builds the key the repository decrypts with.
  • An unconfirmed TOTP enrolment confirmed an account deletion (predates the refresh). better-auth refuses a verified = false row at sign-in; the step-up did not. The WHERE now keeps confirmed rows and legacy NULL ones, as better-auth does, proven on a real database by check:totp-enrolment (wired in CI).

Majors, one commit each:

  • stripe 22 → 23: the SDK pins API version 2026-09-30.endive, now set in stripe-client.ts. @better-auth/stripe 1.7.14 peers on ^23. The removed surfaces (payment_method_types on several params, constructEventWithoutVerification, ErrorType) are used neither here nor in the plugin.
  • inwire 3 → 4: a container is single use after dispose() and local-mode modules with missing prerequisites are refused; neither applies. 4.0 lets a container be assigned to Record<string, unknown>, which removes a double cast in apps/api/src/index.ts. Inference checked: no binding typed any, an unknown key is a type error.
  • @hookform/resolvers 5 → 6: the only break is the effect-ts resolver; every form uses zodResolver. packages/ui's optional peer moves to ^6.0.1, else a 5.x copy stays installed.
  • @sentry/vite-plugin 5 → 11: plugins now release with the SDK and drop @sentry/cli (its allowBuilds entry is gone). A build against a local mock Sentry injected debug ids and left no .map in dist/.
  • tsup 8 → 9 (packages/ddd-kit): the emitted dist/ is byte-identical to the tsup 8 output.

What stays pinned, and why (measured again):

  • @simplewebauthn/server stays on 13: @better-auth/passkey 1.7.14 (and 1.8.0-beta.3) still depends on ^13.3.2. Moving the anchor to 14.0.3, or removing it, brings back TS2883 on auth (AuthenticationResponseJSON, PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialRequestOptionsJSON).
  • conventional-changelog-conventionalcommits stays on 9: semantic-release 25.0.9's stable analyzer and notes generator still bundle conventional-changelog-writer 8. On 10.4.1 the analyzer answers but generateNotes throws "requires conventional-changelog-writer@9"; on 9.3.1 a footer BREAKING CHANGE: is a major and a wrapped prose "BREAKING" stays a minor.
  • drizzle-orm stays on 0.45 and Postgres on 18: 1.0 and 19 are not stable yet.

Checks on the final series: ci:check 1006 files clean; turbo run build type-check test 21 tasks green, 1652 tests (api 922, app 364, ddd-kit 287, events 50, i18n 21, access-control 5, emails 3); jscpd 0.57 %; knip exit 0 (4 configuration hints, none new); the twelve real-database check:* scripts green; every commit of the series type-checks on its own. The four images build (api and app, dev and prod) on the published node:26.12.1-alpine, pulled fresh.

Live verification (local API on Postgres 18, curl): /readyz 200 (database pass, storage non-critical fail with the storage profile off); email sign-up 200, sign-in refused with EMAIL_NOT_VERIFIED until the link from the queued verify_email message is followed, then sign-in 200 and a valid session; the outbox holds org.created, org.member.joined, user.created and user.signed_in, all dispatched.

Not covered:

  • No real Stripe call: the 2026-09-30.endive API version has to be checked against the webhook endpoint in the Stripe dashboard, whose API version is set there.
  • Source map upload to a real Sentry project: proven only against a mock, never with a real auth token.

Resync on the OpenUp foundations ✅ Phase I · Oct 2026

  • Status: shipped, I.1 to I.5 merged into dev one lot per PR.
  • Date: Oct 2026.
  • Why: OpenUp, a sister project on the same stack, hardened five foundations after clean-stack diverged from the shared Afterlive lineage. Today clean-stack has a thinner kit, layering enforced only by convention, telemetry that can carry PII, a transaction parameter threaded through every port, and an outbox that holds locks during handlers and loses user handlers on a throw. Each lot below buys one architectural property.
  • Scope: five ordered lots, each its own branch off dev (refactor/phase-i-1-kit to refactor/phase-i-5-outbox) and its own PR merged as a merge commit. Not ported because they are OpenUp-specific: a per-module outbox, RouteGuards, HttpResponse, PgSingleFlight, packages/audit, dist builds, typed entity ids, the DomainEventHandler class, admin replay of dead events.
  • Rules for every lot: each lot ends on the full chain (pnpm ci:check, turbo run build type-check test, check:duplication, check:unused, plus check:boundaries from I.2) and its real-database checks, then the 5-agent end-of-lot review (Opus for I.4 and I.5). Doc debt moves in the same commit as the code that changes the rule.
  • References: root CLAUDE.md rules 6 to 8, docs/EVENTS.md, docs/EVENT_PIPELINE.md, the events-outbox skill.

I.1: Kit and test helpers ✅ Oct 2026

  • Why: a kit with map, flatMap, match, Option.toResult, a monotonic uuidv7, event ids and JSON-based value-object equality removes hand-rolled glue in every module, and typed test doubles (mockPort) replace per-file mock.module wiring.
  • Scope: Result combinators and a typed Result.all; Option.toResult, tryMap, equals; UUID.parse and monotonic uuidv7; optional eventId on domain events (reused as the outbox row id); sameJson for value objects; an error type parameter on BaseRepository and ScopedRepository; mockPort and mockInstrumentation behind a @packages/test/bun subpath. Purely additive.
  • Depends on: Dependency refresh merged.
  • Done when: every new primitive has a named scenario in the ddd-kit vitest suite, pnpm --filter api type-check and pnpm --filter api test pass with no existing caller touched, and the 4 existing BaseDomainEvent subclasses compile unchanged.

I.2: Architecture guardrails ✅ Oct 2026

  • Why: layering that lives only in CLAUDE.md rots; a rule that fails the build does not. Orphan events (declared, never emitted) are the recurring trap of root rule 6.
  • Scope: dependency-cruiser rules (no cross-module import, pure domain and application layers, packages never import apps, no circular dependency) wired into pre-push and CI as check:boundaries; a framework-free source-scan helper in @packages/test; four layering tests (no throw in domain and application, domain import allowlist, routes never write to the database, environment read only through shared/env); a catalog parity test that fails on any event type nobody emits.
  • Depends on: I.1 (source-scan lives beside the new test helpers).
  • Done when: pnpm check:boundaries exits 0 with 0 dependency violations locally and in CI; two deliberate violations (a cross-module import, a @packages/drizzle import in application/) each make it exit 1 naming the rule, shown in the PR text; the 4 layering tests and 2 parity tests pass; every allowlist entry carries a reason; no-circular is an error, or the remaining cycles are listed here.

I.3: Telemetry hygiene and ordered shutdown ✅ Oct 2026

  • Why: a Drizzle failure message embeds the query parameters, so a swallowed email address can reach Sentry through the exception text; and a process killed mid-drain loses in-flight work and the last telemetry batch.
  • Scope: scrub exception text, messages and breadcrumbs (cut after params:, strip query strings, mask emails, drop console breadcrumbs); flush(timeoutMs) on IInstrumentation; runShutdown running ordered steps (grace, HTTP stop, webhook delivery, email delivery, outbox dispatcher, notification hub, database pool, telemetry flush) under a global deadline, with an explicit Bun.serve handle; an HttpHeartbeat pinging <url>/start, <url> and <url>/fail around the cron sweep.
  • Depends on: I.1 (mockPort), I.2 (boundaries gate).
  • Done when: the scrub tests show no params: text and no email in exception.values, message or breadcrumbs; the shutdown tests prove order, exit 1 on a failed or timed-out step, exit 1 with a flush on the deadline and exit 0 only when every step finished; a manual SIGTERM against bun run dev shows the steps in order and exit 0 in under 5 s idle; bun --hot still reloads.

I.4: Ambient unit of work ✅ Oct 2026

  • Why: threading a tx parameter through every port and repository makes a forgotten parameter a silent write outside the transaction. An ambient unit of work removes the parameter, so the transaction cannot be bypassed by omission.
  • Scope: an AsyncLocalStorage scope carrying the transaction and collected events; nested run joins the open transaction (no savepoint), a failed Result at any depth rolls everything back and returns the same Result, events raised without an outbox roll back, a promise outliving its run reads the pool and never a closed transaction; repositories read resolveExecutor(); mockUnitOfWork with the same nesting rules. Migration is total and in batches (shared ports, then billing, consents, notifications and policies, then api-token, profile, quotas, rgpd and webhooks, then the BetterAuth bridge, then the remaining db.transaction sites), ending with IUnitOfWork.run(work: () => Promise<T>) and no tx anywhere.
  • Depends on: I.1 (mockPort, event collection), I.2 (a layering test enforces the end state).
  • Done when: a grep for \btx\??: and ITransaction over modules/ and shared/ returns nothing; db.transaction( appears only in packages/drizzle and scripts/; check:uow-rollback, check:auth-transaction, check:wipe-rollback, check:enqueue, check:marksent, check:scim-seats and check:scim-connection all pass against Postgres; every db.transaction removed has a recorded decision in its commit body.

I.5: Outbox by lease with per-handler delivery ✅ Oct 2026

  • Why: today the dispatcher holds FOR UPDATE SKIP LOCKED locks while the built-in subscribers run, and user handlers run after commit and are lost on a throw. A lease, handlers outside any transaction and per-handler tracking give at-least-once delivery without long locks.
  • Scope: dead_at and deliveries jsonb columns with partial indexes (migration before code, the old dispatcher stays safe); named handlers (subscribe(type, name, fn), deliver skips handlers already delivered); a claim by lease (FOR UPDATE SKIP LOCKED plus UPDATE ... RETURNING, SQL clock, settle guarded by attempts so a late settle writes nothing); a relay with bounded concurrency and a per-handler time budget, dead after 10 attempts then replayed every 6 h; listener reconnect with backoff and keepalive; audit, webhook fan-out and notification fan-out rewritten as idempotent named handlers; emitEvent without tx and a new internal outbox.event_dead event emitted once; retention that never purges undelivered dead rows.
  • Depends on: I.4 (ambient enqueue, flush in the outer run).
  • Done when: check:outbox-lease passes its scenarios (disjoint concurrent claims, lease expiry, stale settle writes nothing, deliveries merge, dead replay, rolled-back enqueue leaves no row, retention keeps dead rows) and runs in CI; EXPLAIN ANALYZE of the claim shows the partial indexes used; an end-to-end auth event shows deliveries naming the three built-in handlers with attempts = 1, and a handler made to throw leaves a partial deliveries and a last_error naming it; the catalog moves from 85 / 35 / 50 to 86 / 35 / 51 and docs/EVENTS.md describes lease delivery.
  • As built (I.5): six handlers, each with its idempotence argument in docs/EVENTS.md (audit-log, webhook-fanout, notification-fanout, revoke-api-tokens-on-membership-lost, notify-impersonated-user, notify-backup-code-used); emitEvent returns the id stored as the row id; the outbox sweep keeps reading dispatched_at alone, now proven on Postgres to keep a dead undelivered row. End to end, a sign-up with webhook-fanout made to throw left user.created, org.created and org.member.joined at attempts = 1, deliveries holding audit-log and notification-fanout, last_error naming webhook-fanout; once the throw was removed the retry ran webhook-fanout alone and all three rows were delivered with the three names.
  • Debt left open: the email queue and webhook delivery keep their own claims (process clock, no settle guard, no replayable dead state); closed by Queue lease alignment.

Queue lease alignment ✅ Oct 2026

  • Status: shipped on refactor/queue-lease-alignment, four code commits then this one.
  • Date: Oct 2026.
  • Why: after Phase I the outbox claimed by lease, but the email queue and webhook delivery still claimed with the process clock, settled on the id alone and had no replayable dead state. A worker whose lease expired could overwrite the one that reclaimed the row, double-count an attempt and announce a death twice, and a message that exhausted its retries was lost to the operator.
  • Scope: shared SQL fragments in packages/drizzle/src/lease.ts (dbNow, leasedBy, claimedSet, releasedSet) and a pure failureSchedule and retryAfterSeconds in shared/jitter.ts, outbox moved onto them; email queue on the lease model with a new dead status replayed every EMAIL_DEAD_RETRY_HOURS inside EMAIL_DEAD_REPLAY_WINDOW_HOURS; webhook delivery on the same model, dead_letter replayable when next_attempt_at is set and terminal when null; the process-clock retry helpers removed from jitter.ts; two migrations (0025, 0026, index only, no data migration).
  • Decisions: (a) shared fragments, not a generic table-descriptor primitive and not three copies, since the dead markers, the settled markers and the claim filters diverge and a descriptor fights the typed builder (root rule 5) while copies are what let the process-clock bug spread (rule 2); (b) failed stays the email permanent-rejection terminal and every existing webhook dead_letter stays terminal, so a deploy replays no old backlog; (c) the replay window keeps a stale sign-in or reset link from going out late; (d) the exhausted events fire once, on the first death, in the settle's unit of work, and root rule 6's exception now covers delivery queue bookkeeping (attempts, lease, dead state); (e) cadence replay plus manual webhook replay can deliver twice with different idempotency keys, documented rather than prevented; (f) db:push does not detect a changed partial index predicate, so the migration is the source of truth; (g) the retry ceiling keeps the pre-lease count: a row dies on its 4th failed send and the exhausted event carries attempts: 4, as before, so the per-queue ceiling is 4 now that the claim counts the attempt; (h) every network step is bounded (Resend batch 30 s, webhook lookup 5 s and request 30 s, all retryable) and a worker checks its remaining lease before each chunk or delivery and releases the rest, so a slow provider cannot outlive the lease and get the same rows sent twice; (i) the shared fragments stop at predicates (isDue, isDeadDue, deadShareOf): a generic claimDue over the CTE union does not type-check on drizzle 0.45 without casts.
  • As built: claim sizes derived from the lease, 300 (email, 3 chunks at 120 s) and 8 (webhook at 330 s, capped at 10); boot refuses EMAIL_DEAD_REPLAY_WINDOW_HOURS <= EMAIL_DEAD_RETRY_HOURS, EMAIL_LEASE_SECONDS under the 30 s send timeout + a 15 s margin and WEBHOOK_LEASE_SECONDS under the 5 s lookup + 30 s request timeouts + a 30 s margin; an email that fails to render is settled failed alone; fan-out schedules new deliveries on the database clock; a stale settle writes no counter, event or endpoint bump (the webhook attempt row stays, a request was made); unstarted chunks and deliveries are released on stop; the webhook worker no longer holds a transaction across the request. Known quirk kept: a thrown webhook processDelivery marks the delivery failed with no organization.
  • Proof: check:email-lease and check:webhook-lease (new, with CI steps), check:marksent rewritten, the three lease checks on one shared harness, unit suites for the queues, the workers, env-bounds and jitter. Event catalog counts unchanged.
  • Rolling deploy: an old process settles blindly for about one lease, so an in-flight row can end with attempts off by one; accepted.