Skip to content

Latest commit

 

History

History
837 lines (729 loc) · 48.7 KB

README.md

File metadata and controls

837 lines (729 loc) · 48.7 KB

Module Spec — Tenancy

Status: Design stable, partially implemented (Phase 02a Packet 6 shipped the schema and its schema-level isolation suite; commands, host resolution and the request-level isolation suite shipped in Packet 7). P02d-2 Step 1 adds contextual verification/locale reads; Step 2 implements locale/branding writers, with both review rounds complete. The ADR-0078 / ADR-0079 native admission, administration and warm-preservation contracts are Accepted. The phase Status block owns implementation and qualification state for the native read core and subsequent administration, seed preservation and provider/browser consumers. The separate fixed-seed prerequisite source derives from ADR-0080; acceptance does not qualify its owner transition or races. The separate fixed-origin completion contract derives from Accepted ADR-0081; its implementation and proofs remain governed by the phase Status block.

The first module spec in the repository, per Documentation Standards § Per-Module Specifications.

Overview

Tenancy owns who a request belongs to and nothing about what they do with it.

It owns:

  • The Tenant aggregate — the root every other tenant-owned row keys on.
  • The Organization aggregate — a sub-unit within a tenant (ADR-0017). Declared here and nowhere else; ADR-0017's original sample placed it in Identity and Amendment 2 moved it.
  • TenantDomain — a host a tenant claims, and its verification lifecycle.
  • TenantSetting — non-translated configuration, optionally overridden per organization.
  • TenantLocale — the locales a tenant publishes in (ADR-0008).
  • TenantFeatureFlag — the tenant's own switches, keyed by a FeatureKeys member whose descriptor reads FeatureSource.TenantFlag; Tenant.SetFeatureFlag refuses any other.
  • platform_host_to_tenant — the host → tenant resolution index, read before any tenant context exists.
  • platform_entitlement_cache — the durable projection of a tenant's plan. Read and written only through an IEntitlementProvider implementation; no module queries it, this one included (ADR-0045 § 2).
  • platform_killswitches — one platform-wide switch per key. Platform-scoped and tenant-blind: the only table in the schema for which "no tenant context implies zero rows" is deliberately false, because a switch the application role cannot read is one that fails open. Shipped read-only in Packet 9 — every toggle runs inside EnterPlatformAdminScope, so Phase 03 owns the writer.

Two read paths it owns, over tables it does not read directly:

  • IFeatureFlags — the only module-facing feature and limit read. It composes: the plan half through IEntitlementProvider, the tenant half from TenantFeatureFlag, and the killswitch overlay last. That is what makes swapping the registered provider change the answer without touching module code.
  • IKillswitchOverlay — the cached read of platform_killswitches, one entry for the whole set.

It does not own:

  • Users, roles or sessions. Identity does, from Phase 03. Tenancy holds UserId by value in audit columns and never resolves a person.
  • Plan definitions or billing. The Hub does (ADR-0019). Tenancy stores the projection of an entitlement, written only through IEntitlementProvider.RefreshAsync, and never calls the Hub to read it.
  • Certificate material. It moves by secret-store replication and is referenced by path; tenant_domains carries verification state and no keys.
  • The per-organization branding override. OrganizationBranding and the token merge are Phase 06; the column arrives with them rather than as an unused jsonb nobody writes. The tenant's own token values are not a separate store: they are TenantSetting rows (Frontend Architecture Standards § Tenant Branding), and their key set and value grammar are delivered under the accepted P02d-2 contract, closing G16(a–e) in Phase 02d's decision register.
  • Any domain-specific shape. CEFR levels, asana catalogs, kyu/dan ranks and every other vertical concept are tenant customization data (ADR-0018), not columns here.

P02d-2 accepted locale and branding contract

Step 2 complete after both review rounds — 2026-10-02. The phase package records approval and seed inventory. All three commands are unrouted, require resolved tenant-wide context and write one aggregate; organization context is refused rather than silently promoted to tenant scope. Tenant ids are not caller authority.

All three writers refuse missing or soft-deleted tenants with not_found. Branding checks tenant existence before loading a setting, for both creation and replacement. Its scalar reader tracks no tenant root and grants no tenant write capability; live Trial tenants remain supported.

Command Root and contract
AddTenantLocaleCommand Tenant; exact expected version, canonicalizable locale, enabled/default flags and nonnegative sort. Duplicate locale is a business-rule refusal; disabled default is refused before mutation
SetDefaultTenantLocaleCommand Tenant; exact expected version and existing enabled locale. Missing locale is a bounded validation failure; disabled target is refused before root stamping
SetTenantBrandingCommand TenantSetting; fixed setting id for creation, complete theme and nullable expected version. Null means create-only; an exact version means replace-only. No blind upsert/retry, wildcard or partial patch

Locale guarantees and read contract

