Skip to content

Latest commit

 

History

History
1725 lines (1522 loc) · 113 KB

11-security.md

File metadata and controls

1725 lines (1522 loc) · 113 KB

11 — Security Standards

Status: Active Derives from: ADR-0003 Tenant Isolation Defense in Depth (Amendment 1: Organization Scope; Amendment 3: corrected RLS template, database role model, and session-variable placement), ADR-0004 Authentication Strategy (Amendment 1: learnstack-hub realm), ADR-0067 Development Tenant Access-Token Issuance (the configured development issuance lifespan and provider timestamp semantics), ADR-0070 Owned Tenant BFF Sessions (native custody, browser transactions and development provider event storage), ADR-0071 Uncertain AccountSite Refusals (ordinary-read status mapping and positive revision publication), ADR-0068 Development Warm-Realm Qualification (the bounded preparation and stock-console assignment authority), ADR-0015 API Gateway: APISIX, ADR-0017 Tenant + Organization Hierarchy, ADR-0036 Tenant Resolution and Trusted Inputs (the resolution matrix and the authority ceiling), ADR-0040 The Ambient Unit of Work (§ Tenant Context's closed setter set), ADR-0019 LearnStack Hub, ADR-0020 Triple Deployment + Hybrid License, ADR-0033 Audit Durability Model, ADR-0034 Hub Contract Surface Invariant, ADR-0035 Demand-Gated Infrastructure, ADR-0044 The Audit Write Path (the audit row's tenant, capture, redaction, and the append-only layers). Fixed-seed scope-only verification derives from ADR-0080; its separate completion purpose derives from ADR-0081. Exact frontend dependency maintenance derives from ADR-0083. Public-read additions derive from ADR-0052; the private web ingress/caller and anonymous visitor budgets derive from ADR-0053; coordinated accounting, native admission and redirect/query rules derive from ADR-0054. Delivery/recovery additions derive from ADR-0046. The exact authenticated read-mode extension derives from ADR-0064. The composition declarations are frozen once in a singleton exact-type catalogue; request-scoped transaction behaviors share that immutable snapshot. The retained-subject bound, retirement ownership and closed refusal/fault signals derive from ADR-0066, Accepted — 2026-10-20. Implementation and local runtime/exported proofs are delivered; the implementation record owns independent reviews and final-source CI qualification. The bound grants no throughput entitlement. Identity admission/owner contracts additionally derive from Accepted ADR-0072, ADR-0073 and ADR-0074. The phase Status block owns current implementation and qualification state. Live membership and trusted evidence derive from Accepted ADR-0076 and ADR-0077. Bounded authenticated assertion recording derives from ADR-0065; its implementation and local proofs are delivered in the remediation record. Its tenant-only audit budget is separate from the subject request registry and adds no tenant-registry admission cap.

Producer announcement and common execution ownership derive from ADR-0057.

Security is layered. No single control is sufficient. The standards here apply to every PR.

Threat Model Summary

LearnStack defends against:

  • Cross-tenant data leakage (the highest-severity class).
  • Authentication bypass (token theft, weak session handling).
  • Authorization bypass (privilege escalation within a tenant).
  • Code injection (SQL, XSS, command, deserialization).
  • Webhook spoofing.
  • File-upload abuse (malware, polyglot files, path traversal).
  • Denial of service (rate, payload size, expensive queries).
  • Sensitive data exposure (logs, errors, exports).

OWASP Top 10 (2021) Coverage

Every security review walks this lens before signing off.

OWASP LearnStack control
A01 Broken Access Control 4-step auth order (auth → tenant membership → role/permission → resource scope); [Authorize(Policy = ...)] on every endpoint; RLS as defense in depth.
A02 Cryptographic Failures TLS 1.2+; secrets in secret manager; Keycloak-managed credentials; no handwritten crypto.
A03 Injection EF Core LINQ + parameterised raw SQL; React auto-escape; CSP nonces; DOMPurify wrapper for dangerouslySetInnerHTML.
A04 Insecure Design Domain invariants in aggregates, not controllers; cross-module rules go through 01-architecture-standards.md § Distributed-Consistency Tiers.
A05 Security Misconfiguration Strict secure headers (HSTS, CSP, COOP, CORP); container hygiene; no public buckets.
A06 Vulnerable & Outdated Components Renovate/Dependabot; CVE patch SLA 7 days; locked install in CI.
A07 Identification & Auth Failures OIDC via Keycloak; PKCE only; HttpOnly cookies; MFA enforcement per role.
A08 Software & Data Integrity Failures Webhook HMAC verification; outbox + idempotency keys; signed images by digest.
A09 Logging & Monitoring Failures OpenTelemetry traces/metrics/logs; correlation id end-to-end; Sentry on errors; audit log on privileged ops (18-audit-coverage.md).
A10 SSRF Outbound calls allow-listed; rendered outbound URLs validated against an allow-list before emit.

Multi-Tenant + Organization Isolation Review Checklist

A PR that touches tenant- or organization-owned data, queries, or background jobs must answer yes to each item, or attach a written justification:

  • Every new tenant-owned entity is [TenantOwned] and has both an EF query filter and a Postgres RLS policy.
  • Every new organization-scoped entity carries OrganizationId (nullable when the entity may be tenant-wide) and has an org-aware EF query filter + RLS policy per ADR-0017. The architecture test Every_OrgScoped_Entity_HasOrgIdAndFilter checks the marker.
  • No IgnoreQueryFilters() outside the audited EnterPlatformAdminScope(reason) call path.
  • Background jobs use the typed scheduler; trusted submission stamps tenant and optional organization on the authoritative row, and JobExecutor restores them before business work. Vendor wakeups carry opaque references (ADR-0047).
  • Every integration event payload carries tenant_id (and organization_id? when relevant); consumers restore tenant + org context before handling.
  • Raw SQL queries (if any) include tenant_id (and organization_id when applicable) in the predicate explicitly.
  • Cache keys, search index names/filters, storage prefixes, and metric labels all carry tenant id (and org id where applicable) — never as a high-cardinality metric label, see 10-observability.md.
  • At least one tenant-isolation integration test asserts that tenant A cannot read tenant B's data via the new surface, and that org X cannot read org Y's data within the same tenant where the entity is org-scoped.

Transport

  • TLS everywhere. HTTP redirects to HTTPS at the edge.
  • HSTS enabled (max-age=31536000; includeSubDomains; preload).
  • Minimum TLS version: 1.2; prefer 1.3.
  • Certificates auto-renewed via Let's Encrypt or cloud-managed certs.

HTTP Headers

Every response from the API and the rendered apps must set:

Header Value
Strict-Transport-Security max-age=31536000; includeSubDomains
X-Content-Type-Options nosniff
X-Frame-Options DENY (except embeddable LTI surfaces)
Referrer-Policy strict-origin-when-cross-origin
Permissions-Policy restrict to required APIs only (camera, microphone for classroom routes)
Content-Security-Policy strict with nonces; documented per app surface
Cross-Origin-Opener-Policy same-origin
Cross-Origin-Resource-Policy same-site

Authentication

