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.
Tenancy owns who a request belongs to and nothing about what they do with it.
It owns:
- The
Tenantaggregate — the root every other tenant-owned row keys on. - The
Organizationaggregate — 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 aFeatureKeysmember whose descriptor readsFeatureSource.TenantFlag;Tenant.SetFeatureFlagrefuses 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 anIEntitlementProviderimplementation; 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 insideEnterPlatformAdminScope, 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 throughIEntitlementProvider, the tenant half fromTenantFeatureFlag, and the killswitch overlay last. That is what makes swapping the registered provider change the answer without touching module code.IKillswitchOverlay— the cached read ofplatform_killswitches, one entry for the whole set.
It does not own:
- Users, roles or sessions. Identity does, from
Phase 03. Tenancy holds
UserIdby 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_domainscarries verification state and no keys. - The per-organization branding override.
OrganizationBrandingand the token merge are Phase 06; the column arrives with them rather than as an unusedjsonbnobody writes. The tenant's own token values are not a separate store: they areTenantSettingrows (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.
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 |
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.
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 are400 unsupported_locale. - Invalid stored locale/default configuration is bounded
503 dependency_unavailablewith internal diagnostics; no synthesizedenor 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.
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.
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.
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.
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.
SetTenantInvitationLifetimeCommandwrites one TenantSetting root in orgless context under the existing Tenanttenancy.setting.writepair. 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-stringversionand advisorycanEdit; 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 requiresIf-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.writerow 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.
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.
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.
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.
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"
Text fallback, for renderers without Mermaid:
tenantsis the root. Itsidis the tenant id — there is notenant_idcolumn, which is why its RLS policy keys onid.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
organizationsis composite ontenant_id. organizations.reporting_parent_idis 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.
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
Text fallback — Tenant lifecycle:
Trialon provisioning;Activeon first payment, or immediately for a plan needing none.Active ⇄ Suspended— billing failure or policy breach suspends; resolution restores.Archivedfrom 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
Text fallback — TenantDomain lifecycle:
- A
Subdomainis created alreadyVerified: the platform controls the zone, so there is nothing to prove. The aggregate refusesMarkVerified/MarkVerificationFailedon one. - A
Customdomain travels the whole path —Requested → Verifying → Verified, orVerifying → Failed → Verifyingon retry. - A verified row does not serve traffic on its own. A corresponding
platform_host_to_tenantrow that isis_activeandis_publicly_livedoes.
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
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.
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
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.
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
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.
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.
In permissions.md, the file Permission Standards names.
In audit.md, the file Audit Coverage names.
| 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.
app.scopehas 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.ITenantContexthas no writable scope authority; organization writes never widen. The twoAS RESTRICTIVEwrite guards have direct controls:TheTenantScopeHatchWidensReadsAndNeitherWritesets 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, aDELETEremoved 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 —tenantsis tenant-owned self-keyed and its policy keys onid, andplatform_host_to_tenantis platform-scoped and takes no marker at all. Row Level Security remains the isolation boundary: withapp.tenant_idunset every policy predicate isNULLand 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_defaultprevents 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_hostis globally unique — it has to be, or a host would resolve to two tenants — so the first tenant to insert aRequestedrow forschool.example.comblocks every other tenant from claiming it, verified or not. The index is partial ondeleted_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.hostandplatform_host_to_tenant.hostcan 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.
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.