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.
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).
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. |
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 testEvery_OrgScoped_Entity_HasOrgIdAndFilterchecks the marker. - No
IgnoreQueryFilters()outside the auditedEnterPlatformAdminScope(reason)call path. - Background jobs use the typed scheduler; trusted submission stamps tenant
and optional organization on the authoritative row, and
JobExecutorrestores them before business work. Vendor wakeups carry opaque references (ADR-0047). - Every integration event payload carries
tenant_id(andorganization_id?when relevant); consumers restore tenant + org context before handling. - Raw SQL queries (if any) include
tenant_id(andorganization_idwhen 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.
- 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.
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 |
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 acceptlearnstack-hubtokens; the internal API (/api/internal/*) only acceptslearnstack-hubtokens 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.
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_refusedfor 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_refusedis 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.
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-authplugin verifies the access token against thelearnstackrealm's public key set; the API also verifies. Both must pass.corsplugin handles preflight; authenticated cross-origin traffic is allow-listed per environment.limit-req/limit-countplugins enforce the rate-limit policy below.- The
/api/internal/*route set is gated by mTLS configured on the APISIX SSL object (client.ca+client.depthper APISIX 3.x SSL-config; mTLS is not a route plugin) plus anip-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 ininfra/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.
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-Signatureheader (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 return401, with no detail. Do not write a handler that returns401for 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, orIHubTenantSync. 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-mappingsby path, never by value (ADR-0034). - Host resolution never calls the Hub.
IHostToTenantResolverreadsplatform_host_to_tenantand nothing else, so a Hub outage cannot take anonymous public pages down.
Every protected tenant act checks, in order:
- Authentication (valid token).
- Tenant membership (user has a Membership in the resolved tenant).
- Role / permission (user's roles include the required permission).
- 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.
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 anOrganizationIdcolumn and an org-aware EF filter + RLS policy (ADR-0017). NullableOrganizationIdmeans the row may be tenant-wide. IgnoreQueryFilters()is allowed only inside the auditedEnterPlatformAdminScope(reason)call path, which is itself the record of the access: the scope writes itssecurity-eventrow throughIAuditStore.WritePlatformScopeAsyncon 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 § HowEnterPlatformAdminScope(reason)reacheslearnstack_platform). The architecture testNo_IgnoreQueryFilters_Outside_PlatformAdminScopeis 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
IBackgroundJobSchedulerinside the enrolled writable transaction. Tenant/org authority comes from trusted submission and the authoritative job row;JobExecutorrestores context before business work. Hangfire wakeups contain only opaque references. See the submission rule and ADR-0047. - The
app.tenant_idandapp.organization_idsession variables are set withSET LOCALinside the ambient transaction — see § Tenant Context immediately below, which is the single authority for that placement.
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.
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.
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 unsetapp.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.
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 |
IOrganizationScopeValidatorperforms row-7 organization narrowing after affirmative live Membership admission.TenantAssertionMiddlewareseparately 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
ISenderqueries in fresh trusted scopes; its old private transaction and directSetTenantContextAsynccall 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).
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.
- ADR-0003 Amendment 3 — the
corrected RLS policy template, the four-role database model, and the rule that
isolation tests connect as
learnstack_app. - 05-database.md § Tenant-Owned and Organization-Scoped Tables — the single canonical SQL template. It is not repeated here, or anywhere else.
- 05-database.md § Connection Management — PgBouncer transaction-mode pooling, which this rule depends on.
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.
- Backend secret reads go through
ISecretProvider. The registered implementation isConfigurationSecretProvideruntil Vault's trigger fires — a production secret must rotate without a redeploy, or more than one operator needs access to production secrets — at which pointDaprSecretProvider→ Vault takes over per ADR-0038 and ADR-0035. Call sites are identical either way..env.exampleis checked in;.envis gitignored. - Secret namespace:
learnstack/{deployment}/{module}/{key}. The deployment segment isdevelopment | 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
ISecretProvideris 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.
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-Addressandtraceparent. 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.
- Validate MIME type with content sniffing, not just the
Content-Typeheader. - 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}(withorganizations/{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.
- Use parameterized queries everywhere. No string interpolation into SQL.
- EF Core LINQ is preferred; raw SQL only with explicit
FromSqlInterpolatedand parameterized values. - No string concatenation with user input.
- Stored procedures are not used; the application owns the logic.
- React encodes by default — preserve that.
dangerouslySetInnerHTMLonly 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.
- 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-originreferrer policy so native forms retain that Origin proof. Provider redirects and the ID-token-hint logout exception retainno-referrer. Missing ornullOrigin 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.
- 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.
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.
- 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.
- 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
Authorizationheaders.
- 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.
- 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 (
NuGetAuditModeall,NuGetAuditLevellow, 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.yamltoday. The backend has no NuGet lock files yet; they arrive in Phase 11. - No transitive dependency mismatch —
pnpm install --frozen-lockfilein CI today, anddotnet restore --locked-modeonce the backend has lock files to lock to (Phase 11).
- 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.
- 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.
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:AuditLogBehaviorclassifies and parks the intents throughAuditExecutionCoordinator, andTransactionExecutionCoordinatorcallsIAuditStore.WritePendingAsyncon the owning unit-of-work frame, immediately beforeCOMMIT, draining every intent in the scope (ADR-0044 §§ 3–4). So a privileged operation cannot commit unaudited. It also means the insert runs whileapp.tenant_idis set, which is what lets Row Level Security accept it. "The sameDbContext.SaveChanges" was the earlier formulation and ADR-0033 withdrew it: the guarantee is the transaction, which is what a reader ofaudit_logobserves 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 — adeniedauthorisation 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 atCritical, 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
AuditConfigoverrides 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 atError; 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 byAudit_Classification_Does_Not_Read_The_Database_On_The_Request_Pathin 21-architecture-tests-catalogue.md, which observes that a MUST is answered without the loader ever being asked for a connection, alongsideAuditConfigServiceTestsandAuditLogBehaviorTests). - Snapshots are redacted at capture and bounded there.
AuditChangeTrackerInterceptorcaptures everyAdded/Modified/Deletedentry minus a named exclusion list, and two gates run before anything is parked: a property marked[PiiSensitive]or matched by the shippedSensitiveTokenCatalog'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 ofbefore_state,after_stateandchangesis capped atJsonValue.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_logis tenant-owned and organization-scoped;audit_configis tenant-owned and tenant-wide — it has noorganization_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 foraudit_log, implemented in Step 3;audit_configremains 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_logcarries no foreign key totenants: 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_appholdsSELECT, INSERTonly and is stopped by the absent privilege;learnstack_platformholdsUPDATEon the redaction columns alone — any other column is refused by the column-level grant, before the trigger runs — plus the retention purgeDELETE; and the table owner is stopped by neither, because it holds every privilege implicitly and, underFORCE, the policy constrains it by tenant rather than by immutability — measured, an ownerUPDATEreturnsUPDATE 0with no tenant announced andUPDATE 1with one, soaudit_log_append_only_guardis the one layer bindinglearnstack_migration. GDPR redaction and retention purge are the only sanctioned mutations, andIAuditStorehas no update method (ADR-0044 § 9, 18-audit-coverage.md § Storage; asserted byAuditLog_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_logas a single plain table (ADR-0028 Amendment, 2026-09-07).
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.
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.
| 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. |
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.
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.
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 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.
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.
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.
| 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.
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.
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.
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.
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.
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.
- 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.
- 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_idororganization_idfrom 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/IHubTenantSyncadapters (see 20-infrastructure-stack.md). - Accepting
learnstack-hubrealm tokens on tenant-facing endpoints, or acceptinglearnstackrealm tokens on/api/internal/*endpoints. - Bypassing APISIX with direct backend ingress.