Appearance
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
CentralUsermodel living in the central database with its own auth domain. - Rejected: Reuse the tenant
Usermodel with acentral_adminrole. - Why: Central users != tenant users. Reusing would couple central and tenant auth, create a tenant-DB dependency for central auth, and risk permission confusion.
createAuthServicefactory 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
CentralUsermodel (Laravel auto-derives without a$tableoverride). - Rejected: Same
usersname 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. Onenpm run dev, onenpm 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 existingcreateHttpService/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 (pageLengthref) 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,isDarkas 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 computesstartIndex/endIndexand handlesupdatePageLengthinternally. - Rejected: 5 individual props; single pagination object prop with refs inside.
- Why: Named v-model is idiomatic Vue (PaginationBar already has
pageNumberasdefineModel). Object props with nested refs don't auto-unwrap in templates.updatePageLengthonly had one consumer anyway. - Source: KD-0194 D3
RichTextArea mention extension: move to tenant, don't factory-inject
- Chose: Move
MentionSuggestions+MentionListto@tenant/. RichTextArea gains a genericextensionsprop. - 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
extensionsprop 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: numberinto the sharedcreateRouteSettingsFactory; 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 → @tenantand@shared → @centraldependencies — 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=filelocally; 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/statsendpoint for all counts; separate list endpoints loaded on card click. - Rejected: Single
/central/statsreturning 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
notificationsstore (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 statelessTenantSwitcher(singleton) performing the DB/cache/storage switch.IdentifyTenantmiddleware usesterminate()for structural cleanup. - Rejected:
Octane::resolveRequestBased()(couples to Octane dep we don't have);Illuminate\Support\Facades\Contextbag (built for metadata, not services managing DB connections); keep mutable singletonTenantManager. - 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 existingRequestContextpattern inAppServiceProvider. 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 fromTenantContextper 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_nametoProfileResourceData(fromTenantContext::current()->name). - Rejected: Dedicated
/api/tenantendpoint. - Why: No new endpoint, no extra request, arrives with existing auth profile. Same pattern as
tenant_idand 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 addingmeta.titleto 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.tsrunning 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
createStandardRouteConfigdirectly with English paths. - Rejected: Adopt the same
routeSettingsFactoryas 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_typeint-backed enum (All=0/Restricted=1) ontenant_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/Actiontrio with the URL expressing the operation. - Rejected: Generic
PUT /admin/ai-keys/{key}acceptingproject_ids?: number[]and ignoring other keys;PATCHsemantics. - Why: API key and provider are immutable for security. A generic PUT invites callers to send
api_key/providerthat 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].logUpdatedtakes explicit$oldValues/$newValues(caller computes both around thesync()). - Rejected: Extend
AUDITABLE_FIELDSwith'project_ids'and teachsnapshotEntityabout pivots; diff-based logging. - Why:
AUDITABLE_FIELDSis for scalar columns only — pivots don't fit. MirrorsAppSettingAuditLogger::snapshotEnforcementprecedent. 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_versioncolumn. - 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
inviterNametoCreateTenantDataviatoDto(); 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 1SignupActioncan take its own actor type (probablynull) without compromising this contract. - Source: KD-0563 D1
WelcomeUser mailable: string $inviterName, not User|CentralUser
- Chose: Replace
User $inviterwithstring $inviterName. Both callers format the name. - Rejected: Make
inviternullable; use union typeUser|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.phpkeyinvite_ttl_days = 14. Both Actions inject via#[Config]. - Rejected: Hardcode 14d in
CreateTenantActiononly (creates two TTLs in the codebase); updateInviteUserActionto 14d hardcoded too; extract toUser::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:
CreateTenantActiondoes NOT callUserAuditLogger. - Rejected: Add
UserAuditLogger::logBootstrappedactorless overload; pass the freshly-created user as their own actor. - Why: Actor at the bootstrap moment is a
CentralUser— refactoringUserAuditLoggerto acceptUser|CentralUser|nullcascades intoAuditLogWriterand collides with hash-chain integrity. The arch test doesn't force the call (auto-discovery only fires when<Entity>AuditLoggerexists; noTenantAuditLoggeryet). Tenant creation will be audited at the central layer whenTenantAuditLoggerarrives. - Source: KD-0563 D4
first_name/last_name are required, not optional
- Chose:
StoreTenantRequestrequiresadmin_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_nameare 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_idtotenantsrow, 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.
ResendInviteActionalready exists — recovery is one click. MatchesInviteUserActionprecedent. - Source: KD-0563 D8
Tenant create form: required first_name/last_name, match shipped backend
- Chose: Frontend renders
first_name/last_nameas 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
WelcomeUsersubject 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
TenantResourceDatawith cross-DB query forpendingAdminInvite, newPOST /central/tenants/{tenant}/resend-admin-inviteroute + 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
ResetPasswordRequestActionalready 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
failedstate 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.domainsrow. - Rejected: A separate
domain_provisioning_statestable. - 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
SignupActionandCreateTenantActionto delegatedomainsrow creation toCreateDomainAction, 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 intoBillingPlanEnum::Cancelled. No new column. - Rejected: A new admin-settable
TenantStatusEnumcolumn 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>(orCollection<ProvisioningStatusEnum>) into the resolver. - Why:
deptrac.yamlallows the Enums layer no dependencies, so importing an IlluminateCollectionor a Model there is a boundary violation; primitive params mirror the existingBillingPlanEnum::resolve()pattern and keep the enum a pure, easily unit-tested function. - Source: KD-0219 D2