Zero locale rows or an all-disabled set is valid. Supported commands leave exactly one enabled default whenever any enabled locale exists. Adding the first enabled locale promotes it even if disabled rows already exist. Validate pre-existing sets before every supported locale mutation: multiple defaults, a disabled default or enabled rows without a default are refused without choosing a winner or changing root/captured state. Disabled-target and existing-configuration validation precede SetDefaultLocale's MarkUpdated.

Reuse the existing two-pass default switch in TenancyWriteStores, within the command's ambient transaction. An injected second-save failure must roll back the first clear, proven from a fresh scope. A raw store call outside the transaction does not acquire that guarantee.

The shipped partial unique index still enforces at most one default; Step 2 adds CHECK (NOT is_default OR is_enabled) in a new migration. Existing disabled-default rows fail migration preflight/validation for explicit operator remediation; never choose a locale automatically. The CHECK cannot detect enabled-without-default; reader/writer validation does. Arbitrary raw deletes do not gain an exactly-one database guarantee.

ITenantLocaleEligibilityReader returns canonical enabled membership in the announced tenant, uncached in the caller's ambient frame. It rejects invalid legacy configuration with a bounded configuration failure; it does not invent en. Education authoring and contextual Identity profile writers consume this contract. A missing/disabled requested member gives bounded validation_failed. Provider/time-out failures become the fixed TenantLocaleEligibilityReadException, without an inner payload; cancellation and programming failures retain their own semantics. The owning locale call filters its EF query-failure payload and emits a fixed signal; its marked EF error spans pass through the source boundary before export. Other Tenancy reads retain their diagnostics. Content URL/body lookup has no label fallback. Phase 03 owns locale removal/disable commands; those retain Education translations and public readers recheck enabled membership rather than cascading across modules.

P02d-4 public configuration contract

Step 2 implemented — 2026-10-03. Both review rounds and focused fix verification pass. IPublicTenantConfigurationReader reads live scope and locale configuration on the announced/enlisted ambient connection. The existing authoring reader above keeps its bounded failure behavior. No caller tenant/org IDs or cross-module table join is introduced.

  • Require matching real factory HostScope. Tenant must be nondeleted Trial/Active; the resolved organization, whether mapped or claim-selected, must be nondeleted Active. Missing/suspended/archived scope returns masked not_found. Credentials never widen institution public visibility.
  • Use canonical enabled tenant locales and exactly one enabled default. Valid no-row or all-disabled configurations authorize no content locale: bootstrap is 404, Education well-formed locale queries are 400 unsupported_locale.
  • Invalid stored locale/default configuration is bounded 503 dependency_unavailable with internal diagnostics; no synthesized en or silent repair.
  • The Off GET/HEAD site request returns only tenant displayName, enabledLocales, defaultLocale, whole typed theme or null and effective showPlatformAttribution. No raw settings, generic key lookup, resource IDs or organization merge is public.
  • Theme invalidity/absence gives null, not configuration 503. Frontend safe defaults remain the renderer's responsibility. Baseline colors are independent of plan; only attribution removal uses effective WhiteLabelBranding through IFeatureFlags.
  • The public query is marked and dispatched through ISender in READ ONLY under ADR-0052; no-store applies to all responses. Direct internal contracts inherit the ambient mode and create no request/audit intent.

The accepted public allowlist owns serialized fields; HTTP/database positive controls are required before exit.

No platform locale registry is introduced. Admission uses the existing LocaleTag grammar, canonicalization and 35-character application bound; membership is the tenant's own enabled rows, not a speculative platform language allowlist.

P02b-5 authenticated account-site read

GetAccountSiteQuery is a non-public, audit Off request under ADR-0064's closed authenticated read. It accepts no scope selector and returns AccountSite: the context's stable ActorId, resolved TenantId and nullable OrganizationId, plus the existing PublicSite projection. It discloses no permission-sensitive configuration and adds no permission key; Phase 03 owns membership and permissions.

Before any configuration, settings or feature read, its implemented guard requires HostAndClaim or independently validated HostAndNativeSessionScope, a real non-system actor and matching real resolved/host scope. The native origin retains the same actual-host ceiling under ADR-0078; the phase Status block owns later native provider/browser qualification. Missing provenance and ClaimAndMembership return not_found, including future membership-confirmed platform contexts with an apparently matching host scope. Both site handlers call the same projection directly within the pipeline-owned physical READ ONLY frame. The live configuration reader independently retains its host, enlistment, tenant/organization lifecycle and locale checks. Theme/null and attribution follow the public contract above; branding.theme stays tenant-wide. P02b-5 Step 4 exposes only GET /api/v1/account/site; its delivery record owns HTTP validation and review status.

Whole-theme setting and public boundary

The command-local registry admits only tenant-wide branding.theme. It does not constrain every generic TenantSetting key; existing raw tz and organization theme fixtures remain valid. Its descriptor owns the exact object validator, tenant-wide scope and public field-to-CSS map:

