Skip to content

Multi-Tenancy Decisions

Distilled from 11 DECISIONS.md files. Each entry is a real fork in the road — what we chose, what we passed on, and why. Implementation details that aren't a tradeoff are NOT here (read the codebase for those).


Central auth: separate CentralUser model, not reuse of tenant User

  • Chose: Independent CentralUser model living in the central database with its own auth domain.
  • Rejected: Reuse the tenant User model with a central_admin role.
  • Why: Central users != tenant users. Reusing would couple central and tenant auth, create a tenant-DB dependency for central auth, and risk permission confusion. createAuthService factory supports both.
  • Source: KD-0190 D1

Path aliases: explicit @shared/@tenant/@central, no generic @

  • Chose: Three explicit aliases per app boundary.
  • Rejected: Generic @/ alias (common Vue convention).
  • Why: Generic aliases hide which app a file belongs to and break boundary enforcement. ESLint can enforce import boundaries by simple regex with the explicit aliases.
  • Source: KD-0190 D2

Multi-app PR strategy: 3 chained PRs, no backward-compat wrappers

  • Chose: PR 2 targets PR 1's branch, PR 3 targets PR 2's branch. Factories export only the factory function and types — existing imports break and PR 2 rewrites them.
  • Rejected: One massive PR (unreviewable); 7 PRs per phase (app broken between PRs); 3 independent PRs (requires backward-compat wrappers that PR 2 deletes).
  • Why: Chained PRs avoid wrapper code that exists only to be deleted. Each PR is reviewable in isolation but can't be merged out of order.
  • Source: KD-0190 D3

Central users table: central_users, not users

  • Chose: Prefixed table name matching the CentralUser model (Laravel auto-derives without a $table override).
  • Rejected: Same users name as the tenant table, disambiguated by connection.
  • Why: Self-documenting in query logs, database tools, and raw SQL — clear which context a row belongs to without knowing the connection.
  • Source: KD-0190 D9

Vite: single instance with multi-entry, not per-app instances

  • Chose: Both apps as entries in rollupOptions.input. One npm run dev, one npm run build, one manifest.
  • Rejected: Per-app Vite instance with root: src/apps/${appName} and separate dev/build per app.
  • Why: Per-app dev servers caused cross-origin failures and confusing DX (two servers, two builds, two output directories). Single instance matches the emmie pattern. Manifest keys are slightly longer (src/apps/tenant/main.ts) — acceptable.
  • Source: KD-0190 D10

Shared services: factory pattern (pagination, http, markdown)

  • Chose: createPageLength(storageService), createMarkdownRenderer(userLookup) factories, consistent with existing createHttpService/createStorageService/createThemeService.
  • Rejected: Vue provide/inject (no codebase precedent); shared singleton (hides per-app config).
  • Why: Factories are the codebase's established DI pattern (6+ services). Provide/inject for one use case introduces a novel pattern. Singletons share configuration across apps — wrong for multi-tenant.
  • Source: KD-0194 D1, KD-0195 D3

Shared services: split pure utilities from storage-backed state

  • Chose: Pure functions (paginate, resetPageNumber) stay in @shared/. Stateful pieces (pageLength ref) go through a factory.
  • Rejected: Single factory returning everything (consumers must change import path even for pure functions).
  • Why: Consumer pages add one import + one argument; pure functions stay exactly where they were. Smaller blast radius per consumer.
  • Source: KD-0194 D2, KD-0195 D1

Shared component DI: props, not provide/inject

  • Chose: Pass routerLink, currentRouteName, isMobile, isDark as props (MenuTabs, DayRangePicker).
  • Rejected: Provide/inject the dependencies at app level; generic type parameter on the component.
  • Why: Zero provide/inject usage in the codebase; introducing it for one use case adds a new pattern. Consumers (DomainLayout, ProjectLayout) already have access to the dependencies. Vue SFC generics are limited.
  • Source: KD-0194 D4, KD-0301 D1

PaginationBar pageLength: named v-model, not 5 props or a single config object

  • Chose: v-model:page-length="pageLength". PaginationBar computes startIndex/endIndex and handles updatePageLength internally.
  • Rejected: 5 individual props; single pagination object prop with refs inside.
  • Why: Named v-model is idiomatic Vue (PaginationBar already has pageNumber as defineModel). Object props with nested refs don't auto-unwrap in templates. updatePageLength only had one consumer anyway.
  • Source: KD-0194 D3

