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 |