JSON field CSS variable Value
primary --ls-primary Canonical lowercase #rrggbb
background --ls-bg Canonical lowercase #rrggbb
foreground --ls-fg Canonical lowercase #rrggbb
muted --ls-muted Canonical lowercase #rrggbb

The object has exactly these four string fields, without duplicates or extras. No CSS name/function, alpha, font, logo, URL or layout value is admitted. Validate the complete candidate before mutation: foreground/background and muted/background at least 4.5:1; primary/background at least 3:1 for supported UI usage. Do not use primary as normal-sized text or white-on-primary without a separately validated pair. A failing pair returns validation_failed, not a warning-only save without a Studio.

One setting root and exact version protect the whole contrast unit. Concurrent replacement yields a concurrency conflict; competing creates use the existing tenant/scope/key uniqueness. No retry merges colors from different candidates. Invalid existing override projects as a whole null in P02d-4 Step 2; safe CSS defaults remain the renderer's responsibility. Never emit raw JSON or partial unsafe colors. Whole-theme/null and attribution-only semantics are delivered; P02d-6 Step 1 implements G42 atomic CSS injection. Authoring this baseline theme is not gated by tenancy.white_label_branding in P02d-2.

Generic TenantSetting.Value carries [PiiSensitive]; audit capture redacts the whole JSON value under ADR-0044. Pipeline logs do not serialize request payloads. Public allowlisting is independent of this conservative annotation and never permits generic settings disclosure. The setting write is MUST; locale writes are SHOULD over their owning Tenant root, including contained locale changes. Contextual seed verification queries are Off.

P02d-3 accepted typed settings contract

Step 1 implemented — 2026-10-02; both review rounds passed. ITenantSettingsAccessor exposes registered typed settings under ADR-0010's application-contract mechanism. No raw string-key/JSON export, settings HTTP surface or caller-supplied scope is admitted. The first production registration is tenant-wide branding.theme, reusing its four-color grammar and contrast validator above. It returns a complete typed palette or bounded absent/invalid outcome. P02d-4 public allowlisting is delivered by Step 2; P02d-6 Step 1 delivers atomic CSS injection.

Registrations are values in an explicit server-owned TenantSettingRegistry. The composition extension installs its default only when no registry was already registered; a complete replacement registry must be registered before that call. Individual ITenantSettingRegistration DI services are not collected implicitly.

For a registration permitting organization scope, explicitly select the current tenant and (organization_id IS NULL OR organization_id = current organization), excluding soft-deleted rows. No organization selects tenant-wide only. Organization values replace the whole tenant value, not individual JSON fields; an invalid selected override returns a configuration refusal. The explicit predicate excludes siblings even under the future tenant-scope RLS hatch. Synthetic test registrations prove this generic behavior; branding.theme stays tenant-wide in every context. Organization branding and token merging remain Phase 06.

No settings cache in P02d-2/3: no generation migration, TTL or cross-process stale entry. The accessor uses the existing announced ambient transaction, without opening/announcing a new one or retaining a per-scope value snapshot. A subsequent read observes supported writes under the ambient isolation. Measure the indexed read/selection budget in P02d-3. The accepted package records the tests and step plan.

Invitation lifetime policy

ADR-0093 adds the exact typed tenant-wide setting identity.invitation.ttl_days, default 14 days, integer range 1–30. The phase Status owns delivery. Tenancy owns the setting/typed accessor; Identity consumes it through the existing application-contract mechanism, not a table query or generic JSON export.

  • SetTenantInvitationLifetimeCommand writes one TenantSetting root in orgless context under the existing Tenant tenancy.setting.write pair. The permission knowingly gains this exact policy key beyond branding; no organization override, generic setting editor or new permission grant is implied.
  • Existing v1 manifests without that pair cannot edit the policy. The 14-day default still permits ordinary invitations; no automatic grant adoption is added.
  • The own-current-tenant orgless getter is Off and physically ReadOnly. It returns days, present, nullable decimal-string version and advisory canEdit; write capability is not required to read it. The setter still checks its own current authority independently.
  • Reads return the explicit absent/default creation precondition or actual setting version. Absence has no ETag and uses If-None-Match: *; a stored version-zero setting has ETag "0" and requires If-Match: "0". Both successful PUT branches return 200 version receipts. The protected fingerprint distinguishes presence. Conditional create/replacement plus natural uniqueness refuses races; no unconditional upsert or blind retry. Protected receipts bind the original exact setting ID/key and require a fresh MUST sensitive read before disclosure.
  • The setter uses the canonical effect-derived Tenant fence and fresh lifecycle/ post-lock authority check. Its MUST tenancy.setting.write row shares the business transaction and retains whole-value TenantSetting audit redaction.
  • Offer creation and explicit reissue freeze expiry from current policy. Policy changes do not alter existing generations or require a Tenancy/Identity multi-root write. Identity owns acceptance and all delivery state.

Job lifecycle projection

