Skip to content

Latest commit

 

History

History
618 lines (532 loc) · 34.2 KB

README.md

File metadata and controls

618 lines (532 loc) · 34.2 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 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.

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.

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; 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 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.

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.

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 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"
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 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.

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 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.

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 no carrier. ITenantContext exposes no scope member (ADR-0040 Amendment 1), so no application path sets app.scope = 'tenant' and the cross-organization read hatch on tenant_settings is 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 with Membership / Role in 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 two AS RESTRICTIVE write guards are tested now rather than then — TheTenantScopeHatchWidensReadsAndNeitherWrite sets 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, 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.