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.

#DecisionDiscussed in
D1Default subdomain shape {slug}.app.example.comArchitecture, Tenant Resolution
D17Reserved-slug denylist + NFC + xn-- reject + regex ^[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])?$Tenant Resolution
D27parseHostname explicitly rejects admin.example.com and any non-suffix host that isn’t an active custom hostnameTenant Resolution
D18Separate apps/admin worker on admin.example.com (Custom Domain — exact match)Architecture, Admin Panel
D29All workers set workers_dev: false + preview_urls: false + Host-header guardArchitecture, Security
D45apps/app is a static-assets worker reachable only via apps/server’s STATIC_ASSETS service bindingArchitecture, Web Layer
D63apps/admin ASSETS binding serves apps/admin-ui/dist/ from a cross-package directoryWeb Layer
D58apps/app/src/index.ts minimal fetch handler + SPA fallback flagWeb Layer
D76Apex app.example.com serves a static “Find your team” page from apps/app/dist/apex/Web Layer
#DecisionDiscussed in
D4Hybrid model: organization.slug is the canonical subdomain; tenant_custom_hostnames table for custom domainsTenant Resolution
D10Host → org cached in Workers Cache API per-colo (sub-ms); KV-backed cache versioning for cross-colo invalidation; positive TTL 60s, negative TTL 5sTenant Resolution
D28Cross-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
D37Soft-delete + tombstone unified: on org delete, in the same tx clear the slug and insert into reserved_slugsTenant Resolution
D46Local-dev tenant header X-Dev-Tenant-Slug, two-factor gated (NODE_ENV + Cloudflare Secret)Tenant Resolution
D59Local-dev gate strengthened: NODE_ENV alone is forgeable; require ALLOW_DEV_TENANT_HEADER=true secret + CI guardTenant Resolution, Security
D68@repo/tenancy invalidator factored asymmetrically: Invalidator for own-colo, FanOutInvalidator for the admin workerDeep Modules
#DecisionDiscussed in
D2Day-one auth methods: OIDC + email/password (SAML deferred to v2)Auth & SSO
D3organization.enforce_sso boolean column (dedicated, not a metadata jsonb field)Auth & SSO, Schema & Migrations
D6SSO callback is a per-tenant absolute URL derived by Better Auth from the inbound requestAuth & SSO
D8Existing email/password users in an SSO-enabled org auto-link only when email_verified AND existing membership AND domainVerifiedAuth & SSO, Security
D11Tenant context passed to the auth worker via typed RPC parameter (not an HMAC-signed header)Architecture, Auth & SSO
D12JWT aud/iss per-tenant; payload includes an org claim with id, host, sessionVersion; downstream verifiers must check all five invariantsAuth & SSO, Security
D15Host-only cookies + explicit Origin/CSRF enforcement on tenancy and admin mutations; SameSite is compatibility-aware hardening, not the tenant-isolation boundaryAuth & SSO, Security
D32disableSignUp: true globally; users created exclusively via the custom /api/invitations/accept/:invitationId orchestrationAuth & SSO, Admin Panel
D34Tenant suspension revokes sessions in the same tx and bumps session_version; verifiers reject stale claimsAuth & SSO, Security
D47apps/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/serverWeb Layer
D65Cookie 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 mintDeep Modules
#DecisionDiscussed in
D5Custom-domain cutover: CNAME-only HTTP DCV; no certificate_authority; do not depend on custom_metadata; internal lifecycle state stored separately from raw Cloudflare validation stateCustom Hostnames
D7apps/server holds the CF API token (scoped to one zone, three permissions); the tenancy module owns provisioningCustom Hostnames, Security
D9Cron reconciler runs every 60s; webhook integration deferred to v2Custom Hostnames
D14Adding a custom hostname requires our own TXT verification before we POST to CloudflareCustom Hostnames, Security
D38CF Access JWT verification corrected: drop pattern: "/*" and zone_name; broader service-token reject; 3-strike JWKS reset; team-domain normalization; MFA un-mitigatable in v1Admin Panel
#DecisionDiscussed in
D19Cloudflare Access protects admin.example.com; it is the only auth perimeter for the admin workerAdmin Panel
D20Operators live in a dedicated global_admins table — not the Better Auth users tableAdmin Panel, Security
D22Self-serve org sign-up removed; Better Auth organization.create always rejects via an unconditional before hookAuth & SSO, Admin Panel
D23Tenant admin invited via the existing org-plugin invitation table; the first invite is issued by the admin workerAdmin Panel
D24Impersonation deferred to v2; v1 ships read-only support endpointsAdmin Panel
D25New ACTOR_TYPES.GLOBAL_ADMIN; operator-on-tenant actions write CRITICAL audit events attributed to both the global view and the target tenant viewAdmin Panel, Schema & Migrations
D26Global-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 operatorsAdmin Panel
D31First-login cf_access_sub binding requires a per-row enrollment_token (24h TTL); email-fallback removedAdmin Panel, Security
D33admin.support.query classified CRITICAL with a row cap (100) and per-operator rate limit (60/hr)Admin Panel, Security
D35Admin worker creates orgs via direct Drizzle inserts in AdminApiEntrypoint; INTERNAL_ADMIN_TOKEN removed from the designAdmin Panel
D67tenantOperations.by accepts a `GlobalAdminSystemActor` union
#DecisionDiscussed in
D21"global_admin" added to roles (not systemAdminRoles); new tenant/platform resources hold apex actionsAdmin Panel, Deep Modules
D36whereGlobalAdminRole(...subRoles) builder method added to PolicyRuleBuilder; tenant/platform resources use explicit per-action allowsDeep Modules
D55Operator authorization unified into requireOperator(action) → middleware + an OPERATOR_PERMISSIONS const matrixDeep Modules
D71OperatorAction type derived from OPERATOR_PERMISSIONS matrix keys (no separate union to drift)Deep Modules
D72whereGlobalAdminRole and the OPERATOR_PERMISSIONS matrix coexist (different scopes)Deep Modules
#DecisionDiscussed in
D13OIDC client secrets encrypted at rest via pgcrypto; key in Cloudflare Secrets StoreSecurity, Schema & Migrations
D16Tombstone slugs and hostnames in the reserved_slugs table on org/hostname deletion; never re-issueTenant Resolution
D30audit_logs.actor_id FK dropped (polymorphic); organization_id column added; a Postgres trigger enforces append-onlySchema & Migrations, Security
D73SSO secret encryption uses a Postgres view backed by pgcrypto (Better Auth’s raw plugin reads coexist)Deep Modules, Schema & Migrations
D75Phase 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 CSchema & Migrations, Deep Modules
#DecisionDiscussed in
D39apps/web renamed to apps/admin-ui; existing routes map to operator equivalents; existing login form deletedWeb Layer
D40New apps/app worker holds the tenant-facing SPA on *.app.example.com and tenant custom domainsWeb Layer
D41Each web app generates its own typed API client; no shared generated typesWeb Layer
D42apps/admin exports an OpenAPI spec (same pattern as apps/server); enables admin-ui code-genWeb Layer
D43packages/ui (new) holds Radix/shadcn wrappers + a Tailwind preset + StorybookWeb Layer
D44apps/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 signInEmailAdmin Panel, Web Layer
D49Per-tenant branding (logo, primary color, app name) is v1; the tenant uploads a logo to R2; CSS variables applied via setPropertyWeb Layer
D50Turbo build ordering: generate-client depends on ^generate-openapi (existing task name)Web Layer
D61Branding logos hosted in a CF R2 bucket bound as BRANDING_ASSETS; CSP img-src allowlist; Zod regex on colorWeb Layer, Security
D62Reuse 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).