Tenancy declares the status meanings for ADR-0047's closed job lifecycle projection. The framework reads only the minimal columns allowed by Database Standards; tenantless orchestration constructs no Tenancy service and accepts no arbitrary tenant selector. The additive Tenancy migration owns this SELECT-only grant. The Step 8 record records implementation; the merge closeout records its complete source-bound qualification.

Bounded Organization and settings administration

Internal handlers implement ADR-0079. Phase 03 owns packet state and remaining transport proofs. Tenancy permissions owns the exact three Tenant pairs and separate human request bindings. Seed commands remain Seed-only; human handlers share module business logic without dispatching those requests or fabricating seed provenance. Organization CRUD/default API and SDK consumers ship in P03-3; full Organization/locale/theme editors and first reporting-parent authoring belong to Phase 06. ReportingParentId remains an unused additive field outside P03-3 DTOs, authorization and resolution.

Organization create/rename/archive each writes that one Organization root. Explicit default selection writes only Tenant's default reference, with its expected/returned version; it is a sub-operation of Tenant tenancy.organization.admin. Current target checks require an Active/nondeleted Organization belonging to that Tenant. Archive refuses the current default or last Active Organization; select another default in its own transaction first. No automatic multi-root switch, cascade or new ADR-0042 aggregate exception is introduced. Archived Organization history, mappings, memberships and content stay retained, while live lifecycle checks withdraw ordinary branch work. There is no per-organization last-admin invariant or silent archived-tenant exemption.

Locale add/default/enable/remove acts use one Tenant root/version and preserve translations. Missing/invalid configuration or default removal/disable refuses; select another enabled default first. branding.theme remains one complete TenantSetting root/version and its existing safe palette grammar. Organization settings/branding overrides and generic settings power remain outside this set. P03-4 adds only the exact tenant-wide invitation lifetime policy above. All human writes require orgless context and live post-lock authority/target checks. Standard 05 owns the strongest complete fences, fresh lifecycle reads and invoker guards.

Organization list/detail use bounded detached projections with default metadata from one SQL snapshot, stable contextual keyset cursors and MUST read evidence. Writes retain their actual root after lock waits. Removed locales are explicitly deleted as contained Tenant rows; a two-save failure poisons the owner even when an outer caller absorbs it. No-op human edits preserve root version/stamps. Known slug collisions return the fixed Slug/slug_taken field refusal. Human storage failures pass through a source-owned diagnostic boundary; cancellation and programming failures keep their semantics. Existing Seed store behavior is separate from that human boundary.

Original-outcome receipt reads authorize the retained Organization or Tenant ID under current organization-admin disclosure capability, then commit a fresh MUST read event. A default-selection receipt designates the original Tenant root, not the selected Organization. These exact-ID reads may observe a retained terminal target for this receipt; ordinary list/detail filters remain unchanged. They do not rerun the mutation or require its former write/adoption eligibility. The phase Status block owns transport activation and qualification.

The closed Organization/default HTTP actions use these human handlers and receipt reads; generated OpenAPI and SDK shapes preserve exact IDs, string versions, conditional writes and creation Location. Own-current-Tenant navigation uses the separate IOwnTenantNavigationReader contract on the existing ReadOnly owner. Identity's own-navigation boundary owns its invocation-bound coverage, cursor and branch derivation; it grants no general Organization enumeration or additional context setter.

Warm seed and administration origins

Accepted contract. Ordinary warm seed verifies immutable installed root identities and current valid graphs rather than original mutable showcase values. Legitimate names, status/default, locales, theme and Identity administration remain unchanged. Removed locales or child grants are not evidence of never-installed state; retired roots are not recreated. Only a provable pristine incomplete prefix can resume. Edited partial or foreign state refuses before remaining writes. Each potential write rechecks its trusted evidence and target version under the same tenant fence used by administration; a read taken before a competing administrator commits is not authority to repair. The canonical contract owns exact witnesses and held-seed locale/profile/grant race proofs. Its complete writer census also governs all twelve Customization/Education writers in ordinary scope. No new seed receipt table or reset mode is introduced.

Four original Tenancy continuation writers use the closed temporary prerequisite operation after their complete exclusive Tenant plan and applicable target locks; ProvisionTenant retains INSERT-first without a widened pre-insert read. Owning Tenancy prerequisite contracts run narrow before the separate Identity metadata projection enters the hatch. The common owner clears it before a usable result or mutation. Tenancy's contracts remain separate from Identity's metadata reader; closed composition joins them without a dependency cycle or nested dispatch. Standard 05 owns exact source binding, consumer population, barriers and cleanup.