RichTextArea mention extension: move to tenant, don't factory-inject

  • Chose: Move MentionSuggestions + MentionList to @tenant/. RichTextArea gains a generic extensions prop.
  • Rejected: Factory pattern with DI (createMentionExtension(deps)) keeping mentions in shared.
  • Why: YAGNI — central app has zero RichTextArea consumers. Factory with 4 dependency injections for hypothetical reuse is overkill. The extensions prop is a clean extension point if central ever needs mentions.
  • Source: KD-0195 D4

UserRoleEnum + BaseEnum: move to @shared/

  • Chose: Move both files to @shared/enums/. ~19 import paths update.
  • Rejected: Parameter-inject defaultMinRole: number into the shared createRouteSettingsFactory; hardcode the magic number.
  • Why: UserRoleEnum is genuinely cross-layer (shared router + every route file). Parameter injection would cascade through every route file — inconsistency or universal change. Hardcoding loses the enum link.
  • Source: KD-0195 D2

Toast/Modal/Error services: singletons in shared, NOT factories

  • Chose: Direct singleton imports from @shared/services/toast, @shared/services/modal, @shared/services/error.
  • Rejected: Factory pattern matching createHttpService/createStorageService.
  • Why: These services have identical behavior in both apps — no per-app configuration (unlike http with different base URLs or storage with different prefixes). Factory adds indirection for no benefit.
  • Source: KD-0204 D1

Toast HTTP middleware: explicit registerToastMiddleware(httpService)

  • Chose: Export a registration function each app calls in its middleware setup.
  • Rejected: Side-effect-on-import that auto-registers on the tenant httpService; importing both http services into shared and registering on both.
  • Why: Auto-registration tightly couples shared code to tenant. Importing both creates @shared → @tenant and @shared → @central dependencies — boundary violation. Explicit registration is consistent with the existing middleware pattern.
  • Source: KD-0204 D3

SESSION_DRIVER bug for central app: workaround, separate issue

  • Chose: Use SESSION_DRIVER=file locally; track the real fix as its own issue.
  • Rejected: Fix dynamic session connection switching in this PR.
  • Why: Predates this feature, affects all central development, deserves its own issue and tests. Conflating it with the dashboard PR bloats scope.
  • Source: KD-0215 D1

Central dashboard: hybrid endpoint design (counts + lazy lists)

  • Chose: One GET /central/stats endpoint for all counts; separate list endpoints loaded on card click.
  • Rejected: Single /central/stats returning everything (8+ queries, breaks codebase convention); fully separate endpoints per stat (4 parallel requests on page load).
  • Why: Fast initial load (one COUNT query), lists lazy-load on demand. Matches codebase's focused-Action convention. Natural synergy with clickable drill-down cards.
  • Source: KD-0215 D3

Central dashboard data fetching: custom store, not adapter-store factory

  • Chose: Custom store mirroring notifications store (manual state, direct HTTP calls).
  • Rejected: Adapter-store factory (designed for single-resource CRUD); composable; fetch directly in component.
  • Why: Adapter-store doesn't fit multi-endpoint read-only dashboard data. Composable isn't a pattern used elsewhere for data fetching. Direct fetching in a component breaks the convention that no central page fetches in components.
  • Source: KD-0215 D6

