Skip to content

Multi-Tenancy Decisions

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

Central-user security events: typed event marker inside the hashed new_values, not a new enum case or column

  • Chose: Reuse the coarse AuditActionEnum for the action column and record the specific security event as a CentralUserAuditEventEnum value written into new_values.event.
  • Rejected: Extend the shared AuditActionEnum with security cases; add a dedicated event column to central_user_audit_logs.
  • Why: new_values is already inside the SHA-256 hash recipe, so an event label stored there is tamper-evident for free — a new column sits outside the fixed recipe (flippable without breaking the chain), and changing computeHash to cover it would alter the recipe for every audit table and re-break verification of all historical rows.
  • Source: KD-0221 D1

Audit snapshots exclude every secret, even forensically useful ones

  • Chose: CentralUserAuditLogger snapshots only first_name/last_name/email/two_factor_confirmed_at/invite_token_expires_at; password hash, 2FA secret, recovery codes, remember token and invite_token are never written — so password-change and recovery-code rows carry old_values = null and an event marker only.
  • Rejected: Snapshot invite_token (genuinely useful in a diff) and the other credential columns.
  • Why: The log is append-only, so anything written is permanent, un-rotatable and un-erasable — privacy beats usefulness, and the forensic value of those events is the tamper-evident fact (actor + event + timestamp), not a field diff.
  • Source: KD-0221 D2

Audit viewer reads all five central tables via UNION ALL, not one table at a time

  • Chose: One paginated feed unioning the tenant / domain / billing / GitHub-installation / central-user audit tables, with a batched entity-label resolver inside the read Action that falls back to the row's own snapshot for purged entities.
  • Rejected: Ship a central-user-only viewer now and defer the cross-table console.
  • Why: The five tables are structurally identical apart from the entity FK, so the union is cheap to build once while the single-table version would be largely thrown away when the console arrives — the accepted cost is a contained five-way entity_type coupling in the resolver.
  • Source: KD-0221 D3

Audit endpoint: server-side keyset (cursor) pagination, not client-side or offset

  • Chose: GET /api/central/audit-logs takes ?cursor=&per_page=, orders on (created_at, id, source_table), and still returns a total from five summed COUNT(*)s.
  • Rejected: The central adapter-store's fetch-everything-and-slice-in-the-browser convention; offset pagination (the endpoint's first shipped shape).
  • Why: An audit log is append-only and unbounded, so client-side would ship the entire central forensic history to the browser to render 25 rows, and offset both duplicates rows at page boundaries on a prepend-heavy feed and is ruled out by name for this tier in ADR-0044 — keyset index-seeks to the cursor value and retires the deep-page full-scan entirely.
  • Source: KD-0221 D5

Keyset driven by hand, not Laravel's cursorPaginate()

  • Chose: Keep the per-branch bounded scans and emit the keyset predicate per union branch, using Illuminate\Pagination\Cursor only to encode/decode position.
  • Rejected: paginateUsingCursor(), which does support unions.
  • Why: Laravel applies the keyset WHERE and the perPage + 1 limit only on the outer builder, so combined with the per-branch caps the "previous" direction silently returns the wrong rows and the first page full-scans all five tables.
  • Source: KD-0221 D5

Central audit viewer is not role-gated — any central user can read the whole trail

  • Chose: Gate the viewer at "authenticated central user", accepting that every central user can read all billing events and other central users' 2FA/password history.
  • Rejected: Role-gate the viewer, or redact other users' security events.
  • Why: CentralUser has no role column at all (roleNames() returns the literal 'central'), so gating would mean building a central role system first — closing the write-side gap (these events were entirely unaudited) is the priority, and the flat privilege model is pre-existing, not a regression this feature introduces.
  • Source: KD-0221 D7

Central table scale: reverted working server-side pagination in favour of client-side filtering

  • Chose: Revert the fully built and tested LengthAwarePaginator endpoints plus list stores, and ship in-memory search/filter/sort/paginate on top of the existing fetch-all adapter stores.
  • Rejected: Ship the finished backend pagination, establishing the first real paginator precedent for admin tables.
  • Why: Central tenant and user counts are dozens of rows, so the endpoint contract, named rate limiter and refetch-after-mutation logic were ongoing surface for a problem that hasn't arrived — the reverted design stays in git history as the reference design to resurrect when it does.
  • Source: KD-0222 D0

