This is the searchable registry of every locked decision behind the design — 78 of them (D1–D78), grouped by topic rather than by phase.
How to use it. When a chapter mentions a decision number, look it up here for the one-line rationale and a link to the chapter that explains it in full. When you discuss the design (in a review, an issue, a PR), cite the Dnn so everyone is pointing at the same locked choice. Decisions are immutable: a superseded idea isn’t edited away — it moves to the Superseded decisions table at the bottom with a pointer to whatever replaced it.
| # |
Decision |
Discussed in |
| D1 |
Default subdomain shape {slug}.app.example.com |
Architecture, Tenant Resolution |
| D17 |
Reserved-slug denylist + NFC + xn-- reject + regex ^[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])?$ |
Tenant Resolution |
| D27 |
parseHostname explicitly rejects admin.example.com and any non-suffix host that isn’t an active custom hostname |
Tenant Resolution |
| D18 |
Separate apps/admin worker on admin.example.com (Custom Domain — exact match) |
Architecture, Admin Panel |
| D29 |
All workers set workers_dev: false + preview_urls: false + Host-header guard |
Architecture, Security |
| D45 |
apps/app is a static-assets worker reachable only via apps/server’s STATIC_ASSETS service binding |
Architecture, Web Layer |
| D63 |
apps/admin ASSETS binding serves apps/admin-ui/dist/ from a cross-package directory |
Web Layer |
| D58 |
apps/app/src/index.ts minimal fetch handler + SPA fallback flag |
Web Layer |
| D76 |
Apex app.example.com serves a static “Find your team” page from apps/app/dist/apex/ |
Web Layer |
| # |
Decision |
Discussed in |
| D4 |
Hybrid model: organization.slug is the canonical subdomain; tenant_custom_hostnames table for custom domains |
Tenant Resolution |
| D10 |
Host → org cached in Workers Cache API per-colo (sub-ms); KV-backed cache versioning for cross-colo invalidation; positive TTL 60s, negative TTL 5s |
Tenant Resolution |
| D28 |
Cross-worker cache invalidation via service-binding RPC fan-out + KV cache-key versioning (not Cloudflare Queues — those are work-distribution, not pub/sub) |
Tenant Resolution |
| D37 |
Soft-delete + tombstone unified: on org delete, in the same tx clear the slug and insert into reserved_slugs |
Tenant Resolution |
| D46 |
Local-dev tenant header X-Dev-Tenant-Slug, two-factor gated (NODE_ENV + Cloudflare Secret) |
Tenant Resolution |
| D59 |
Local-dev gate strengthened: NODE_ENV alone is forgeable; require ALLOW_DEV_TENANT_HEADER=true secret + CI guard |
Tenant Resolution, Security |
| D68 |
@repo/tenancy invalidator factored asymmetrically: Invalidator for own-colo, FanOutInvalidator for the admin worker |
Deep Modules |
| # |
Decision |
Discussed in |
| D2 |
Day-one auth methods: OIDC + email/password (SAML deferred to v2) |
Auth & SSO |
| D3 |
organization.enforce_sso boolean column (dedicated, not a metadata jsonb field) |
Auth & SSO, Schema & Migrations |
| D6 |
SSO callback is a per-tenant absolute URL derived by Better Auth from the inbound request |
Auth & SSO |
| D8 |
Existing email/password users in an SSO-enabled org auto-link only when email_verified AND existing membership AND domainVerified |
Auth & SSO, Security |
| D11 |
Tenant context passed to the auth worker via typed RPC parameter (not an HMAC-signed header) |
Architecture, Auth & SSO |
| D12 |
JWT aud/iss per-tenant; payload includes an org claim with id, host, sessionVersion; downstream verifiers must check all five invariants |
Auth & SSO, Security |
| D15 |
Host-only cookies + explicit Origin/CSRF enforcement on tenancy and admin mutations; SameSite is compatibility-aware hardening, not the tenant-isolation boundary |
Auth & SSO, Security |
| D32 |
disableSignUp: true globally; users created exclusively via the custom /api/invitations/accept/:invitationId orchestration |
Auth & SSO, Admin Panel |
| D34 |
Tenant suspension revokes sessions in the same tx and bumps session_version; verifiers reject stale claims |
Auth & SSO, Security |
| D47 |
apps/app Better Auth client plugins: organizationClient(), twoFactorClient(), ssoClient() |
Web Layer |
| D60 |
/accept-invite/:invitationId recovery handles Better Auth’s USER_ALREADY_EXISTS (createUser is not idempotent) |
Admin Panel |
| D64 |
/sso/callback is not an apps/app route — Better Auth handles the callback at /api/auth/sso/callback/{providerId} on apps/server |
Web Layer |
| D65 |
Cookie Domain stays unset on custom hostnames (host-only — no cross-tenant leak) |
Auth & SSO |
| D70 |
@repo/auth-tokens is verifier-side only; Better Auth continues to mint |
Deep Modules |
| # |
Decision |
Discussed in |
| D5 |
Custom-domain cutover: CNAME-only HTTP DCV; no certificate_authority; do not depend on custom_metadata; internal lifecycle state stored separately from raw Cloudflare validation state |
Custom Hostnames |
| D7 |
apps/server holds the CF API token (scoped to one zone, three permissions); the tenancy module owns provisioning |
Custom Hostnames, Security |
| D9 |
Cron reconciler runs every 60s; webhook integration deferred to v2 |
Custom Hostnames |
| D14 |
Adding a custom hostname requires our own TXT verification before we POST to Cloudflare |
Custom Hostnames, Security |
| D38 |
CF Access JWT verification corrected: drop pattern: "/*" and zone_name; broader service-token reject; 3-strike JWKS reset; team-domain normalization; MFA un-mitigatable in v1 |
Admin Panel |
| # |
Decision |
Discussed in |
| D19 |
Cloudflare Access protects admin.example.com; it is the only auth perimeter for the admin worker |
Admin Panel |
| D20 |
Operators live in a dedicated global_admins table — not the Better Auth users table |
Admin Panel, Security |
| D22 |
Self-serve org sign-up removed; Better Auth organization.create always rejects via an unconditional before hook |
Auth & SSO, Admin Panel |
| D23 |
Tenant admin invited via the existing org-plugin invitation table; the first invite is issued by the admin worker |
Admin Panel |
| D24 |
Impersonation deferred to v2; v1 ships read-only support endpoints |
Admin Panel |
| D25 |
New ACTOR_TYPES.GLOBAL_ADMIN; operator-on-tenant actions write CRITICAL audit events attributed to both the global view and the target tenant view |
Admin Panel, Schema & Migrations |
| D26 |
Global-admin “sessions” are stateless: every request re-validates the CF Access JWT + DB row; no cookie session inside the admin worker; no tenant JWTs for operators |
Admin Panel |
| D31 |
First-login cf_access_sub binding requires a per-row enrollment_token (24h TTL); email-fallback removed |
Admin Panel, Security |
| D33 |
admin.support.query classified CRITICAL with a row cap (100) and per-operator rate limit (60/hr) |
Admin Panel, Security |
| D35 |
Admin worker creates orgs via direct Drizzle inserts in AdminApiEntrypoint; INTERNAL_ADMIN_TOKEN removed from the design |
Admin Panel |
| D67 |
tenantOperations.by accepts a `GlobalAdmin |
SystemActor` union |
| # |
Decision |
Discussed in |
| D21 |
"global_admin" added to roles (not systemAdminRoles); new tenant/platform resources hold apex actions |
Admin Panel, Deep Modules |
| D36 |
whereGlobalAdminRole(...subRoles) builder method added to PolicyRuleBuilder; tenant/platform resources use explicit per-action allows |
Deep Modules |
| D55 |
Operator authorization unified into requireOperator(action) → middleware + an OPERATOR_PERMISSIONS const matrix |
Deep Modules |
| D71 |
OperatorAction type derived from OPERATOR_PERMISSIONS matrix keys (no separate union to drift) |
Deep Modules |
| D72 |
whereGlobalAdminRole and the OPERATOR_PERMISSIONS matrix coexist (different scopes) |
Deep Modules |
| # |
Decision |
Discussed in |
| D13 |
OIDC client secrets encrypted at rest via pgcrypto; key in Cloudflare Secrets Store |
Security, Schema & Migrations |
| D16 |
Tombstone slugs and hostnames in the reserved_slugs table on org/hostname deletion; never re-issue |
Tenant Resolution |
| D30 |
audit_logs.actor_id FK dropped (polymorphic); organization_id column added; a Postgres trigger enforces append-only |
Schema & Migrations, Security |
| D73 |
SSO secret encryption uses a Postgres view backed by pgcrypto (Better Auth’s raw plugin reads coexist) |
Deep Modules, Schema & Migrations |
| D75 |
Phase 0 validates the Better Auth SSO schema and Cloudflare hostname state model before Phase A; @repo/tenancy moves into Phase A and the remaining deepening stays in Phase C |
Schema & Migrations, Deep Modules |
| # |
Decision |
Discussed in |
| D39 |
apps/web renamed to apps/admin-ui; existing routes map to operator equivalents; existing login form deleted |
Web Layer |
| D40 |
New apps/app worker holds the tenant-facing SPA on *.app.example.com and tenant custom domains |
Web Layer |
| D41 |
Each web app generates its own typed API client; no shared generated types |
Web Layer |
| D42 |
apps/admin exports an OpenAPI spec (same pattern as apps/server); enables admin-ui code-gen |
Web Layer |
| D43 |
packages/ui (new) holds Radix/shadcn wrappers + a Tailwind preset + Storybook |
Web Layer |
| D44 |
apps/app auth-client.ts uses baseURL: window.location.origin (not a build-time env var) |
Web Layer |
| D48 |
/accept-invite/:invitationId route in apps/app lives outside the (protected) group; the server forwards Set-Cookie from Better Auth signInEmail |
Admin Panel, Web Layer |
| D49 |
Per-tenant branding (logo, primary color, app name) is v1; the tenant uploads a logo to R2; CSS variables applied via setProperty |
Web Layer |
| D50 |
Turbo build ordering: generate-client depends on ^generate-openapi (existing task name) |
Web Layer |
| D61 |
Branding logos hosted in a CF R2 bucket bound as BRANDING_ASSETS; CSP img-src allowlist; Zod regex on color |
Web Layer, Security |
| D62 |
Reuse the existing generate-openapi Turbo task name (don’t fabricate openapi:cache) |
Web Layer |
| D78 |
/api/tenancy/current final response shape: { organizationId, slug, enforceSSO, providers, branding } |
Auth & SSO, Web Layer |
Decisions from the late refactor that pulled scattered logic into a few focused packages and services (@repo/tenancy, @repo/auth-tokens, tenantOperations, the lifecycle/repository services).
| # |
Decision |
Discussed in |
| D51 |
@repo/tenancy package — resolveTenant(host, deps) + an asymmetric invalidator |
Deep Modules |
| D52 |
authenticateOperator unified middleware (CF Access verify + DB lookup + enrollment-token + activity ping) |
Deep Modules |
| D69 |
authenticateOperator returns a discriminated AuthFailure union; JwksCache is a class for testability |
Deep Modules |
| D53 |
@repo/auth-tokens package — verifyTenantJwt(token, opts) checks all five invariants |
Deep Modules |
| D54 |
tenantOperations service — single owner of create/suspend/restore/delete |
Deep Modules |
| D66 |
tenantOperations.rename deferred to v2 (slug rename breaks IdP-registered SSO callbacks) |
Deep Modules |
| D56 |
ssoProviderRepository with a withDecryptedSecret(providerId, fn) scoped closure |
Deep Modules |
| D57 |
customHostnameLifecycle service — single owner of add/verify/reconcile/remove (lives in apps/server, not a separate package) |
Deep Modules |
| D74 |
customHostnameLifecycle is co-located in apps/server, not a separate package |
Deep Modules |
Decisions that don’t belong to one subsystem because they touch the whole design at once.
| # |
Decision |
Discussed in |
| D77 |
Old self-serve onboarding prose retracted everywhere; tenants are exclusively operator-created |
Admin Panel |
These appeared in earlier drafts and are now explicitly removed. They are kept here only for traceability — each was replaced by a decision above.
| Idea (retracted) |
Superseded by |
INTERNAL_ADMIN_TOKEN shared secret between the admin and auth workers |
D35 — the service binding is the perimeter; the admin worker creates orgs directly via Drizzle |
TENANT_INVALIDATION_Q Cloudflare Queue for cache invalidation |
D28 — queues are work-distribution, not pub/sub; replaced with RPC fan-out + cache versioning |
whereRole policy DSL builder |
D36 — the actual builder name is whereGlobalAdminRole; whereRole was a draft typo |
adminBypassTenantIsolation middleware |
D55 — folded into the requireOperator action contract |
Email-fallback matching for first-login cf_access_sub |
D31 — the enrollment-token model closes the takeover vector |
Module-level betterAuth singleton in the auth worker |
The per-request createAuth(db, env, ctx, options) factory is the pattern; a module-level singleton would force AsyncLocalStorage |
tenantOperations.rename in v1 |
D66 — deferred; needs a separate v2 design with an operator runbook |
| Mocked-IdP impersonation in v1 |
D24 — deferred; needs a separate v2 design with audit and step-up |
certificate_authority: "google" on CF custom hostname creation |
D5 — Enterprise-only; omitted |
custom_metadata on CF custom hostname creation |
D5 — Enterprise-only; omitted; reverse lookup keys on cf_hostname_id |