Tenancy state: Laravel scoped() binding, not Octane-specific helpers

  • Chose: Split tenancy into TenantContext ($app->scoped() — flushed per request/job) holding the current tenant, and stateless TenantSwitcher (singleton) performing the DB/cache/storage switch. IdentifyTenant middleware uses terminate() for structural cleanup.
  • Rejected: Octane::resolveRequestBased() (couples to Octane dep we don't have); Illuminate\Support\Facades\Context bag (built for metadata, not services managing DB connections); keep mutable singleton TenantManager.
  • Why: Singleton-with-mutable-state leaks across requests under Octane and is a footgun even under PHP-FPM if reset() is missed. scoped() is Laravel-native, gives Octane-readiness for free, and matches the existing RequestContext pattern in AppServiceProvider. Splitting state from action makes both pieces independently testable.
  • Source: Tenancy refactor (request-scoped) — draft 2026-03-04, executed

Config-mutation removal: phase 5 is in scope, not deferred

  • Chose: Replace config()->set('database.connections.tenant.database', ...) and the cache-prefix/storage-path mutations with tenant-aware managers that resolve from TenantContext per call.
  • Rejected: Keep config()->set() mutation under PHP-FPM, defer rewrite until Octane adoption.
  • Why: Config mutation is shared global state — under Octane, two concurrent requests race on the same config keys. Even pre-Octane, "scoped binding + terminate cleanup" is incomplete protection: any code path that reads config('database.default') between requests on the same worker sees the wrong value. Deferring would mean rewriting every tenant-aware boot path twice.
  • Source: Tenancy refactor (request-scoped) — draft 2026-03-04, executed

Tenant name in browser title: source from ProfileResourceData

  • Chose: Add tenant_name to ProfileResourceData (from TenantContext::current()->name).
  • Rejected: Dedicated /api/tenant endpoint.
  • Why: No new endpoint, no extra request, arrives with existing auth profile. Same pattern as tenant_id and feature flags.
  • Source: KD-0283 D1

Title format: Tenant - kendo.dev (no per-page title)

  • Chose: Static format that doesn't include the page name.
  • Rejected: Tenant - Page - kendo.dev (would require adding meta.title to every route).
  • Why: Primary goal is tenant identification across browser tabs, not page identification. Per-page titles can be added later as a separate enhancement.
  • Source: KD-0283 D2

Title set in App.vue, not router middleware

  • Chose: Set once in App.vue after auth profile loads.
  • Rejected: New titleMiddleware.ts running on every navigation.
  • Why: Title is static per tenant — no per-route variation. Middleware adds a file and runs on every navigation for no benefit. If per-page titles arrive (D2 follow-up), move to a router callback then.
  • Source: KD-0283 D3

Subdomain fallback for pre-auth title

  • Chose: Extract tenant name from subdomain (capitalized) before login; replace with DB name after.
  • Rejected: Keep static "kendo.dev" before login.
  • Why: Login page should show tenant context for tab-switching. Two title updates (subdomain → DB name) is a minor cost.
  • Source: KD-0283 D4

Shared MenuTabs: direct import, not a thin tenant wrapper

  • Chose: Delete tenant MenuTabs.vue; update 2 consumers to import from @shared/.
  • Rejected: Keep tenant MenuTabs as a passthrough re-export.
  • Why: Only 2 consumers (DomainLayout, ProjectLayout). Wrapper file hides the real dependency for no real benefit.
  • Source: KD-0301 D2

Central routing: English paths, no translation factory

  • Chose: Use createStandardRouteConfig directly with English paths.
  • Rejected: Adopt the same routeSettingsFactory as tenant (Dutch paths via i18n).
  • Why: Central app is internal/admin-facing — no end-user i18n needs. Translation factory adds unnecessary complexity for an admin app.
  • Source: KD-0302 D1

Pricing/login/invited: skip DomainLayout

  • Chose: Keep these as flat routes with their own wrappers (AuthContainer for auth pages, marketing layout for pricing).
  • Rejected: Wrap pricing in DomainLayout for "everything wrapped" consistency.
  • Why: Pricing is a public marketing page with hero/cards/comparison/footer; tab bar makes no sense. Login/invited are auth flows. Only authenticated domain pages get DomainLayout.
  • Source: KD-0302 D2

Tenant AI key edit: validation-based scope disambiguation, not a scope_type enum

  • Chose: Server rejects project_ids: [] when the field is present; omit it (or null) to mean "allow all." No schema change.
  • Rejected: New scope_type int-backed enum (All=0/Restricted=1) on tenant_ai_keys (the original KD-0490 plan).
  • Why: The ambiguity lives in UX, not the data model. Two sources of truth (enum + pivot) breeds bugs. The enum's third state (Restricted + empty pivot) is dead state nobody asked for. Cost vs benefit was wildly off — migration + backfill + DTO + Resolver + Resource + audit + frontend + 6 arch-test compliance touchpoints to fix what one validation rule fixes. KD-0490 was scrapped and rolled into KD-0489.
  • Source: KD-0489 D1

AI key update: dedicated sub-resource route PUT /admin/ai-keys/{key}/projects

  • Chose: Narrow UpdateTenantAiKeyProjectsRequest/Data/Action trio with the URL expressing the operation.
  • Rejected: Generic PUT /admin/ai-keys/{key} accepting project_ids?: number[] and ignoring other keys; PATCH semantics.
  • Why: API key and provider are immutable for security. A generic PUT invites callers to send api_key/provider that we'd then have to explicitly reject. Sub-resource URL prevents the misunderstanding.
  • Source: KD-0489 D2

AI key audit: purpose-built snapshotProjects, no AUDITABLE_FIELDS change

  • Chose: New snapshotProjects(TenantAiKey) returning ['project_ids' => sorted ids]. logUpdated takes explicit $oldValues/$newValues (caller computes both around the sync()).
  • Rejected: Extend AUDITABLE_FIELDS with 'project_ids' and teach snapshotEntity about pivots; diff-based logging.
  • Why: AUDITABLE_FIELDS is for scalar columns only — pivots don't fit. Mirrors AppSettingAuditLogger::snapshotEnforcement precedent. Sorted IDs ensure stable hashing.
  • Source: KD-0489 D3

AI key concurrent edits: last-write-wins, no optimistic locking

  • Chose: No version column; rely on the hash-chain audit log for forensics.
  • Rejected: Optimistic locking via a lock_version column.
  • Why: Tenant AI keys are admin-only, low-traffic, rarely-edited — collision probability is negligible. Schema change + conflict-resolution UX is overkill. Audit log captures both updates in order.
  • Source: KD-0489 D5

Inviter: positional execute() arg, not on the DTO

  • Chose: CreateTenantAction::execute(CreateTenantData, CentralUser $inviter).
  • Rejected: Add inviterName to CreateTenantData via toDto(); pass only the formatted string.
  • Why: Mirrors InviteUserAction(InviteUserData, User $inviter, ...). DTOs in this codebase carry validated request payload, not auth context — keeping that distinction matters. Future Branch 1 SignupAction can take its own actor type (probably null) without compromising this contract.
  • Source: KD-0563 D1

WelcomeUser mailable: string $inviterName, not User|CentralUser

  • Chose: Replace User $inviter with string $inviterName. Both callers format the name.
  • Rejected: Make inviter nullable; use union type User|CentralUser $inviter.
  • Why: Union types drag central-DB layer into a tenant-context email. Nullable loses the personal touch ("Jasper invited you" reads warmer than "You've been invited") and creates two divergent subject paths. String is the smallest type surface — PHPStan max happy.
  • Source: KD-0563 D2

Invite TTL: config/auth.php, not a hardcoded constant or model property

  • Chose: New config/auth.php key invite_ttl_days = 14. Both Actions inject via #[Config].
  • Rejected: Hardcode 14d in CreateTenantAction only (creates two TTLs in the codebase); update InviteUserAction to 14d hardcoded too; extract to User::INVITE_TTL_DAYS.
  • Why: Operational policy lives in config, not on a model (constants on models feel awkward when the value is policy, not entity behavior). Branch 2's purge job will consume the same key.
  • Source: KD-0563 D3

Tenant bootstrap: skip user audit logging

  • Chose: CreateTenantAction does NOT call UserAuditLogger.
  • Rejected: Add UserAuditLogger::logBootstrapped actorless overload; pass the freshly-created user as their own actor.
  • Why: Actor at the bootstrap moment is a CentralUser — refactoring UserAuditLogger to accept User|CentralUser|null cascades into AuditLogWriter and collides with hash-chain integrity. The arch test doesn't force the call (auto-discovery only fires when <Entity>AuditLogger exists; no TenantAuditLogger yet). Tenant creation will be audited at the central layer when TenantAuditLogger arrives.
  • Source: KD-0563 D4

first_name/last_name are required, not optional

  • Chose: StoreTenantRequest requires admin_email, first_name, last_name (overrides issue text saying optional).
  • Rejected: Default to empty strings; derive from email local-part.
  • Why: users.first_name/last_name are NOT NULL. Empty strings produce welcome subjects like " invited you to Kendo" and a blank profile. Local-part derivation ("joe") is rarely the real first name.
  • Source: KD-0563 D5

Stripe customer: rolls back tenant creation on failure

  • Chose: Order is provision → user-create → Stripe → email. Stripe failure drops the tenant DB and deletes central rows.
  • Rejected: Run Stripe before user-create (impossible — Cashier writes stripe_id to tenants row, which must exist first); best-effort log-and-continue (creates inconsistent state class to repair later).
  • Why: Aligns with issue text ("any failure rolls back the freshly-provisioned tenant DB"). A Stripe outage at signup blocks tenant creation entirely — acceptable for an internal admin tool. Branch 1 (public signup) may revisit.
  • Source: KD-0563 D6

Email send failure: log, don't roll back

  • Chose: Mail failure propagates as a 500 to the central admin but tenant + user + Stripe rows persist. Resend is the recovery path.
  • Rejected: Roll back on mail failure; catch + log + return success (hides the failure).
  • Why: Tearing down a freshly-provisioned tenant DB + Stripe customer because SMTP hiccupped is wasteful. ResendInviteAction already exists — recovery is one click. Matches InviteUserAction precedent.
  • Source: KD-0563 D8

Tenant create form: required first_name/last_name, match shipped backend

  • Chose: Frontend renders first_name/last_name as required (matches the merged backend from KD-0563).
  • Rejected: Make them optional and relax the backend.
  • Why: Reopening KD-0563's contract expands this issue into backend territory and forces a placeholder-name policy that ripples into WelcomeUser subject formatting.
  • Source: KD-0564 D1

Show.vue resend invite button: descope, point at forgot-password instead

  • Chose: Don't build the button. Document forgot-password (<subdomain>.kendo.dev/forgot-password) as the workaround for invite recovery.
  • Rejected: Build the cross-DB resend infrastructure (extend TenantResourceData with cross-DB query for pendingAdminInvite, new POST /central/tenants/{tenant}/resend-admin-invite route + controller + Action with connection-switching).
  • Why: The button has hidden cross-DB scope: admin user lives in tenant DB, Show.vue renders in central app. Roughly the same surface as KD-0563 — doubles the issue size. The admin-invite flow is rarely used. Existing ResetPasswordRequestAction already works for invited-not-claimed users.
  • Source: KD-0564 D6

Provisioning failure: roll back, not retain-on-failure

  • Chose: Terminal provisioning failure deletes the central tenant + domain rows and drops the tenant DB; provider-side DNS/cert resources are cleaned up; failed signups become fully reversible and the prospect retries via the public form.
  • Rejected: Retain-on-failure semantics that persist a failed state for operator inspection and manual retry.
  • Why: Self-service recovery beats operator visibility for public signup — the same subdomain/email free up immediately and central tables accumulate no orphaned rows; forensic visibility moves to the log + audit trail.
  • Source: KD-0580

Domain provisioning lifecycle: columns on central.domains, not a side table

  • Chose: Provisioning status and error metadata live as columns (7 new fields) on the central.domains row.
  • Rejected: A separate domain_provisioning_states table.
  • Why: Provisioning is per-hostname, so a side table couples writes across two tables on every status transition for no benefit; the failure columns are transient anyway, populated only at terminal failure then carried into the deletion audit snapshot.
  • Source: KD-0580

Domain row creation: refactor all writers to one canonical Action, not event dispatch

  • Chose: Refactor SignupAction and CreateTenantAction to delegate domains row creation to CreateDomainAction, paying the refactor cost now so there's a single canonical writer.
  • Rejected: Event-driven dispatch from all three writers (preserves the bypass shape), or duplicating dispatch in three Actions (no enforcement seam).
  • Why: A three-call-site dispatch contract policed by tests is lower blast radius today but higher maintenance forever and drifts on the first feature change — one canonical writer is the cleaner long-term enforcement seam.
  • Source: KD-0580

External-call timeouts: explicit per-call value enforced by arch test

  • Chose: Every Cloudflare/Fly provider call sets an explicit ->timeout() from injected config, enforced by a dedicated arch test.
  • Rejected: Inheriting the framework's default HTTP client timeout.
  • Why: Two prior incidents proved framework defaults silently propagate timeout violations on slow external calls; an arch test makes the contract enforceable rather than merely documented.
  • Source: KD-0580

TLS strategy: per-host certs, accept CT-log subdomain exposure

  • Chose: Issue a per-host certificate per tenant subdomain, accepting that subdomains become publicly enumerable via Certificate Transparency logs.
  • Rejected: A single wildcard cert that would hide subdomains from CT logs.
  • Why: The wildcard hides CT but exposes subdomains via DNS NS-walking and marketing pages anyway, so net privacy is unchanged; subdomain enumeration is a rate-limit/privacy reality of per-host TLS, not solvable architecturally, and tenants needing privacy can request BYOD.
  • Source: KD-0580

Tenant health status: derived from existing signals, not an admin-settable status column

  • Chose: Compute health at serialization time in TenantResourceData::from() from BillingPlanEnum + verified_at + domain provisioning status; "suspended" folds into BillingPlanEnum::Cancelled. No new column.
  • Rejected: A new admin-settable TenantStatusEnum column with toggle UI, allowing explicit suspension independent of billing.
  • Why: The issue's out-of-scope clause ("changing what triggers each status") confirms triggers are coded, not configurable — a derived status needs no migration, admin action, or form UI, and the only state it can't express (non-billing suspension) was explicitly out of scope.
  • Source: KD-0219 D1

Enum resolver takes primitive bools, not a model collection (deptrac safety)

  • Chose: TenantHealthStatusEnum::resolve(BillingPlanEnum, bool $isVerified, bool $hasDomainFailed, bool $hasDomainProvisioning) — the caller (ResourceData) inspects the domains collection and passes computed bools.
  • Rejected: Pass a Collection<Domain> (or Collection<ProvisioningStatusEnum>) into the resolver.
  • Why: deptrac.yaml allows the Enums layer no dependencies, so importing an Illuminate Collection or a Model there is a boundary violation; primitive params mirror the existing BillingPlanEnum::resolve() pattern and keep the enum a pure, easily unit-tested function.
  • Source: KD-0219 D2