#DecisionDiscussed in
D51@repo/tenancy package — resolveTenant(host, deps) + an asymmetric invalidatorDeep Modules
D52authenticateOperator unified middleware (CF Access verify + DB lookup + enrollment-token + activity ping)Deep Modules
D69authenticateOperator returns a discriminated AuthFailure union; JwksCache is a class for testabilityDeep Modules
D53@repo/auth-tokens package — verifyTenantJwt(token, opts) checks all five invariantsDeep Modules
D54tenantOperations service — single owner of create/suspend/restore/deleteDeep Modules
D66tenantOperations.rename deferred to v2 (slug rename breaks IdP-registered SSO callbacks)Deep Modules
D56ssoProviderRepository with a withDecryptedSecret(providerId, fn) scoped closureDeep Modules
D57customHostnameLifecycle service — single owner of add/verify/reconcile/remove (lives in apps/server, not a separate package)Deep Modules
D74customHostnameLifecycle is co-located in apps/server, not a separate packageDeep Modules

Decisions that don’t belong to one subsystem because they touch the whole design at once.

#DecisionDiscussed in
D77Old self-serve onboarding prose retracted everywhere; tenants are exclusively operator-createdAdmin 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 workersD35 — the service binding is the perimeter; the admin worker creates orgs directly via Drizzle
TENANT_INVALIDATION_Q Cloudflare Queue for cache invalidationD28 — queues are work-distribution, not pub/sub; replaced with RPC fan-out + cache versioning
whereRole policy DSL builderD36 — the actual builder name is whereGlobalAdminRole; whereRole was a draft typo
adminBypassTenantIsolation middlewareD55 — folded into the requireOperator action contract
Email-fallback matching for first-login cf_access_subD31 — the enrollment-token model closes the takeover vector
Module-level betterAuth singleton in the auth workerThe per-request createAuth(db, env, ctx, options) factory is the pattern; a module-level singleton would force AsyncLocalStorage
tenantOperations.rename in v1D66 — deferred; needs a separate v2 design with an operator runbook
Mocked-IdP impersonation in v1D24 — deferred; needs a separate v2 design with audit and step-up
certificate_authority: "google" on CF custom hostname creationD5 — Enterprise-only; omitted
custom_metadata on CF custom hostname creationD5 — Enterprise-only; omitted; reverse lookup keys on cf_hostname_id