The fixed new administration origins are manage.demo-english.learnstack.local and manage.demo-yoga.learnstack.local, each mapping to its existing Tenant with NULL organization. Existing public mappings remain unchanged. Fresh installation uses the same confined adoption act as the explicit one-time completed-warm upgrade, make seed-admin-origins. This closed Seed act advances Tenant's adopted_seed_origin_version from 0 or 1 to 2 and installs/verifies only its exact owned mapping projection on that same announced unit of work. AdoptSeedAdministrationOriginCommand handles one Tenant per invocation; the two Tenants commit independently. Its common audit frame declares two MUST intents: tenancy.tenant.adopt_seed_origin for the Tenant marker and existing tenancy.hostmapping.write for the mapping projection. The projection is not a second aggregate root or a new provisioning exception. An exact existing mapping is designated as an unchanged audit projection without an UPDATE. No v2 Role grant is adopted by this act.

After the exclusive Tenant plan and actual Tenant/mapping preparation, a separate closed completion operation checks the retained foundation through owning Application.Contracts on that owner. Tenancy verifies its original Tenant/Organization identities and history, valid current default/locale topology, original public mapping association and retained theme root/key/scope with a valid current palette, in narrow scope. Customization also reads narrow; only fixed Education/Identity branch readers use the temporary hatch. Narrow restoration precedes any usable result or mapping/marker mutation. This is a distinct one-consumer purpose; the eight pristine consumers remain unchanged. Standard 05 owns the exact source, immutable prepared snapshot, completion facts and common-owner guards.

The runner's marker branches are explicit: 0 dispatches and requires 2; 1 first requires the exact live mapping, dispatches full completion qualification and requires 2; 2 requires the exact live mapping and skips dispatch; every other value refuses. The marker-2 branch mints no witness, opens no hatch and creates no MUST intent. Missing/disabled/conflicting mapping refuses without repair. Marker 2 records historical qualified adoption, not current mutable-graph or provider eligibility. Warm make seed accepts and preserves that marker. Its wrapper selects newly created and already marker-2 Tenants from the successful seed process result, so retained qualified mappings still reach this narrow runner verification. That result is selection data, not completion evidence. Ordinary completed warm seed requires the explicit upgrade for marker 0 or legacy marker 1; it never silently creates or repairs the origin. Standard 05 owns the forward transition guards and unknown-COMMIT recovery contract.

Local demo destinations/readiness derive from actual live scope and locales. An intentionally archived public branch is reported unavailable without retargeting its host; its tenant administration origin remains the recovery destination. Fresh CI still requires its original complete nine-page/27-audit population; edited local state does not replace that source-bound qualification.

Entity-relationship diagram

Aggregate roots in the shipped code are Tenant, Organization, TenantDomain and TenantSetting — the four that implement IAggregateRoot<TId>, the last two by the promotion below. SetTenantBrandingCommand writes TenantSetting; no command writes TenantDomain yet. PlatformHostMapping and PlatformEntitlement are projections rather than aggregate roots. The accepted fixed seed-origin act writes its mapping projection under the Tenant owner's transaction without a second aggregate root.

The other four split two ways, and Packet 7 settled the split. TenantDomain and TenantSetting are root-shaped — a surrogate Vogen id, AuditableEntity, their own row_version and RLS policy, and a top-level DbSet on TenancyDbContext — so they are aggregate roots in their own right. TenantLocale and TenantFeatureFlag have composite natural keys and no id, so they cannot be IAggregateRoot<TId> under any reading: they are navigations owned by Tenant, with internal factories and no top-level DbSet, so every state change goes through the root, as Standards 01 § Aggregate Ownership requires, and a write to either bumps Tenant.row_version. Packet 7 landed the promotion, and none of its three commands touches TenantDomain, TenantSetting, TenantLocale or TenantFeatureFlag. The first commands that do — the locale and setting commands raising tenancy.locale.write and tenancy.setting.write — belong to P02d-2. The accepted contract names AddTenantLocaleCommand, SetDefaultTenantLocaleCommand and SetTenantBrandingCommand; Step 2 implements all three as unrouted tenant-wide writers. Provisioning writing Tenant and its default Organization in one transaction is sanctioned by enumeration in ADR-0042.

