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 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.
The shipped authoring-only 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. A missing/disabled requested member gives Education bounded
validation_failed. 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; a mapped organization 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.
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.
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
aggregates: nothing in this module mutates them through a 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 a ninth
setter of app.tenant_id against a set ADR-0040 closes at eight, 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
owns validation/review status; neither subscription is a placeholder.
| 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 no carrier.ITenantContextexposes no scope member (ADR-0040 Amendment 1), so no application path setsapp.scope = 'tenant'and the cross-organization read hatch ontenant_settingsis unreachable at runtime. That is the correct default, and no carrier ships in Packet 7: the flag derives from the actor's role, and roles land withMembership/Rolein Phase 03 — after Phase 02b's authenticated principal, which is the prerequisite and not the carrier. The deferral is forced, not chosen (Security Standards § Tenant Context). The twoAS RESTRICTIVEwrite guards are tested now rather than then —TheTenantScopeHatchWidensReadsAndNeitherWritesets the flag directly — because under any ordinary organization-scoped session the base policy's own organization term already refuses a sibling's row, so both guards could be deleted with the whole suite green. 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.