Central list view state lives at module scope, not in the component

  • Chose: Keep searchTerm, sortKey, sortDescending and pageNumber in each domain's filters.ts (session-only, unpersisted), and clamp the page number inside the shared PaginationBar.
  • Rejected: Add <KeepAlive> to the central router so component-local refs survive navigation.
  • Why: Central's DomainLayout uses a plain RouterView, so anything held in setup silently resets on navigation — module scope fixes it for these two pages without changing every central page's lifecycle, and clamping in the shared component also closes the identical stranded-empty-page gap in the tenant table this was copied from.
  • Source: KD-0222 D9, D10

TenantDeleteModeEnum, not booleans, for delete semantics

  • Chose: Replace bool $isRollback on DeleteTenantAction with a three-case TenantDeleteModeEnum (Rollback/SoftDelete/Purge) across all six call sites.
  • Rejected: Two independent booleans ($skipCleanup, $hardDelete); two named booleans policed by a docblock.
  • Why: Only three of the four boolean combinations are ever meaningful, and an enum rules the meaningless fourth out at the type level instead of by convention — matching the codebase's existing enum-over-boolean precedent.
  • Source: KD-0571 D1

Grace period destroys nothing: purge is the first real cleanup

  • Chose: SoftDelete mode leaves child rows, domains (DNS/certs) and the tenant database untouched — Stripe cancellation is the sole immediate side effect — and Purge performs all destruction for the first time.
  • Rejected: Keep the existing behaviour where soft-delete hard-deletes children, making "restore" a re-provision rather than an un-delete.
  • Why: Access is already fully blocked the moment the central Tenant row is hidden by Eloquent's default scope, so enforcing the grace period requires destroying nothing — and that principle doesn't distinguish a small central metadata row from the entire tenant database, which collapses restore to clearing one column.
  • Source: KD-0571 D2

Scheduled-vs-manual actor distinguished by an explicit --actor option

  • Chose: PurgeSoftDeletedTenantsCommand takes {--actor=cli} and routes/console.php registers the schedule with --actor=scheduler.
  • Rejected: Always record ActorTypeEnum::Scheduler regardless of what triggered the run.
  • Why: Laravel's scheduler invokes the identical Artisan process either way and exposes no built-in signal, so without the flag the audit trail can't tell cron from a human — this is the first real use of either Scheduler or Cli.
  • Source: KD-0571 D5

Purge job is a custom Action + Command, not Laravel's Prunable

  • Chose: PurgeSoftDeletedTenantsAction + PurgeSoftDeletedTenantsCommand, matching the five existing scheduled cleanup siblings.
  • Rejected: The first-party Prunable/MassPrunable traits + model:prune, which explicitly support force-deleting already-trashed rows on a schedule.
  • Why: prune() calls delete()/forceDelete() straight on the model with no Action in the loop, so the tenant cascade (Stripe, DNS/cert release, child rows, database drop, audit rows, actor resolution) would have to run through a model event listener — regressing against ADR-0011's explicit-over-implicit rule.
  • Source: KD-0571 D8

Concurrency: Isolatable command, not withoutOverlapping()

  • Chose: PurgeSoftDeletedTenantsCommand implements Isolatable with $isolated = true and a lock TTL raised well past any realistic sweep.
  • Rejected: Schedule::command(...)->withoutOverlapping().
  • Why: The scheduler mutex only guards scheduled-vs-scheduled runs and never sees a human running the bare command mid-sweep, whereas command-level isolation acquires the lock on every invocation path and exits SUCCESS (not a cron failure) when it can't.
  • Source: KD-0571 D9

Purge sweep has a deliberately tiny circuit breaker (default 2)

  • Chose: Count candidates before cursoring; if the count exceeds tenants.purge_max_per_run (default 2), log at critical, report() a dedicated exception, and abort the entire run without purging anything.
  • Rejected: No cap; a hardcoded constant that can't be tuned without a code change and deploy.
  • Why: A bug in purge_at population would otherwise become an unbounded same-day mass DROP DATABASE with nothing standing between the bad data and the destruction — and a generous cap could never fire at current tenant volume, which defeats the point of a breaker.
  • Source: KD-0571 D11

