Skip to content

Decision Log (D1–D78)

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