LearnStack runs two Keycloak realms per ADR-0004 Amendment 1:

  • learnstack — the tenant-facing realm. All tenant admins, instructors, and learners authenticate here. The realm includes the tenant id in token claims; the API re-validates against the resolved tenant context.
  • learnstack-hub — the operator realm, used only by the Hub operator portal (hub.learnstack.dev). Tenant-facing apps never accept learnstack-hub tokens; the internal API (/api/internal/*) only accepts learnstack-hub tokens plus the mTLS client certificate.

General rules:

  • OIDC. No handwritten password code; no handwritten token rotation.
  • Refresh/access/ID tokens stay in the native BFF session owner. Browser cookies contain only opaque handles/proofs under the tenant BFF rules below.
  • Access tokens short-lived (≤ 1 hour) and refreshed silently by the BFF.
  • MFA enrollment supported for tenant-admin roles; required for platform-admin and for every Hub operator account.
  • Password policy delegated to Keycloak: minimum 12 chars, breach check via HIBP, no password reuse for the last 5.
  • Account lockout after 5 failed attempts in 10 minutes; lifted after 15 minutes or admin unlock.

Development warm-realm qualification — Accepted 2026-10-22. ADR-0068 owns the exception allowing qualified missing-definition preparation before a fresh full plan and the three stock-console assignment detachments. All identity-independent refusals precede preparation; all other writes require complete planning. Existing readable definitions, credentials and unrelated state remain preserved. This is account-preserving reconciliation with named assignment deletion, not general delete authority. All three correction steps complete local implementation and both independent review rounds. The qualification record owns actual historical lifecycle, usable credential/MFA, signed API and stored self-service proof, together with exact hosted assignments and evidence limits. Acceptance or a partial experiment alone does not establish qualification.

Development access-token issuance — Accepted 2026-10-21. This standard owns the issuance-lifetime rule decided by ADR-0067, retaining the earlier approval in the P02b-6 package: the tenant development realm uses a configured 900-second access-token lifespan. learnstack.json owns the executable value; the development reconciler enforces it with no conflicting override on either named client. P02b-6 Steps 1–3 supply the fresh-import definition and bounded reconciliation through both seed/demo entries; their delivery records own actual local qualification. Step 4 delivers complete real-token/API proof after both independent reviews; its record owns local proof/review closure and source-bound qualification receipts. The required CI checks qualify their exact source and assignment. ADR-0064's one-hour API acceptance ceiling and 30-second skew remain separate from issuance. ADR-0070 owns BFF custody/lifetimes; P02b-7 delivers that Accepted contract.

The pinned provider also copies the access-token expiration into generated ID tokens, including shorter session-clamped issuance. ID tokens remain identity credentials rejected by the tenant API; this shared expiration does not select a BFF session lifetime.

Keycloak 26.6.2 reads issuance and expiration clocks separately. The configured lifespan is not an exact exp - iat guarantee or a fixed 901-second ceiling. Qualify exact realm configuration and absence of client overrides independently; fresh signed expiration must fit the observed UTC/monotonic code-exchange window plus that lifespan. Any explicit fixture/provider clock tolerance is at most the API's existing skew; an indeterminate clock/window fails qualification. Preserve session-clamping coverage for shorter late-session issuance and independent mutants with wrong expiration or configuration. Existing tokens retain their API acceptance bound; a shorter issuance setting does not revoke them immediately.

Tenant BFF sessions

ADR-0070, Accepted 2026-10-23, selects openid-client and native single-process custody behind BffSessionStore. P02b-7's five-step package owns implementation; acceptance alone enables no route. Use 256-bit random opaque __Host- session cookies: HttpOnly, Secure, SameSite=Lax, Path=/, no Domain. Credentials and raw profiles enter no browser projection. Verified host, browser binding, epoch and server state are required. The delivered opaque session/browser cookie format uses an eight-hour Max-Age; the entry, standalone form-CSRF and login/logout proof cookies use 300 seconds, with Max-Age zero for an authorized deletion. These browser retention bounds never extend server idle, provider or absolute authority and do not make restart survivable. The real cookie controls compare emitted headers, not browser defaults.

  • Idle is 30 minutes, absolute eight hours; effective expiry also respects the provider refresh deadline. Response owners refresh expired/near-expiry access, touch qualifying activity and publish one revision. auth() is read-only. Explicit extension refreshes the provider; a timestamp alone does not extend it. The safe projection includes effective expiry and whether the known absolute deadline limits it. UI never promises extension at that maximum or when the provider returns an unchanged deadline. Full document navigation and the protected-document history veil require fresh network admission on return.
  • Retain 1,024 total owners, including unsettled work; 128 pending for five minutes, 16 pending per verified visitor IP, eight verified entry/session/logout-return owners per validated issuer/subject, including ADR-0093 invitation owners once verified. Preauthentication invitation owners share the pending/source limits. Same-browser replacement settles old work first. No live other-device eviction or capacity bypass; source/subject refusal is 429 and global capacity refusal is 503. Full expiry scans precede global refusal. AccountSite also remains subject to the API's shared peer/visitor limits under ADR-0064; neither session capacity nor a subject allowance grants independent end-to-end throughput to a host or browser.
  • Revocation/epochs fence late callbacks and refresh. Unknown/mismatched state changes no cookies; verified failure clears only its own transaction cookie. Logout revokes locally first, discards tokens after URL construction and keeps only its bounded return proof/accounting. Late cookie delivery can force sign-in.
  • ADR-0071's status table distinguishes ordinary checks from new revision publication. An uncertain ordinary 401 or other availability failure retains a live owner without idle touch and denies checked authenticated content. 403/404 and actor/scope conflicts revoke; a masked host/hop configuration refusal can therefore close a session too. Callback publication always requires positive API authority; every failed post-refresh check revokes. A valid API 401 produces BFF POST 503/provider_refused for callback/extension, not a retryable-grant promise. Public pages remain anonymous; unavailable protected checked reads return 503. Passive projections describe local state only. Genuine 401 revocation can be delayed; retention does not extend the existing refresh threshold or deadline. The route failure classification separates malformed input from unavailable upstream/server work; it does not change grant-publication or revocation rules.
  • Return destinations are / and the exact local protected document set, with canonical detail IDs and no query or fragment. No browser URL, unverified Host/forwarded header, provider error or callback query chooses the issuer, API origin, provider endpoint or post-logout authority. Callback/logout origins use only the independently verified native host/port. Fixed discovery/token/JWKS purposes use the private issuer, no redirects, one operation-wide deadline through body consumption and a decoded-byte bound.
  • Only the native configured callers attach owned bearers to the exact AccountSite, self/admission and bounded administration operations. The public caller stays credential-free; none forwards browser Authorization or tenant/organization selectors. Source admission uses the verified socket provenance, not a browser-supplied address. Proxy deployment requires Phase 11's provenance qualification first.
  • BFF diagnostics emit only their closed lifecycle/failure categories. Do not expose raw provider exceptions, responses, URLs, subjects, handles or tokens. A category such as provider_refused is an observation, not a causal verdict, proof of settled cleanup or a durable application session-audit record.
  • Explicit BFF opt-in validates issuer/client secret, stable AuthorityId and the separate native source secret together. Disabled mode keeps public requests independent of Keycloak; auth paths are 404 and protected shells 503. Restart signs sessions out. Multiple web processes require Phase 11's distributed adapter and actual refresh/revocation/interruption qualification first.
  • Before importing Next, the native process closes its raw development URL trace sink; Next configuration also disables automatic request/fetch logging. This suppresses framework performance events for public development requests too, while preserving separate build traces and API/business telemetry. A real enabled-development callback/logout canary scans console and trace artifacts; removing the trace guard must fail even with console logging disabled.

Tenant development provider events use explicit LOGIN/LOGIN_ERROR, LOGOUT/LOGOUT_ERROR, CODE_TO_TOKEN/CODE_TO_TOKEN_ERROR and REFRESH_TOKEN/REFRESH_TOKEN_ERROR storage. Merge the existing effective selection; preserve listeners/admin policy. Fresh/default-empty retention is seven days. Warm adoption requires bounded empty-history proof; preserve finite retention of at least seven days, refusing shorter/indefinite/ambiguous custom policy before any write. Separate readback and zero-write rerun are mandatory. Provider records contain personal data, stay out of raw artifacts and are not an atomic BFF ledger. Audit 18 owns that narrow audit boundary; Phase 11 owns production retention/capacity/security qualification.

Ingress / Gateway (APISIX)

All tenant-facing traffic enters through APISIX in standalone mode per ADR-0015. The gateway is a defense-in-depth layer, not the sole control — the API re-verifies everything.

When this becomes live. APISIX is demand-gated per ADR-0035: its trigger is the first non-development deployment that needs edge rate limiting, host routing, or JWT pre-validation, and it lands in Phase 11. Until then ASP.NET middleware carries the same responsibilities in-process. Nothing in this section is optional once the gateway is in front of production traffic; none of it is a reason to weaken the in-process control, because the API re-verifies either way.

  • jwt-auth plugin verifies the access token against the learnstack realm's public key set; the API also verifies. Both must pass.
  • cors plugin handles preflight; authenticated cross-origin traffic is allow-listed per environment.
  • limit-req / limit-count plugins enforce the rate-limit policy below.
  • The /api/internal/* route set is gated by mTLS configured on the APISIX SSL object (client.ca + client.depth per APISIX 3.x SSL-config; mTLS is not a route plugin) plus an ip-restriction (and, when applicable, consumer-restriction) route plugin that only admits the documented Hub egress; the client certificate must be signed by the LearnStack-internal CA. The route-level pattern is shown in the commented /api/internal/* stub in infra/apisix/apisix.yaml.
  • Gateway config lives in infra/apisix/ as YAML, version-controlled. No live edits.

Direct ingress to backend pods (bypassing APISIX) is blocked at the network policy level.

Hub Contract Surface

The LearnStack ↔ Hub API is a separate, narrow surface with stronger controls than tenant-facing endpoints. Per ADR-0019 and ADR-0034:

  • mTLS with LearnStack-internal CA-signed client certs. Certs are rotated yearly.
  • Signed JWT (RS256) carrying iss, aud=learnstack-internal, exp ≤ 5 min, jti (replay-protected via short-TTL inbox).
  • HMAC body signature in the X-Signature header (HMAC-SHA256 of the raw body with a per-deployment shared secret).
  • All three layers must validate on every endpoint in the surface, but they fail at two different layers. /api/internal/* is bound to its own mTLS listener and is never proxied by APISIX, so a missing, expired or untrusted client certificate is rejected during the TLS handshake — no HTTP request reaches the application, so there is no status code and no body to leak. Only the JWT and HMAC checks return 401, with no detail. Do not write a handler that returns 401 for a certificate failure; it would never be reached, and its existence implies a listener that terminates TLS without requiring the client certificate.
  • The surface is governed by two invariants, not by an endpoint count: the Hub stores no tenant content, and every crossing goes through IEntitlementProvider, IUsageReporter, or IHubTenantSync. The enumerated endpoint set lives in 20-infrastructure-stack.md § Hub HTTPS Contract Surface; adding an endpoint still requires an ADR, because the surface is a cross-repository contract.
  • TLS certificates and private keys never travel in the entitlement payload. That payload is cached, logged, audited and mirrored. Cert material moves by secret-store replication and is referenced from PUT /api/internal/tenants/{id}/host-mappings by path, never by value (ADR-0034).
  • Host resolution never calls the Hub. IHostToTenantResolver reads platform_host_to_tenant and nothing else, so a Hub outage cannot take anonymous public pages down.

Authorization

Every protected tenant act checks, in order:

  1. Authentication (valid token).
  2. Tenant membership (user has a Membership in the resolved tenant).
  3. Role / permission (user's roles include the required permission).
  4. Resource scope (e.g. instructor can only edit their own courses).

The live policy boundary owns precise masked 404/capability 403/dependency 503 precedence; the generic list above does not override earlier credential, host or binding refusals. The exact internal global-identity creation class instead uses the adopted subject-confined owner, with no tenant selection or membership authority. It is not a routed HTTP write in P03-1.

Tenant + Organization Isolation

See docs/architecture/09-tenant-isolation.md for the full strategy. Standards-side:

  • Every [TenantOwned] entity has a query filter and an RLS policy. Architecture tests enforce this.
  • Every [OrganizationScoped] entity additionally carries an OrganizationId column and an org-aware EF filter + RLS policy (ADR-0017). Nullable OrganizationId means the row may be tenant-wide.
  • IgnoreQueryFilters() is allowed only inside the audited EnterPlatformAdminScope(reason) call path, which is itself the record of the access: the scope writes its security-event row through IAuditStore.WritePlatformScopeAsync on its own platform-role connection before the operation runs, and commits it on its own transaction rather than the scope's — which an abandoned frame rolls back — so an operation that later fails is still recorded (ADR-0044 § 10, 05-database.md § How EnterPlatformAdminScope(reason) reaches learnstack_platform). The architecture test No_IgnoreQueryFilters_Outside_PlatformAdminScope is a path check: no per-call-site attribute or comment marker exempts a call (09-tenant-isolation.md § Platform admin access, 21-architecture-tests-catalogue.md).
  • Background jobs use IBackgroundJobScheduler inside the enrolled writable transaction. Tenant/org authority comes from trusted submission and the authoritative job row; JobExecutor restores context before business work. Hangfire wakeups contain only opaque references. See the submission rule and ADR-0047.
  • The app.tenant_id and app.organization_id session variables are set with SET LOCAL inside the ambient transaction — see § Tenant Context immediately below, which is the single authority for that placement.

Confined event dispatcher

ADR-0046 accepts this P02b-2 boundary. Step 1 implements source/role guards; both review rounds are complete. Step 3 implements mandatory worker startup resolution and connectivity checks; both independent review rounds are complete. Step 4 credential/publication enforcement and local closeout are complete; all four steps completed both independent review rounds. The merge closeout owns CI/merge status. The named keyed, lazy dispatcher source belongs only to the API host. Configuration validates the exact username without connecting or echoing parse input. Worker startup must verify connectivity. Every new physical connection verifies exact session/current learnstack_outbox_admin, BYPASSRLS, NOSUPERUSER, NOCREATEDB, NOCREATEROLE, NOREPLICATION and no outgoing role membership or incoming membership by a non-superuser, directly or transitively. Superusers inherently reach every role; this check detects grant drift on a new physical connection, not continuous revocation of an existing session. No Seeder/web forwarding or shared app credential is allowed. Database Standards owns exact status grants and audit INSERT-only. ADR-0047 additionally permits the closed Tenancy lifecycle projection for durable job fan-out. Other business columns and tenancy mutations remain denied, as do audit read, update and delete.

Routine confined poll/claim/mark receives ADR-0046's bounded audit exception; terminal state is MUST/SecurityEvent and atomic with its row-bound witness audit. INSERT-only and GUCs cannot tenant-bind a bypass credential. Real mismatch controls must roll back terminal state and audit together. Existing platform-admin entry audit and gate stay unchanged. P02b-3 Steps 3–4 implement separate app-role business scopes, restore validated tenant/organization and announce RLS context before reading the envelope and constructing a handler. The subscriber scheduler shares the confined source for enumerated routing/status columns; it receives no payload, causal-envelope or inbox SELECT privilege. The ordinary executor refuses privileged subscriptions until their Phase 03 execution contract exists.

Tenant Context

This section is the single authority for RLS session-variable placement. Every other document links here rather than restating the mechanism.

Row Level Security predicates read four PostgreSQL session variables — app.tenant_id, app.organization_id, app.scope, and app.resolving_host — whose canonical spellings and canonical policy templates live in 05-database.md § Tenant-Owned and Organization-Scoped Tables and 05-database.md § Table classes. This section fixes where the first three belong. app.resolving_host is set by CachedHostToTenantResolver alone, in its own short read-only transaction before the host lookup, because the row that determines the tenant must be readable before any tenant context exists; it is read by exactly one policy, on platform_host_to_tenant. Its value is the normalized effective host as EffectiveHost.Normalize produces it — the single producer of both this variable and the lookup key, per ADR-0036 § Effective host and the trusted hop. That ADR, not this section, is the authority for which request input the host comes from; this section remains the authority for session-variable placement only.

The rule

app.tenant_id and app.organization_id are announced inside the ambient transaction by the step-6 TransactionBehavior adapter through TransactionExecutionCoordinator, from the context admitted at step 4. Default ReadWrite transactions announce them as their first SQL statement.

For the implemented physical PublicSurface ReadOnly owner or exact authenticated GetAccountSiteQuery ReadOnly owner, ADR-0052 and ADR-0040 Amendment 8 with each ADR's 2026-10-19 dated scope-extension disclosure under ADR-0064, authorize one predecessor: begin READ COMMITTED, execute and await SET TRANSACTION READ ONLY, then return the owner frame. The existing tenant/ organization setter follows before any data SQL or handler dispatch. Mode setup alone never stamps the tenant-announced marker. If setup fails or is cancelled, Begin cleans up the partial transaction and marks rollback-only before rethrowing. Mode resets per transaction; poisoning remains sticky for the unit's lifetime.

The authenticated-query extension is implemented by P02b-5 Step 2; Step 4 owns HTTP activation. Selection is by exact request type, never a query-name suffix, public marker or general authenticated read category. It reuses the existing physical owner and tenant announcement; that extension does not change the setter set or transaction ownership. ADR-0076 separately accepts the ninth candidate owner below, with the same READ COMMITTED → awaited physical READ ONLY → exact announcement ordering.

Every institution public read dispatches a marked request through ISender, audit Off and GET/HEAD only. Factory HostScope is an independent host ceiling intersected with normal reconciliation, query filters and RLS; it cannot widen through claims or the tenant-scope read hatch. Publication, access and lifecycle remain explicit application predicates. The four context writers remain unchanged; the current closed contract has eleven logical Tenant setters below under ADR-0093; its invitation contract is Accepted with runtime proof pending. Host resolution, flag-loader connections and rejected-assertion audit keep their separately sanctioned paths.

READ ONLY protects the enlisted ambient transaction. Structural guards detect controller/helper/independent-connection bypasses; real app-role HTTP/database proofs are required. No public-data endpoint may use a direct persistence path.

SET LOCAL and set_config(name, value, true) are transaction-local. PostgreSQL discards them when the transaction ends, and they have no effect at all outside one. Two consequences follow, and both are load-bearing:

  • Not from a MediatR behavior that runs before TransactionBehavior. A value set at step 4 is gone before the step-6 transaction opens, so every subsequent query runs with an unset app.tenant_id.
  • Not from a DbConnectionInterceptor. Interceptors fire when the connection opens, not when a transaction starts. Under PgBouncer transaction pooling the connection is shared across transactions, so the value is either absent or — worse — left over from another tenant's transaction.

app.scope has an implemented operation-bound carrier. Accepted ADR-0076 replaces the former role-missing deferral with an operation-bound witness; P03-1 already stores Memberships, Roles and scoped definitions. The database read-authority contract owns SQL/EF derivation, owner generation, nesting and cleanup. Step 6 announces that admitted read authority inside the actual transaction; no middleware or ITenantContext member may manufacture it. The Phase 03 Status block owns current qualification and review state. HostScope remains an independent narrowing ceiling.

ADR-0080 separately admits fixed-seed prerequisite verification through the canonical common-owner contract. Only its separately witnessed, closed operation may enable and clear app.scope temporarily on the already-announced ordinary ReadWrite owner. These scope-only SQL sites preserve Tenant/organization values and announcement generation; they are separately enumerated callers, not Tenant setters or context writers. Human OperationReadWitness minting remains Protected-only. The fixed source adds no SQL owner, connection, role, definer, permission or independently callable reader.

ADR-0081 reuses that transition/barrier/cleanup machinery through a separate single-consumer completion census, leaving ADR-0080's exactly eight pristine consumers/source/projection unchanged. Its typed completion witness admits only the fixed origin command, actual locked immutable target preparation and current scoped console provenance. The completion contract owns narrow Tenancy/Customization versus wide Education/Identity reads, qualified marker 2 and recovery. Its local current-state predicate grants no global/provider eligibility or historical profile-initializer claim. The fixed runner handles marker-2 exact-mapping repeats before command dispatch, MUST intents, witness or temporary scope; the command itself refuses completed markers. No new Tenant setter/context writer or physical owner follows. Separate exact minter/caller controls preserve Protected-human authority and the two existing scope-only SQL sites in one private transition.

P02b-1 Step 1 adds a read-only announcement snapshot through IUnitOfWork.GetTenantContextAnnouncement(DbTransaction?), under ADR-0057. Its generation/normalized values describe successful SQL on the current physical transaction. It is cleared before attempts and terminal transitions. Foreign transactions and failed/no-tenant announcements supply no usable snapshot; unresolved ambient provisioning still uses its sanctioned announcement. Frames compare the live snapshot with their enrolled generation. This adds no setter, context writer, factory privilege or tenant retargeting authority.

The out-of-band setters

TransactionBehavior delegates the general announcement path to TransactionExecutionCoordinator. The current eleven logical setter paths are enumerated below. ADR-0076 adds the ninth candidate reader and ADR-0078 the tenth own-context discovery reader; their source bounds are in the owner census. ADR-0077's confined evidence methods and ADR-0080's scope-only enable/clear sites do not add logical Tenant setters. The set is closed (ADR-0040 § Who sets app.tenant_id, completely, with its dated Amendments, is the authority; it is reproduced here because this section is the placement authority). Two set it on the ambient transaction; eight own a short transaction of their own under their exact source contract; ADR-0093 adds one operation-bound invitation witness setter using the existing physical unit-of-work writer, ReadOnly for inspection/result and ordinary ReadWrite for acceptance:

Setter Transaction Why it is not TransactionBehavior
TransactionBehavior → TransactionExecutionCoordinator ambient — the existing general announcement path; common execution adds no authority
The integration-event transport, per delivery ambient — it opens it DeliveryExecutor uses the common transaction owner and validated retained envelope before restoring EventTenantContext, claiming inbox and constructing the handler. InProcessEventBus owns durable acceptance only; nested commands join the prepared business transaction
IOrganizationScopeValidator its own short read-only one The organization assertion is validated in the request edge, before the pipeline reaches step 6 (ADR-0036)
IIdempotencyStore (durable) its own short one A claim is taken before the pipeline reaches step 6 (ADR-0037)
IAuditStore.WriteStandaloneAsync its own short one An audit row that must survive the rollback of the operation it describes cannot share that operation's transaction (ADR-0033)
IAuditStore.WriteBestEffortAsync its own short one Same shape, SHOULD/MAY class; failures are logged and dropped
The AuditConfig override loader its own short read-only one An out-of-band cached projection, never a request-path query
The tenant-flag loader (FeatureFlags) its own short read-only one A cached projection of tenant_feature_flags, read on a cache miss wherever IFeatureFlags is asked — inside a request's transaction or outside any (ADR-0045 § 2, ADR-0040 Amendment 7)
PreContextIdentityAuthorizationReader its isolated physical READ ONLY child Exact verified candidate coverage/grant and Tenancy lifecycle reads; never parent business state or ambient TenantContext
PreContextSelfDiscoveryScopeReader its own short physical READ ONLY one Single-use captured own-Membership scope witness, followed only by bounded Tenancy label/destination reads
InvitationEntryScope operation-bound existing owner, physical ReadOnly for inspection/result or ordinary ReadWrite for acceptance Single-use exact offer/host/verified-subject witness; no ordinary native context, directory or general-purpose scope

IOrganizationScopeValidator performs row-7 organization narrowing after affirmative live Membership admission. TenantAssertionMiddleware separately refuses differences between asserted and resolved values. Neither path supplies authority or widens scope. The live authorization contract owns admission and its boundaries.

P02d-2 closes G15 without a new setter. Seed ownership verification now sends module-owned contextual ISender queries in fresh trusted scopes; its old private transaction and direct SetTenantContextAsync call are removed. The caller fence and planted companion reject direct calls, method groups, private transactions, accessor assignment and announcing SQL. G22/G23's projection loader and settings reader decisions remain owned by P02d-3; this change admits no out-of-band reader.

Both audit writers announce both variables. WriteStandaloneAsync and WriteBestEffortAsync issue app.tenant_id and app.organization_id from the draft, as the first statements of their short transaction, because audit_log is organization-scoped: a row whose organization_id is non-null while the organization variable is unset fails WITH CHECK (ADR-0033 Amendment 2 § 5, ADR-0044 § 9).

Every one of the eleven setters connects as learnstack_app. A setter that reached for learnstack_platform would be invisible to the isolation suite, which is the failure mode ADR-0003 names by hand.

Because every current_setting read is called with its missing-OK argument (true) and wrapped in NULLIF(…, ''), both an unset and a reset variable yield NULL and the policy predicate filters the row out. The failure mode is an empty result set, not a leak — but an empty result set arriving from production is an outage, so TenantContextGuardInterceptor asserts that a sanctioned setter has already issued the SET LOCAL pair on this transaction before any command a module DbContext issues runs on it.

Keyed on the transaction, not on the table, and marked through the ambient unit's announcement seam. Matching [TenantOwned] table names would put a parser between every query and the database to decide something the transaction already answers. Of the eleven logical paths, the MediatR transaction owner and P02b-3's integration transport mark the ambient transaction through sanctioned IUnitOfWork announcement methods. Durable acceptance and prepared subscriber execution both announce before their first app-role command; the production call-site census closes these implementations without adding another logical setter. Durable idempotency activation/proof belongs to the phase Status block. The five original raw paths, OrganizationScopeValidator, the two standalone audit writers and the two cached-projection loaders, issue raw NpgsqlCommands on connections of their own, which EF interception never sees. The ninth and tenth use their confined common-owner announcement seams and exact source/generation guards. The eleventh consumes its operation-bound invitation witness through the existing physical unit-of-work writer. The preliminary offer function owner closes before that consumer owner starts. CachedHostToTenantResolver is not in this set at all: it sets app.resolving_host, not app.tenant_id. That is also why the exemption list is empty, and why PlatformAdminScope, whose BYPASSRLS connection announces no tenant by design, is invisible to the guard by construction rather than by a hand-written exception. The concern the earlier wording had — that naming TransactionBehavior alone would reject legitimate transport and audit writes, and the durable idempotency store's writes on its own short transaction — is real, and is answered by the guard reading a marker rather than a behavior's name: a setter marks the transaction it opens if and only if it reaches EF. The audit store's writers have landed and reach none; the durable idempotency store must mark its own whenever it does. It throws TenantContextMissingException when it has not, which Tenant_Context_Guard_Fires_Only_On_An_Unmarked_Transaction asserts in both directions. Packet 7 owns it: Packet 6 ships the setter (IUnitOfWork.SetTenantContextAsync) and the policies it will back up, and the first tenant-owned read on a request path is Packet 7's, which is where the guard belongs. It cannot be a connection-checkout interceptor, for the same reason it cannot be a DbConnectionInterceptor that sets the values: checkout precedes the transaction, so the transaction-local value is not there to be observed (05-database.md § Connection Management).

Corrections this supersedes

Six other places previously described different placements. All are corrected; if a stale copy surfaces, this section wins:

Document Previously said Now
02-backend-coding.md § Pipeline Behaviors Pipeline step 4 (TenantContextBehavior) sets the variables via a DbConnectionInterceptor Step 4 asserts and carries tenant context; step 6 sets the variables inside the transaction, and links here
Phase 02a Packet 3 TenantContextBehavior code TODO Names a DbConnectionInterceptor as the mechanism Corrected in Phase 02a Packet 3b to point here and at Packet 7, which implements it
33-cross-cutting-concerns.md § Pipeline Step 4 sets the RLS GUCs via a DbConnectionInterceptor Step 4 asserts only; step 6 issues SET LOCAL inside the transaction
ADR-0032 § Sub-decision 2 diagram TenantContextBehavior (assert resolved; set RLS GUC) Corrected in that ADR's Amendment 2, item 3
09-tenant-isolation.md § Isolation flow mermaid The accessor issues SET LOCAL from middleware, before any transaction exists The transaction issues it at step 6
Phase 02a § Cross-cutting Concerns TenantContextBehavior (asserts resolved + sets RLS GUCs) Asserts only; TransactionBehavior sets them

Neither error was visible in review because the corpus described the layers correctly while describing the ordering wrongly, and no code exercised the ordering yet. The implementation lands in Phase 02a Packet 7.

Related authorities

A test that connects as the table owner or as a BYPASSRLS role passes even when every policy is inert. Isolation tests connect as learnstack_app; the suite is a Phase 02a Packet 7 deliverable.

Secrets and Configuration

  • Backend secret reads go through ISecretProvider. The registered implementation is ConfigurationSecretProvider until Vault's trigger fires — a production secret must rotate without a redeploy, or more than one operator needs access to production secrets — at which point DaprSecretProvider → Vault takes over per ADR-0038 and ADR-0035. Call sites are identical either way. .env.example is checked in; .env is gitignored.
  • Secret namespace: learnstack/{deployment}/{module}/{key}. The deployment segment is development | saas | dedicated | selfhosted.
  • Production secrets rotated at least every 90 days where rotation is feasible (DB passwords, provider API keys, Hub HMAC shared secret, mTLS client certs).
  • Secret access via ISecretProvider is logged.

ADR-0053's local Node launcher is a bounded exception to the .NET provider interface: the paired private local configuration supplies the ingress/API-hop secret without shell evaluation. It is never a NEXT_PUBLIC_* value, browser input or SDK default. This exception does not authorize a production secret-store adapter; Phase 11 owns the production ingress/secret lifecycle. See Infrastructure Standards.

Private public-rendering boundary

ADR-0053 owns the protocol; ADR-0054 replaces its accounting, native admission and redirect/query rules. These are the ongoing security obligations:

  • Only the native listener captures host/socket provenance. Strip client tenancy, forwarding, internal and framework-override carriers before Next; validate the signed envelope again before bootstrap and before the configured API caller.
  • The envelope, hop secret and visitor address never enter responses, client assets, retained logs or audit data. Framework debug output and test diagnostics are part of that exclusion. A marker or imported server-only module grants no tenant scope.
  • The configured caller uses its private loopback origin and builds exactly five application headers: Accept, X-LearnStack-Host, X-LearnStack-Hop-Secret, X-LearnStack-Visitor-Address and traceparent. The HTTP Host remains the API origin. Rebuild that set from verified context; never forward browser cookies, Authorization, tenant/organization assertions or proxy headers.
  • The complete headers/body operation has one 10-second deadline and an 8 MiB decoded-body bound, with no retries or redirects. Rendering and responses remain dynamic/no-store. The injected SDK owns no host, secret or global fetch default.

P02d-5 delivers this boundary and ADR-0054's accounting, native admission and URL controls. The remediation record owns execution, independent reviews and PR closeout evidence. Native paths, including matcher exemptions, admit GET/HEAD; ADR-0070's three proof-bearing POSTs, ADR-0078/0079's entry/administration forms and ADR-0093's invitation forms are the closed extensions named in Frontend Standards. Other methods remain masked. Production upgrades close and development retains only validated GET HMR. See Frontend Standards § Native Admission and URL Identity for the native controls and their delivery/review boundary.

File Uploads

  • Validate MIME type with content sniffing, not just the Content-Type header.
  • Validate extension against an allow-list per content type.
  • Enforce per-content-type size limits. The numbers and the tenant-override rule are owned by 16-media-pipeline.md § Validation; three documents used to state them and disagreed three ways about the same image.
  • Strip EXIF where appropriate.
  • Store in tenant-scoped object storage prefix.
  • Never trust the original filename. Generate a server-side key under the canonical tenant prefix: tenants/{tenantId}/{category}/{uuid}.{ext} (with organizations/{orgId}/ segment for org-scoped assets), per 09-tenant-isolation.md § Storage (SeaweedFS) and 16-media-pipeline.md § Key Layout.
  • Virus scan hook (ClamAV or cloud equivalent) before files become accessible.
  • Signed URLs for private files; TTL ≤ 1 hour.

SQL & ORM

  • Use parameterized queries everywhere. No string interpolation into SQL.
  • EF Core LINQ is preferred; raw SQL only with explicit FromSqlInterpolated and parameterized values.
  • No string concatenation with user input.
  • Stored procedures are not used; the application owns the logic.

XSS & Output Encoding

  • React encodes by default — preserve that.
  • dangerouslySetInnerHTML only through a sanitization wrapper (DOMPurify) with a documented policy.
  • CSP nonces enforce inline-script restrictions.
  • Markdown rendered via a library with allowlist sanitization.
  • Email templates use a templating engine with HTML-escape default.

CSRF

  • Cookie sessions use SameSite=Lax.
  • BFF sign-in POST requires exact Origin and a random host-only double-submit proof; authenticated sign-out/extension requires exact Origin and a random constant-time checked session-bound token. Entry creation/selection/sign-out uses the current entry-bound token; switch and administration forms use the current session token. Apply admission before allocation or API work.
  • Sign-in HTML uses same-origin referrer policy so native forms retain that Origin proof. Provider redirects and the ID-token-hint logout exception retain no-referrer. Missing or null Origin remains a refusal.
  • The cross-origin provider callback instead requires its single-use state, nonce, PKCE verifier and host/browser/epoch-bound transaction cookie.
  • Auth cookie families and decoded forms each have a 16 KiB ceiling. Native auth POST admission requires a bounded Content-Length and no transfer encoding before Next can clone a body; the form parser independently bounds actual bytes, UTF-8, declared scalar fields and duplicates.
  • Only the exact native auth/administration POST set and invitation POST set are open. SameSite and implicit framework checks authorize neither sibling origins nor Server Actions.
  • Webhook endpoints verify signatures, not CSRF tokens.

CORS

  • Default deny.
  • Allowed origins explicit per environment.
  • Studio and Portal apps share a single origin with the API (no cross-origin requests needed).
  • Tenant custom domains use server-side rendering; client-side cross-origin to the API uses CORS preflight on a controlled allow-list.

Rate Limiting

Two layers, two jobs, two key spaces — and they are not the same numbers. The edge (APISIX) limits a noisy client to protect the platform, keyed on the remote address; its policy is owned by 30-api-gateway.md § 6 and is deliberately much looser per second. The table below is the application policy: fair use per principal, on whatever names one — a token where the caller is authenticated, the peer where it is not, the mTLS client certificate for the Hub surface, the provider account for a webhook. Only a principal key can express "this tenant's plan allows N", which is why the edge cannot own these rows. A request passes both layers or neither.

Until the gateway fronts the app (ADR-0035 gates it to Phase 11), the application enforces anonymous limits in process. Accepted P02d-5 policy — 2026-10-08. Derives from: ADR-0053. Use one canonical-IP quota for direct socket peers and authenticated-hop visitor addresses, plus a separate physical-peer ceiling. Neither forwarded headers nor a host/cookie creates a visitor partition. Trusted malformed visitor metadata spends the fallback budget, then receives masked 404 before lookup. P5 Step 2 implements both budgets and pre-lookup admission, with real-socket controls. This is local limiting, not distributed DDoS protection. NAT/local loopback callers share an IP quota; budgets count API calls.

ADR-0054 accounting implemented — 2026-10-09; both review rounds passed. Remediation Step 1 owns the actual visitor limiters, with one process-local owner lock across all visitor keys and physical peers. The lock serializes positive acquisition, creation and retirement; no network/database work, await or disposal runs under it. One-minute fixed windows, 60/min per canonical IP, 600/min per peer and no queue remain. No request retains a limiter removed by retirement.

  • Probe an existing visitor with zero permits. Its refusal returns unchanged Retry-After before any peer acquisition. Otherwise acquire the peer permit; peer refusal neither debits the visitor nor creates an unknown visitor limiter. Create an unknown visitor only after peer admission, then acquire one visitor permit. No competing debit/retirement separates the known-visitor probe and debit.
  • First refusal wins, including the visitor when both budgets are exhausted. Do not combine metadata, debit to obtain failure metadata, or use a partition lookup that allocates merely to check membership. Reuse successful and refused outcomes on framework retry of the same HTTP request; returned leases have independent lifetimes. Cancellation, Retry-After and disposal need explicit proofs.
  • Retire only after full-quota idle state lasts a complete window. One non-overlapping sweep visits the whole registry in bounded batches; removal shares the acquisition lock and disposal occurs outside it. Recreated state never restores unexpired quota. DI shutdown closes admission, joins the sweep and disposes every remaining limiter exactly once on both teardown paths.
  • The 600/min peer budget bounds successful peer acquisitions and new visitor allocations. It is not a fixed global cardinality cap or a bound on all attempts/refusal work. Known exhausted visitors can generate arbitrarily many 429 responses without spending peer permits; their path performs no registry sweep, new limiter allocation or host/database lookup. No unbounded parallel identity registry is permitted. Phase 11 owns upstream transport/edge protection and contention/load measurement; no measured throughput is claimed here.
Surface Limit
/api/v1/auth/* (login, password reset, register) 5 req/min per IP
Early API admission (all credentials) 60 req/min per canonical IP + 600 req/min per physical peer (P02d-5 Step 2)
Authenticated API Additional 600 req/min per validated issuer + subject (ADR-0064)
Write endpoints (planned; Phase 03) 60 req/min per token
Webhook endpoints 1000 req/min per provider
Hub internal API (/api/internal/*) 60 req/min per mTLS client cert

P02b-5 Step 3 implements the subject component; Step 4 activates it in production. The stable issuer/subject key survives token rotation and tenant selection; actor, host and credential text do not select a partition. It uses a one-minute fixed window with no queue. Accepted ADR-0066 adds the process-wide 4096 retained-key bound; the internal smaller-bound constructor is only a test seam. Retirement reclaims only full-quota partitions idle for a complete window. Acquisition inspects at most 128 entries and disposes at most one; the timer inspects/retires at most 128 every 30 seconds. One shared cleanup owner prevents overlapping batches; at most 4224 limiters are owned, including retirement. Exhausted live quotas cannot be evicted or reset by pressure.

The acquiring cleanup owner must complete disposal, then recheck shutdown, fault, key existence and capacity before its own allocation or debit. Other requests do not wait for cleanup and may take freed slots. A sticky cleanup fault stops new allocations and batches; already retained keys keep their original quota. Joined shutdown closes admission and observes timer/acquisition cleanup and retained failures. One blocked disposal can indefinitely delay its request and shutdown; a host deadline is not cleanup evidence and adds no fatal process policy. Local ownership, HTTP and actual OTLP proofs exercise these refinements; their review and final-source CI status live in the implementation record. The earlier visitor/peer limits apply to every credential, so this is an extra ceiling, not a promise of independent 600/min end-to-end throughput. Whole-chain reduced-budget proofs isolate the subject limit below those early floors; a separate real-default proof exercises the 600 boundary. Subject refusals stop before reconciliation and durable rejection audit. Quota/capacity use 429 with stock-quota/60-second retry hints; new-key cleanup fault uses 503 dependency_unavailable and the common 30-second retry helper. Hints do not promise capacity or recovery. See ADR-0064.

ADR-0065 separately limits authenticated rejection audit attempts to 600 per independently defended host tenant per API instance per first-request-anchored monotonic minute. The singleton audit budget is shared by all scoped recorders and has no actor, subject, source or dimension split. Failed writes spend permits; suppression retains the refusal, count and exactly one attributed Warning without database I/O or extending expiry. Admitted events remain MUST; tenant AuditConfig cannot override the allowance or weaken general mutation durability. Ten admitted IPs can exhaust the allowance through the physical-peer floor, so later actors may have no durable evidence in that window; aggregate log volume remains unbounded. Subject admission therefore supplies no independent actor-coverage guarantee. Anonymous bursts and tenantless refusals retain their separate contracts.

429 responses include Retry-After. The edge half runs on APISIX (limit-req / limit-count plugins); the application half runs in ASP.NET, where the plan-level API-rate limit — limits.api_rate_per_minute, in the Hub's key vocabulary, which LearnStack adopts (ADR-0045 Amendment 1, ADR-0021) — can differ per tenant and the edge has no way to know it.

Webhooks (Inbound)

  • HMAC signature verification before any work runs.
  • Reject events older than 5 minutes (replay protection).
  • Idempotency: (provider, event_id) stored; duplicates ignored.
  • Tenant id derived from the stored provider account, never trusted from the payload.

Logging Hygiene

  • Never log secrets, passwords, tokens, full card numbers, full national ids, or full email bodies.
  • Redact at log-builder level via a property filter.
  • PII-sensitive fields (email, phone) are hashed in analytics; logged in plain only when necessary, with explicit allow-list.
  • Tracing tags exclude Authorization headers.

Error Messages

  • Exception strings contain no credentials or sensitive personal values. Runtime property redaction does not scrub arbitrary exception messages; the bounded compile-time guard and its limits are in Error Handling § Sensitive Exception Messages, under ADR-0032 Amendment 6. Review and closed diagnostic controls still apply.
  • Server-side messages explain what happened; client-facing messages avoid disclosure.
  • 404 used to hide cross-tenant existence.
  • Stack traces never reach clients.
  • Internal correlation id surfaced to clients to support back-channel debugging.

Dependency Hygiene

  • Renovate / Dependabot enabled. Not yet: no update bot is configured, and Phase 11 enables one.
  • Critical / high vulnerabilities patched within 7 days. The backend's restore reports an advisory of any severity anywhere in the graph, transitive packages included, and CI fails on it (NuGetAuditMode all, NuGetAuditLevel low, warnings as errors under CI) — so the seven days are a deadline for a fix, never for noticing.
  • ADR-0083's exact frontend pin neither suspends this fix deadline nor exempts a security update from qualification. Its maintainer prioritizes the reviewed replacement path; likely inability to meet the deadline reopens the choice immediately. No unqualified release or emergency bypass is authorized by that decision.
  • Lockfiles committed — frontend/pnpm-lock.yaml today. The backend has no NuGet lock files yet; they arrive in Phase 11.
  • No transitive dependency mismatch — pnpm install --frozen-lockfile in CI today, and dotnet restore --locked-mode once the backend has lock files to lock to (Phase 11).

Container & Infrastructure

  • Base images pinned by digest.
  • Run as non-root.
  • Read-only filesystem where possible.
  • Drop unnecessary Linux capabilities.
  • Image vulnerability scan in CI (Trivy or equivalent).
  • Cluster ingress restricted to documented ports.

Live Classroom Specifics

  • Join tokens scoped per user + room + role; TTL ≤ 1 hour.
  • Identity includes tenant id ({tenantId}:{userId}) to prevent room-name collision across tenants.
  • Recording indicator shown to all participants when recording is active, regardless of who started it.
  • Recording consent enforced; see docs/architecture/16-media-pipeline.md.
  • LiveKit webhook secret rotated quarterly.

Audit Log

Every privileged operation writes an audit log entry, subject to the confined routine dispatcher exception above, per ADR-0033 Audit Durability Model (supersedes ADR-0016), ADR-0044 The Audit Write Path and the 18 Audit Coverage Standard. Coverage is MUST / SHOULD / MAY per module-operation; rows are written only through IAuditStore — modules never write audit_log directly. Entries are append-only and queryable by tenant admins for their own tenant (org-admins for their org).

Security-relevant durability rules:

  • MUST-class audit fails closed. Its rows — one per audited (resource, operation), not one per request — are written on the same transaction as the business write: AuditLogBehavior classifies and parks the intents through AuditExecutionCoordinator, and TransactionExecutionCoordinator calls IAuditStore.WritePendingAsync on the owning unit-of-work frame, immediately before COMMIT, draining every intent in the scope (ADR-0044 §§ 3–4). So a privileged operation cannot commit unaudited. It also means the insert runs while app.tenant_id is set, which is what lets Row Level Security accept it. "The same DbContext.SaveChanges" was the earlier formulation and ADR-0033 withdrew it: the guarantee is the transaction, which is what a reader of audit_log observes and which needs no cross-context machinery. Exactly two failures reject the operation — an operation the catalogue does not classify (audit_unclassified_operation, 500) and a MUST-class row that cannot be written durably (audit_unavailable, 503).
  • A standalone MUST-class write failure follows the operation's own outcome. A standalone row recording an access that is being granted still rejects with 503 audit_unavailable — without it, data leaves the system unaudited. A standalone row recording an operation that is already being refused — a denied authorisation outcome, a rejected tenant assertion — keeps its own 403 / 404, because nothing proceeds unaudited and downgrading the refusal would tell an anonymous caller more, not less. Either way the failure logs at Critical, increments the standalone-write-failure counter and marks the audit health check unhealthy (ADR-0033 Amendment 1). The in-transaction class above is untouched by this.
  • A failure to read a tenant's AuditConfig overrides does not reject. Classification falls back to the in-process catalogue, which carries the same MUST floor, so nothing proceeds unaudited — and denying every request platform-wide because one cached projection is unreachable is the worse compliance outcome. The failure logs at Error; the audit health check does not move, because it answers only whether a MUST-class row can be written (ADR-0033 Amendment 6). A tenant override may narrow SHOULD/MAY coverage; it may never remove baseline MUST coverage (ADR-0033 § Fail-closed, stated precisely; asserted by Audit_Classification_Does_Not_Read_The_Database_On_The_Request_Path in 21-architecture-tests-catalogue.md, which observes that a MUST is answered without the loader ever being asked for a connection, alongside AuditConfigServiceTests and AuditLogBehaviorTests).
  • Snapshots are redacted at capture and bounded there. AuditChangeTrackerInterceptor captures every Added / Modified / Deleted entry minus a named exclusion list, and two gates run before anything is parked: a property marked [PiiSensitive] or matched by the shipped SensitiveTokenCatalog's name tokens has its value replaced with the redaction sentinel — the property is kept, so the diff still records that it changed — and each of before_state, after_state and changes is capped at JsonValue.MaxRowBytes (256 KiB), above which the value becomes an explicit elision record ({"_elided": true, "bytes": …, "sha256": …}) that preserves the column's JSON type. Never a silent truncation and never a dropped field (ADR-0044 §§ 7–8).
  • audit_log is tenant-owned and organization-scoped; audit_config is tenant-owned and tenant-wide — it has no organization_id, so it carries the tenant term only and no restrictive write guards (ADR-0044 Amendment 1). Both take the canonical RLS template for their class in the current applied schema. ADR-0072 accepts the exact global-identity departure for audit_log, implemented in Step 3; audit_config remains unmodified. Database Standards owns that policy, without a second template here. A tenant admin's read is bounded by the policy, not by the query. audit_log carries no foreign key to tenants: the record of what happened to a tenant has to outlive the tenant.
  • Append-only holds in three layers, and each stops a different actor. learnstack_app holds SELECT, INSERT only and is stopped by the absent privilege; learnstack_platform holds UPDATE on the redaction columns alone — any other column is refused by the column-level grant, before the trigger runs — plus the retention purge DELETE; and the table owner is stopped by neither, because it holds every privilege implicitly and, under FORCE, the policy constrains it by tenant rather than by immutability — measured, an owner UPDATE returns UPDATE 0 with no tenant announced and UPDATE 1 with one, so audit_log_append_only_guard is the one layer binding learnstack_migration. GDPR redaction and retention purge are the only sanctioned mutations, and IAuditStore has no update method (ADR-0044 § 9, 18-audit-coverage.md § Storage; asserted by AuditLog_Update_Is_Column_Restricted).
  • SHOULD/MAY-class audit stays best-effort, and its accepted loss is written down rather than assumed.
  • Monthly partitioning and the retention job land in Phase 11 per ADR-0035 — audit correctness cannot be retrofitted, audit scale can. Phase 02a ships audit_log as a single plain table (ADR-0028 Amendment, 2026-09-07).

Identity admission contract

Status: Global/contextual storage, common audit, seed and existing-binding API admission and live membership execution are implemented. Derived from ADR-0072, ADR-0073 and ADR-0074. The bounded P03-1 global owner alone does not activate new-user HTTP creation. The API uses the live membership adapter. The Accepted native/global-self extension expands the nine-setter implementation baseline to a ten-setter contract. The adopted database contract owns the global class, authority anchor, subject-key encoding, exact policies, owner functions and lock/snapshot rules; they are not duplicated here.

Credential, quota and binding order

Stock cryptographic/provider validation creates an immutable VerifiedProviderSubject projection. It carries the exact validated subject and issuer, matched to the installed stable authority anchor, plus validated optional actor/scope evidence. It is distinct from the normalized application identity; only a successful existing binding lookup creates that identity on P03-1's HTTP path. Neither raw claims nor a supplied UserId substitutes for either proof. Present malformed/reserved/duplicate actor or scope claims still refuse; absence of the compatibility actor claim permits only resolution of an existing Active binding. Ordinary HTTP never allocates or adopts a User. The three exact global-self descriptors below are the only Accepted endpoint-dependent exception.

Qualify the authority anchor and configuration before enabling identity admission; requests use that immutable admitted pair, with no anchor lookup before the quota. A mismatch refuses without deriving a new authority, repairing the anchor or using an email. The baseline classified HTTP pipeline remains route-independent outside that closed global-self exception:

Stage P03-1 handoff
Existing admission and host classification Preserve visitor/peer/body bounds and precedence; unknown host remains masked 404 before token/provider work.
Provider validation and credential-result gate Produce VerifiedProviderSubject; the gate recognizes that verified stage without requiring the later normalized actor. Present invalid/undecidable credentials remain fixed 401 with bare Bearer challenge.
Subject admission Debit the existing validated issuer/sub quota before any binding SQL. Preserve its refusal/fault result and request-local ownership.
Existing-binding lookup Use Standard 05's short physically READ ONLY self owner; resolve the stored actor only for an Active matching subject and absent/equal actor evidence, then close before tenant resolution.
Ordinary tenant pipeline Feed normalized ValidatedTenantIdentity into the existing resolver, assertions, authorization and surface admission. No endpoint selects a different resolution outcome or bypasses those stages.

The subject quota consumes VerifiedProviderSubject before any binding database access, including ordinary authenticated requests. Its current consumer of an actor-bearing ValidatedTenantIdentity must be replaced explicitly; merely omitting user_id from a token cannot reach that consumer. Preserve the existing validated issuer/subject key, 600-per-window quota, 4096-partition registry, retirement/fault protocol, early quotas and refusal precedence. Re-entry consumes no second permit. A changed actor, tenant, host or bearer never resets the quota; no application actor is required to debit it.

Ordinary outcomes and source handoffs

Observation Adopted response / database work
Credential-free public GET/HEAD Existing anonymous behavior; no binding lookup or allocation.
AccountSite, absent bearer Existing masked 404; no binding lookup. This is not the rule for anonymous public reads.
AccountSite, admitted valid identity and host-backed authority, wrong method Existing routing 405 with Allow: GET; absent credentials remain masked 404.
Subject admission refuses Existing 429 or registry-fault 503; no binding access.
Unknown binding, conflicting actor, collision or Closing/Closed binding Fixed 401; no allocation, adoption, alias, recreation or replacement actor.
Binding lookup unavailable Fixed 401 under ADR-0064/0071; no anonymous fallback or automatic retry.
Active matching binding Continue ordinary reconciliation with its stored actor; authentication supplies no new membership or scope authority.

Error bodies and challenges disclose no claim values or internal refusal detail. binding_store_unavailable is a closed internal diagnostic reason, not a new public Problem Details code or a trusted identity-refusal witness. Translate expected binding-store availability failures at the new boundary while preserving uniform 401; arbitrary exceptions do not establish an identity outcome. ADR-0065's host-tenant assertion allowance remains independent and supplies no global creation-audit budget.

The ordinary API's uniform 401 and ADR-0071's uncertain AccountSite policy remain: withhold authenticated content, retain the ordinary live owner without idle renewal, and add no automatic retry/refresh. Initial and refreshed publication still require the independently verified ID-token identity and matching positive AccountSite actor/scope witness, with existing late-publication fences. A failed post-refresh check revokes. The original P03-1 boundary required complete native actor/tenant claims and immediate session completion. ADR-0078 supersedes that projection with the separate verified-provider, entry and published-session types in Native entry and publication. Native opaque subject validation retains the API's 255-character bound.

P03-1 adds no POST, SDK operation or endpoint-census entry. Its source changes must cover the actual handoffs rather than only relaxing a claim parser:

Source seam Required change / preserved boundary
TenantTokenProfile, TenantCredentialFeature, TenantAuthenticationEvents Separate the verified provider subject from normalized application identity; only stock-validated facts enter either stage.
TenantCredentialAdmissionMiddleware Recognize verified-subject success before binding normalization; keep invalid-present refusal and absent-credential behavior.
TenantSubjectAdmissionMiddleware, TenantSubjectRateLimiter Consume the verified stage, retaining issuer/sub key, once-per-request debit and closed quota/capacity/fault outcomes.
New binding-admission stage and TenantResolverMiddleware Resolve only existing bindings after quota and before the existing route-independent resolver; no allocation or endpoint-dependent bypass.
TenantAuthenticationPipelineProof and current HTTP/OpenAPI/SDK controls Qualify the new stage's order and real owner; retain the exact existing public/AccountSite operation union and native publication contract.

P03-3 new-user HTTP and native activation gate

ADR-0078/0079 select the following P03-3/G1/G6 contracts in the native/global-self extension. Implementation must qualify these prerequisites before adding the HTTP caller:

  • Provider signup/admission policy and the durable User/creation-audit budget. The retained subject registry and per-subject quota do not bound lifetime new-subject cardinality.
  • Exact route/method, absent-token and masking behavior, and an explicit bounded supersession of ADR-0064's prohibition on endpoint-dependent resolution.
  • A separate bootstrap caller and exact SDK/OpenAPI ownership, cache/input/body bounds, verified host/source/hop contract and complete operation census.
  • An independently verified pre-session provider projection and exact bootstrap issuer/subject/actor association before ordinary session publication.
  • Typed session/no-session completion with an explicit response, cookie, cleanup and expiry table, including tenantless success and preservation of an existing session during a failed replacement.
  • Exact native handling of bootstrap 400/401/404/429/503, cancellation and unknown results, with no automatic mutation retry or inference of rollback from a missing response. Do not inherit AccountSite's read classification by accident.
  • Real actorless and tenantless provider/native controls, wrong association, response loss, capacity, cancellation and late-publication qualification.

The necessary ADR-0053/0064/0070 changes and G1/G6 authority acceptance precede that activation. Standard 05 remains the canonical prepared global owner/audit contract; its existence admits no HTTP endpoint or native caller.

Owner and setter census

Standard 05's global self lookup, prepared creation, trusted seed adoption and central reconciliation owners announce verified subject authority only. Their immutable connection/transaction marker is a separate mode of the common guard, not a table-name exemption or a tenant context. Only their enumerated setters may announce identity variables derived from verified/captured facts, never payload or headers. Missing markers, foreign subject reuse, nested tenant/global ownership and pooled authority leakage refuse. Ordinary tenant setters, factories and audit writers must hard-refuse TenantId.PlatformSentinel and global owner mode. Ordinary pending gains its missing explicit refusal before admitting the global branch; the source-bound requirements are in Standard 05.

The adopted global audit methods and confined definer functions are enumerated in Standard 05. Their bounded target/operation/subject evidence is the only new authority. Sentinel attribution conveys neither PlatformAdminScope nor operator permission. Runtime never receives a migration credential; the new functions do execute with migration-owner rights and need their own exact privilege/body/caller controls before admission. No general SQL executor, global User directory or direct self audit read follows from them.

PreContextIdentityAuthorizationReader is the Accepted ninth logical tenant setter under ADR-0076. Its confined reader/source and normalized HTTP candidate minter supply the API membership adapter. A verified normalized actor or separately enumerated trusted execution descriptor supplies the exact candidate actor/tenant/org; UserId alone supplies no authority. Its isolated child uses the confined source ledger, physical READ ONLY and narrow UoW announcement seam, with no ambient TenantContext or new global User/profile/directory projection. It closes before ordinary business continuation, including when a nested/post-lock parent already exists. Tenancy's lifecycle reader uses this same physical child owner/generation.

Ten is the P03-3 logical setter baseline under ADR-0078; ADR-0093 adds the eleventh operation-bound invitation setter described below. The phase Status block owns current implementation, activation and proof for those paths. The ninth's reader/owner has source, database and exact trusted-source controls. Global subject owners and the confined central denial-event methods add no tenant-context writer or logical setter. ADR-0080's fixed-seed scope-only sites preserve the P03-3 ten-setter baseline; ADR-0093 retains the same four physical context writers. Standard 05 owns their distinct source/caller census and temporary state/write barriers as well as the source/configuration/retained-cleanup ledger.

Bounded contention and pending proof

Use Standard 05's canonical owner, lock, snapshot, deadline and cleanup contracts; no second allocation/reconciliation algorithm is defined here. The canonical lock signal contract owns names and closed labels. Phase 11 owns production alert thresholds and capacity tuning; bounded acquisition and cancellation are P03-1 prerequisites.

ADR-0074's contextual admission contract requires the actual mutation trigger's fresh target check even when the complete ordered lock plan is preheld. Its heldness observation does not establish session-versus-transaction ownership, acquisition history or the CLR caller. Standard 05's closed caller/source and transaction-lifetime controls supply those separate obligations. Internal lock-plan faults and expected unknown/nonadmitting target refusals have distinct closed classifications and the same outward fixed refusal, without a User-state oracle. Standard 05 owns the exact checks; no general User probe is admitted.

P03-1 qualification covers actorless API credentials for an existing adopted binding, unknown/conflicting/closed bindings without allocation, quota before every binding read, unchanged anonymous/AccountSite method behavior and uniform 401 during store failure. Existing seed provider/native sessions retain their complete identity and independent AccountSite witness. Standard 05 owns the creation/audit, RLS, race, reconciliation and owner-cleanup proof obligations; new-user HTTP/native entry follows the native admission contract. Phase 03 owns qualification and packet status.

Uncached binding admission adds a database dependency to every authenticated request, including credential-bearing public reads. Qualify actual app-role lookup plans, representative before/after local latency, bounded pool checkout and cancellation/connection cleanup under contention and outage. A lock timeout alone does not establish pool capacity or a complete request deadline. No measured production p95 or extension of ADR-0047's qualified capacity follows; Phase 11 retains production sizing. Step 4 qualifies the actual Membership trigger's pg_locks observation under Standard 15's p95 budgets, as specified by the lock signal contract. Its real mutation/race/cleanup and local command-path measurement controls qualify their recorded contextual scope. Existing-binding load observations retain their distinct control and scope; live-admission qualification remains P03-2. None establishes production capacity.

Live authorization contract

Live HTTP and application policy share the canonical admission boundary. ADR-0076/0077 replace only the live-policy, ninth-setter and evidence deferrals identified here. The existing credential/quota/binding, context writers, public ceilings and BFF contracts remain; the phase Status block owns implementation and qualification state.

The frozen OperationPolicyCatalog, scoped coordinator and Identity evaluator are registered in the API and seed composition roots. Exact seed, validated delivery and retained job descriptors supply finite authority independently of SystemActor values. HTTP controller/infrastructure metadata and named requirements are enforced before dispatch. The canonical AuthorizationBehavior occupies pipeline slot 5; the administrative directory is the sole added HTTP route.

  • Freeze exact HTTP endpoint and application-request policies at composition: public reads, special AccountSite, scoped protected capabilities and finite trusted seed/system operations. Infrastructure endpoints are explicitly classified too. Missing, unknown or conflicting declarations deny at runtime, in addition to the structural census. Named ASP.NET requirements must agree with application metadata in SharedKernel/contracts; modules gain no API or ASP.NET dependency. When no endpoint matches, routing retains 404/405.
  • Preserve earlier visitor/host/credential/subject/binding/assertion precedence. On admitted protected surfaces absent credentials or noncoverage is masked 404; valid coverage without capability is 403 before handler/resource lookup. Only admitted capability permits lookup: missing/foreign-scope rows are 404, a visible row failing ownership policy is 403. Wrong methods keep routing semantics after earlier refusals. HostOnly never becomes protected authority.
  • Every protected act queries committed Membership/Role/grant authority again. Resolver evidence never authorizes a later HTTP or application act; direct MediatR repeats classification/capability at step 5. Only active/nondeleted Memberships, compatible assignments, nonretired Roles and active registered definitions contribute. NULL-org Tenant grants compose only with the requested organization's grants. Coverage and capability remain distinct outcomes.
  • The separate lifecycle statement admits Trial/Active tenant and, when named, Active/nondeleted belonging organization. Identity and Tenancy statements each see committed state; they are not one atomic cross-module snapshot. A revocation committed before the act's grant query affects admission; already-admitted work may finish. Earlier subject binding remains the global User eligibility check. Lifecycle transitions retain the P03-3 administration contract; P03-10 owns closure fences before writers.
  • Authorization storage unavailability is 503 dependency_unavailable, never false noncoverage or invalid-token 401. Binding lookup retains its separate uniform 401 contract. Cancellation propagates through owned cleanup. No cache, retry or configurable authority override is admitted.
  • AccountSite gains live Membership/lifecycle admission on matching resolution rows 6/9, retaining audit Off, exact physical READ ONLY and real matching HostScope; ClaimAndMembership remains ineligible. Row 7 independently retains its organization validator. Public reads retain publication/HostScope ceilings; credential-free public reads perform no identity/grant lookup.
  • Seed, event and job owners keep exact validated source/request contracts. SystemActor UUID, provider role or unknown/direct-untrusted source supplies no bypass. Platform admission remains P03-9. Unrouted production requests also require exact classification; no pass-through authorization behavior remains.

Standard 19 owns the finite public definitions and grant/delegation rules; Standard 04 owns the sole new HTTP consumer. The database ledger owns physical owners, progress, deadlines and production topology qualification.

Standard 18 owns the typed per-occurrence MUST denial population, separate plural resource intents, fixed microsecond occurrence identity and clean scoped full-draft duplicate verification. Its pre-success barrier binds unresolved denials to an enclosing ReadWrite commit or acknowledged standalone evidence before ReadOnly/ownerless disclosure. An actual refused caller keeps 403/404 on audit failure; an outer success that absorbs that denial fails before commit/disclosure if required evidence is unavailable. Event evidence never proves an uncertain business COMMIT. Successful ClaimAndMembership resolution followed by a conflicting assertion has its own Membership-confirmed event, outside the host allowance. Existing row-7/10/refused-14, anonymous/tenantless and earlier refusal evidence boundaries remain unchanged.

Native scope and global-self admission

Derives from: ADR-0078 and ADR-0079. Accepted contract; activation proof pending. The phase Status block records implementation. The database extension owns function/ACL/policy/counter/setter and seed/idempotency lock details.

Source and pipeline

The public host hop and native scope source are distinct purposes. Require the existing trusted network/host-hop proof plus a separate random 32-byte canonical 43-character base64url X-LearnStack-Native-Session-Secret. Public-hop credentials alone never select scope. Native strips browser headers and constructs the exact held bearer, captured host/visitor and native headers itself. SDK/public callers gain no arbitrary bearer, selector or header escape hatch.

Scoped calls carry one canonical lowercase nonreserved Tenant UUID in X-LearnStack-Native-Tenant-Id and optional Organization UUID in X-LearnStack-Native-Organization-Id; absence means explicitly orgless. The exact global-self set forbids both selectors. Empty, duplicate, malformed or partial families fail without fallback. Existing X-Tenant-Id/X-Organization-Id remain assertions against the final resolved context and never fill a native field.

API secret acceptance is current plus at most one previous value, compared in constant time. A previous value needs an absolute UTC retirement instant at most 15 minutes after qualification; restart never renews it and expiry adds no skew grace. Native sends current only. Reject placeholders, malformed/equal public and native values and incomplete rotation. Public-only mode needs no native key. Secrets remain behind the existing secret-provider boundary, outside logs, artifacts, DTOs, cookies and client code.

The API reads current/previous credentials from secret-provider keys Identity:NativeSession:CurrentSecret and Identity:NativeSession:PreviousSecret; Identity:NativeSession:PreviousRetiresAt supplies the absolute retirement time. Identity:SelfBootstrap:Enabled defaults to false. Enabling creation without a qualified native source fails startup. Self-discovery destinations use the independently stored normalized host and deployment setting Identity:NativeSession:HttpsPort (443 by default, canonical decimal 1–65535). The local HTTPS web owner uses 3000. No submitted URL, forwarding port or query parameter can select this origin. Unrelated traffic and early source/shape refusals never resolve the lazy global-self SQL ports merely to traverse this middleware. Binding-dependent outcomes require their confined lookup.

Order Boundary
1 Existing peer/visitor/body admission and independent real-host classification. Unknown-host precedence remains.
2 Stock provider validation produces immutable VerifiedProviderSubject; credential gate preserves invalid-present 401.
3 Existing once-per-request issuer/sub quota, before any identity SQL.
4 Validate native-purpose credential and exact source/header shape; this grants no scope.
5a Only the three exact global-self descriptors below branch; no generic unresolved MediatR entry or TenantContext.
5b All other authenticated calls resolve the existing Active binding; retain provider/binding facts in ValidatedTenantIdentity.
6 Admit a separate immutable NativeScopeEvidence bound to the actual request/credential/host/provenance; live candidate checks confirm Membership and lifecycle.
7 Resolver creates HostAndNativeSessionScope; legacy assertion, surface classification and independent HTTP/application/resource policy checks remain.

Stored actor always comes from the Active binding. Provider tenant/org remain provider evidence, never rewritten as native evidence. For tenantless issuance, the full candidate tuple comes from native ownership. If provider tenant is also present, its entire tuple, including absent organization, must exactly match; an optional actor claim must match the stored actor. No source wins a conflict. Tenant host admits its own tenant and valid covered branches; Organization host requires the exact tenant/org and rejects orgless selection. Platform hosts (localhost/configured platform entry) have no native scoped AccountSite or Studio/Portal authority. Existing direct-bearer/platform-host rules remain. Native Studio/Portal use independently mapped Tenant/Organization origins; manage.* development origins are Tenant hosts with NULL org, not Platform hosts.

The new origin permits only independently declared protected scoped operations, the existing read-only tenant-read witness where its Tenant clause succeeds, and actual-host-backed AccountSite. An Organization clause never mints tenant read authority. It adds no Platform authority, provisioning exception, SQL write hatch or [PublicSurface] admission. Extend the enum comment, factories, candidate minters, resolver, surface middleware and exact caller/operation tests together; an origin enum member alone is not authority.

Global-self outcomes and association

Descriptor Closed result contract
GET /api/v1/identity/self Off/no-store own Active binding witness or genuinely missing. Closing/Closed/conflict/collision is fixed 401, never missing; unavailable/indeterminate storage is 503.
POST /api/v1/identity/self/bootstrap Empty JSON, at most 512 bytes; explicit person-confirmed opt-in. Acknowledged new allocation 201 with exact self Location; matching Existing 200. No Membership or tenant session is implied.
GET /api/v1/identity/self/contexts Off/no-store own relationship navigation, 20 default/100 maximum and 1,024-character cursor; no supplied actor, profile, grant or global directory.

These require a known Tenant/Organization host, verified provider subject and native source. Missing bearer/source is masked 404; present invalid credential is fixed 401. Wrong method after admission is 405 with exact Allow. Malformed input is 400. Creation disabled is 403, disclosed only to this admitted subject; spent attempt windows are 429 with integer-seconds Retry-After in 1–86,400. Permanent capacity/unavailable/unknown creation result is 503, with no invented time hint. No token/source value or internal failure reason appears in the error body.

Bootstrap is a tenantless natural-key convergent create, not a tenant-keyed Idempotent endpoint and not an external-side-effect operation. Reject an Idempotency-Key rather than inventing an unscoped namespace. The prepared subject-key owner serializes creation. A concurrent admitted loser returns 200 Existing without another creation intent; the winner returns 201 only after acknowledged commit. A 503 or lost response establishes neither rollback nor success. A deliberate retry reads 200 if creation committed, or may attempt a new 201 if still absent and all budgets admit; it never replays a fabricated audit success. Actual declared failed/indeterminate creation attempts retain central reconciliation. Existing binding reads declare no new creation intent.

The server-only association witness contains stable AuthorityId, canonical subject digest, stored actor and applicable context. Native computes the existing canonical digest from its independently validated ID-token subject and configured authority; exact issuer must also match that authority's admitted current issuer. It never hashes the issuer URI in place of the stable AuthorityId or trusts decoded access claims. Keep the witness only in the corresponding entry/credential revision; clear it with actual owner cleanup, refresh replacement, expiry and logout. It remains linkable personal data, not a browser projection or anonymous identifier.

ADR-0078's server-only boundary also covers identity material embedded in an upstream cursor: reversible encoding does not make it a browser projection. Native entry continuations use authenticated confidentiality under one fresh, server-only entry key, bound to the actual owner, host, epoch and paging purpose. The key is independent of cookies/handles/CSRF and is cleared on publication, logout, expiry and close; actual work still governs owner release. This adds no configured credential, page registry or admission authority. The existing browser cursor bound and original entry lifetime remain. Validate canonical input and authentication before any discovery call or selection transition; malformed or foreign continuations are 400, not configuration/availability failures. API cursor/response validation remains strict.

Entry selection freshly checks global-self /identity/self/contexts. A published same-host switch instead freshly reads /identity/me/contexts with the old held credential and immutable current scope, so tenant-wide coverage can suggest its eligible derived branches. Each API retains its own opaque cursor binding; a scoped refusal never falls back to global discovery. Destination hints remain non-authorizing suggestions and never bypass destination discovery/confirmation.

Initial selection/explicit switch uses ordinary ReadWrite POST /api/v1/identity/session-scope/admissions, with MUST identity.membership.admit_session_scope before its witness and positive AccountSite. Unknown commit cannot publish success or prove browser delivery. Ordinary refresh instead runs internal ValidateRefreshedScope: Off GET self with the new held token and no selectors, then Off AccountSite with that same token and unchanged native context. It writes no new admission event, creates no User and changes no scope. Existing stricter post-refresh failure/revoke, single-flight, epoch and original lifetime rules remain.

Native entry and publication

ProviderIdentity, the pre-session projection, separates verified issuer/sub and optional compatibility actor from PublishedSessionIdentity's stored actor and API-admitted tenant/org. LoginCompletion carries only an entry; SessionCompletion requires explicit selection and positive API authority. Callback reserves the verified subject's owner capacity before self-discovery and publishes only the separate opaque __Host-ls-entry handle, never a tenant session. Credentials stay in existing bounded server owners; browser receives only opaque host cookies and entry/session-bound CSRF. RSC projections and immutable page snapshots carry no bearer, provider subject or association witness. Snapshot admission is 128 global/eight per owner and at most 256 KiB each; retain reservations through actual request/render/body cleanup, not timeout.

An escaped transient provider label is advisory, never account continuity proof. Two subjects with the same name, truncation or Unicode presentation cannot authorize replacement: a live destination session is replaceable only by exact verified issuer/sub equality. A different subject keeps the old owner and uses explicit sign-out/re-entry recovery. Cross-host navigation supplies no source-person continuity or credential transfer. Same-host switching retains the original verified subject and absolute/provider deadlines.

Entry sign-out uses the current entry CSRF and the held ID token to start provider logout, clearing only the entry cookie and setting an independent bounded logout proof. It preserves the old live session, sibling login transactions and their epoch. Actual selection work is aborted and joined; the entry/logout owner keeps its capacity until cleanup and return/expiry settle. If provider logout URL construction fails, return only to local /api/auth/signin; do not restore the discarded credentials. Provider SSO logout may affect other institution tabs, so localized recovery explains that scope without promising their continued login.

An Off capability preview supplies nine finite Booleans with the exact predicates in the Identity specification. False previews produce no denial event and do not replace fresh mutation checks. Role/permission configuration reads remain MUST; aggregate ETags never silently cover independent capability state.

Activation requires actual actorless/tenantless code+refresh, wrong association, terminal binding, held-work/switch/logout, source rotation, native HTTPS and same-label/different-subject browser controls. Gate acceptance and document lint establish none of those runtime outcomes.

Verified Invitation Admission

Derives from: ADR-0093. This is an Accepted contract with P03-4 implementation and qualification pending in the phase Status block. Ordinary native/global-self admission above remains; the three global-self descriptors do not gain a fourth generic consumer.

Exact offer authority

The separate invitation branch follows captured visitor/body/host checks, stock credential validation, issuer/sub quota and independent native-purpose source validation. Only known Tenant/Organization hosts participate; Platform or unknown hosts refuse. Browser body/header tenant/org selectors cannot supply authority. Provider scope and legacy assertions, when present, must exactly agree with the final offered scope and never win a conflict.

A short physically ReadOnly app-role owner invokes the exact migration-owned offer-entry function with Invitation ID, the application-derived 32-byte domain-separated SHA-256 digest, admitted host tenant, installed authority and verified subject key. Raw codes do not cross into SQL parameters. Standard 05 owns its exact signature, static body, fixed search path, ACL, Invitation-only owner visibility and projection. It exposes only the exact offer tuple, actor/inviter admission facts and bounded terminal reconciliation facts, never email, provider subject, profile, Role graph or arbitrary User state. SQL digest equality is not claimed constant-time; actual fixed-length application secret comparisons use fixed-time equality. This is no general User reader.

The preliminary owner closes before InvitationEntryScope consumes a single-use immutable witness on the subsequent operation owner. It binds actual request/invocation, credential, verified subject/actor, native source, admitted host and exact stored offer/version/generation/tenant/org tuple. TenantHost caps tenant; OrganizationHost requires the exact organization. Changed, missing, foreign-purpose or reused evidence refuses before announcement. The new HostAndInvitationOffer origin admits only inspection, acceptance and original own-result consumers. It grants no PublicSurface, AccountSite, directory, ordinary protected act, Platform capability or SQL hatch. The existing four physical context writers implement eleven logical tenant setters. Standard 05 owns eleven confined definers: the prior eight, exact offer entry and the two SMTP source lease functions; accepting this census proves no runtime body/ACL.

New actors use deliberate opt-in global-self bootstrap first, committing and disposing its owner before tenant work. Disabled bootstrap stays disabled. Matching email cannot merge identities; global User and tenant owners never join or retarget. Membership creation does not itself admit a native session.

Recipient, atomicity and fences

ICurrentInvitationRecipientReader calls only the configured issuer's fixed UserInfo endpoint with the stock-validated access token. Bound one fresh no-cache observation to each Pending inspection/first acceptance, exact sub, token/request/invocation identity and original deadline. Require one well-typed email, Boolean verified=true and the ADR's normalized mailbox match. Duplicate, ill-typed, malformed or missing claims refuse; bound the response to 16 KiB and five seconds within the existing native deadline, with no retry extension and no business lock during I/O. No realm-admin credential, supplied URL, redirect or email mirror is admitted. A later provider edit may race database commit; fresh UserInfo is not an atomic provider/database snapshot.

Acceptance takes sorted exclusive invitee/stored-inviter User keys before the complete strongest Tenant fence, then offered Role, Invitation and existing target Membership locks. Nonadministrator effects retain shared Tenant mode; actual eligible Tenant Admin effects require exclusive mode. Create/revoke/ reissue take shared mode unless an actual protected Membership effect requires more. Fresh post-wait reads revalidate the complete mode/graph; changed mode returns 409 without fallback or upgrade. Role scope must match the offer exactly: Tenant Role for NULL organization, Organization Role for non-NULL organization. The same-key delegation bridge never licenses a cross-scope Role assignment. The stored inviter's current capability and whole pinned Role must remain within the current ceiling. Recheck invitee admission under the retained User fence, including already_member, where no INSERT trigger can supply that check.

One existing ordinary ReadWrite owner atomically consumes Invitation and creates Membership when needed, with two central MUST resource intents. Active existing Membership consumes without elevation; Suspended refuses; Revoked history gets a fresh successor ID. The created/already_member disposition is tracked Invitation state and its central capture, never arbitrary audit metadata. An unchanged Membership is a designated subject-only security event with no fake creation diff. Rollback or failed MUST audit persists neither new grant nor consumption. Accepted preflight selects the exact Off result query before mutation intents; a Pending race loser records truthful failure and uses a fresh read. Original-result disclosure requires the same Active actor, consumed digest and original current Active Membership, without reimposing old email, inviter, Role pin, expiry or version. Unknown COMMIT never becomes asserted rollback.

Automatic email and private custody

Offer creation and reissue atomically stage generation/expiry/verifier, private authenticated encrypted mail custody and an identifier-only typed job. No raw code/link enters API admin responses, replay receipts, job/event payloads, snapshots, diagnostics or audit. Credentials/verifiers stay outside DSAR artifacts; authorized recipient/relationship access remains P03-10's contract. The exact SMTP adapter, lease functions, private data source and four-slot source admission follow Standard 05. Only JobExecutor mints the source witness; external SMTP execution owns no application database unit of work. A timeout or crash with unknown effect retains the reservation until positive quiescence/reconciliation; no elapsed timer releases unsettled work. A positively observed final 250 survives a subsequent disconnect fault only after actual local socket/task cleanup and a durable exact provider receipt. Unconfirmed SMTP cleanup retains its concrete transport resources and admitted provider connector, marks that lane unhealthy and blocks its disposal; Unknown alone does not prove physical cleanup. Actual server DETAIL and sampling qualification follows the database privacy controls. Explicit reissue can cause a late old invalid email but does not release its reservation or imply exactly-once external delivery. Report queued/submission accepted/failed/uncertain facts; never claim inbox delivery from SMTP acceptance.

Raw codes are confined to encrypted private mail custody, email/SMTP delivery and bounded native volatile continuation. Fragment-only email links, native capture, the separate opaque __Host-ls-invitation cookie, owner/subject quotas, fixed verification action and cleanup follow the frontend contract. The preauthentication invitation owner shares the 1,024 total/128 pending/16 per-source/five-minute pending bounds and, after verification, eight owners per issuer/sub, including other entry/session/logout owners. A new invitation replaces only an earlier invitation continuation in that host/browser group. It cannot replace another subject's live session. Actual cleanup retains credit for unsettled work. GET renders navigation; secret inspection/result use POST bodies. Real app-role source/body/ACL, competing-commit, provider changed-email, SMTP fault/reconciliation, sink-canary and HTTPS/browser controls precede activation. Human accessibility evidence remains an independent obligation.

Incident Response

  • A security incident playbook lives in docs/runbooks/security-incident.md (Phase 11 deliverable).
  • The team rotates an on-call slot for production.
  • Critical incidents trigger pager + Slack channel; post-mortem within 7 days.

Forbidden

  • Storing passwords or password derivatives in any LearnStack table.
  • Storing third-party tokens in plain text — encrypt at rest if storage is unavoidable.
  • Trusting tenant_id or organization_id from a request body or query param. Both come from the resolved tenant context only (API Standards § Tenant Context), which an anonymous request resolves from its effective host.
  • Implementing custom crypto. Use established libraries.
  • Disabling RLS in production for any reason short of an investigated incident with an ADR.
  • Echoing user-supplied HTML without sanitization.
  • Logging full request/response bodies.
  • Calling Hub endpoints from anywhere except the dedicated IEntitlementProvider / IUsageReporter / IHubTenantSync adapters (see 20-infrastructure-stack.md).
  • Accepting learnstack-hub realm tokens on tenant-facing endpoints, or accepting learnstack realm tokens on /api/internal/* endpoints.
  • Bypassing APISIX with direct backend ingress.