purge_at is set inside DeleteTenantAction, not by a wrapping SoftDeleteTenantAction

  • Chose: Inject #[Config('tenants.purge_grace_days')] and set purge_at in the existing SoftDelete match arm, one atomic write in the existing transaction.
  • Rejected: A new SoftDeleteTenantAction that sets the field and then delegates.
  • Why: The wrapper's entire body would be "set one field, then call the other Action" — a shallow module split across two transactions, when the plausible next requirement (something else happening at soft-delete time) would add a parameter to the existing Action anyway.
  • Source: KD-0572 D1

Restore makes no Stripe call at all

  • Chose: RestoreTenantAction only clears deleted_at; the preserved Subscription row stays canceled and the existing billing UI handles reactivation.
  • Rejected: Reactivate billing on restore, either free/admin-granted or as a full paid re-subscription.
  • Why: Deletion cancels via Cashier's cancelNow() and Subscription::resume() requires onGracePeriod(), so "reactivate" could only ever mean creating a brand-new subscription — and BillingSection.vue already can't tell "never had Pro" from "had Pro, now cancelled", so it needs no new code.
  • Source: KD-0572 D2

Signup tracking token minted unconditionally by the single canonical Domain writer

  • Chose: CreateDomainAction mints the token on every domain row, including operator-driven creates that will never read it.
  • Rejected: Mint only when the actor is PublicSignup; mint in SignupAction as a second write after creation.
  • Why: A never-read secret on operator domains is harmless, whereas conditioning the canonical write adds branches to test and a second-step write would split the Domain write across two Actions, breaking ADR-0024's single-writer rule.
  • Source: KD-0947 D1

Tracking token stored plaintext with no expiry

  • Chose: Nullable unique string column, plaintext, no companion expires_at — mirroring invite_token/verification_token storage but dropping their TTL.
  • Rejected: Hash at rest; add an expiry column plus the cleanup and frontend handling it implies.
  • Why: The token only gates a read of {status, failed_step?} that freezes at a terminal value within minutes, so a DB-leak threat model doesn't justify hash-on-read complexity and a TTL would buy a column, a cleanup concern and an expiry branch for nothing.
  • Source: KD-0947 D3, D4

Failed signups are communicated by email; the status endpoint 404s instead

  • Chose: Keep ADR-0024's rollback-delete — the Domain row disappears on terminal failure, so the token lookup returns the uniform 404 — and keep the endpoint's failed branch as a tested, defensive path.
  • Rejected: Retain the failed row so failed stays pollable; split the failure UX into a follow-up issue.
  • Why: Retaining rows reverses ADR-0024's deliberate rollback-delete and is cross-cutting enough to need its own amendment, while SignupCouldNotBeCompletedMail already delivers the failure to the prospect.
  • Source: KD-0947 D10

Frontend reads signup failure as a 200 → 404 transition

  • Chose: Track a hasSeenStatus flag — a 404 after at least one 200 means the row was deleted, so render failed; a 404 on the first poll degrades to the static copy.
  • Rejected: Trust an explicit {status:'failed'} payload (which never fires on the live path); treat any 404 as failure.
  • Why: The uniform 404 that hides token enumeration also erases the difference between "provisioning failed" and "bad token", and the transition is the only signal that recovers it — the residual gap (a failure before the first poll shows as degraded) is covered by the failure email.
  • Source: KD-0948 D2

Progress stages map by runtime order, not by enum name

  • Chose: Collapse the six ProvisioningStatus values into roughly four prospect-facing stages via an ordinal map keyed on live runtime position, clamping unknown or dead values so the bar never breaks.
  • Rejected: One tick per enum value; an indeterminate spinner.
  • Why: KD-0802 reordered cert-before-DNS without renaming the cases, so the enum names now mislead and dns_pending is dead on the live path — a per-enum bar would surface that confusion and jump, while a spinner fails the "labelled stages" requirement.
  • Source: KD-0948 D4