erDiagram
    TENANTS ||--o{ ORGANIZATIONS : "has"
    TENANTS ||--o| ORGANIZATIONS : "default_organization_id"
    TENANTS ||--o{ TENANT_DOMAINS : "claims"
    TENANTS ||--o{ TENANT_LOCALES : "publishes in"
    TENANTS ||--o{ TENANT_SETTINGS : "configures"
    TENANTS ||--o{ TENANT_FEATURE_FLAGS : "switches"
    TENANTS ||--o| PLATFORM_ENTITLEMENT_CACHE : "is entitled by"
    TENANTS ||--o{ PLATFORM_HOST_TO_TENANT : "is reached at"
    ORGANIZATIONS ||--o{ TENANT_SETTINGS : "overrides"
    ORGANIZATIONS ||--o{ PLATFORM_HOST_TO_TENANT : "may serve"
    ORGANIZATIONS ||--o| ORGANIZATIONS : "reporting_parent_id"
Loading

Text fallback, for renderers without Mermaid:

  • tenants is the root. Its id is the tenant id — there is no tenant_id column, which is why its RLS policy keys on id.
  • organizations.tenant_id → tenants.id, single-column by the one written exception in Database Standards § Foreign keys: the composite form is not expressible against a self-keyed parent, and it is unnecessary because the referencing column is the tenant id.
  • tenants.default_organization_id → organizations (tenant_id, id), composite, and nullable only inside the provisioning transaction.
  • Every other foreign key into organizations is composite on tenant_id.
  • organizations.reporting_parent_id is a self-reference for reporting only. It is not an isolation boundary: nothing resolves through it and no policy reads it. The hierarchy stays two levels.

State diagrams

Two entities have a non-trivial lifecycle.

stateDiagram-v2
    direction LR
    [*] --> Trial : provisioned
    Trial --> Active : first payment, or a plan needing none
    Active --> Suspended : billing failure, policy breach
    Suspended --> Active : resolved
    Active --> Archived : ended
    Suspended --> Archived : ended
    Archived --> [*] : retained for audit, never served
Loading

Text fallback — Tenant lifecycle:

  • Trial on provisioning; Active on first payment, or immediately for a plan needing none.
  • Active ⇄ Suspended — billing failure or policy breach suspends; resolution restores.
  • Archived from either, and it is terminal for serving: rows are retained for audit and retention obligations, never served.
stateDiagram-v2
    direction LR
    [*] --> Requested : custom domain submitted
    [*] --> Verified : platform subdomain (verified by construction)
    Requested --> Verifying : check started
    Verifying --> Verified : DNS proof accepted
    Verifying --> Failed : proof rejected
    Failed --> Verifying : retried
Loading

Text fallback — TenantDomain lifecycle:

  • A Subdomain is created already Verified: the platform controls the zone, so there is nothing to prove. The aggregate refuses MarkVerified / MarkVerificationFailed on one.
  • A Custom domain travels the whole path — Requested → Verifying → Verified, or Verifying → Failed → Verifying on retry.
  • A verified row does not serve traffic on its own. A corresponding platform_host_to_tenant row that is is_active and is_publicly_live does.

Sequence diagrams

Primary write: provisioning a tenant

sequenceDiagram
    participant R as Registry (Hub / config / fixture)
    participant T as TransactionBehavior
    participant H as Handler
    participant U as IUnitOfWork
    participant S as IUnitOfWorkScope
    participant D as TenancyDbContext
    participant P as PostgreSQL

    R->>T: ProvisionTenantCommand (tenant id, slug, names)
    T->>U: BeginTransactionAsync
    U->>P: BEGIN
    T->>U: SetProvisioningTenantContextAsync(command.TenantId)
    U->>P: SELECT set_config('app.tenant_id', <id>, true),<br/>set_config('app.organization_id', '', true)
    T->>H: next()
    H->>D: INSERT tenants
    D->>P: WITH CHECK passes — app.tenant_id already equals id
    H->>D: INSERT organizations (default)
    H->>D: UPDATE tenants SET default_organization_id
    T->>S: CompleteAsync
    S->>P: COMMIT
Loading

Text fallback — provisioning a tenant: the registry (Hub, config or fixture) sends a ProvisionTenantCommand; TransactionBehavior opens the ambient transaction, announces app.tenant_id as the tenant being created and blanks app.organization_id in the same statement — the first inside the transaction — and calls the handler; the handler inserts tenants, inserts the default organizations row, updates tenants.default_organization_id, and returns; the behavior completes the scope, which commits. One transaction, one connection, one commit point.

The handler opens nothing and announces nothing. Both belong to TransactionBehavior, and the distinction is not stylistic. A BeginTransactionAsync from inside a handler is a joiner — ADR-0040 returns a nested frame on the same transaction, so it would be a no-op that reads like a boundary. An announcement from inside a handler would be an unlisted setter of app.tenant_id outside the closed census, and would hand every handler in the solution the ability to move the ambient tenant.

Three statements, one transaction. The tenant id is never minted in the handler: the registry assigns it, and the behavior announces it before the insert by reading IProvisionsTenant off the request, so the self-keyed policy's WITH CHECK passes. A handler that generated its own could not satisfy its own policy. Measured against the shipped policies on a throwaway container: with app.tenant_id unset or set to the empty string the tenants insert raises 42501 identically, and only the new tenant's own id lets the sequence commit. Of the two, the empty-string case is the one a test pins — A_request_that_does_not_provision_still_fails_closed_when_unresolved — because it is the state this pipeline actually produces; nothing in the request path leaves the variable unset.

The announcement requires an unresolved context, which is what closes the confused deputy: a caller already authenticated for tenant A who sends a provisioning command naming tenant B takes the ordinary path, the transaction carries A, and B's insert is refused by the database rather than by a check somebody has to remember to write. Nothing verifies that the requested id is unused, and the guarantee does not need it to be: the only statement the announcement authorises is an INSERT the primary key rejects when the tenant already exists. Anything later added inside that transaction — the MUST-class audit write, the immediate outbox INSERT — inherits that assumption and must not rely on the announced tenant being new.

The default_organization_id update is separate because the composite foreign key has nothing to reference until the organization exists; MATCH SIMPLE skips the check while the column is null, which is what makes the ordering legal.

Primary integration-event flow: host mapping changed

sequenceDiagram
    participant Hub as Hub (02c)
    participant API as /api/internal/tenants/{id}/host-mappings
    participant D as TenancyDbContext
    participant O as outbox_messages
    participant C as CachedHostToTenantResolver

    Hub->>API: PUT host mappings
    API->>D: upsert platform_host_to_tenant
    API->>O: enqueue learnstack.hub.custom-domain.activated
    Note over O,C: same transaction — the outbox IS the boundary
    O-->>C: invalidate the resolver cache entry
Loading

Text fallback — host mapping changed: the Hub PUTs host mappings to /api/internal/tenants/{id}/host-mappings; the endpoint upserts platform_host_to_tenant and enqueues learnstack.hub.custom-domain.activated into outbox_messages on the same transaction — the outbox row is the boundary — and the dispatched event invalidates the resolver's cache entry.

IHostToTenantResolver never calls the Hub: an anonymous page load must not depend on a control plane being reachable (ADR-0034). The resolver reads platform_host_to_tenant and nothing else.

Component diagram

flowchart LR
    subgraph Tenancy
        DOM[Domain<br/>4 aggregate roots]
        CON[Application.Contracts<br/>6 write commands, 4 seed queries,<br/>locale eligibility and typed settings]
        APP[Application<br/>Handlers and validators,<br/>write and read ports]
        INF[Infrastructure<br/>TenancyDbContext,<br/>4 write stores and filtered readers]
    end
    SK[SharedKernel<br/>TenantId, OrganizationId, IUnitOfWork]
    CORE[Core Infrastructure<br/>TenantScopedDbContext]
    PG[(PostgreSQL<br/>9 tables, RLS)]
    HUB[Hub adapters<br/>IEntitlementProvider, IHubTenantSync]
    OTHER[Other modules]

    DOM --> SK
    CON --> SK
    APP --> CON
    APP --> DOM
    INF --> APP
    INF --> CORE
    INF --> PG
    HUB -.-> APP
    OTHER -.->|application contract only| CON
Loading

Text fallback — components: Tenancy is four assemblies — Domain (the Tenant, Organization, TenantDomain and TenantSetting roots), Application.Contracts (six write commands, four contextual seed queries and locale eligibility and typed settings), Application (handlers, validators and module-owned persistence ports) and Infrastructure (TenancyDbContext, four write stores and filtered readers). The stores implement ITenantWriteStore, IOrganizationWriteStore, ITenantSettingWriteStore and IPlatformHostMappingStore; the context maps nine tables. Domain depends on SharedKernel for TenantId, OrganizationId and IUnitOfWork; Application on Domain; Infrastructure on Application, on core LearnStack.Infrastructure — where TenantScopedDbContext, the base TenancyDbContext derives from, applies the query filters — and on PostgreSQL. Other modules reach the exported application contracts. Hub adapters retain their named Phase 02c boundaries; this diagram does not claim they are implemented.

Other modules reach Tenancy only through an application contract in LearnStack.Modules.Tenancy.Application.Contracts — never a navigation property, never a cross-module join (ADR-0010). They hold TenantId and OrganizationId by value from LearnStack.SharedKernel.

Integration-event catalogue

Completed-provisioning publisher implemented by P02b-1 Step 3. Accepted ADR-0057 governs the publisher. The initial default-organization assignment raises TenantCreatedDomainEvent; only its successful final save qualifies it for owner-only translation. TenantCreatedDomainEventHandler emits TenantCreatedIntegrationEventV1 only after tenant, default organization and back-reference completion; partition source is TenantId, payload has no extra fields and envelope organization is null. Enqueue reads the transaction's announcement while provisioning ambient context remains unresolved. State, event and the existing two MUST audit rows share one transaction. P02b-1 persists pending rows; P02b-2 implements hosted dispatch and production-census enforcement. All four steps completed both independent review rounds and local closeout. The merge closeout owns CI/merge status. P02b-3 Steps 1–4 implement consumer ownership and scheduling; Step 5 supplies the two actual default subscriptions and semantic Seeder handoff.

P02b-3 Step 5 activates both real Customization subscriptions together over ADR-0046's shared transport-owned inbox and independent delivery ledger. The stable identities are customization.initialize-content-type-default.v1 for card@1 content type and customization.initialize-taxonomy-default.v1 for plain@1 taxonomy. The Step 5 record records validation and reviews; neither subscription is a placeholder. P02b-3 is complete and merged through PR #31; its merge closeout owns final verification.

Topic Payload Publisher Consumers Phase
learnstack.tenancy.tenant TenantCreatedIntegrationEventV1, completed provisioning Tenancy Independent Customization card@1 and plain@1 initializers P02b-1 publisher; P02b-3 Step 5 subscribers
learnstack.hub.entitlement entitlement projection refreshed Hub adapter entitlement cache readers 02c
learnstack.hub.custom-domain.activated / .deactivated host mapping changed Hub adapter CachedHostToTenantResolver 02c

Initialization is create-only for untouched built-in concepts; Draft, Active, Deprecated, successor and deliberately deleted definitions are preserved. Each P02b-3 effect owns one Customization root, generation change, inbox outcome and MUST audit on its delivery transaction. Customization owns UUIDv7 IDs and semantic key/version identity. Seeder verifies exact Active built-in shape by semantic identity, preserves warm IDs and keeps authored curriculum IDs/pins exact. The shared initializers preserve any existing revision or tombstone. Typed settings are uncached; no settings-invalidation event or Audit-mirror consumer is promised. Future tenant-status/organization publishers need their owning feature decision.

Topic names follow learnstack.{module}.{aggregate}; Integration_Event_TopicNames_FollowConvention is the authority for the pattern and this table does not restate it.

Permission matrix

In permissions.md, the file Permission Standards names.

Audit coverage matrix

In audit.md, the file Audit Coverage names.

Performance budget

Path Budget Why this number
Host → tenant resolution (cache hit) < 1 ms On every anonymous page load, before anything else can start
Host → tenant resolution (cache miss) < 15 ms p95 One indexed single-row read in its own short transaction
Entitlement projection read (L1 hit) < 1 ms Read on every feature check
Tenant provisioning (3 statements) < 100 ms p95 Interactive but rare
Settings read for a request < 5 ms p95 target, not measured Accepted P02d-2/3: no settings cache; P02d-3 measures the ambient indexed read/merge. No invalidation consumer/cache is promised

The two resolution numbers are the load-bearing ones: they sit in front of every request and are the only Tenancy work an anonymous visitor pays for.

Risks and open questions

  • app.scope has an operation-bound carrier. Eligible Tenant capability and an explicitly declared tenant-wide read supply the immutable witness consumed by the transaction owner under Security Standards § Tenant Context. ITenantContext has no writable scope authority; organization writes never widen. The two AS RESTRICTIVE write guards have direct controls: TheTenantScopeHatchWidensReadsAndNeitherWrite sets the flag explicitly because an ordinary organization-scoped session's base policy already refuses sibling rows. Measured: with the hatch set and the delete guard dropped, a DELETE removed another organization's row.
  • The query filters landed in Packet 7 step 3, ahead of TenantResolverMiddleware, which supplies the resolved context they read. Every entity marked [TenantOwned] carries one; the two exceptions are table classes rather than omissions — tenants is tenant-owned self-keyed and its policy keys on id, and platform_host_to_tenant is platform-scoped and takes no marker at all. Row Level Security remains the isolation boundary: with app.tenant_id unset every policy predicate is NULL and every query returns zero rows whether or not a filter exists. The filter is the layer above it, and it fails closed the same way — an unresolved context narrows to the all-zero tenant, which no row can carry.
  • At most one default is already enforced. The shipped partial unique index UNIQUE (tenant_id) WHERE is_default prevents competing defaults. It does not require a default whenever enabled locales exist. P02d-2's accepted command contract above closes that supported-write gap; Step 2 also adds the default-enabled CHECK; arbitrary raw deletes do not gain an exactly-one database guarantee.
  • Nothing stops a tenant claiming a hostname it does not own. ux_tenant_domains_host is globally unique — it has to be, or a host would resolve to two tenants — so the first tenant to insert a Requested row for school.example.com blocks every other tenant from claiming it, verified or not. The index is partial on deleted_at IS NULL, so releasing a claim frees the name; what has no owner yet is the policy that decides how long an unverified claim may hold one. The custom-domain lifecycle is Phase 02c, and this is one of the rules it has to write.
  • tenant_domains.host and platform_host_to_tenant.host can disagree. They are separate tables on purpose — one is read under tenant context, the other before any context exists — but nothing enforces that a verified domain has a mapping or vice versa. The Hub-side lifecycle that keeps them in step is Phase 02c.
  • Tenant hard-deprovisioning has no owning phase. Every foreign key is ON DELETE RESTRICT, so the absence is loud rather than silent — a delete fails instead of cascading through a path nobody designed.

P02d-2 Step 1 delivery

ITenantLocaleEligibilityReader checks canonical enabled membership on the ambient transaction without fallback or caching. Missing membership and invalid stored locale configuration return a bounded validation refusal. Locale writers and the default-enabled database CHECK follow in Step 2.

Contextual tenant, organization, host-mapping and setting verification queries return immutable DTOs through the composed request pipeline. They are explicitly Off, have no public marker or HTTP endpoint, and are registered in both API and Seeder roots. Setting reads retain organization filters; host-mapping verification additionally restricts the platform lookup row to the announced tenant.