"Workspace owner" is any tenant Admin — no ownership primitive introduced

  • Chose: Gate the rename on the existing AppSettings:Update permission (Admin role), the same gate the 2FA-enforcement settings endpoint uses.
  • Rejected: Persist tenants.owner_user_id at signup and gate on identity with the original signup admin.
  • Why: No acceptance criterion distinguishes the first admin from later ones, and a real owner concept means a new column plus a backfill with no principled way to pick an admin for existing tenants — matching on email can't substitute, since email is changeable.
  • Source: KD-0983 D1

Creating any {Entity}AuditLogger commits you to that entity's full CRUD surface

  • Chose: Build TenantAuditLogger CRUD-shaped (logCreated/logUpdated/logDeleted) and wire it into CreateTenantAction and DeleteTenantAction, well beyond the rename feature that needed it.
  • Rejected: A scoped logger with logUpdated() only; routing the rename's audit row through some other path.
  • Why: AuditTest.php's auto-discovery fails CI for every existing Create/Update/Delete/Store{Entity}Action that doesn't reference the logger the moment the logger class exists, and that block has no exemption mechanism — so the scoped version isn't achievable, and the remaining alternative is inventing a convention to dodge an enforced one.
  • Source: KD-0983 D2

Tenant-facing rename returns {name}, not the central TenantResourceData

  • Chose: A small ad-hoc JSON shape, with the frontend calling authService.fetchUser() afterwards to refresh the reactive profile everything else already reads.
  • Rejected: Return the existing TenantResourceData.
  • Why: That resource exposes Stripe billing IDs, payment last-four, health status, GitHub installations and domain IDs on a route any tenant Admin can reach — and building a proper "tenant's view of itself" resource for a single field would be premature.
  • Source: KD-0983 D4

Audit wiring follows the actual write paths, not the arch test's reach

  • Chose: Wire TenantAuditLogger into SignupAction as well, which creates the Tenant row directly via newInstance()->save().
  • Rejected: Stick to the issue's literal list of Create/Delete/RestoreTenantAction and record the gap.
  • Why: Neither the CRUD-prefix auto-discovery test nor the raw-DML test in AuditTest.php matches SignupAction's shape, so the majority creation path — self-serve signup — would have stayed silently unaudited while the logger claimed to record every Created transition.
  • Source: KD-1008 D1

AuditActionEnum::Purged added as a real case

  • Chose: Add Purged = 4 to the shared int-backed enum and give TenantAuditLogger a logPurged().
  • Rejected: Reuse Deleted with a marker in the snapshot to distinguish soft-delete from hard purge.
  • Why: Soft-delete and purge are genuinely distinct lifecycle transitions any entity can have — unlike KD-0221's logger-specific security events, which stayed out of the shared enum — and the case is additive with no migration, since action is already a plain unsignedTinyInteger.
  • Source: KD-1008 D3

Purge order reversed: commit the audit row before dropping the database

  • Chose: PurgeSoftDeletedTenantsAction runs DeleteTenantAction(Purge) — force-delete plus Purged audit row, atomically — first, and DropTenantDatabaseAction second.
  • Rejected: KD-0571's original drop-then-delete order, which left the Tenant row queryable by the next day's sweep if the drop failed.
  • Why: A failed drop now orphans the physical database and the already-released DNS/certs with no soft-deleted row left to self-heal, but that manual cleanup is a lesser harm than an unrecorded irreversible tenant destruction — and the drop already retries three times with backoff and logs a structured, operator-visible error on exhaustion.
  • Source: KD-1008 D4

AUDITABLE_FIELDS carries lifecycle timestamps, not just identity fields

  • Chose: ['name', 'database', 'deleted_at', 'purge_at'], so a Purged row shows when the grace window started and when it was due to end.
  • Rejected: Identity-only fields (['name', 'database']), following DomainAuditLogger's precedent.
  • Why: The identity-only loggers are identity-only because their entities have no lifecycle timestamp (UserAuditLogger already snapshots deleted_at and invite_token_expires_at), and widening the list forced replacing DeleteTenantAction's @audit-snapshot-retry-safety exemption marker with a real refresh() — a marker's premise is only as durable as the field list it cites.
  • Source: KD-1008 D7