diff --git a/.claude/skills/add-frontend-route/SKILL.md b/.claude/skills/add-frontend-route/SKILL.md
index b4d9de2f..9dc4623c 100644
--- a/.claude/skills/add-frontend-route/SKILL.md
+++ b/.claude/skills/add-frontend-route/SKILL.md
@@ -15,9 +15,9 @@ description: >
## Purpose
-Land a new route in `frontend/apps/web` that respects route-group conventions, tenant + org
-resolution at the edge, Server-Component-first rendering, and the typed SDK
-contract per
+Land a new route in `frontend/apps/web` that respects route-group conventions,
+API-owned tenant/organization resolution, Server Component rendering and the typed
+SDK contract per
[14-frontend-architecture.md](../../../docs/architecture/14-frontend-architecture.md)
+ [07-frontend-architecture.md](../../../docs/standards/07-frontend-architecture.md).
@@ -53,7 +53,7 @@ contract per
| Group | Purpose | Auth | Default render |
|-------|---------|------|----------------|
-| `(public)` | Tenant public site (marketing, catalog, blog). | Anonymous by default; auth optional. | SSR + ISR-like cache per `(tenantId, organizationId?, locale, slug)` (under an open gate — see Step 8). |
+| `(public)` | Tenant public site (marketing, catalog, blog). | Anonymous by default; auth optional. | Dynamic SSR, no-store per ADR-0053; see Step 8. |
| `(studio)` | Tenant admin Studio. | Tenant-admin or org-admin. Required at the edge. | SSR, no cache (always fresh). |
| `(portal)` | Learner / instructor portal. | Membership in the resolved tenant. | SSR shell + Client Component for interactivity. |
@@ -76,22 +76,23 @@ unless the screen genuinely needs one.
```tsx
// page.tsx (Server Component by default)
-import { sdk } from "@learnstack/sdk/server";
-import { UsersTable } from "./_components/users-table";
+import { createConfiguredPublicClient } from '@/server/configured-public-client';
-export default async function UsersPage({ searchParams }: { searchParams: { q?: string } }) {
- const users = await sdk.identity.listUsers({ query: searchParams.q });
- return ;
+// The configured server caller supplies this transport; never a tenant-ID option.
+export function loadCourses(envelope: string | null) {
+ const client = createConfiguredPublicClient(envelope);
+ if (!client) throw new Error('Invalid public ingress');
+ return client.getCourses(); // Locale comes from the authenticated route.
}
```
-> **Open in Phase 02d.** The call shape above is illustrative. At HEAD
-> `@learnstack/sdk/server` exports `createServerSdk(options)`, a typed stub that takes
-> a `tenantId` and a `locale` and returns `{}`; no `sdk` object and no module namespace
-> exist. What the SDK surface becomes once regeneration makes `paths` non-empty is G31,
-> and the server transport's options are G35, in
+> **P02d-4 delivered.** `@learnstack/sdk/server` exports an injected
+> `createServerSdk(transport)` with four typed public GET wrappers; no global `sdk`
+> object, tenant-ID option or module namespace exists. The example is a loader,
+> not a complete route. P02d-5/G35 delivers the configured trusted transport in
> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register);
-> the passes that close them edit this step.
+> that caller precedes P02d-6 public page consumers. Read the verified envelope
+> from request-local `headers()`; never create or expose a provenance stamp in a page.
Rules:
@@ -106,20 +107,48 @@ Rules:
The API resolves tenant and organization from the host
([ADR-0036 § Effective host and the trusted hop](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md#effective-host-and-the-trusted-hop)).
-The frontend never calls `IHostToTenantResolver`, and ADR-0036 makes
-`frontend/packages/sdk/src/server.ts` the only frontend place that sets the hop
-headers. At HEAD that file is a typed stub, and `src/middleware.ts` is a scaffold
-that copies the raw host into `x-tenant-id` and sets `x-locale`; it sets no
-`x-organization-id`. Their replacement is recorded as G35 and G36 in
+The frontend never calls `IHostToTenantResolver`. P02d-4's SDK is a pure injected
+transport contract and sets no hop headers. ADR-0053 replaces ADR-0036's older
+exact setter
+path with one server-only adapter in apps/web. P02d-5 Step 3's Node middleware
+verifies the native envelope, bootstraps the live host and applies enabled-locale
+entry before rebuilding downstream request headers. The caller verifies the
+envelope again. G35 and G36 are recorded in
[Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register).
-Don't read `host` directly inside a page.
+Don't read `host` directly inside a page. The
+[public entry matrix](../../../docs/roadmap/phase-02d-walking-skeleton.md#public-entry-matrix)
+is authoritative: bootstrap the live host before locale/redirect decisions;
+enabled-locale membership precedes `courses` shorthand and exact `/studio` or
+`/portal` scaffold handling. Disabled/malformed/unknown prefixes fail closed;
+no locale header, cookie or query supplies authority. The API remains the only
+tenant/organization owner. Continue the request-local validated `traceparent`
+through bootstrap and the configured caller; Phase 11 owns participation/sampling.
+
+**ADR-0054 native/URL controls delivered — 2026-10-09.**
+[The replacement contract](../../../docs/decisions/0054-bounded-public-renderer-admission.md)
+requires native GET/HEAD admission on every HTTP callback path, including
+middleware-exempt health/assets/scaffolds, with masked no-store refusal of other
+methods and bodyless HEAD. Production upgrades close; development retains only
+validated GET HMR. The [remediation record](../../../docs/roadmap/phase-02d-walking-skeleton.md#remediation-step-2--native-ingress-and-url-boundary)
+owns validation and both independent review rounds. Do not add
+Server Actions/write routes or WebSocket consumers without an explicit owning
+Phase 02b/Phase 06
+admission decision. Preserve signed raw-target route/locale authority when
+accounting for pinned Next's URL projection. Redirects retain inert query values,
+duplicates and order with equivalent percent encoding; only verified live host,
+accepted HTTPS port and local path select the destination.
### Step 5: Authentication + permission gating
-For `(studio)` and `(portal)` routes:
+Authentication below is the Phase 02b target, not current scaffold behavior.
+Today's exact `/studio` and `/portal` roots pass only after public host bootstrap;
+no sign-in/session helper is implemented. Phase 02b must explicitly admit its
+login/callback namespace before adding authenticated routes.
-- The middleware redirects unauthenticated requests to the Keycloak login.
-- Permission check happens at the page level via the `auth()` helper:
+For those future `(studio)` and `(portal)` routes:
+
+- Unauthenticated requests redirect through the accepted BFF/Keycloak sign-in flow.
+- A page-level permission check uses the future `auth()` helper:
```tsx
import { auth } from "@learnstack/auth/server"; // illustrative: no such package exists yet; Phase 02b's session work owns the real helper
@@ -178,17 +207,14 @@ See [add-i18n-key](../add-i18n-key/SKILL.md).
### Step 8: Public-site SSR caching
-This step is under an open gate. How `(public)` routes render, and which Next.js
-caches they may use, is G37 in
-[Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register).
-The step is rewritten when that gate closes; until then add no `revalidate`,
-`generateStaticParams` or `unstable_cache` to a `(public)` route. The cache key is
-**not** tenant-bearing automatically. A statically rendered route, which is what
-`revalidate` produces when the page reads no request data, is cached by path, and a
-public URL carries no tenant
-([Frontend Architecture Standards](../../../docs/standards/07-frontend-architecture.md)).
-Every cache key carries the tenant, the organization where applicable, and the locale
-([Security Standards § Multi-Tenant + Organization Isolation Review Checklist](../../../docs/standards/11-security.md#multi-tenant--organization-isolation-review-checklist)).
+**Accepted P02d-5 G37 — 2026-10-08.** Follow
+[ADR-0053](../../../docs/decisions/0053-trusted-public-server-rendering.md): dynamic
+public rendering and no-store API transport; no positive `revalidate`, ISR,
+`generateStaticParams`, `unstable_cache` or shared bootstrap/data/route cache.
+Request-local reuse is isolated to one incoming request. Server Component HMR caching
+is disabled. A new server/document request rechecks eligibility; client history is
+not a revocation guarantee. Use the configured server caller; never derive tenancy
+from a page header or add hop options to the injected SDK. P6 owns page consumers.
### Step 9: Loading + error boundaries
@@ -213,8 +239,8 @@ Every route ships its own:
recorded in the PR description. The phase that ships a route names its test set in
its decision register.
- Lighthouse budget check on representative public routes — CI's `lighthouse budget`
- job is a deferred placeholder, so judge by reading. Whether Phase 02d activates it
- is G44 in its
+ job remains deferred until P02d-7/G44/G45 after P6 pages; judge by reading until
+ that harness is implemented. Its remaining details are in the
[decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register);
the pass that closes it rewrites this bullet.
@@ -234,17 +260,13 @@ Every route ships its own:
## Common pitfalls
-- **Mounting under the wrong route group.** `(public)` SSR + ISR is wrong for a
- Studio screen — caching across users is a leak. How a `(public)` route renders, and
- which Next.js caches it may use, is G37 in
- [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register),
- held open in [Step 8](#step-8-public-site-ssr-caching); the pass that closes it
- edits this pitfall.
-- **Hand-rolled `fetch`.** The ESLint rule rejects it; use the SDK.
-- **Reading `host` inside a page.** The middleware is the only legal resolver.
- Whether the middleware resolves anything, and what it carries, is G25 and G36 in
- [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register);
- the pass that closes them edits this pitfall.
+- **Mounting under the wrong route group.** Public institution routes are dynamic
+ and no-store under ADR-0053; Studio/Portal authentication remains Phase 02b.
+- **Hand-rolled `fetch`.** Only the configured server adapter has an API transport
+ exemption. Page consumers call its SDK; no second header setter is permitted.
+- **Reading `host` inside a page.** The API resolves authority. The native ingress
+ authenticates connection provenance; middleware only bootstraps and selects the
+ enabled route locale. Plain internal headers are not authority.
- **Client Component by default.** Default to Server. Don't sprinkle
`"use client"` to avoid thinking about boundaries; that's how INP regresses.
- **Trusting frontend permission check.** Hidden buttons are not security; the
diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md
index 50a03a93..5340890d 100644
--- a/.claude/skills/code-review/SKILL.md
+++ b/.claude/skills/code-review/SKILL.md
@@ -128,7 +128,7 @@ Each finding → severity (Blocker / Major / Minor) + concrete fix.
| **Bundle size (FE)** | Large dependency imported into a public-route Client Component. Lighthouse JS budget violated. |
| **Re-render storm (FE)** | Context value computed inline in the provider — every consumer re-renders on every render. |
| **Suspense boundary missing** | Server Component awaits slow data on the critical path; missing `` for streaming. |
-| **Per-tenant SSR cardinality** | Cache key includes tenant + org + locale + slug; reviewer can confirm memory budget. Whether a `(public)` route may hold rendered output or fetched data in a Next.js cache at all is G37 in [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register); until it closes, [add-frontend-route § Step 8](../add-frontend-route/SKILL.md#step-8-public-site-ssr-caching) bars `revalidate`, `generateStaticParams` and `unstable_cache` there, and the pass that closes it edits this row. |
+| **Public SSR freshness** | ADR-0053 / Accepted P02d-5 G37 requires dynamic/no-store institution rendering: no shared data/route/ISR/bootstrap cache, positive revalidation, static params or Next unstable cache. Request-local reuse is allowed. Verify source fences plus production same-path/different-host and next-request eligibility controls; P6 supplies page consumers. |
### Step 6 — Refactor / Clean Code lens
@@ -161,16 +161,15 @@ This is the lens that generic reviewers miss. Walk:
There is no `IModule` type in `backend/src`; a diff that names one is writing
against a contract nobody has built.
- `docs/modules//audit.md` / `permissions.md` updated.
-- For frontend changes: route group is correct, SDK is the only API path,
- middleware-resolved `x-tenant-id` / `x-organization-id` honoured, no
- hand-rolled `fetch('/v1/...')`. The API treats an `X-Tenant-Id` or `X-Organization-Id`
- it receives as an assertion, never a source: it validates the header against its own
- resolution and answers a mismatch with `404`
- ([ADR-0036](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md#one-header-names-a-host-and-it-is-still-not-a-source)).
- Whether the frontend holds a tenant or organization id at all, and what the middleware
- carries inward, is G25 and G36 in
- [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register);
- the pass that closes them edits this item.
+- For frontend changes: route group is correct; the injected SDK and one configured
+ server-only adapter are the API path. Under ADR-0052/0053 and Accepted P02d-4/5,
+ native ingress supplies verified host/peer provenance, middleware applies live
+ membership-first locale entry, and the API alone resolves tenant/organization.
+ No tenant/organization IDs or assertion headers enter public representations or
+ caller options; no raw host/forwarded address selects public scope. API assertions
+ remain refusal-only under
+ [ADR-0036](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md#one-header-names-a-host-and-it-is-still-not-a-source).
+ Check private carrier/secret exclusion and the real Client Component import control.
- For customization changes: data-only, no domain term in core code.
If the change is doc-only, the equivalent checks: no `docs/analysis/` refs,
@@ -186,7 +185,7 @@ change.
| Outbox round-trip test for new integration event | Always |
| Permission denied test for every new permission key | Always |
| Boundary tests for every DSL band threshold | Scoring / completion rules |
-| Lighthouse / axe-core for public-route changes | Frontend — Lighthouse **from Phase 02d**, axe-core **from Phase 06**; neither is wired today, so do not raise a Blocker for a missing run. Whether Lighthouse activates in Phase 02d is G44 in [that phase's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register), and the pass that closes it edits this row |
+| Lighthouse / axe-core for public-route changes | Accepted P02d-5 G44 assigns Lighthouse activation to P02d-7/G44/G45 after P6 pages. It is still a disabled placeholder; no passing audit is claimed. P02d-6/G43 selects the skeleton's accessibility gate; the broader axe/Playwright suite belongs to Phase 06. |
A change without tests is incomplete; flag as Blocker unless the user
explicitly deferred the test.
diff --git a/.claude/skills/implement-task/SKILL.md b/.claude/skills/implement-task/SKILL.md
index f33a48b6..370c9afc 100644
--- a/.claude/skills/implement-task/SKILL.md
+++ b/.claude/skills/implement-task/SKILL.md
@@ -264,6 +264,10 @@ template. The prompt must:
Deliver the prompt as a code-fence in the response so the user can copy and
dispatch it.
+**Lighthouse ownership — Accepted P02d-5, 2026-10-08.** P02d-7/G44/G45
+activates the full-stack job after P6 pages; it remains a disabled placeholder.
+The entry decision does not provide a Lighthouse command or passing audit.
+
## Validation (Definition of Done)
The task is **not** done until every box below is checked:
diff --git a/.claude/skills/local-dev-setup/SKILL.md b/.claude/skills/local-dev-setup/SKILL.md
index 201d2867..d6cf5e88 100644
--- a/.claude/skills/local-dev-setup/SKILL.md
+++ b/.claude/skills/local-dev-setup/SKILL.md
@@ -33,8 +33,8 @@ the backend runs today calls them, so `make dev` starts 7 services and
- You need to exercise one of the five real deployment-mode values locally:
`Development`, `SaaS`, `Dedicated`, `SelfHostedOnline`, or
`SelfHostedAirGapped`.
-- You want to reproduce a Hub-backed (`SaaS` / `Dedicated`) scenario by pointing
- at a local Hub stack from the `learnstack-hub` repo.
+- You want to inspect the current deployment-mode composition paths. The Hub
+ entitlement adapter is Phase 02c work, not a current local mode dependency.
## When not to use
@@ -50,7 +50,8 @@ the backend runs today calls them, so `make dev` starts 7 services and
|-------|----------|-------------|
| Docker Desktop | Yes | Required for every container. |
| .NET 10 SDK | Yes | `dotnet --version` returns `10.0.x`. |
-| Node >=20.11.0 + pnpm | Yes | For the frontend; `frontend/package.json` sets the floor and CI pins `20.11.0`. |
+| OpenSSL CLI | Yes for frontend tests | Isolated TLS fixtures generate temporary certificates without installing trust. |
+| Node >=22.23.1 + pnpm | Yes | For the frontend; `frontend/package.json` sets the floor and CI pins `22.23.1`. |
| Deployment mode | Yes | `Development` (default) / `SaaS` / `Dedicated` / `SelfHostedOnline` / `SelfHostedAirGapped` (per [Standards 12 § Deployment Modes](../../../docs/standards/12-infrastructure.md)). |
| `.env` (gitignored) | Optional | Local overrides; `.env.example` is the source of truth. |
@@ -60,7 +61,7 @@ the backend runs today calls them, so `make dev` starts 7 services and
```bash
dotnet --version # 10.0.x
-node --version # >=20.11.0
+node --version # >=22.23.1
pnpm --version
docker info >/dev/null && echo "docker OK"
```
@@ -68,8 +69,8 @@ docker info >/dev/null && echo "docker OK"
If any of these is missing, install:
- .NET 10 SDK:
-- Node: use Volta or fnm; `frontend/package.json` requires `>=20.11.0` and CI
- pins `20.11.0`.
+- Node: use Volta or fnm; `frontend/package.json` requires `>=22.23.1` and CI
+ pins `22.23.1`.
- pnpm: `corepack enable`; `frontend/package.json` pins `pnpm@9.12.3`.
- Docker Desktop: .
@@ -93,48 +94,28 @@ make dev # the daily loop: 7 services, the ones the backend can call
make dev-gated # all 14, including Valkey, Kafka, kafka-ui, Vault, APISIX and Dapr
```
-`make dev` is `docker compose up -d` plus a status line. It does **not** start
-the API or the web app, and it does not run migrations or seeds; those are
-separate commands you run yourself:
+`make dev` starts infrastructure only. Apply migrations and seed first:
```bash
-make migrate # apply both migration chains
-dotnet run --project backend/src/LearnStack.Api # API on 5080
-pnpm --filter @learnstack/web dev # web on 3000
-make seed # health gate + demo credentials
+make seed
```
-**`dotnet run` needs `ConnectionStrings:Default`, and nothing hands it over.**
-`.env` reaches Compose (through `--env-file`) and `make migrate` (which reads it
-key by key), but not a host you start yourself: there is no `ConnectionStrings`
-section in `appsettings*.json` and no `.env` loader in `backend/src`. Since Packet
-6 the composition root builds the application data source from that key, so
-without it the first request that touches the database fails with a message
-naming it. Two ways to supply it, and the second survives a new shell:
-
-```bash
-# Per shell. Read one key at a time — a connection string contains semicolons,
-# so `. ./.env` parses them as statement separators, and .env.example quotes the
-# value.
-export ConnectionStrings__Default=$(sed -n "s/^ConnectionStrings__Default=//p" .env \
- | tail -1 | tr -d "\r" | sed "s/^['\"]//; s/['\"]$//")
-
-# Or once, into the user-secrets store the API project already declares
-# (UserSecretsId learnstack-api-dev) — kept outside the repository, so it cannot
-# be committed. Reads .env itself rather than the variable above, so it works in
-# a shell that never ran the export, and refuses to store an empty value.
-default_cs=$(sed -n "s/^ConnectionStrings__Default=//p" .env \
- | tail -1 | tr -d "\r" | sed "s/^['\"]//; s/['\"]$//")
-[ -n "$default_cs" ] || { echo "ConnectionStrings__Default missing from .env"; exit 1; }
-dotnet user-secrets --project backend/src/LearnStack.Api \
- set "ConnectionStrings:Default" "$default_cs"
-```
-
-The value names **`learnstack_app`** and the composition root refuses anything
-else — by name, and then by asking the server whether the role it connected as
-bypasses row security. Pointing it at `ConnectionStrings__Migration` or either
-`BYPASSRLS` role makes every policy in the database inert, which is why it is
-checked rather than assumed.
+P02d-5 Step 1 supplies the paired loopback launcher. Follow
+[README Quickstart](../../../README.md#3-prepare-local-https-and-start-the-applications)
+for `make public-env`, explicit mkcert trust/leaf creation and manual hosts aliases.
+Then run `make public-api` and `make public-web` in separate terminals. The API
+helper loads only runtime database credentials and the paired hop network/secret;
+the web launcher captures native socket provenance before Next. Neither script
+shell-evaluates `.env`, edits hosts or installs trust. The optional web projection
+must match the root private source.
+
+**Ordinary API startup is unchanged.** `dotnet run` still does not load `.env`.
+For an API-only session, supply `ConnectionStrings:Default` through environment or
+the project's `learnstack-api-dev` user-secrets store. Both empty hop lists remain
+valid in every mode; a network-only or secret-only configuration is refused.
+The application connection must name `learnstack_app`; the data-source guard also
+refuses direct or transitive BYPASSRLS/superuser access. Migration credentials
+never enter either application launch recipe.
**`make migrate` runs as `learnstack_migration`, not as the API's role.** From
Phase 02a Packet 6 the stack provisions four database roles on the first boot of
@@ -185,8 +166,8 @@ Three properties of that inventory matter while you are setting up:
silently leaves them running, which is why every teardown target carries
`--profile '*'`.
- **Neither application host is a compose service.** `LearnStack.Api` runs on
- the workstation via `dotnet run` on the launch profile's `applicationUrl`
- (5080), and `apps/web` runs via `pnpm dev` on 3000.
+ loopback HTTP through `make public-api` (5080), and the native web listener runs
+ on loopback HTTPS through `make public-web` (3000).
To read the resolved truth rather than any document, ask the stack:
@@ -200,37 +181,13 @@ docker compose --env-file .env -f infra/compose/dev.yml config --format json
checks the two Keycloak realms, and — since Phase 02a Packet 7 — writes the two
demo tenants. One command from a clean checkout.
-What it writes today:
-
-1. `demo-english` ("English Hero") and `demo-yoga` ("Anatolia Yoga"), each with a
- default organization and a second one, through `ProvisionTenantCommand` and
- `CreateOrganizationCommand`.
-2. One `platform_host_to_tenant` row per tenant —
- `demo-english.learnstack.local` mapping to the tenant, and
- `demo-yoga.learnstack.local` to its default organization, so both live host
- classifications exist in the seed.
-
-What it does not write yet, and which phase owns each:
-
-3. Keycloak realm users beyond the ones the realm JSON imports at compose boot —
- the `users` table arrives with
- [Phase 03](../../../docs/roadmap/phase-03-identity-admin.md)'s Identity
- migration, and Packet 7 creates none.
-4. Each tenant's **own** content type, level taxonomy and branding token values, and
- the customization aggregates that have no schema yet (`TenantPageBlock`, …). The
- built-in `card` content type and `plain` taxonomy are already written for both
- tenants, since
- [Phase 02a Packet 8](../../../docs/roadmap/phase-02a-kernel-tenancy.md). Which phase
- adds each of the rest is in
- [seed-tenant § Step 4: What a later phase adds](../seed-tenant/SKILL.md#step-4-what-a-later-phase-adds).
-5. SeaweedFS buckets and Meilisearch indexes — both adapters are demand-gated to
- [Phase 11](../../../docs/roadmap/phase-11-production-hardening.md) under
- [ADR-0035](../../../docs/decisions/0035-demand-gated-infrastructure.md).
-
-The seed is idempotent: a second run recognises its own first by the uniqueness
-refusal and exits 0. There is no separate reset target — for fresh local data use
-the destructive `make clean`, then `make seed`, which re-applies the migrations on
-its way through.
+[SeedData](../../../backend/src/LearnStack.Tools.Seeder/SeedData.cs) owns the exact
+inventory. P02d-2 supplies the two tenants, organizations/host mappings, locales,
+built-in and tenant-authored definitions, branding and Course/Lesson content.
+[seed-tenant](../seed-tenant/SKILL.md) owns convergence, mismatch handling and future
+data scope. A completed rerun is write/audit-neutral; a divergent run fails without
+overwriting or resetting authored state. Public reads are delivered by P02d-4;
+seed alone does not deliver pages.
### Step 5: Verify
@@ -249,19 +206,20 @@ open http://localhost:8080/realms/learnstack-hub/account
# SeaweedFS filer UI (replaces the MinIO console of the prior stack)
open http://localhost:9001 # S3 access: learnstack / learnstack-dev-secret
-# Web app (after `pnpm --filter @learnstack/web dev`): the scaffold page only.
-# `localhost` is a platform host (Tenancy:PlatformHosts in
-# appsettings.Development.json) and never resolves a tenant. No tenant-rendered
-# page exists on any host yet: browsing the two demo tenants, and the host step
-# it needs, arrive with Phase 02d (docs/roadmap/phase-02d-walking-skeleton.md
-# § Host-based tenant resolution, end to end).
-open http://localhost:3000
+# Web HTTPS readiness after the paired launcher and explicit CA trust.
+curl -fsS https://localhost:3000/api/healthz
```
+P02d-5 delivers native ingress/TLS, live-host bootstrap and enabled-locale entry.
+P02d-6 owns public page composition and P02d-7 owns the two-host browser harness.
+TLS socket tests with isolated trust roots do not claim a workstation browser run.
+
### Step 6: Switch deployment modes locally
-Set `Deployment__Mode` in the shell that runs `dotnet run`, or `Deployment:Mode` in the
-user-secrets store Step 3 uses, to flip the mode. Editing `.env` does nothing: it has no
+For an API-only run, set `Deployment__Mode` in the shell that runs `dotnet run`,
+or `Deployment:Mode` in its user-secrets store, to flip the mode. The paired
+`make public-api` recipe explicitly selects Development; it is not a deployment
+mode switch. Editing `.env` does nothing: it has no
mode key, and `dotnet run` reads no `.env` (Step 3). The committed value is
`Development`, under `Deployment:Mode` in `appsettings.Development.json`, and the
composition root refuses to start without the key rather than defaulting it. The mode
@@ -281,7 +239,9 @@ For **every** value today, the three demand-gated ports still resolve to
`ConfigurationSecretProvider`. `DaprEventBus`, `DaprCacheService`, and
`DaprSecretProvider` land in Phase 11 only after their ADR-0035 triggers fire.
-After changing the mode, stop and rerun the API process:
+After changing the mode for an API-only run, stop and rerun that process. Keep its
+separately configured runtime credential. For the paired renderer workflow, restart
+with `make public-api` so its child-only database and hop configuration is retained:
```bash
# In the terminal running `dotnet run`, press Ctrl+C, then:
@@ -296,7 +256,7 @@ dotnet run --project backend/src/LearnStack.Api
| `relation "tenants" does not exist` | The owning Tenancy migrations have not landed or were not applied; check the active phase plan before adding an ad-hoc target. |
| `unable to read app.tenant_id` | The `DbCommandInterceptor` tenant-context guard is unwired, or `TransactionBehavior` did not issue the `SET LOCAL` pair. It is deliberately **not** a connection-checkout interceptor — checkout precedes `BEGIN`. |
| Keycloak realm not found | Recreate local data with destructive `make clean`, then `make seed`. The realms are imported at compose boot from `infra/keycloak/realms/`, not by the seeder. |
-| Hub-backed mode hangs | The `learnstack-hub` repo's stack isn't up; start it or switch to `Development`. |
+| A non-Development mode fails startup | Check that mode's required Sentry/telemetry configuration and [current readiness](../../../docs/architecture/25-deployment-models.md#supported-today-versus-prepared-seam). Every mode currently uses `NullEntitlementProvider`; starting Hub does not supply the Phase 02c adapter. |
| LiveKit join fails with TURN error | coturn not reachable from the browser; check firewall + container network. |
### Step 8: Tear-down
@@ -311,16 +271,15 @@ state.
## Validation
-> **Open in Phase 02d.** Which hostnames serve the two demo tenants, and the step a
-> browser needs to reach them, is G32; what `make demo` starts and guarantees is G45.
-> Both are in
-> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register),
-> and the pass that closes each adds its check to this list with its answer.
+[ADR-0053](../../../docs/decisions/0053-trusted-public-server-rendering.md)
+and the [P5 package](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-5-decision-package-2026-10-08)
+own the accepted host/TLS/transport contract. `make demo` remains P02d-7/G45.
- `make dev` exits 0; after starting the API separately, `/healthz` responds 200.
- After `make dev-gated` and with that API running, APISIX forwards `/healthz`.
-- The web app serves the scaffold page on `http://localhost:3000` (Step 5); no tenant
- page renders on either demo host yet.
+- After explicit local trust/leaf setup, the native HTTPS `/api/healthz` returns 200.
+- Direct stock Next cannot authenticate forged ingress carriers; ordinary
+ `pnpm dev/start` use the mandatory launcher.
- Keycloak login works for both realms.
- `dotnet test backend/tests/LearnStack.Tests.Integration` passes against the
same containers (the Testcontainers fixture is independent; this is just a
@@ -328,9 +287,10 @@ state.
## Common pitfalls
-- **Mixing local Postgres + Testcontainers Postgres.** Both bind 5432 by default.
- Use distinct ports or shut down the dev Postgres before running integration
- tests.
+- **Confusing compose and test ports.** The dev compose stack publishes loopback
+ 5432; Testcontainers chooses a random host port. They can coexist. P5's native
+ renderer fixtures instead require free 3000/3011 and fail on collision without
+ stopping an existing developer process.
- **Editing `.env.example`.** That file is the **template**; commit changes only
if the project's default really should change. Your local overrides go in
`.env` (gitignored).
diff --git a/.claude/skills/run-tests-locally/SKILL.md b/.claude/skills/run-tests-locally/SKILL.md
index 02fb6aa8..bfeb6d4c 100644
--- a/.claude/skills/run-tests-locally/SKILL.md
+++ b/.claude/skills/run-tests-locally/SKILL.md
@@ -44,7 +44,7 @@ flags, and a triage map for the most common failure shapes.
```bash
# Required toolchain
dotnet --version # 10.0.x
-node --version # 20+ for the frontend
+node --version # 22.23.1 (frontend/.nvmrc)
pnpm --version
# Restore — from the directories that hold the solution and the workspace.
@@ -71,10 +71,9 @@ backend/tests/
LearnStack.Tests.Contract/ # OpenAPI / SDK contract assertions.
frontend/apps/web/ # Vitest. The axe-core and Playwright suites
- # arrive in Phase 06. Whether CI's deferred
- # `lighthouse budget` job activates in Phase 02d
- # is G44 in that phase's decision register; the
- # pass that closes it edits this comment.
+ # arrive in Phase 06. Accepted P02d-5 G44 assigns
+ # the deferred Lighthouse job to P02d-7/G44/G45
+ # after P6 pages; it remains disabled today.
```
### Step 3: Run unit tests
@@ -147,27 +146,35 @@ Common failure shapes:
| Test passes locally, fails in CI | Race condition; check `await` chains. |
| Empty result where rows should exist | No `app.tenant_id` on this transaction, or the wrong one — RLS working as designed. Set it with `SchemaQueries.SetTenantAsync` as the transaction's first statement. |
| `relation "" does not exist` | Migration didn't apply; check the module's `Persistence/Migrations`. |
-| Docker container fails to start | Port collision on 5432 — stop a local Postgres. Testcontainers maps a random host port, so this only bites when something else already holds the container port. |
+| Docker container fails to start | Check Docker connectivity, image availability and the fixture's startup error. Testcontainers maps PostgreSQL to a random host port; a separate local 5432 listener is not a collision. |
| `password authentication failed` | The four roles are provisioned by the fixture from `infra/compose/postgres-init/02-create-roles.sql`; a failure there fails the fixture, not one test. |
### Step 6: Run frontend tests
```bash
-cd frontend/apps/web
-pnpm test # vitest
-pnpm typecheck # tsc --noEmit
-pnpm lint # next lint — what `pnpm -r lint` runs in CI
+cd frontend
+pnpm test # all discovered test packages; nonempty, zero skips/todos
+pnpm typecheck # workspace tsc --noEmit
+pnpm lint # workspace lint, including next lint
```
+
+The root frontend runner reads actual Vitest reports, compares discovered files,
+and refuses missing scripts, unreadable/empty reports, failures, skipped or todo
+cases. Use `pnpm --filter @learnstack/web test ` only for focused development;
+packet/PR validation uses the guarded workspace command. Backend Docker integration
+also runs P5's disposable production Next/TLS fixture against the real API and
+PostgreSQL; Node, pnpm, installed frontend dependencies and OpenSSL are required.
+It owns ports 3000/3011 while running and refuses occupied ports without stopping
+an existing user process. Product-page browser/a11y tests remain P6/P7.
+
> **`pnpm test:a11y` and `pnpm test:e2e` do not exist yet.** `package.json`
> defines `dev`, `build`, `start`, `lint`, `typecheck` and `test`, and neither
> `axe-core` nor `@playwright/test` is a dependency. Both arrive in **Phase 06**, per
-> [Testing Standards § End-to-End Tests](../../../docs/standards/06-testing.md). Whether
-> CI's deferred `lighthouse budget` job activates earlier, in Phase 02d, is G44, and
-> which accessibility checks fail a build on that phase's pages is G43;
-> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register)
-> holds both, and the pass that closes each edits this note. Today there is no
-> accessibility or end-to-end gate to run.
+> [Testing Standards § End-to-End Tests](../../../docs/standards/06-testing.md).
+> Accepted P02d-5 G44 assigns Lighthouse to P02d-7/G44/G45 after P6 pages; the job
+> remains disabled. P02d-6/G43 selects the skeleton's accessibility gate. P5's
+> production HTML/RSC fixture proves transport, not a browser/a11y audit.
### Step 7: Single-test focus
@@ -187,8 +194,9 @@ dotnet test --filter "Requires=Docker"
`vitest`:
```bash
-pnpm test usage-meter # path filter
-pnpm test -t "shows danger tone" # name filter
+# From frontend/; the guarded workspace runner accepts no filters.
+pnpm --filter @learnstack/web test usage-meter # path filter
+pnpm --filter @learnstack/web test -t "shows danger tone" # name filter
```
### Step 8: Coverage (optional)
@@ -254,15 +262,20 @@ For flaky tests, run with `--blame-hang` and `--blame-hang-timeout`:
dotnet test --blame-hang --blame-hang-timeout 5min
```
+**Lighthouse ownership — Accepted P02d-5, 2026-10-08.** P02d-7/G44/G45
+activates the full-stack job after P6 pages; it remains a disabled placeholder.
+The entry decision does not provide a Lighthouse command or passing audit.
+
## Validation
- The relevant suite passes locally with the same `dotnet --version` and
`pnpm --version` CI uses.
-- For integration suites, Docker is running and nothing else holds 5432.
+- For integration suites, Docker is running; PostgreSQL uses random host ports.
+ The P5 native renderer fixtures require free 3000/3011 and fail on collisions.
- A failing test message points at the specific rule / scenario it violates.
- For frontend changes, `pnpm test`, `pnpm lint` and `pnpm typecheck` are clean.
The axe suite joins this list in Phase 06. Whether route tests or `jsx-a11y`
- findings fail a build earlier, on Phase 02d's pages, is G43 in
+ findings fail a build on Phase 02d's pages is P02d-6/G43 in
[Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register),
and the pass that closes it edits this line.
@@ -274,9 +287,9 @@ dotnet test --blame-hang --blame-hang-timeout 5min
change — the lockfile must match.
- **Skipped architecture test.** Forbidden. If a test is marked `[Skip]`, treat
it as a bug.
-- **Port collisions.** A local Postgres on 5432 collides with the fixture's
- container. Stop it, or let Testcontainers pick the host port (it does) and stop
- publishing 5432 from `make dev`.
+- **Port collisions.** Testcontainers PostgreSQL uses a random host port and can
+ coexist with `make dev` on 5432. Stop your own web process before P5's native
+ renderer fixtures: they require free 3000/3011 and never kill a foreign listener.
- **`--no-build` after a source change.** Drop the flag — the test would run
against stale binaries.
- **Assuming an accessibility gate exists.**
diff --git a/.claude/skills/seed-tenant/SKILL.md b/.claude/skills/seed-tenant/SKILL.md
index e109d995..9c329710 100644
--- a/.claude/skills/seed-tenant/SKILL.md
+++ b/.claude/skills/seed-tenant/SKILL.md
@@ -16,8 +16,9 @@ description: >
The [P02d-2 accepted package](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-2-decision-package-2026-10-02)
records the decisions. Step 4 delivers the complete inventory and contextual
verification; both implementation review rounds and final verification passed.
-PR review/merge remains pending. Public API reads and browser rendering remain
-P02d-4 and P02d-5–7. Do not claim a rendered demo from seed alone.
+P02d-2 is merged, and P02d-4 supplies public API reads. Server ingress/transport
+belongs to P02d-5; pages and the browser demo remain P02d-6/7. Do not claim a
+rendered demo from seed alone.
[SeedData](../../../backend/src/LearnStack.Tools.Seeder/SeedData.cs) owns all demo
identities, schemas, labels, palettes, bodies and computed inventory. The runner
@@ -151,10 +152,13 @@ remains Registered for P02d-5/6 scope and P02d-7 exit.
### 6. Reach the hosts
-The existing names are under `*.learnstack.local` and need hosts-file aliases for
-local browsing. Development transport/host changes remain G32 in P02d-5. The web
-middleware is still a scaffold; no browser render is supplied by this seed packet.
-Do not add a host alias or change a deployment's reserved-host registry implicitly.
+The existing names remain under `*.learnstack.local`. P02d-5/G32 accepts manual
+hosts aliases and local HTTPS; Step 1 supplies the native launcher and private
+shared-source configuration. Follow [README Quickstart](../../../README.md#3-prepare-local-https-and-start-the-applications)
+for explicit CA trust/leaf creation and `make public-api` / `make public-web`.
+Repository scripts never edit hosts or system trust. P02d-5 delivers live-host
+bootstrap and locale entry; P02d-6 owns pages, and P02d-7 owns the two-site browser
+demo. No browser render is supplied by seeding alone.
### 7. Reset only an explicitly disposable development environment
diff --git a/.claude/skills/standards-check/SKILL.md b/.claude/skills/standards-check/SKILL.md
index b8f1463c..ef3e44b3 100644
--- a/.claude/skills/standards-check/SKILL.md
+++ b/.claude/skills/standards-check/SKILL.md
@@ -246,12 +246,11 @@ domain the diff doesn't touch.
#### `07-frontend-architecture.md`
- [ ] Route group correct (`(public)` / `(studio)` / `(portal)`).
- [ ] Server Components default; `"use client"` only when needed.
-- [ ] Tenant + organization + locale resolution via middleware; not
- re-implemented in the page. The API resolves tenant and organization from the host
- ([ADR-0036](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md#effective-host-and-the-trusted-hop));
- what the middleware resolves or carries for `(public)` routes is G25 and G36 in
- [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register),
- and the pass that closes them edits this item.
+- [ ] Accepted ADR-0052/0053 public scope: the API resolves tenant/organization;
+ native ingress supplies verified host/peer and middleware bootstraps live locale
+ membership. Pages use the configured server-only SDK caller, never raw host,
+ forwarding headers or tenant IDs. Education locale comes from the canonical
+ route. Secret/provenance values stay out of public data and client assets.
- [ ] Folder structure under `frontend/apps/web/`.
#### `08-localization.md`
@@ -317,10 +316,9 @@ domain the diff doesn't touch.
[15-performance.md § Initial Budgets](../../../docs/standards/15-performance.md#initial-budgets)
and [§ Bundle Size](../../../docs/standards/15-performance.md#bundle-size) respected.
**Not mechanically checkable yet** — `ci.yml`'s `lighthouse-budget` job is a
- placeholder. Whether it activates in Phase 02d is G44 in
- [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register);
- the pass that closes it edits this paragraph. Judge by reading; do not report a pass
- or a failure as measured.
+ placeholder. Accepted P02d-5 G44 assigns activation to P02d-7/G44/G45 after
+ P6 pages. Judge by reading until that harness ships; do not report an unrun
+ audit as a measured pass or failure.
- [ ] Backend latency budget per module respected.
#### `16-accessibility.md`
diff --git a/.env.example b/.env.example
index f6120376..f72ac3e7 100644
--- a/.env.example
+++ b/.env.example
@@ -106,12 +106,12 @@ MEILI_MASTER_KEY=learnstack-dev-master-key
ASPNETCORE_ENVIRONMENT=Development
ASPNETCORE_URLS=http://localhost:5080
-# ─── Frontend host (Next.js apps/web; see frontend/apps/web/.env.local.example
-# for the apps/web-only overrides — Next reads .env.local from the app dir,
-# not the repo root, so the FE has its own copy mirroring these values) ───
-# No frontend variable is listed here yet: NEXT_PUBLIC_API_BASE_URL and the OIDC and
-# LiveKit values exist only in frontend/apps/web/.env.local.example, and no code reads
-# them. Open in Phase 02d: the server-only API origin and the trusted-hop
-# configuration are G33, and how environment reaches both host processes is G45, in
-# docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register. The pass that
-# closes each gate edits this section with its answer.
+# ─── Public renderer (ADR-0053; API and web share this private source) ───
+# make public-env fills the empty secret with 32 random bytes encoded as ASCII.
+# No TLS/hosts/trust-store changes are automatic. See README Quickstart.
+# make public-api loads only runtime DB credentials plus network AND secret.
+# pnpm dev/start loads these values; an optional web .env.local must match.
+LEARNSTACK_PUBLIC_API_ORIGIN=http://127.0.0.1:5080
+LEARNSTACK_PUBLIC_HOP_SECRET=
+LEARNSTACK_PUBLIC_TLS_CERT=.data/tls/public.pem
+LEARNSTACK_PUBLIC_TLS_KEY=.data/tls/public-key.pem
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index 01f44fc7..ac1fb206 100755
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -7,7 +7,7 @@
#
# Languages handled:
# *.cs → dotnet format (whitespace + style)
-# frontend/**.{ts,tsx,js,jsx,mjs,cjs} → prettier --write + ESLint via pnpm
+# frontend/**.{ts,tsx,mts,cts,js,jsx,mjs,cjs} → prettier --write + ESLint via pnpm
# frontend/**.{json,md} → prettier --write
#
# Intentionally NOT handled: *.yml / *.yaml. Compose / Dapr / APISIX YAMLs
@@ -107,7 +107,7 @@ while IFS= read -r -d '' f; do
*.cs) cs_files+=("$f") ;;
frontend/*)
case "$f" in
- *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs) js_like_files+=("$f") ;;
+ *.ts|*.tsx|*.mts|*.cts|*.js|*.jsx|*.mjs|*.cjs) js_like_files+=("$f") ;;
*.json|*.md) prettier_only_files+=("$f") ;;
esac
;;
@@ -290,24 +290,17 @@ if [[ ${#real_prettier_batch[@]} -gt 0 || ${#js_like_files[@]} -gt 0 ]]; then
restage "${real_prettier_batch[@]}"
fi
- # ESLint is wired in exactly one workspace: `@learnstack/web` is the only
- # package with a `lint` script, so `pnpm -r lint` in CI covers
- # `frontend/apps/web` and nothing else. Two reasons the previous
- # invocation could never work: `eslint` is a devDependency of that package
- # only, so `pnpm exec eslint` from `frontend/` fails with
- # ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL; and ESLint 9 does not read the
- # `.eslintrc.cjs` this app still uses, so the bare binary bails with
- # "couldn't find an eslint.config.js" even where it does resolve.
- # `next lint` is what the package's own script runs and what CI therefore
- # runs, and it understands the eslintrc format.
- # `next lint` takes one `--file` FLAG per path, so the array is built as
- # alternating flag/value pairs — a `${a[@]/#/--file }` rewrite would make
- # each element a single argument containing a space and never split.
+ # Staged web files use the package-local Next lint wrapper, which reads
+ # this app's eslintrc format. SDK/UI use their own workspace lint commands.
+ # Compile the native ingress dependency first, matching the web lint
+ # preflight so module resolution also works on a clean checkout.
+ # The shared web subject census covers source/scripts/root configs and
+ # excludes generated declarations/output equally in CI and staged lint.
web_lint_args=()
web_count=0
for f in "${js_like_files[@]:-}"; do
if [[ -n "$f" && "$f" == frontend/apps/web/* ]]; then
- web_lint_args+=(--file "${f#frontend/apps/web/}")
+ web_lint_args+=("${f#frontend/apps/web/}")
web_count=$(( web_count + 1 ))
fi
done
@@ -315,7 +308,17 @@ if [[ ${#real_prettier_batch[@]} -gt 0 || ${#js_like_files[@]} -gt 0 ]]; then
if [[ $web_count -gt 0 ]]; then
# `--max-warnings 0` turns ANY warning into a hook failure.
printf "pre-commit: next lint --fix (%d file(s)) …\n" "$web_count"
- (cd frontend/apps/web && pnpm exec next lint --fix --max-warnings 0 "${web_lint_args[@]}")
+ (cd frontend/apps/web && pnpm lint:staged -- "${web_lint_args[@]}")
restage "${js_like_files[@]}"
fi
+ for package in sdk ui; do
+ package_touched=false
+ for f in "${js_like_files[@]:-}"; do
+ [[ "$f" == frontend/packages/"$package"/* ]] && package_touched=true
+ done
+ if [[ "$package_touched" == true ]]; then
+ printf "pre-commit: %s workspace lint …\n" "$package"
+ (cd frontend && pnpm --filter "@learnstack/$package" lint)
+ fi
+ done
fi
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index fdfc7d08..836335bc 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -61,9 +61,16 @@ Configure these in **GitHub → Settings → Branches → Branch protection rule
every other protection setting unchanged. The
[packet delivery record](../docs/roadmap/phase-02d-walking-skeleton.md#p02d-4-step-4-contract-sdk-and-ci)
records the successful job and activation proof.
- - `lighthouse budget (deferred to Phase 02d)` remains behind its unset
- `vars.ENABLE_LIGHTHOUSE_BUDGET` condition. P02d-6/G44 owns activation,
- pages, harness and assertions; its rename also needs live registration.
+ - P02d-5 keeps the existing check names. The frontend job uses the guarded
+ workspace test runner and native production/cleanup controls. Backend
+ integration also builds a disposable Next app against the real API/PostgreSQL;
+ Node 22.23.1/pnpm, frozen frontend dependencies and OpenSSL are required by
+ both that job and the frontend native-ingress proof.
+ Its test-owned HTML/RSC routes do not claim P6 pages or a browser audit.
+ - `lighthouse budget (deferred to P02d-7)` remains behind its unset
+ `vars.ENABLE_LIGHTHOUSE_BUDGET` condition. Accepted P02d-5 G44 assigns
+ activation to P02d-7/G44/G45 after P6 pages. It remains optional and disabled;
+ P7 selects the harness/assertions and any live required-check registration.
GitHub matches required checks **by name**. The
[packet record](../docs/roadmap/phase-02d-walking-skeleton.md#p02d-4-step-4-contract-sdk-and-ci)
@@ -121,9 +128,11 @@ exactly what CI's `backend` job runs.
The pre-commit hook (activated by `make install`) runs, on staged files only:
`dotnet format` on `*.cs`; prettier on JS / TS / JSON / Markdown **under
-`frontend/`**; `next lint --fix` on JS / TS under `frontend/apps/web` — the one
-workspace with a `lint` script, so this is exactly what `pnpm -r lint` covers in
-CI; and, when the binary is on PATH, `leakwatch scan fs `. So the
+`frontend/`**; shared-subject `next lint --fix` on web JS/TS (including `.mts` and
+`.cts`); SDK/UI workspace lint when their JS/TS changes; and, when the binary is
+on PATH, `leakwatch scan fs `. Web source, scripts and root configs
+share one subject census with CI; generated `next-env.d.ts`, `.next` and `.server`
+are excluded deliberately. So the
three commands above are mostly a sanity check. There is deliberately no
`make secret-scan` to pair with them — the hook and CI are the scanner's only
runners, and CI re-runs every check as a hard gate, so a bypassed local commit
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8dda58e8..f0579a46 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,7 +26,7 @@
#
# P02d-4 activates the always-running `openapi diff` check: pinned oasdiff,
# ADR-0024 policy controls and verified base/head snapshots. Lighthouse remains
-# deferred under P02d-6's G44; a public-page harness has not shipped yet.
+# deferred under P02d-7's G44/G45; a public-page harness has not shipped yet.
name: ci
@@ -57,7 +57,7 @@ env:
# workstation, this workflow and a release build cannot disagree about it. It moved there
# when a security bump landed here alone and left every local build on the runtime the
# advisories were about (Packet 9's fifth review).
- NODE_VERSION: "20.11.0"
+ NODE_VERSION: "22.23.1"
PNPM_VERSION: "9.12.3"
DOTNET_NOLOGO: "true"
DOTNET_CLI_TELEMETRY_OPTOUT: "true"
@@ -185,6 +185,24 @@ jobs:
with:
global-json-file: backend/global.json
+ # P02d-5 compiles a disposable production Next fixture against this job's
+ # real API/PostgreSQL host. It remains a Docker-trait integration test.
+ - name: Set up pnpm for production rendering fixture
+ uses: pnpm/action-setup@v4
+ with:
+ version: ${{ env.PNPM_VERSION }}
+
+ - name: Set up Node.js for production rendering fixture
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: pnpm
+ cache-dependency-path: frontend/pnpm-lock.yaml
+
+ - name: Install production rendering fixture dependencies
+ working-directory: frontend
+ run: pnpm install --frozen-lockfile
+
- name: Restore
working-directory: backend
# CI=true here too, not only on the build: NuGet's audit reports at RESTORE, and
@@ -279,7 +297,12 @@ jobs:
run: pnpm -r build
- name: Test (Vitest)
- run: pnpm -r test
+ run: pnpm test
+
+ - name: Prove native production ingress and failed-fixture cleanup
+ run: |
+ node apps/web/scripts/verify-ingress.mjs
+ node apps/web/scripts/verify-ingress-cleanup.mjs
# ─── OpenAPI breaking-change check (active — P02d-4) ─────────────────
openapi-diff:
@@ -346,11 +369,11 @@ jobs:
# ─── Lighthouse budget (deferred — Phase 02d) ─────────────────────────
lighthouse-budget:
- name: lighthouse budget (deferred to Phase 02d)
+ name: lighthouse budget (deferred to P02d-7)
runs-on: ubuntu-latest
if: vars.ENABLE_LIGHTHOUSE_BUDGET == 'true'
steps:
- - run: echo "Placeholder — Phase 02d wires LHCI against the built Next.js app."
+ - run: echo "Placeholder — P02d-7 wires the full-stack harness after P02d-6 pages."
# ─── Meta (commit-message format, link audit) ─────────────────────────
meta:
@@ -528,6 +551,9 @@ jobs:
exit 1
fi
+ - name: ADR lifecycle parser controls
+ run: python3 scripts/test-adr-status.py
+
- name: Accepted ADR disclosure
env:
EVENT_NAME: ${{ github.event_name }}
@@ -563,11 +589,10 @@ jobs:
git cat-file -e "${base}:${f}" 2>/dev/null || continue
[[ -f "$f" ]] || continue
adr_status=$(git show "${base}:${f}" \
- | awk '/^## Status/ { want = 1; next } want && NF { print; exit }')
- # Prefix, not equality: three Accepted ADRs carry a prose Status
- # line ("Accepted (Amendment 1: … )", "Accepted with two
- # amendments — …"), and an equality test skipped every one of them.
- [[ "$adr_status" == Accepted* ]] || continue
+ | python3 scripts/adr-status.py)
+ # Parse the lifecycle within Status, ignoring delivery banners.
+ # Consume the full input; an early-exiting pipe can SIGPIPE git.
+ [[ "$adr_status" == Accepted ]] || continue
# Link-target-only edits owe nothing.
if diff -q \
<(git show "${base}:${f}" | sed -E 's/\]\([^)]*\)/](LINK)/g') \
@@ -576,7 +601,7 @@ jobs:
fi
# A dated amendment heading, added by this diff, in this file.
if git diff "$base"...HEAD -- "$f" \
- | grep -qE '^\+#{2,3} Amendment|^\+#{2,3} [0-9]{4}-[0-9]{2}-[0-9]{2}'; then
+ | grep -E '^\+#{2,3} Amendment|^\+#{2,3} [0-9]{4}-[0-9]{2}-[0-9]{2}' >/dev/null; then
echo "ok: $f discloses its change"
else
echo "UNDISCLOSED: $f — an Accepted ADR changed with no dated Amendment in its own file."
diff --git a/.gitignore b/.gitignore
index b245c5ce..bcd9a715 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,7 @@ TestResults/
# Node
node_modules/
.next/
+.server/
dist/
build/
coverage/
@@ -47,4 +48,3 @@ valkey-data/
# Local-only analyses (not shared via git)
docs/analysis/
-
diff --git a/.prettierignore b/.prettierignore
index c67be9b8..c7e1f854 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -34,3 +34,6 @@
# that run from `frontend/`; this file covers the editor, which runs from here.
# Measured — without it, an explicit Format Document rewrites 3761 lines of it.
/frontend/pnpm-lock.yaml
+
+# Generated native ingress output; format its TypeScript source.
+/frontend/apps/web/.server/
diff --git a/CLAUDE.md b/CLAUDE.md
index 9cff7359..f576ce2d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -103,9 +103,17 @@ marketing/content projections and scoped seek cursors; both independent review
rounds pass. Step 4 implements served/snapshot contract controls, the injected typed
SDK and always-running OpenAPI compatibility CI. Both independent review rounds
and fresh fix verification pass. All six real CI jobs pass; the approved sixth
-required check is active with all other live settings preserved. P02d-4 is
-implementation-complete in [PR #25](https://github.com/HodeTech/LearnStack/pull/25),
-awaiting maintainer review and merge. P02d-5 is next; its decision pass is not started.
+required check is active with all other live settings preserved. **P02d-4 is complete
+and merged** through [PR #25](https://github.com/HodeTech/LearnStack/pull/25) on
+2026-10-08; its [merge closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-4-merge-and-closeout-2026-10-08)
+records final verification. P02d-5 delivers trusted public server rendering under
+Accepted ADR-0053 and ADR-0054: native HTTPS ingress, coordinated visitor/peer
+admission, the configured bounded server caller, live-host/locale entry and
+source/runtime proof controls. The [remediation and closeout record](docs/roadmap/phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09)
+is the current owner of implementation steps, independent reviews, validation and
+PR readiness. P02d-5 remains unmerged in [PR #26](https://github.com/HodeTech/LearnStack/pull/26).
+Work stays on development. P02d-6's decision pass and product pages are next;
+P02d-7 owns the browser demo and Lighthouse. Neither packet is delivered by P5.
**Phase 01** shipped the .NET 10 solution scaffold under `backend/`
(core + 7 modules × 4 projects + 4 test projects including the
diff --git a/Makefile b/Makefile
index 2362ee32..27774619 100644
--- a/Makefile
+++ b/Makefile
@@ -64,6 +64,18 @@ help: ## Show this help, listing every target and its one-line description.
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z0-9_.-]+:.*?## / {printf " $(CYAN)%-18s$(RESET) %s\n", $$1, $$2}' $(MAKEFILE_LIST)
# ─── Dev infrastructure ───────────────────────────────────────────────────
+.PHONY: public-env public-api public-web
+public-env: .env ## Generate the private local renderer secret (no hosts/trust edits).
+ (cd frontend && pnpm --filter @learnstack/web build:ingress)
+ node scripts/public-local.mjs prepare
+
+public-api: ## Start the loopback API with the paired trusted-hop configuration.
+ (cd frontend && pnpm --filter @learnstack/web build:ingress)
+ node scripts/public-local.mjs api
+
+public-web: ## Start the native HTTPS renderer (manual local certificate required).
+ cd frontend && pnpm --filter @learnstack/web dev
+
.PHONY: dev dev-gated
dev: .env ## Bring the local dev stack up (Postgres, Keycloak, SeaweedFS, …).
$(COMPOSE_DEV) up -d
@@ -257,8 +269,8 @@ test-integration: ## Just the LearnStack.Tests.Integration assembly (a subset of
(cd backend && dotnet test tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj --nologo)
.PHONY: test-frontend
-test-frontend: ## `pnpm -r test` (Vitest component + lib tests).
- (cd frontend && pnpm -r test)
+test-frontend: ## Guarded workspace Vitest run (nonempty, zero skips/todos).
+ (cd frontend && pnpm test)
# ─── Lint / format ────────────────────────────────────────────────────────
.PHONY: lint
@@ -300,7 +312,7 @@ install: .env hooks ## Restore backend NuGet + frontend pnpm deps + activate git
.PHONY: hooks
hooks: ## Activate the repo's pre-commit hook (.githooks/pre-commit).
@git config core.hooksPath .githooks
- @printf "$(CYAN)git hooks → .githooks/ (pre-commit: dotnet format *.cs | prettier frontend/ | next lint frontend/apps/web | leakwatch if present)$(RESET)\n"
+ @printf "$(CYAN)git hooks → .githooks/ (pre-commit: dotnet format *.cs | prettier frontend/ | web staged lint + SDK/UI lint | leakwatch if present)$(RESET)\n"
# ─── Env scaffolding ──────────────────────────────────────────────────────
# `.env` is gitignored; this rule copies `.env.example` on first run so the
diff --git a/README.md b/README.md
index ad672b34..1a7cfe13 100644
--- a/README.md
+++ b/README.md
@@ -85,26 +85,29 @@ generation caching and scope-safe bypass; both review rounds passed.
[PR #24](https://github.com/HodeTech/LearnStack/pull/24) on 2026-10-03; its
[merge closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-3-merge-and-closeout-2026-10-03)
records final verification.
-**P02d-4 is implementation-complete** in
-[PR #25](https://github.com/HodeTech/LearnStack/pull/25), awaiting review and merge.
+**P02d-4 is complete and merged** through
+[PR #25](https://github.com/HodeTech/LearnStack/pull/25) on 2026-10-08; its
+[merge closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-4-merge-and-closeout-2026-10-08)
+records final verification.
Its four steps deliver the read-only host boundary, anonymous site/Education
GET/HEAD reads and OpenAPI/SDK/CI controls. Every step completed both review
rounds; final fix verification and all six real required CI jobs pass. The sixth
required OpenAPI check is active, with all other live protection settings preserved.
-Browser rendering follows
-in P02d-5–7. The
+Trusted server rendering is delivered by P02d-5; public product pages and the
+browser demo remain P02d-6–7. The
[P02d-4 decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-4-decision-package-2026-10-03)
is Accepted — 2026-10-03. All four implementation steps and their review loops
-are complete; server rendering and browser delivery remain P02d-5/6.
+are complete. P02d-5 supplies trusted server rendering; P02d-6 supplies public
+page consumers and P02d-7 the two-host browser demo.
| Area | Delivered now | Next milestone |
|---|---|---|
| **Tenancy** | Tenant provisioning, organizations, locales, typed settings/branding reads, host resolution and database isolation | User membership and permissions in [Phase 03](docs/roadmap/phase-03-identity-admin.md) |
| **Customization** | Content types, level taxonomies, exact-definition and generation-cached batched display readers, text-card metadata validation and tenant-authored seeds | Remaining authoring capabilities across [Phases 04–08a](docs/roadmap/README.md) |
| **Audit** | Classified write path and transactional durability for business changes | Operational hardening in [Phase 11](docs/roadmap/phase-11-production-hardening.md) |
-| **Education** | Course and Lesson aggregates, translations, protected-content policy, scoped authoring commands, complete demo seeds and isolated public GET/HEAD reads | Trusted server consumer in [P02d-5](docs/roadmap/phase-02d-walking-skeleton.md) |
+| **Education** | Course and Lesson aggregates, translations, protected-content policy, scoped authoring commands, complete demo seeds and isolated public GET/HEAD reads | Public page consumers in [P02d-6](docs/roadmap/phase-02d-walking-skeleton.md) |
| **API foundation** | Error contracts, validation, tenancy, concurrency and observability infrastructure | Authentication and durable event processing in [Phase 02b](docs/roadmap/phase-02b-events-auth.md) |
-| **Frontend** | Next.js route scaffolds and generated typed SDK with injected transport | First two-tenant browser demo in [P02d-5–7](docs/roadmap/phase-02d-walking-skeleton.md) |
+| **Frontend** | Native TLS ingress, live locale entry, trusted server caller, route scaffolds and generated typed SDK | Public pages and two-tenant browser demo in [P02d-6–7](docs/roadmap/phase-02d-walking-skeleton.md) |
**Four modules contain domain implementations:** Tenancy, Customization, Audit and
Education. Identity, Content and Media remain scaffolded.
@@ -124,12 +127,13 @@ The [roadmap dependency map](docs/roadmap/README.md) owns the order: **02d runs
### 1. Prepare the tools
-Use Docker with **Compose V2**, Git, Make, Bash, Python 3 and curl, plus:
+Use Docker with **Compose V2**, Git, Make, Bash, Python 3, curl and OpenSSL
+(the TLS test fixtures require its CLI), plus:
| Tool | Repository requirement |
|---|---|
| .NET SDK | `10.0.112` with the roll-forward policy in [backend/global.json](backend/global.json) |
-| Node.js | `>=20.11.0`, as declared in [frontend/package.json](frontend/package.json) |
+| Node.js | `>=22.23.1`, as declared in [frontend/package.json](frontend/package.json) |
| pnpm | `9.12.3`, pinned in [frontend/package.json](frontend/package.json) |
### 2. Bootstrap from the repository root
@@ -145,36 +149,53 @@ and eight scoped courses, ten lessons and twenty-seven translations. It uses the
ordinary authoring pipeline and verifies completed acts before skipping them on rerun.
[SeedData](backend/src/LearnStack.Tools.Seeder/SeedData.cs) owns the complete inventory.
-### 3. Start the applications in separate terminals
+### 3. Prepare local HTTPS and start the applications
+
+```bash
+make public-env # generate a private 32-byte hop secret in the ignored root .env
+```
+
+Install mkcert using its [official instructions](https://github.com/FiloSottile/mkcert).
+Run its trust step yourself, then create the local leaf certificate:
+
+```bash
+mkcert -install
+mkdir -p .data/tls
+mkcert -cert-file .data/tls/public.pem -key-file .data/tls/public-key.pem \
+ localhost demo-english.learnstack.local demo-yoga.learnstack.local
+```
-`make seed` starts infrastructure. The API and web app run separately on your host.
-The API needs the application connection string from `.env`; it does not load that
-file automatically. From the repository root:
+Manually map the two seeded names to `127.0.0.1` in your hosts file. Repository
+commands never edit that file or install trust. Keep the CA private key local;
+only the ignored leaf/key paths belong in `.env`.
```bash
-# Terminal 1 — read only the application credential, then start the API.
-export ConnectionStrings__Default="$(sed -n 's/^ConnectionStrings__Default=//p' .env \
- | tail -1 | tr -d '\r' | sed "s/^['\"]//; s/['\"]$//")"
-(cd backend && dotnet run --project src/LearnStack.Api)
+# Terminal 1 — loopback API with runtime credentials and network + hop secret.
+make public-api
```
```bash
-# Terminal 2 — start the frontend.
-(cd frontend && pnpm dev)
+# Terminal 2 — the mandatory native HTTPS launcher, then Next.js.
+make public-web
```
-The API's liveness endpoint is ; the frontend scaffold
-is at . The application connection uses `learnstack_app`;
-`make migrate` manages the separate migration credential. See
-[local setup](.claude/skills/local-dev-setup/SKILL.md) for the persistent user-secrets
-alternative and [Compose documentation](infra/compose/README.md) for service endpoints
-and troubleshooting.
-
-> **The two-site browser demo is not available yet.** The seed currently registers
-> `demo-english.learnstack.local` and `demo-yoga.learnstack.local`.
-> [Phase 02d gates G32 and G45](docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register)
-> own the final host setup and demo command. The frontend's production tenant-resolution
-> guard currently returns `503`; a successful build is not a production-ready site.
+Readiness is for the API and
+ for the web listener. `pnpm dev/start` also use
+this launcher. Both processes read the same private root source; a web `.env.local`
+projection is optional and must match. No shell evaluates the values. Ordinary
+no-hop API startup remains supported outside this paired renderer recipe.
+
+**P02d-5 implements ingress, visitor budgets and public entry.** The web
+bootstraps the captured host through the API, redirects using enabled/default
+locales and refuses unavailable scope. The server caller enforces no-store,
+deadline/body limits and the closed authenticated hop. All four steps completed
+both independent review rounds and fix verification. Source fences, the no-skip
+test runner and real-API production HTML/RSC proofs pass. The
+[remediation and closeout record](docs/roadmap/phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09)
+owns current review/CI evidence. Public pages are P02d-6;
+the two-host browser demo with `make demo` is P02d-7. See
+[local setup](.claude/skills/local-dev-setup/SKILL.md) and
+[Compose documentation](infra/compose/README.md) for troubleshooting.
### Everyday commands
diff --git a/backend/src/LearnStack.Api/Program.cs b/backend/src/LearnStack.Api/Program.cs
index faa22776..1edaf26e 100644
--- a/backend/src/LearnStack.Api/Program.cs
+++ b/backend/src/LearnStack.Api/Program.cs
@@ -87,6 +87,9 @@
// nothing delivered it, and ADR-0035 puts the gateway that would replace it in
// Phase 11.
app.UseRateLimiter();
+// Invalid authenticated visitor metadata has spent its bounded fallback budget.
+// Refuse before host classification so malformed values cannot purchase lookups.
+app.UseLearnStackVisitorAdmission();
// Below MapLearnStackClientErrors, so a 413 acquires the one Problem Details
// shape without a second writer; after the rate limiter, so a client flooding
diff --git a/backend/src/LearnStack.Api/Tenancy/AnonymousRequestIdentity.cs b/backend/src/LearnStack.Api/Tenancy/AnonymousRequestIdentity.cs
new file mode 100644
index 00000000..e2a81aa5
--- /dev/null
+++ b/backend/src/LearnStack.Api/Tenancy/AnonymousRequestIdentity.cs
@@ -0,0 +1,54 @@
+using System.Net;
+using System.Net.Sockets;
+using Microsoft.AspNetCore.Http.Features;
+
+namespace LearnStack.Api.Tenancy;
+
+/// One request-local identity for ADR-0053/0054's visitor and peer budgets.
+public sealed class AnonymousRequestIdentity(EffectiveHostAccessor hosts)
+{
+ public const string VisitorHeaderName = "X-LearnStack-Visitor-Address";
+ public const int MaximumAddressLength = 45;
+
+ public AnonymousVisitor For(HttpContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ if (context.Features.Get() is { } captured) return captured;
+
+ var peer = context.Features.Get()?.RemoteIpAddress;
+ var peerKey = peer is null ? RateLimitingExtensions.UnknownPeerPartition : Canonical(peer);
+ var trusted = hosts.IsTrustedHop(context);
+ var stated = context.Request.Headers[VisitorHeaderName];
+ var visitor = trusted && stated.Count == 1 ? ParseAddress(stated[0]) : null;
+ var identity = new AnonymousVisitor(peerKey, visitor ?? peerKey, trusted && visitor is null);
+ // Coordinated admission and the pre-lookup refusal use the same capture.
+ context.Features.Set(identity);
+ return identity;
+ }
+
+ /// Strict IP literals only; legacy abbreviated IPv4 and scoped IPv6 are refused.
+ public static string? ParseAddress(string? value)
+ {
+ if (string.IsNullOrEmpty(value) || value.Length > MaximumAddressLength
+ || value.Any(character => !char.IsAsciiHexDigit(character) && character is not ':' and not '.')
+ || !IPAddress.TryParse(value, out var address)) return null;
+
+ if (value.Contains('.', StringComparison.Ordinal))
+ {
+ var dotted = value[(value.LastIndexOf(':') + 1)..];
+ if (!IPAddress.TryParse(dotted, out var ipv4)
+ || ipv4.AddressFamily != AddressFamily.InterNetwork
+ || !string.Equals(dotted, ipv4.ToString(), StringComparison.Ordinal)) return null;
+ }
+ // TryParse accepts integer/hex IPv4 without any dots; those are not literals.
+ if (address.AddressFamily == AddressFamily.InterNetwork
+ && !string.Equals(value, address.ToString(), StringComparison.Ordinal)) return null;
+ return Canonical(address);
+ }
+
+ private static string Canonical(IPAddress address) =>
+ (address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : new IPAddress(address.GetAddressBytes())).ToString();
+}
+
+/// Immutable metadata, never a tenant selector or an audit actor.
+public sealed record AnonymousVisitor(string PeerKey, string VisitorKey, bool Refuse);
diff --git a/backend/src/LearnStack.Api/Tenancy/NoQueueAdmissionLimiter.cs b/backend/src/LearnStack.Api/Tenancy/NoQueueAdmissionLimiter.cs
new file mode 100644
index 00000000..80b1c1e5
--- /dev/null
+++ b/backend/src/LearnStack.Api/Tenancy/NoQueueAdmissionLimiter.cs
@@ -0,0 +1,223 @@
+using System.Threading.RateLimiting;
+
+namespace LearnStack.Api.Tenancy;
+
+/// Coordinates ADR-0054's single-permit, no-queue anonymous admission.
+///
+/// Known exhausted visitors never spend peer permits. The actual visitor registry
+/// is owned here so a peer refusal cannot allocate an unknown visitor partition.
+/// ASP.NET may retry even successful global admission after endpoint refusal;
+/// both outcomes are request-local snapshots with independent returned leases.
+///
+internal sealed class NoQueueAdmissionLimiter : PartitionedRateLimiter
+{
+ private const int SweepBatchSize = 128;
+ private static readonly TimeSpan SweepPeriod = TimeSpan.FromSeconds(30);
+ private readonly object _gate = new();
+ private readonly PartitionedRateLimiter _peer;
+ private readonly Func _createVisitor;
+ private readonly TimeSpan _window;
+ // The traversal queue holds the same owned entries, never shadow membership.
+ private readonly Dictionary _visitors = new(StringComparer.Ordinal);
+ private readonly Queue _sweepOrder = new();
+ private readonly ITimer _sweeper;
+ private readonly TaskCompletionSource _shutdown = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private bool _closed;
+ private int _sweeping;
+
+ public NoQueueAdmissionLimiter(PartitionedRateLimiter peer)
+ : this(peer, () => CreateVisitorBudget(), RateLimitingExtensions.Window, TimeProvider.System) { }
+
+ /// The clock controls sweeping; the factory owns quota/replenishment.
+ internal NoQueueAdmissionLimiter(PartitionedRateLimiter peer,
+ Func createVisitor, TimeSpan window, TimeProvider timeProvider)
+ {
+ ArgumentNullException.ThrowIfNull(peer);
+ ArgumentNullException.ThrowIfNull(createVisitor);
+ ArgumentNullException.ThrowIfNull(timeProvider);
+ ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(window, TimeSpan.Zero);
+ _peer = peer;
+ _createVisitor = createVisitor;
+ _window = window;
+ _sweeper = timeProvider.CreateTimer(_ => Sweep(), null, SweepPeriod, SweepPeriod);
+ }
+
+ internal static FixedWindowRateLimiter CreateVisitorBudget(bool autoReplenishment = true) =>
+ new(new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = RateLimitingExtensions.AnonymousPermitPerWindow,
+ Window = RateLimitingExtensions.Window,
+ AutoReplenishment = autoReplenishment,
+ QueueLimit = 0,
+ QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
+ });
+
+ // There is no meaningful composite snapshot, and diagnostics must not allocate.
+ public override RateLimiterStatistics? GetStatistics(HttpContext resource) => null;
+
+ protected override RateLimitLease AttemptAcquireCore(HttpContext resource, int permitCount)
+ {
+ ArgumentNullException.ThrowIfNull(resource);
+ // This is the middleware's one-permit owner, not a general queued adapter.
+ ArgumentOutOfRangeException.ThrowIfNotEqual(permitCount, 1);
+ var identity = resource.RequestServices.GetRequiredService().For(resource);
+ List leases = [];
+ try
+ {
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_closed, this);
+ if (resource.Items.TryGetValue(this, out var saved) && saved is Outcome prior)
+ return new SnapshotLease(prior);
+
+ var outcome = Admit(resource, identity, leases);
+ resource.Items[this] = outcome;
+ return new SnapshotLease(outcome);
+ }
+ }
+ finally
+ {
+ // Even lease disposal is outside the owner lock. Snapshots retain no
+ // child lease or limiter reference, including after registry retirement.
+ foreach (var lease in leases) lease.Dispose();
+ }
+ }
+
+ private Outcome Admit(HttpContext request, AnonymousVisitor identity, List leases)
+ {
+ _visitors.TryGetValue(identity.VisitorKey, out var entry);
+ if (entry is not null)
+ {
+ var probe = entry.Limiter.AttemptAcquire(0);
+ leases.Add(probe);
+ if (!probe.IsAcquired) return Capture(probe);
+ }
+
+ var peerLease = _peer.AttemptAcquire(request);
+ leases.Add(peerLease);
+ if (!peerLease.IsAcquired) return Capture(peerLease);
+
+ if (entry is null)
+ {
+ entry = new Entry(identity.VisitorKey, _createVisitor());
+ _visitors.Add(entry.Key, entry);
+ _sweepOrder.Enqueue(entry);
+ }
+
+ var visitorLease = entry.Limiter.AttemptAcquire(1);
+ leases.Add(visitorLease);
+ // The factory supplies a full limiter; no competing debit/retirement can
+ // follow the successful zero probe. Replenishment only increases quota.
+ if (!visitorLease.IsAcquired)
+ throw new InvalidOperationException("Anonymous visitor quota changed within coordinated admission.");
+ return Capture(visitorLease);
+ }
+
+ private static Outcome Capture(RateLimitLease lease) =>
+ new(lease.IsAcquired, lease.GetAllMetadata().ToDictionary(StringComparer.Ordinal));
+
+ protected override ValueTask AcquireAsyncCore(HttpContext resource, int permitCount, CancellationToken cancellationToken) =>
+ cancellationToken.IsCancellationRequested
+ ? ValueTask.FromCanceled(cancellationToken)
+ : ValueTask.FromResult(AttemptAcquireCore(resource, permitCount));
+
+ private void Sweep()
+ {
+ if (Interlocked.CompareExchange(ref _sweeping, 1, 0) != 0) return;
+ try
+ {
+ int remaining;
+ lock (_gate) remaining = _closed ? 0 : _sweepOrder.Count;
+ // Entries admitted during this pass join behind the original census.
+ // Survivors rotate, so every original entry is inspected exactly once.
+ while (remaining > 0)
+ {
+ List retired = [];
+ lock (_gate)
+ {
+ if (_closed) return;
+ var batch = Math.Min(remaining, SweepBatchSize);
+ for (var index = 0; index < batch; index++)
+ {
+ var entry = _sweepOrder.Dequeue();
+ if (entry.Limiter.IdleDuration is { } idle && idle >= _window)
+ {
+ _visitors.Remove(entry.Key);
+ retired.Add(entry.Limiter);
+ }
+ else _sweepOrder.Enqueue(entry);
+ }
+ remaining -= batch;
+ }
+ foreach (var limiter in retired) limiter.Dispose();
+ }
+ }
+ finally { Volatile.Write(ref _sweeping, 0); }
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing) Shutdown(asynchronous: false).GetAwaiter().GetResult();
+ base.Dispose(disposing);
+ }
+
+ protected override async ValueTask DisposeAsyncCore()
+ {
+ await Shutdown(asynchronous: true).ConfigureAwait(false);
+ await base.DisposeAsyncCore().ConfigureAwait(false);
+ }
+
+ private Task Shutdown(bool asynchronous)
+ {
+ lock (_gate)
+ {
+ if (_closed) return _shutdown.Task;
+ _closed = true;
+ }
+ _ = CompleteShutdown(asynchronous);
+ return _shutdown.Task;
+ }
+
+ private async Task CompleteShutdown(bool asynchronous)
+ {
+ List failures = [];
+ try { await _sweeper.DisposeAsync().ConfigureAwait(false); }
+ catch (Exception error) { failures.Add(error); }
+
+ Entry[] remaining;
+ lock (_gate)
+ {
+ remaining = _sweepOrder.ToArray();
+ _sweepOrder.Clear();
+ _visitors.Clear();
+ }
+ foreach (var entry in remaining)
+ {
+ try
+ {
+ if (asynchronous) await entry.Limiter.DisposeAsync().ConfigureAwait(false);
+ else entry.Limiter.Dispose();
+ }
+ catch (Exception error) { failures.Add(error); }
+ }
+ try
+ {
+ if (asynchronous) await _peer.DisposeAsync().ConfigureAwait(false);
+ else _peer.Dispose();
+ }
+ catch (Exception error) { failures.Add(error); }
+ if (failures.Count == 0) _shutdown.SetResult();
+ else _shutdown.SetException(new AggregateException(failures));
+ }
+
+ private sealed record Entry(string Key, RateLimiter Limiter);
+ private sealed record Outcome(bool Acquired, IReadOnlyDictionary Metadata);
+
+ private sealed class SnapshotLease(Outcome outcome) : RateLimitLease
+ {
+ public override bool IsAcquired => outcome.Acquired;
+ public override IEnumerable MetadataNames => outcome.Metadata.Keys;
+ public override bool TryGetMetadata(string metadataName, out object? value) =>
+ outcome.Metadata.TryGetValue(metadataName, out value);
+ }
+}
diff --git a/backend/src/LearnStack.Api/Tenancy/RateLimitingExtensions.cs b/backend/src/LearnStack.Api/Tenancy/RateLimitingExtensions.cs
index 4de052f1..7f4d4c4a 100644
--- a/backend/src/LearnStack.Api/Tenancy/RateLimitingExtensions.cs
+++ b/backend/src/LearnStack.Api/Tenancy/RateLimitingExtensions.cs
@@ -2,7 +2,6 @@
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.DependencyInjection;
@@ -32,19 +31,12 @@ namespace LearnStack.Api.Tenancy;
/// Postgres transaction and a cache entry, on a pre-auth surface.
///
///
-/// Partitioned on the socket peer, and only the socket peer. There is no
-/// authenticated partition yet because there is no authentication yet — Phase
-/// 02b adds the token-keyed budgets Standards 04 also fixes, and adding a
-/// partition key that is constant-null today would be a partition in name only.
-///
-///
-/// Open in Phase 02d. A server-rendered page reaches the API from the
-/// renderer's peer, so under this key every visitor of such a page would share the
-/// renderer's partition. How a request arriving over the trusted hop is keyed and
-/// budgeted is G34 in
-/// Phase
-/// 02d's decision register; the pass that closes it edits this class and these
-/// remarks with its answer.
+/// ADR-0053 / P02d-5 G34 uses a canonical visitor-IP budget shared by direct and
+/// authenticated-hop traffic, plus a physical-peer ceiling. Only a network-and-
+/// secret authenticated hop may state the visitor; invalid trusted metadata spends
+/// the peer fallback budget before masked refusal. ADR-0054 coordinates admission
+/// so known visitor refusals do not burn peer permits and new visitor allocation
+/// remains peer-gated. Total refusal work is not bounded. Phase 02b owns token budgets.
///
///
public static class RateLimitingExtensions
@@ -52,6 +44,9 @@ public static class RateLimitingExtensions
/// The anonymous budget from Standards 04: 60 requests a minute per IP.
public const int AnonymousPermitPerWindow = 60;
+ /// The separate physical-peer backstop accepted in ADR-0053.
+ public const int PeerPermitPerWindow = 600;
+
public static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
/// The partition an unidentifiable peer falls into.
@@ -66,26 +61,15 @@ public static IServiceCollection AddLearnStackRateLimiting(this IServiceCollecti
{
ArgumentNullException.ThrowIfNull(services);
+ services.AddSingleton();
+ // DI owns the coordinated registry, periodic sweep and peer budget.
+ services.AddSingleton(_ => new NoQueueAdmissionLimiter(CreatePeerBudget()));
+ services.AddOptions().Configure(
+ (options, limiter) => options.GlobalLimiter = limiter);
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
- options.GlobalLimiter = PartitionedRateLimiter.Create(
- context => RateLimitPartition.GetFixedWindowLimiter(
- PartitionKeyFor(context),
- _ => new FixedWindowRateLimiterOptions
- {
- PermitLimit = AnonymousPermitPerWindow,
- Window = Window,
-
- // No queue. Queuing a request that is over budget spends
- // the server's memory to delay an answer the client is
- // going to get anyway, and under a flood the queue is
- // the thing that falls over.
- QueueLimit = 0,
- QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
- }));
-
options.OnRejected = (context, cancellationToken) =>
{
// Retry-After is required on a 429 by Standards 04 § Status
@@ -105,19 +89,30 @@ public static IServiceCollection AddLearnStackRateLimiting(this IServiceCollecti
return services;
}
- ///
- /// Reads the socket peer, with the same caveat as the trusted hop:
- /// is the storage
- /// UseForwardedHeaders mutates, so if that middleware ever ran ahead of
- /// this one, the key would become whatever address a forwarded header states —
- /// a partition the caller mints for itself unless only a trusted proxy may
- /// state it. The remarks on RefuseAmbientForwardedHeaders record the
- /// measurement: zero rejections under a rotating X-Forwarded-For. In
- /// the shipped state, with no such middleware, every request arriving through
- /// one proxy shares that proxy's partition. Forwarded_Headers_Are_Not_Wired
- /// is the tripwire for this key and the hop alike.
- ///
- private static string PartitionKeyFor(HttpContext context) =>
- context.Features.Get()?.RemoteIpAddress?.ToString()
- ?? UnknownPeerPartition;
+ internal static PartitionedRateLimiter CreatePeerBudget() =>
+ PartitionedRateLimiter.Create(context =>
+ RateLimitPartition.GetFixedWindowLimiter(
+ context.RequestServices.GetRequiredService().For(context).PeerKey,
+ _ => new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = PeerPermitPerWindow,
+ Window = Window,
+ QueueLimit = 0,
+ QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
+ }));
+
+ /// Runs after both budgets, before host classification or any database lookup.
+ public static IApplicationBuilder UseLearnStackVisitorAdmission(this IApplicationBuilder app)
+ {
+ ArgumentNullException.ThrowIfNull(app);
+ return app.Use(async (context, next) =>
+ {
+ if (context.RequestServices.GetRequiredService().For(context).Refuse)
+ {
+ context.Response.StatusCode = StatusCodes.Status404NotFound;
+ return;
+ }
+ await next(context);
+ });
+ }
}
diff --git a/backend/tests/LearnStack.Tests.Integration/AnonymousAdmissionRetryTests.cs b/backend/tests/LearnStack.Tests.Integration/AnonymousAdmissionRetryTests.cs
new file mode 100644
index 00000000..0c268fdb
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Integration/AnonymousAdmissionRetryTests.cs
@@ -0,0 +1,193 @@
+using System.Globalization;
+using System.Net;
+using System.Threading.RateLimiting;
+using FluentAssertions;
+using LearnStack.Api.Tenancy;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.RateLimiting;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Xunit;
+
+namespace LearnStack.Tests.Integration;
+
+public sealed class AnonymousAdmissionRetryTests
+{
+ private const string Secret = "test-only-endpoint-retry-secret-at-least-32";
+
+ [Fact]
+ public async Task Endpoint_refusal_replays_successful_global_admission_without_spending_either_budget_twice()
+ {
+ await using var app = await CreateHost();
+ using var client = app.GetTestClient();
+ var global = app.Services.GetRequiredService();
+
+ using var first = await Send(client, "/endpoint-limited", "203.0.113.1");
+ first.StatusCode.Should().Be(HttpStatusCode.OK);
+ using var endpointRefusal = await Send(client, "/endpoint-limited", "203.0.113.1");
+ endpointRefusal.StatusCode.Should().Be(HttpStatusCode.TooManyRequests);
+ endpointRefusal.Headers.RetryAfter?.Delta.Should().BeGreaterThan(TimeSpan.Zero);
+ global.Retries.Should().ContainSingle().Which.Acquired.Should().BeTrue(
+ "ASP.NET retries global acquisition after its endpoint policy refuses");
+
+ for (var index = 2; index < RateLimitingExtensions.AnonymousPermitPerWindow; index++)
+ {
+ using var admitted = await Send(client, "/admitted", "203.0.113.1");
+ admitted.StatusCode.Should().Be(HttpStatusCode.OK,
+ "the endpoint refusal consumed one global admission, including its framework retry");
+ }
+ using var visitorRefusal = await Send(client, "/admitted", "203.0.113.1");
+ visitorRefusal.StatusCode.Should().Be(HttpStatusCode.TooManyRequests);
+ global.Retries.Should().HaveCount(2);
+ global.Retries[1].Acquired.Should().BeFalse();
+
+ var remaining = RateLimitingExtensions.PeerPermitPerWindow - RateLimitingExtensions.AnonymousPermitPerWindow;
+ for (var index = 0; index < remaining; index++)
+ {
+ var visitor = "203.0.113." + (index / RateLimitingExtensions.AnonymousPermitPerWindow + 2)
+ .ToString(CultureInfo.InvariantCulture);
+ using var admitted = await Send(client, "/admitted", visitor);
+ admitted.StatusCode.Should().Be(HttpStatusCode.OK,
+ "neither the endpoint retry nor the exhausted visitor spent an extra peer permit");
+ }
+ using var peerRefusal = await Send(client, "/admitted", "203.0.113.99");
+ peerRefusal.StatusCode.Should().Be(HttpStatusCode.TooManyRequests);
+ global.Retries.Should().HaveCount(3);
+ global.Retries.Should().OnlyContain(retry => retry.CompletedSynchronously,
+ "no-queue global admission completes immediately on both success and refusal");
+ foreach (var retry in global.Retries)
+ {
+ retry.Acquired.Should().Be(retry.InitiallyAcquired);
+ retry.Metadata.Should().BeEquivalentTo(retry.InitialMetadata,
+ "each framework retry uses the same HttpContext and saved outcome metadata");
+ }
+ }
+
+ private static async Task CreateHost()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Logging.ClearProviders();
+ builder.Services.AddSingleton>(Options.Create(new TrustedHopOptions
+ {
+ Networks = ["127.0.0.1/32"],
+ Secrets = [Secret],
+ }));
+ builder.Services.AddSingleton();
+ builder.Services.AddLearnStackRateLimiting();
+ // Exercise real fixed-window/no-queue budgets, with replenishment frozen
+ // so the HTTP accounting proof does not depend on finishing within a minute.
+ builder.Services.RemoveAll();
+ builder.Services.AddSingleton(_ => new NoQueueAdmissionLimiter(
+ FrozenAnonymousPeerBudget.Create(),
+ () => NoQueueAdmissionLimiter.CreateVisitorBudget(autoReplenishment: false),
+ RateLimitingExtensions.Window, TimeProvider.System));
+ builder.Services.AddSingleton();
+ builder.Services.AddOptions().Configure(
+ (options, limiter) => options.GlobalLimiter = limiter);
+ // The endpoint partition must also hide the replenishing interface;
+ // AddFixedWindowLimiter's partition heartbeat would otherwise reset it.
+ builder.Services.AddRateLimiter(options => options.AddPolicy("endpoint", _ =>
+ RateLimitPartition.Get("endpoint", _ => FrozenAnonymousPeerBudget.CreateLimiter(1))));
+
+ var app = builder.Build();
+ app.Use((context, next) =>
+ {
+ // TestServer has no physical socket; supply its peer feature before
+ // the production identity resolver authenticates the test hop headers.
+ context.Connection.RemoteIpAddress = IPAddress.Loopback;
+ return next(context);
+ });
+ app.UseRouting();
+ app.UseRateLimiter();
+ app.MapGet("/admitted", () => Results.Ok());
+ app.MapGet("/endpoint-limited", () => Results.Ok()).RequireRateLimiting("endpoint");
+ await app.StartAsync();
+ return app;
+ }
+
+ private static async Task Send(HttpClient client, string path, string visitor)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, path);
+ request.Headers.Add(TrustedHopOptions.SecretHeaderName, Secret);
+ request.Headers.Add(AnonymousRequestIdentity.VisitorHeaderName, visitor);
+ return await client.SendAsync(request);
+ }
+
+ private sealed class ObservedGlobalLimiter(NoQueueAdmissionLimiter inner) : PartitionedRateLimiter
+ {
+ public List Retries { get; } = [];
+
+ public override RateLimiterStatistics? GetStatistics(HttpContext resource) => inner.GetStatistics(resource);
+
+ protected override RateLimitLease AttemptAcquireCore(HttpContext resource, int permitCount)
+ {
+ var lease = inner.AttemptAcquire(resource, permitCount);
+ resource.Items[this] = (lease.IsAcquired, lease.GetAllMetadata().ToDictionary(StringComparer.Ordinal));
+ return lease;
+ }
+
+ protected override async ValueTask AcquireAsyncCore(
+ HttpContext resource, int permitCount, CancellationToken cancellationToken)
+ {
+ var initial = ((bool Acquired, Dictionary Metadata))resource.Items[this]!;
+ var pending = inner.AcquireAsync(resource, permitCount, cancellationToken);
+ var completedSynchronously = pending.IsCompletedSuccessfully;
+ var lease = await pending;
+ Retries.Add(new RetryObservation(initial.Acquired, lease.IsAcquired, completedSynchronously,
+ initial.Metadata, lease.GetAllMetadata().ToDictionary(StringComparer.Ordinal)));
+ return lease;
+ }
+ }
+
+ private sealed record RetryObservation(bool InitiallyAcquired, bool Acquired, bool CompletedSynchronously,
+ IReadOnlyDictionary InitialMetadata, IReadOnlyDictionary Metadata);
+}
+
+internal static class FrozenAnonymousPeerBudget
+{
+ internal static PartitionedRateLimiter Create() =>
+ PartitionedRateLimiter.Create(context => RateLimitPartition.Get(
+ context.RequestServices.GetRequiredService().For(context).PeerKey,
+ _ => CreateLimiter(RateLimitingExtensions.PeerPermitPerWindow)));
+
+ internal static RateLimiter CreateLimiter(int permits) =>
+ new FrozenLimiter(new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = permits,
+ Window = RateLimitingExtensions.Window,
+ QueueLimit = 0,
+ QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
+ AutoReplenishment = false,
+ }));
+
+ // DefaultPartitionedRateLimiter replenishes ReplenishingRateLimiter children
+ // from its own heartbeat, even when their AutoReplenishment is false. This
+ // test-only RateLimiter wrapper keeps the actual fixed-window implementation
+ // while hiding that replenishment interface from the partition owner.
+ private sealed class FrozenLimiter(FixedWindowRateLimiter inner) : RateLimiter
+ {
+ public override TimeSpan? IdleDuration => inner.IdleDuration;
+ public override RateLimiterStatistics? GetStatistics() => inner.GetStatistics();
+ protected override RateLimitLease AttemptAcquireCore(int permitCount) => inner.AttemptAcquire(permitCount);
+ protected override ValueTask AcquireAsyncCore(int permitCount, CancellationToken cancellationToken) =>
+ inner.AcquireAsync(permitCount, cancellationToken);
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing) inner.Dispose();
+ base.Dispose(disposing);
+ }
+
+ protected override async ValueTask DisposeAsyncCore()
+ {
+ await inner.DisposeAsync();
+ await base.DisposeAsyncCore();
+ }
+ }
+}
diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicLocalLaunchTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicLocalLaunchTests.cs
new file mode 100644
index 00000000..36d4f2dd
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicLocalLaunchTests.cs
@@ -0,0 +1,175 @@
+using System.Diagnostics;
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using FluentAssertions;
+using LearnStack.Api.Tenancy;
+using LearnStack.Tools.Seeder;
+using Xunit;
+
+namespace LearnStack.Tests.Integration.Database;
+
+/// Exercises the actual local API launcher with only the application credential.
+[Collection(PublicReadTestGroup.Name)]
+[Trait(RequiresDocker.Key, RequiresDocker.Value)]
+public sealed class PublicLocalLaunchTests(PublicReadFixture fixture)
+{
+ private const string Secret = "test-only-public-local-launch-secret-at-least-32";
+
+ [Fact]
+ public async Task Public_local_API_launcher_serves_seeded_reads_without_a_PlatformAdmin_credential()
+ {
+ var repository = RepositoryRoot();
+ var temporary = Path.Combine(Path.GetTempPath(), "learnstack-public-local-" + Guid.NewGuid().ToString("N"));
+ var app = Path.Combine(temporary, "frontend/apps/web");
+ var backend = Path.Combine(temporary, "backend");
+ using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(3));
+ using var process = new Process();
+ var started = false;
+ Task output = Task.CompletedTask;
+ Task errors = Task.CompletedTask;
+ try
+ {
+ Directory.CreateDirectory(Path.Combine(temporary, "scripts"));
+ Directory.CreateDirectory(Path.Combine(app, "src/server"));
+ Directory.CreateDirectory(Path.Combine(temporary, "user-secrets"));
+ File.WriteAllText(Path.Combine(temporary, "package.json"), "{\"type\":\"module\"}");
+ File.Copy(Path.Combine(repository, "scripts/public-local.mjs"), Path.Combine(temporary, "scripts/public-local.mjs"));
+ foreach (var file in new[] { "tsconfig.json", "tsconfig.server.json", "src/server/ingress.ts" })
+ File.Copy(Path.Combine(repository, "frontend/apps/web", file), Path.Combine(app, file));
+ Directory.CreateSymbolicLink(Path.Combine(app, "node_modules"), Path.Combine(repository, "frontend/apps/web/node_modules"));
+ Directory.CreateSymbolicLink(backend, Path.Combine(repository, "backend"));
+ File.Exists(Path.Combine(temporary, ".env")).Should().BeFalse();
+ File.Exists(Path.Combine(app, ".env.local")).Should().BeFalse();
+
+ // Compile the current ingress source with its real project options;
+ // never depend on a preexisting ignored .server build artifact.
+ await CompileIngressAsync(repository, app, deadline.Token);
+ process.StartInfo = NodeStart(temporary);
+ process.StartInfo.ArgumentList.Add(Path.Combine(temporary, "scripts/public-local.mjs"));
+ process.StartInfo.ArgumentList.Add("api");
+ process.StartInfo.Environment["ConnectionStrings__Default"] = fixture.AppConnectionString;
+ process.StartInfo.Environment["LEARNSTACK_PUBLIC_API_ORIGIN"] = "http://127.0.0.1:0";
+ process.StartInfo.Environment["LEARNSTACK_PUBLIC_HOP_SECRET"] = Secret;
+ // API configuration validates paths but does not open renderer TLS files.
+ process.StartInfo.Environment["LEARNSTACK_PUBLIC_TLS_CERT"] = "unused-public.pem";
+ process.StartInfo.Environment["LEARNSTACK_PUBLIC_TLS_KEY"] = "unused-public-key.pem";
+ // WebApplication's Development user-secret provider otherwise consults
+ // the developer's profile. APPDATA selects an empty owned secret root.
+ process.StartInfo.Environment["APPDATA"] = Path.Combine(temporary, "user-secrets");
+ process.StartInfo.Environment.ContainsKey("ConnectionStrings__PlatformAdmin").Should().BeFalse();
+
+ var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ started = process.Start();
+ started.Should().BeTrue();
+ output = ObserveListeningAddressAsync(process.StandardOutput, ready);
+ errors = DrainAsync(process.StandardError);
+ var origin = await ready.Task.WaitAsync(deadline.Token);
+ origin.Port.Should().NotBe(3000).And.NotBe(3011);
+ using var client = new HttpClient(new SocketsHttpHandler { UseProxy = false }) { BaseAddress = origin };
+ using var health = await client.GetAsync("/healthz", deadline.Token);
+ health.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ foreach (var tenant in new[] { SeedData.English, SeedData.Yoga })
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/public/site");
+ request.Headers.Host = "gateway.internal";
+ request.Headers.Add(TrustedHopOptions.HostHeaderName, tenant.Host);
+ request.Headers.Add(TrustedHopOptions.SecretHeaderName, Secret);
+ request.Headers.Add(AnonymousRequestIdentity.VisitorHeaderName, "203.0.113.90");
+ using var response = await client.SendAsync(request, deadline.Token);
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ response.Headers.CacheControl?.NoStore.Should().BeTrue();
+ var site = await response.Content.ReadFromJsonAsync(deadline.Token);
+ site.GetProperty("displayName").GetString().Should().Be(tenant.DisplayName);
+ }
+ }
+ finally
+ {
+ // The actual launcher owns dotnet run and its application descendant.
+ // Join the owned tree before removing its temporary configuration root.
+ if (started)
+ {
+ if (!process.HasExited) process.Kill(entireProcessTree: true);
+ using var shutdown = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ await process.WaitForExitAsync(shutdown.Token);
+ await Task.WhenAll(output, errors).WaitAsync(shutdown.Token);
+ }
+ if (Directory.Exists(backend)) Directory.Delete(backend);
+ if (Directory.Exists(temporary)) Directory.Delete(temporary, recursive: true);
+ }
+ }
+
+ private static async Task CompileIngressAsync(string repository, string app, CancellationToken cancellationToken)
+ {
+ using var compiler = new Process { StartInfo = NodeStart(app) };
+ compiler.StartInfo.ArgumentList.Add(Path.Combine(repository, "frontend/apps/web/node_modules/typescript/bin/tsc"));
+ compiler.StartInfo.ArgumentList.Add("--project");
+ compiler.StartInfo.ArgumentList.Add("tsconfig.server.json");
+ var started = false;
+ Task output = Task.CompletedTask;
+ Task errors = Task.CompletedTask;
+ try
+ {
+ started = compiler.Start();
+ started.Should().BeTrue();
+ output = DrainAsync(compiler.StandardOutput);
+ errors = DrainAsync(compiler.StandardError);
+ await compiler.WaitForExitAsync(cancellationToken);
+ compiler.ExitCode.Should().Be(0, "the staged current ingress source must compile before launching");
+ }
+ finally
+ {
+ if (started)
+ {
+ if (!compiler.HasExited) compiler.Kill(entireProcessTree: true);
+ using var shutdown = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ await compiler.WaitForExitAsync(shutdown.Token);
+ await Task.WhenAll(output, errors).WaitAsync(shutdown.Token);
+ }
+ }
+ }
+
+ private static ProcessStartInfo NodeStart(string directory)
+ {
+ var start = new ProcessStartInfo("node")
+ {
+ WorkingDirectory = directory,
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ };
+ start.Environment.Clear();
+ foreach (var key in new[] { "PATH", "HOME", "DOTNET_ROOT", "TMPDIR" })
+ if (Environment.GetEnvironmentVariable(key) is { } value) start.Environment[key] = value;
+ return start;
+ }
+
+ private static async Task ObserveListeningAddressAsync(StreamReader stream, TaskCompletionSource ready)
+ {
+ while (await stream.ReadLineAsync() is { } line)
+ {
+ // Retain only the owned listener address, never provider/build logs.
+ // The compact JSON sink may quote/escape the rendered address.
+ if (!line.Contains("Now listening on:", StringComparison.Ordinal)) continue;
+ var match = Regex.Match(line, @"http://127\.0\.0\.1:\d+",
+ RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1));
+ if (match.Success) ready.TrySetResult(new Uri(match.Value));
+ }
+ ready.TrySetException(new InvalidOperationException("The owned local API exited before announcing readiness."));
+ }
+
+ private static async Task DrainAsync(StreamReader stream)
+ {
+ var buffer = new char[1024];
+ while (await stream.ReadAsync(buffer) > 0) { }
+ }
+
+ private static string RepositoryRoot()
+ {
+ for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent)
+ if (File.Exists(Path.Combine(directory.FullName, "scripts/public-local.mjs"))) return directory.FullName;
+ throw new InvalidOperationException("Cannot find local API launcher source.");
+ }
+}
diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs
new file mode 100644
index 00000000..37c4ac6c
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs
@@ -0,0 +1,396 @@
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Globalization;
+using System.Net;
+using System.Security.Cryptography;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using FluentAssertions;
+using LearnStack.Tools.Seeder;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Hosting.Server;
+using Microsoft.AspNetCore.Hosting.Server.Features;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Serilog.Core;
+using Serilog.Events;
+using Serilog.Parsing;
+using Xunit;
+
+namespace LearnStack.Tests.Integration.Database;
+
+///
+/// Production Next/native TLS/SDK/API/PostgreSQL proof. The disposable renderer
+/// owns its route; no diagnostic page is added to the shipped application.
+///
+[Collection(PublicReadTestGroup.Name)]
+[Trait(RequiresDocker.Key, RequiresDocker.Value)]
+public sealed class PublicServerRenderingTests(PublicReadFixture fixture)
+{
+ private const string SuppliedTrace = "00-1234567890abcdef1234567890abcdef-1234567890abcdef-01";
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+
+ [Fact]
+ public async Task Production_rendering_is_host_isolated_fresh_and_private_through_the_real_public_API()
+ {
+ var secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+ var observed = new RenderingObservation(secret);
+ await using var factory = fixture.WithWebHostBuilder(builder =>
+ {
+ builder.UseSetting("Tenancy:TrustedHop:Networks:0", "127.0.0.1/32");
+ builder.UseSetting("Tenancy:TrustedHop:Secrets:0", secret);
+ builder.ConfigureTestServices(services =>
+ {
+ services.AddSingleton(observed);
+ // Production Serilog reads sinks from DI; keep its real
+ // configuration/enrichers and observe the same emitted events.
+ services.AddSingleton(observed);
+ });
+ });
+ factory.UseKestrel(options => options.Listen(IPAddress.Loopback, 0));
+ using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
+ var addresses = factory.Services.GetRequiredService().Features.Get();
+ addresses.Should().NotBeNull();
+ var origin = addresses!.Addresses.Single();
+
+ // The shared exact-locale slug and all identities/expected content come
+ // from the seeder's sole declaration, not a second JS seed registry.
+ var firstCourse = SeedData.English.Curriculum!.Courses.First(course =>
+ course.Status == "Published" && course.ContentAccess == "public" && course.OrganizationId is null);
+ var firstTranslation = firstCourse.Translations.Single();
+ var locale = firstTranslation.Locale;
+ var sharedSlug = firstTranslation.Slug;
+ var secondCourse = SeedData.Yoga.Curriculum!.Courses.Single(course =>
+ course.Translations.Any(translation => translation.Locale == locale && translation.Slug == sharedSlug));
+ var tenants = new[]
+ {
+ TenantInput(SeedData.English, firstCourse, locale),
+ TenantInput(SeedData.Yoga, secondCourse, locale)
+ };
+ var root = RepositoryRoot();
+ using var process = RendererProcess(root);
+ using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(8));
+ var started = false;
+ var draft = false;
+ var verified = false;
+ var tracePosition = 0;
+ var beforeProtocol = -1;
+ var beforeStock = -1;
+ var beforeStockReads = -1;
+ var checkpoints = new List();
+ try
+ {
+ started = process.Start();
+ started.Should().BeTrue();
+ var errors = BoundedErrorsAsync(process.StandardError, deadline.Token);
+ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new
+ {
+ apiOrigin = origin,
+ secret,
+ locale,
+ courseSlug = sharedSlug,
+ traceparent = SuppliedTrace,
+ tenants
+ }, JsonOptions));
+ while (await process.StandardOutput.ReadLineAsync(deadline.Token) is { } checkpoint)
+ {
+ checkpoint.Length.Should().BeLessThan(40, "only bounded control tokens leave the fixture");
+ checkpoints.Add(checkpoint);
+ switch (checkpoint)
+ {
+ case "build-complete":
+ observed.Requests.Should().BeEmpty("production compilation must never fetch tenant data");
+ break;
+ case "trace-supplied":
+ case "trace-missing":
+ case "trace-malformed":
+ var requests = observed.Requests.Skip(tracePosition).ToArray();
+ requests.Should().HaveCount(3, "middleware bootstrap, page bootstrap and content all use the real API");
+ requests.Take(2).Should().OnlyContain(request => request.Path == "/api/v1/public/site");
+ requests[2].Path.Should().StartWith("/api/v1/public/courses");
+ requests.Select(request => request.TraceId).Distinct().Should().ContainSingle("one incoming render has one distributed trace");
+ requests.Should().OnlyContain(request => request.TraceId.Length == 32 && request.TraceId != new string('0', 32));
+ if (checkpoint == "trace-supplied") requests[0].TraceId.Should().Be(SuppliedTrace.Substring(3, 32));
+ else requests[0].TraceId.Should().NotBe(SuppliedTrace.Substring(3, 32), "missing/malformed contexts get a fresh trace");
+ tracePosition += requests.Length;
+ break;
+ case "protocol-before":
+ beforeProtocol = observed.Requests.Length;
+ break;
+ case "protocol-after":
+ observed.Requests.Skip(beforeProtocol).Should().HaveCount(6,
+ "five protocol GET probes and HEAD each run the real middleware bootstrap once");
+ observed.Requests.Skip(beforeProtocol).Should().OnlyContain(request => request.Path == "/api/v1/public/site",
+ "the test-owned protocol route makes no page/content API calls");
+ break;
+ case "stock-before":
+ beforeStock = observed.Requests.Length;
+ beforeStockReads = fixture.Observation.Reads;
+ beforeStock.Should().BeGreaterThan(0, "the same API already served native positive controls");
+ break;
+ case "stock-after":
+ observed.Requests.Length.Should().Be(beforeStock, "stock Next forgery fails before any API call");
+ fixture.Observation.Reads.Should().Be(beforeStockReads, "stock forgery cannot bootstrap or read PostgreSQL");
+ break;
+ case "make-draft":
+ await SetStatusAsync(firstCourse, "Draft");
+ draft = true;
+ break;
+ case "restore-published":
+ await SetStatusAsync(firstCourse, firstCourse.Status);
+ draft = false;
+ break;
+ case "verified":
+ verified = true;
+ break;
+ default:
+ Assert.Fail("Unexpected production-rendering control token.");
+ break;
+ }
+ await process.StandardInput.WriteLineAsync("continue");
+ }
+ await process.WaitForExitAsync(deadline.Token);
+ var errorOutput = await errors;
+ process.ExitCode.Should().Be(0, errorOutput);
+ verified.Should().BeTrue("the production fixture must execute every scenario");
+ checkpoints.Should().Equal("build-complete", "trace-supplied", "trace-missing", "trace-malformed", "protocol-before", "protocol-after", "stock-before", "stock-after", "make-draft", "restore-published", "verified");
+ observed.Requests.Length.Should().BeInRange(1, 59, "the fixture stays within one real anonymous visitor budget");
+ observed.Requests.Should().OnlyContain(request => request.ValidHop, "the real caller uses the closed authenticated hop");
+ observed.Logs.Should().BeGreaterThan(0, "API log containment needs a nonempty real logging subject");
+ observed.PrivateLog.Should().BeFalse("private carriers, provenance and the hop credential cannot enter API logs");
+ }
+ finally
+ {
+ // Closing the control pipe asks the Node fixture to terminate its
+ // own process groups and delete TLS/build files, before a last resort kill.
+ try
+ {
+ if (started) process.StandardInput.Close();
+ if (started && !process.HasExited)
+ {
+ using var shutdown = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ try { await process.WaitForExitAsync(shutdown.Token); }
+ catch (OperationCanceledException) { process.Kill(entireProcessTree: true); await process.WaitForExitAsync(CancellationToken.None); }
+ }
+ }
+ finally { if (draft) await SetStatusAsync(firstCourse, firstCourse.Status); }
+ }
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Renderer_control_pipe_failure_cleans_up_the_owned_build(bool brokenOutput)
+ {
+ var temporaryRoot = Path.Combine(Path.GetTempPath(), "learnstack-renderer-control-" + Guid.NewGuid().ToString("N"));
+ using var process = RendererProcess(RepositoryRoot(), temporaryRoot);
+ using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(3));
+ var started = false;
+ try
+ {
+ Directory.CreateDirectory(temporaryRoot);
+ started = process.Start();
+ started.Should().BeTrue();
+ var errors = BoundedErrorsAsync(process.StandardError, deadline.Token);
+ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new
+ {
+ apiOrigin = "http://127.0.0.1:1",
+ secret = new string('s', 43),
+ tenants = new[] { new { host = "first.example" }, new { host = "second.example" } }
+ }, JsonOptions));
+ // Wait for a real compiler acquisition before failing its parent
+ // pipe. No listener or API call is needed before build-complete.
+ string? ownedRoot = null;
+ while (!process.HasExited)
+ {
+ ownedRoot = Directory.EnumerateDirectories(temporaryRoot, "learnstack-public-rendering-*").SingleOrDefault();
+ if (ownedRoot is not null && Directory.Exists(Path.Combine(ownedRoot, "frontend/apps/web/.next"))) break;
+ await Task.Delay(50, deadline.Token);
+ }
+ ownedRoot.Should().NotBeNull("the actual renderer must acquire its private build root");
+ Directory.Exists(Path.Combine(ownedRoot!, "frontend/apps/web/.next")).Should().BeTrue("the owned compiler must have started");
+ if (brokenOutput) process.StandardOutput.Close();
+ else process.StandardInput.Close();
+ await process.WaitForExitAsync(deadline.Token);
+ var errorOutput = await errors;
+ process.ExitCode.Should().Be(1, errorOutput);
+ if (brokenOutput) errorOutput.Should().BeEmpty("the build-complete write must trigger EPIPE cancellation after successful configured builds");
+ Directory.EnumerateFileSystemEntries(temporaryRoot).Should().BeEmpty(
+ "pipe failure must join child cleanup before deleting the owned build/TLS root");
+ }
+ finally
+ {
+ if (started && !process.HasExited)
+ {
+ process.StandardInput.Close();
+ using var shutdown = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ try { await process.WaitForExitAsync(shutdown.Token); }
+ catch (OperationCanceledException) { process.Kill(entireProcessTree: true); await process.WaitForExitAsync(CancellationToken.None); }
+ }
+ if (Directory.Exists(temporaryRoot)) Directory.Delete(temporaryRoot, recursive: true);
+ }
+ }
+
+ [Theory]
+ [InlineData("ordinary-route", false)]
+ [InlineData("ordinary-version", false)]
+ [InlineData("bare-version", false)]
+ [InlineData("short-mac", false)]
+ [InlineData("long-mac", false)]
+ [InlineData("padded-mac", false)]
+ [InlineData("empty-payload", false)]
+ [InlineData("embedded-version", false)]
+ [InlineData("envelope", true)]
+ [InlineData("long-envelope", true)]
+ [InlineData("header", true)]
+ [InlineData("secret", true)]
+ public void Api_log_containment_recognizes_private_values_without_matching_ordinary_versions(string scenario, bool expected)
+ {
+ const string secret = "test-owned-private-hop-credential";
+ var mac = new string('m', 43);
+ var value = scenario switch
+ {
+ "ordinary-route" => "/api/v1/public/courses",
+ "ordinary-version" => "API contract v1.2.3 remains supported",
+ "bare-version" => "v1.",
+ "short-mac" => "v1.payload." + new string('m', 42),
+ "long-mac" => "v1.payload." + new string('m', 44),
+ "padded-mac" => "v1.payload." + mac + "=",
+ "empty-payload" => "v1.." + mac,
+ "embedded-version" => "av1.payload." + mac,
+ "envelope" => "v1.payload." + mac,
+ "long-envelope" => "v1." + new string('p', 12_000) + "." + mac,
+ "header" => "X-LearnStack-Ingress-Provenance",
+ "secret" => secret,
+ _ => throw new InvalidOperationException("Unknown log containment control.")
+ };
+ var observation = new RenderingObservation(secret);
+ observation.Emit(new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Information, null,
+ new MessageTemplateParser().Parse("Fixture {Value}"),
+ [new LogEventProperty("Value", new ScalarValue(value))]));
+ observation.Logs.Should().Be(1, "the control must pass through the real structured log formatter");
+ observation.PrivateLog.Should().Be(expected);
+ }
+
+ private Task SetStatusAsync(SeedCourse course, string status) => fixture.ExecuteOwnerFixtureAsync(
+ SeedData.English,
+ "UPDATE courses SET status=@status WHERE tenant_id=@tenant AND id=@id",
+ new Dictionary { ["id"] = course.Id, ["status"] = status.ToLowerInvariant() });
+
+ private static object TenantInput(SeedTenant tenant, SeedCourse course, string locale)
+ {
+ var lesson = course.Lessons.First(row => row.Status == "Published");
+ var translation = lesson.Translations.Single(row => row.Locale == locale);
+ using var body = JsonDocument.Parse(translation.Body);
+ return new
+ {
+ host = tenant.Host,
+ name = tenant.DisplayName,
+ courseTitle = course.Translations.Single(row => row.Locale == locale).Title,
+ lessonSlug = translation.Slug,
+ lessonTitle = translation.Title,
+ lessonText = body.RootElement.EnumerateObject().First().Value.GetString(),
+ defaultLocale = tenant.Curriculum!.Locales.Single(row => row.IsDefault).Locale
+ };
+ }
+
+ private static Process RendererProcess(string root, string? temporaryRoot = null)
+ {
+ var start = new ProcessStartInfo("node")
+ {
+ WorkingDirectory = root,
+ UseShellExecute = false,
+ RedirectStandardInput = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ };
+ start.ArgumentList.Add(Path.Combine(root, "frontend/apps/web/scripts/verify-public-rendering.mjs"));
+ // Configuration travels on stdin; only the tool path and an explicitly
+ // verified TLS policy enter from this process's environment.
+ start.Environment.Clear();
+ start.Environment["PATH"] = Environment.GetEnvironmentVariable("PATH");
+ start.Environment["NODE_TLS_REJECT_UNAUTHORIZED"] = "1";
+ if (temporaryRoot is not null) start.Environment["TMPDIR"] = temporaryRoot;
+ return new Process { StartInfo = start };
+ }
+
+ private static string RepositoryRoot()
+ {
+ for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent)
+ if (File.Exists(Path.Combine(directory.FullName, "frontend/pnpm-lock.yaml"))) return directory.FullName;
+ throw new InvalidOperationException("Cannot find production-rendering fixture source.");
+ }
+
+ private static async Task BoundedErrorsAsync(StreamReader stream, CancellationToken cancellationToken)
+ {
+ var result = new System.Text.StringBuilder();
+ var buffer = new char[1024];
+ int read;
+ while ((read = await stream.ReadAsync(buffer, cancellationToken)) > 0)
+ {
+ // Node emits only finite test-owned stages; never forward arbitrary
+ // provider/compiler diagnostics into test results, even on a crash.
+ if (result.Length + read <= 4096) result.Append(buffer, 0, read);
+ }
+ return result.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries)
+ .All(line => line.StartsWith("Production rendering fixture ", StringComparison.Ordinal))
+ ? result.ToString() : "Production rendering fixture exited unexpectedly.";
+ }
+
+ private sealed record ObservedRequest(string Path, string TraceId, bool ValidHop);
+
+ /// Observes the real Kestrel request without changing API resolution or transactions.
+ private sealed class RenderingObservation(string secret) : IStartupFilter, ILogEventSink
+ {
+ // Match the complete unpadded wire shape, rather than ordinary v1 route
+ // or version text. Retain boundaries so truncated/padded tokens cannot
+ // accidentally satisfy the containment proof.
+ private static readonly Regex PrivateEnvelope = new(
+ @"(? _requests = new();
+ private int _logs;
+ private int _privateLog;
+ public ObservedRequest[] Requests => _requests.ToArray();
+ public int Logs => Volatile.Read(ref _logs);
+ public bool PrivateLog => Volatile.Read(ref _privateLog) != 0;
+
+ public void Emit(LogEvent logEvent)
+ {
+ using var text = new StringWriter(CultureInfo.InvariantCulture);
+ new Serilog.Formatting.Compact.RenderedCompactJsonFormatter().Format(logEvent, text);
+ var value = text.ToString();
+ // Retain only counters/verdicts, never sensitive event values.
+ Interlocked.Increment(ref _logs);
+ if (value.Contains(secret, StringComparison.Ordinal)
+ || value.Contains("x-learnstack-", StringComparison.OrdinalIgnoreCase)
+ || PrivateEnvelope.IsMatch(value))
+ Interlocked.Exchange(ref _privateLog, 1);
+ }
+
+ public Action Configure(Action next) => app =>
+ {
+ app.Use(async (context, continuation) =>
+ {
+ if (context.Request.Path.StartsWithSegments("/api/v1/public"))
+ {
+ var headers = context.Request.Headers;
+ var validHop = headers["X-LearnStack-Hop-Secret"].Count == 1
+ && headers["X-LearnStack-Hop-Secret"] == secret
+ && headers["X-LearnStack-Visitor-Address"] == "127.0.0.1"
+ && headers["X-LearnStack-Host"].Count == 1
+ && !headers.ContainsKey("X-LearnStack-Ingress-Provenance")
+ && !headers.ContainsKey("Authorization") && !headers.ContainsKey("Cookie")
+ && !headers.ContainsKey("X-Tenant-Id") && !headers.ContainsKey("X-Organization-Id")
+ && !headers.ContainsKey("X-Locale") && !headers.ContainsKey("X-Forwarded-For");
+ _requests.Enqueue(new(context.Request.Path.Value!, Activity.Current?.TraceId.ToHexString() ?? "", validHop));
+ }
+ await continuation();
+ });
+ next(app);
+ };
+ }
+}
diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TrustedVisitorHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TrustedVisitorHttpTests.cs
new file mode 100644
index 00000000..f88ba2fa
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Integration/Database/TrustedVisitorHttpTests.cs
@@ -0,0 +1,353 @@
+using System.Globalization;
+using System.Net;
+using System.Net.Http.Json;
+using System.Net.Sockets;
+using System.Text;
+using System.Text.Json;
+using FluentAssertions;
+using LearnStack.Api.Tenancy;
+using LearnStack.Infrastructure.MultiTenancy;
+using LearnStack.SharedKernel.Tenancy;
+using LearnStack.Tools.Seeder;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Hosting.Server;
+using Microsoft.AspNetCore.Hosting.Server.Features;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Xunit;
+
+namespace LearnStack.Tests.Integration.Database;
+
+[Collection(PublicReadTestGroup.Name)]
+[Trait(RequiresDocker.Key, RequiresDocker.Value)]
+public sealed class TrustedVisitorHttpTests(PublicReadFixture fixture)
+{
+ private const string Secret = "test-only-trusted-visitor-secret-at-least-32";
+ private const string Rotated = "test-only-second-rotation-secret-at-least-32";
+ private const string Site = "/api/v1/public/site";
+
+ [Fact]
+ public async Task Trusted_hop_Problem_Details_preserves_the_incoming_W3C_trace_identifier()
+ {
+ using var host = new SocketHost(fixture);
+ const string traceparent = "00-11223344556677889900aabbccddeeff-1234567890abcdef-01";
+ using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/public/courses/absent?locale=en");
+ request.Headers.Host = SeedData.English.Host;
+ request.Headers.Add(TrustedHopOptions.HostHeaderName, SeedData.English.Host);
+ request.Headers.Add(TrustedHopOptions.SecretHeaderName, Secret);
+ request.Headers.Add(AnonymousRequestIdentity.VisitorHeaderName, "203.0.113.11");
+ request.Headers.Add("traceparent", traceparent);
+ // Send the specified wire parent, not HttpClient's ambient test Activity.
+ using var client = new HttpClient(new SocketsHttpHandler { ActivityHeadersPropagator = null })
+ {
+ BaseAddress = host.Client.BaseAddress,
+ };
+ using var response = await client.SendAsync(request);
+ response.StatusCode.Should().Be(HttpStatusCode.NotFound);
+ var problem = await response.Content.ReadFromJsonAsync();
+ var correlation = problem.GetProperty("correlationId").GetString();
+ correlation.Should().NotBeNull();
+ correlation!.Split('-')[1].Should().Be(traceparent.Split('-')[1],
+ "the API creates its own span inside the adapter's propagated trace");
+ correlation.Should().NotBe(traceparent, "a server span is not an echo of the caller's span");
+ response.Headers.CacheControl?.NoStore.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Trusted_socket_requests_keep_API_host_resolution_and_real_application_role_reads()
+ {
+ using var host = new SocketHost(fixture);
+ var before = fixture.Observation.Reads;
+ using var english = await Send(host, Site, "203.0.113.9", SeedData.English.Host);
+ english.StatusCode.Should().Be(HttpStatusCode.OK);
+ var first = await english.Content.ReadFromJsonAsync();
+ first.GetProperty("displayName").GetString().Should().Be(SeedData.English.DisplayName);
+ using var yoga = await Send(host, Site, "203.0.113.10", SeedData.Yoga.Host, Rotated);
+ yoga.StatusCode.Should().Be(HttpStatusCode.OK);
+ var second = await yoga.Content.ReadFromJsonAsync();
+ second.GetProperty("displayName").GetString().Should().Be(SeedData.Yoga.DisplayName);
+ fixture.Observation.Reads.Should().Be(before + 2,
+ "the production reader observer asserts READ ONLY and learnstack_app without BYPASSRLS");
+ host.Lookups.Should().Be(2);
+ english.Headers.CacheControl?.NoStore.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Direct_and_hop_calls_share_one_canonical_IP_budget_across_hosts_and_header_spellings()
+ {
+ using var host = new SocketHost(fixture);
+ for (var index = 0; index < RateLimitingExtensions.AnonymousPermitPerWindow; index++)
+ {
+ // The actual socket is 127.0.0.1; mapped spellings cannot buy a second quota.
+ using var response = await Send(host, Site,
+ index % 2 == 0 ? "::ffff:127.0.0.1" : "198.51.100.4",
+ index % 2 == 0 ? SeedData.English.Host : SeedData.Yoga.Host,
+ index % 2 == 0 ? Secret : null);
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ }
+ var lookups = host.Lookups;
+ using var denied = await Send(host, Site, "0:0:0:0:0:ffff:7f00:1", "new-budget.example");
+ await RateLimited(denied);
+ host.Lookups.Should().Be(lookups, "the limiter precedes host classification");
+ }
+
+ [Fact]
+ public async Task One_visitor_cannot_exhaust_another_or_multiply_quota_by_novel_hosts()
+ {
+ using var host = new SocketHost(fixture);
+ for (var index = 0; index < RateLimitingExtensions.AnonymousPermitPerWindow; index++)
+ {
+ using var response = await Send(host, Site, "2001:db8::1",
+ "novel-" + index.ToString(CultureInfo.InvariantCulture) + ".example");
+ response.StatusCode.Should().Be(HttpStatusCode.NotFound);
+ }
+ host.Lookups.Should().Be(RateLimitingExtensions.AnonymousPermitPerWindow);
+ using var denied = await Send(host, Site, "2001:0DB8::0001", "another-novel.example");
+ await RateLimited(denied);
+ host.Lookups.Should().Be(RateLimitingExtensions.AnonymousPermitPerWindow);
+ host.Factory.Services.GetRequiredService().Count.Should().Be(
+ RateLimitingExtensions.AnonymousPermitPerWindow);
+ using var independent = await Send(host, Site, "2001:db8::2", SeedData.Yoga.Host);
+ independent.StatusCode.Should().Be(HttpStatusCode.OK);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("127.1")]
+ [InlineData("2001:db8::1%eth0")]
+ [InlineData("127.0.0.1, 203.0.113.9")]
+ [InlineData("oversized")]
+ public async Task Bad_authenticated_metadata_spends_fallback_quota_and_never_reaches_lookup(string? visitor)
+ {
+ using var host = new SocketHost(fixture);
+ if (visitor == "oversized") visitor = new string('1', AnonymousRequestIdentity.MaximumAddressLength + 1);
+ for (var index = 0; index < RateLimitingExtensions.AnonymousPermitPerWindow; index++)
+ {
+ using var refused = await Send(host, Site, visitor, SeedData.English.Host);
+ refused.StatusCode.Should().Be(HttpStatusCode.NotFound);
+ refused.Headers.CacheControl?.NoStore.Should().BeTrue();
+ var error = await refused.Content.ReadFromJsonAsync();
+ error.GetProperty("status").GetInt32().Should().Be(404);
+ }
+ using var denied = await Send(host, Site, visitor, "novel.example");
+ await RateLimited(denied);
+ host.Lookups.Should().Be(0);
+ using var positive = await Send(host, Site, "203.0.113.9", SeedData.English.Host);
+ positive.StatusCode.Should().Be(HttpStatusCode.OK, "valid visitor metadata has a separate quota");
+ }
+
+ [Fact]
+ public async Task Repeated_raw_visitor_headers_are_refused_before_lookup()
+ {
+ using var host = new SocketHost(fixture);
+ using var socket = new TcpClient();
+ using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ await socket.ConnectAsync(IPAddress.Loopback, host.Client.BaseAddress!.Port, deadline.Token);
+ var request = $"GET {Site} HTTP/1.1\r\nHost: {SeedData.English.Host}\r\n"
+ + $"{TrustedHopOptions.SecretHeaderName}: {Secret}\r\n"
+ + $"{AnonymousRequestIdentity.VisitorHeaderName}: 203.0.113.9\r\n"
+ + $"{AnonymousRequestIdentity.VisitorHeaderName}: 203.0.113.10\r\nConnection: close\r\n\r\n";
+ await socket.GetStream().WriteAsync(Encoding.ASCII.GetBytes(request), deadline.Token);
+ using var reader = new StreamReader(socket.GetStream());
+ var response = await reader.ReadToEndAsync(deadline.Token);
+ response.Should().StartWith("HTTP/1.1 404").And.Contain("no-store");
+ host.Lookups.Should().Be(0);
+ using var positive = await Send(host, Site, "203.0.113.9", SeedData.English.Host);
+ positive.StatusCode.Should().Be(HttpStatusCode.OK);
+ }
+
+ [Theory]
+ [InlineData("wrong-secret")]
+ [InlineData("wrong-network")]
+ [InlineData("unconfigured")]
+ public async Task Untrusted_visitor_and_forwarding_headers_never_mint_partitions(string mode)
+ {
+ using var host = new SocketHost(fixture, mode);
+ for (var index = 0; index <= RateLimitingExtensions.AnonymousPermitPerWindow; index++)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, "/healthz");
+ request.Headers.TryAddWithoutValidation(TrustedHopOptions.SecretHeaderName,
+ mode == "wrong-secret" ? "wrong" : Secret);
+ request.Headers.TryAddWithoutValidation(AnonymousRequestIdentity.VisitorHeaderName,
+ "203.0.113." + (index + 1).ToString(CultureInfo.InvariantCulture));
+ request.Headers.TryAddWithoutValidation("X-Forwarded-For", "198.51.100." + (index + 1).ToString(CultureInfo.InvariantCulture));
+ using var response = await host.Client.SendAsync(request);
+ response.StatusCode.Should().Be(index < RateLimitingExtensions.AnonymousPermitPerWindow
+ ? HttpStatusCode.OK : HttpStatusCode.TooManyRequests);
+ }
+ }
+
+ [Fact]
+ public async Task Repeated_raw_secret_headers_spend_the_exhausted_direct_fallback_budget()
+ {
+ var observed = new SecretMultiplicityObservation();
+ using var host = new SocketHost(fixture, observation: observed);
+ for (var index = 0; index < RateLimitingExtensions.AnonymousPermitPerWindow; index++)
+ {
+ using var direct = await Send(host, "/healthz", null, SeedData.English.Host, secret: null);
+ direct.StatusCode.Should().Be(HttpStatusCode.OK);
+ }
+
+ using var socket = new TcpClient();
+ using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ await socket.ConnectAsync(IPAddress.Loopback, host.Client.BaseAddress!.Port, deadline.Token);
+ var request = $"GET {Site} HTTP/1.1\r\nHost: novel.example\r\n"
+ + $"{TrustedHopOptions.SecretHeaderName}: {Secret}\r\n"
+ + $"{TrustedHopOptions.SecretHeaderName}: {Secret}\r\n"
+ + $"{AnonymousRequestIdentity.VisitorHeaderName}: 203.0.113.99\r\nConnection: close\r\n\r\n";
+ await socket.GetStream().WriteAsync(Encoding.ASCII.GetBytes(request), deadline.Token);
+ using var reader = new StreamReader(socket.GetStream());
+ var response = await reader.ReadToEndAsync(deadline.Token);
+ observed.Count.Should().Be(2, "Kestrel must receive two actual fields, not one comma-joined HttpClient value");
+ response.Should().StartWith("HTTP/1.1 429").And.Contain("no-store")
+ .And.MatchRegex(@"(?im)^Retry-After: [1-9]\d*\r?$");
+ host.Lookups.Should().Be(0, "untrusted repeated secrets retain the already exhausted socket-IP quota");
+
+ using var positive = await Send(host, Site, "203.0.113.99", SeedData.English.Host);
+ positive.StatusCode.Should().Be(HttpStatusCode.OK, "one valid secret admits the fresh visitor independently");
+ host.Lookups.Should().Be(1);
+ }
+
+ [Fact]
+ public async Task Physical_peer_ceiling_bounds_rotating_trusted_visitors_before_lookup()
+ {
+ using var host = new SocketHost(fixture);
+ for (var index = 0; index < RateLimitingExtensions.PeerPermitPerWindow; index++)
+ {
+ var visitor = "203.0.113." + (index / RateLimitingExtensions.AnonymousPermitPerWindow + 1)
+ .ToString(CultureInfo.InvariantCulture);
+ using var response = await Send(host, "/healthz", visitor, SeedData.English.Host);
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ }
+ using var denied = await Send(host, Site, "203.0.113.99", "novel.example");
+ await RateLimited(denied);
+ host.Lookups.Should().Be(0);
+ }
+
+ private static async Task Send(SocketHost host, string path, string? visitor, string tenantHost, string? secret = Secret)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, path);
+ // Direct controls use the real Host; hop controls use the trusted host field.
+ request.Headers.Host = tenantHost;
+ request.Headers.TryAddWithoutValidation(TrustedHopOptions.HostHeaderName, tenantHost);
+ if (secret is not null) request.Headers.TryAddWithoutValidation(TrustedHopOptions.SecretHeaderName, secret);
+ if (visitor is not null) request.Headers.TryAddWithoutValidation(AnonymousRequestIdentity.VisitorHeaderName, visitor);
+ return await host.Client.SendAsync(request);
+ }
+
+ [Fact]
+ public async Task Known_visitor_refusals_preserve_all_remaining_peer_permits_before_lookup()
+ {
+ using var host = new SocketHost(fixture);
+ for (var index = 0; index < 330; index++)
+ {
+ using var response = await Send(host, "/healthz", "203.0.113.1", SeedData.English.Host);
+ response.StatusCode.Should().Be(index < RateLimitingExtensions.AnonymousPermitPerWindow
+ ? HttpStatusCode.OK : HttpStatusCode.TooManyRequests);
+ }
+ // Only the 60 admitted requests spent peer permits. The 270 known-visitor
+ // refusals leave 540 permits, including ASP.NET's asynchronous retry.
+ var remaining = RateLimitingExtensions.PeerPermitPerWindow - RateLimitingExtensions.AnonymousPermitPerWindow;
+ for (var index = 0; index < remaining; index++)
+ {
+ var visitor = "203.0.113." + (index / RateLimitingExtensions.AnonymousPermitPerWindow + 2)
+ .ToString(CultureInfo.InvariantCulture);
+ using var response = await Send(host, "/healthz", visitor, SeedData.English.Host);
+ response.StatusCode.Should().Be(HttpStatusCode.OK, "request {0} is within the physical allowance", index + 331);
+ }
+ using var denied = await Send(host, Site, "203.0.113.99", "novel.example");
+ await RateLimited(denied);
+ host.Lookups.Should().Be(0);
+ }
+
+ private static async Task RateLimited(HttpResponseMessage response)
+ {
+ response.StatusCode.Should().Be(HttpStatusCode.TooManyRequests);
+ response.Headers.RetryAfter?.Delta.Should().BeGreaterThan(TimeSpan.Zero);
+ var error = await response.Content.ReadFromJsonAsync();
+ error.GetProperty("status").GetInt32().Should().Be(429);
+ response.Content.Headers.ContentType?.MediaType.Should().Be("application/problem+json");
+ }
+
+ private sealed class SocketHost : IDisposable
+ {
+ private readonly CountedResolver _resolver;
+ public WebApplicationFactory Factory { get; }
+ public HttpClient Client { get; }
+ public int Lookups => _resolver.Lookups;
+
+ public SocketHost(PublicReadFixture fixture, string mode = "configured", SecretMultiplicityObservation? observation = null)
+ {
+ Factory = fixture.WithWebHostBuilder(builder =>
+ {
+ if (mode != "unconfigured")
+ {
+ builder.UseSetting("Tenancy:TrustedHop:Networks:0", mode == "wrong-network" ? "192.0.2.0/24" : "127.0.0.1/32");
+ builder.UseSetting("Tenancy:TrustedHop:Secrets:0", Secret);
+ builder.UseSetting("Tenancy:TrustedHop:Secrets:1", Rotated);
+ }
+ builder.ConfigureTestServices(services =>
+ {
+ if (observation is not null) services.AddSingleton(observation);
+ // Keep the production fixed-window/no-queue limiters and quotas,
+ // but freeze replenishment so socket-test duration cannot reset them.
+ services.RemoveAll();
+ services.AddSingleton(_ => new NoQueueAdmissionLimiter(
+ FrozenAnonymousPeerBudget.Create(),
+ () => NoQueueAdmissionLimiter.CreateVisitorBudget(autoReplenishment: false),
+ RateLimitingExtensions.Window, TimeProvider.System));
+ services.RemoveAll();
+ services.RemoveAll();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(provider => provider.GetRequiredService());
+ services.AddSingleton(provider => provider.GetRequiredService());
+ });
+ });
+ Factory.UseKestrel(options => options.Listen(IPAddress.Loopback, 0));
+ Client = Factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
+ var addresses = Factory.Services.GetRequiredService().Features.Get();
+ addresses.Should().NotBeNull();
+ Client.BaseAddress = new Uri(addresses!.Addresses.Single());
+ _resolver = Factory.Services.GetRequiredService();
+ }
+
+ public void Dispose()
+ {
+ Client.Dispose();
+ Factory.Dispose();
+ }
+ }
+
+ /// Records only field multiplicity at the real socket pipeline entry.
+ private sealed class SecretMultiplicityObservation : IStartupFilter
+ {
+ private int _count;
+ public int Count => Volatile.Read(ref _count);
+
+ public Action Configure(Action next) => app =>
+ {
+ app.Use(async (context, continuation) =>
+ {
+ Volatile.Write(ref _count, context.Request.Headers[TrustedHopOptions.SecretHeaderName].Count);
+ await continuation();
+ });
+ next(app);
+ };
+ }
+
+ private sealed class CountedResolver(CachedHostToTenantResolver inner) : IHostToTenantResolver
+ {
+ private int _lookups;
+ public int Lookups => Volatile.Read(ref _lookups);
+ public Task ResolveAsync(string host, CancellationToken cancellationToken = default)
+ {
+ Interlocked.Increment(ref _lookups);
+ return inner.ResolveAsync(host, cancellationToken);
+ }
+ }
+}
diff --git a/backend/tests/LearnStack.Tests.Integration/RateLimitingHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/RateLimitingHttpTests.cs
index a40fb1ad..ad6f7b33 100644
--- a/backend/tests/LearnStack.Tests.Integration/RateLimitingHttpTests.cs
+++ b/backend/tests/LearnStack.Tests.Integration/RateLimitingHttpTests.cs
@@ -88,12 +88,9 @@ public async Task A_Forwarded_For_Header_Does_Not_Buy_A_Fresh_Budget()
// start in that configuration; this asserts the other half — that the
// header buys nothing while it is off.
//
- // Open in Phase 02d: this caller is outside the trusted hop, and the case
- // stays a non-hop case. How a request arriving over the hop is keyed and
- // budgeted is G34 in Phase 02d's decision register at
- // ../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register
- // — the pass that closes it edits "it must stay that way" above with its
- // answer.
+ // This remains a non-hop control. Accepted P02d-5 G34/ADR-0053 and
+ // ADR-0054 own authenticated visitor metadata and coordinated budgets;
+ // TrustedVisitorHttpTests exercises the separate actual socket path.
using var client = fixture.CreateClient();
var path = new Uri("/healthz", UriKind.Relative);
var rejected = 0;
diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/AnonymousAdmissionTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/AnonymousAdmissionTests.cs
new file mode 100644
index 00000000..10651a3e
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/AnonymousAdmissionTests.cs
@@ -0,0 +1,739 @@
+using System.Collections.Concurrent;
+using System.Threading.RateLimiting;
+using FluentAssertions;
+using LearnStack.Api.Tenancy;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Xunit;
+
+namespace LearnStack.Tests.Unit.Api.Tenancy;
+
+public sealed class AnonymousAdmissionTests
+{
+ private static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
+ private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
+
+ [Theory]
+ [InlineData(330)]
+ [InlineData(600)]
+ public void AttemptAcquire_ExhaustedKnownVisitor_DoesNotSpendTheFreshVisitorsPeerBudget(int refusals)
+ {
+ using var fixture = new AdmissionFixture();
+ for (var index = 0; index < 60; index++)
+ {
+ using var admitted = fixture.Owner.AttemptAcquire(fixture.Request("exhausted"));
+ admitted.IsAcquired.Should().BeTrue();
+ }
+
+ for (var index = 0; index < refusals; index++)
+ {
+ using var refused = fixture.Owner.AttemptAcquire(fixture.Request("exhausted"));
+ refused.IsAcquired.Should().BeFalse();
+ }
+ using var fresh = fixture.Owner.AttemptAcquire(fixture.Request("fresh"));
+
+ fresh.IsAcquired.Should().BeTrue();
+ fixture.Peer.For("peer").PositiveAttempts.Should().Be(61);
+ fixture.Peer.For("peer").Remaining.Should().Be(539);
+ fixture.Visitors.Should().HaveCount(2);
+ fixture.Visitors[0].ZeroProbes.Should().Be(59 + refusals);
+ fixture.Visitors[0].IdleReads.Should().Be(0, "refusal work must not sweep the visitor registry");
+ fixture.Timer.Fires.Should().Be(0);
+ }
+
+ [Fact]
+ public void AttemptAcquire_ExhaustedPeer_DoesNotAllocateUnknownVisitors()
+ {
+ using var fixture = new AdmissionFixture();
+ for (var index = 0; index < 660; index++)
+ {
+ using var lease = fixture.Owner.AttemptAcquire(fixture.Request($"visitor-{index}"));
+ lease.IsAcquired.Should().Be(index < 600);
+ }
+
+ fixture.Visitors.Should().HaveCount(600);
+ fixture.Visitors.Sum(visitor => visitor.PositiveAttempts).Should().Be(600);
+ fixture.Visitors.Should().OnlyContain(visitor => visitor.IdleReads == 0);
+ fixture.Peer.For("peer").Remaining.Should().Be(0);
+ }
+
+ [Fact]
+ public void AttemptAcquire_BothBudgetsExhausted_SelectsVisitorRetryAfterWithoutProbingPeer()
+ {
+ using var fixture = new AdmissionFixture(peerPermits: 1, visitorPermits: 1);
+ fixture.Peer.For("peer").RetryAfter = TimeSpan.FromSeconds(13);
+ using var admitted = fixture.Owner.AttemptAcquire(fixture.Request("visitor"));
+ fixture.Visitors[0].RetryAfter = TimeSpan.FromSeconds(37);
+
+ using var refused = fixture.Owner.AttemptAcquire(fixture.Request("visitor"));
+
+ refused.IsAcquired.Should().BeFalse();
+ RetryAfter(refused).Should().Be(TimeSpan.FromSeconds(37));
+ fixture.Peer.For("peer").PositiveAttempts.Should().Be(1);
+ fixture.Peer.For("peer").ZeroProbes.Should().Be(0);
+ fixture.Visitors[0].PositiveAttempts.Should().Be(1);
+ fixture.Visitors[0].ZeroProbes.Should().Be(1);
+ }
+
+ [Fact]
+ public void AttemptAcquire_VisitorAvailableButPeerExhausted_PreservesVisitorQuotaAndPeerRetryAfter()
+ {
+ using var fixture = new AdmissionFixture(peerPermits: 1, visitorPermits: 2);
+ fixture.Peer.For("peer").RetryAfter = TimeSpan.FromSeconds(13);
+ using var admitted = fixture.Owner.AttemptAcquire(fixture.Request("visitor"));
+ fixture.Visitors[0].RetryAfter = TimeSpan.FromSeconds(37);
+
+ using var refused = fixture.Owner.AttemptAcquire(fixture.Request("visitor"));
+
+ refused.IsAcquired.Should().BeFalse();
+ RetryAfter(refused).Should().Be(TimeSpan.FromSeconds(13));
+ fixture.Visitors[0].Remaining.Should().Be(1);
+ fixture.Visitors[0].PositiveAttempts.Should().Be(1);
+ fixture.Visitors[0].ZeroProbes.Should().Be(1);
+ }
+
+ [Fact]
+ public async Task AcquireAsync_SuccessfulRequestReplay_ReturnsIndependentLeasesWithoutAnotherCharge()
+ {
+ using var fixture = new AdmissionFixture();
+ var request = fixture.Request("visitor");
+ var first = fixture.Owner.AttemptAcquire(request);
+ first.IsAcquired.Should().BeTrue();
+ first.Dispose();
+
+ using var second = await fixture.Owner.AcquireAsync(request);
+ using var third = fixture.Owner.AttemptAcquire(request);
+
+ second.IsAcquired.Should().BeTrue();
+ third.IsAcquired.Should().BeTrue();
+ second.Should().NotBeSameAs(first).And.NotBeSameAs(third);
+ fixture.Peer.For("peer").PositiveAttempts.Should().Be(1);
+ fixture.Visitors.Single().PositiveAttempts.Should().Be(1);
+ fixture.Visitors.Single().ZeroProbes.Should().Be(0);
+ fixture.Peer.For("peer").Leases.Should().OnlyContain(lease => lease.Disposals == 1);
+ fixture.Visitors.Single().Leases.Should().OnlyContain(lease => lease.Disposals == 1);
+ }
+
+ [Fact]
+ public async Task AcquireAsync_RefusedRequestReplay_RetainsTheOutcomeAfterReplenishment()
+ {
+ using var fixture = new AdmissionFixture(visitorPermits: 1);
+ using var admitted = fixture.Owner.AttemptAcquire(fixture.Request("visitor"));
+ var visitor = fixture.Visitors.Single();
+ visitor.RetryAfter = TimeSpan.FromSeconds(37);
+ var request = fixture.Request("visitor");
+ var first = fixture.Owner.AttemptAcquire(request);
+ first.IsAcquired.Should().BeFalse();
+ first.Dispose();
+ visitor.Replenish();
+ visitor.RetryAfter = TimeSpan.FromSeconds(5);
+
+ using var second = await fixture.Owner.AcquireAsync(request);
+ using var third = fixture.Owner.AttemptAcquire(request);
+
+ second.IsAcquired.Should().BeFalse();
+ third.IsAcquired.Should().BeFalse();
+ RetryAfter(second).Should().Be(TimeSpan.FromSeconds(37));
+ RetryAfter(third).Should().Be(TimeSpan.FromSeconds(37));
+ second.Should().NotBeSameAs(first).And.NotBeSameAs(third);
+ visitor.PositiveAttempts.Should().Be(1);
+ visitor.ZeroProbes.Should().Be(1);
+ fixture.Peer.For("peer").PositiveAttempts.Should().Be(1);
+ visitor.Leases.Should().OnlyContain(lease => lease.Disposals == 1);
+ }
+
+ [Fact]
+ public async Task AcquireAsync_UnknownVisitorPeerRefusalReplay_DoesNotAllocateAfterPeerReplenishment()
+ {
+ using var fixture = new AdmissionFixture(peerPermits: 0);
+ var request = fixture.Request("visitor");
+ using var first = fixture.Owner.AttemptAcquire(request);
+ fixture.Peer.For("peer").Replenish(1);
+
+ using var second = await fixture.Owner.AcquireAsync(request);
+
+ first.IsAcquired.Should().BeFalse();
+ second.IsAcquired.Should().BeFalse();
+ fixture.Visitors.Should().BeEmpty();
+ fixture.Peer.For("peer").PositiveAttempts.Should().Be(1);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(2)]
+ public async Task Acquisition_UnsupportedPermitCount_RejectsBeforeAnyBudgetWork(int permits)
+ {
+ using var fixture = new AdmissionFixture();
+ var request = fixture.Request("visitor");
+ var attempt = () => fixture.Owner.AttemptAcquire(request, permits);
+ var acquire = async () => await fixture.Owner.AcquireAsync(request, permits);
+
+ attempt.Should().Throw();
+ await acquire.Should().ThrowAsync();
+ fixture.Visitors.Should().BeEmpty();
+ fixture.Peer.TotalPositiveAttempts.Should().Be(0);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task AcquireAsync_PreCanceledRequest_DoesNotChargeOrCreateAVisitor(bool alreadyAdmitted)
+ {
+ using var fixture = new AdmissionFixture();
+ var request = fixture.Request("visitor");
+ if (alreadyAdmitted)
+ {
+ using var admitted = fixture.Owner.AttemptAcquire(request);
+ }
+ using var cancellation = new CancellationTokenSource();
+ cancellation.Cancel();
+ var acquire = async () => await fixture.Owner.AcquireAsync(request, cancellationToken: cancellation.Token);
+
+ await acquire.Should().ThrowAsync();
+ fixture.Peer.TotalPositiveAttempts.Should().Be(alreadyAdmitted ? 1 : 0);
+ fixture.Visitors.Should().HaveCount(alreadyAdmitted ? 1 : 0);
+ using var retried = await fixture.Owner.AcquireAsync(request);
+ retried.IsAcquired.Should().BeTrue();
+ fixture.Peer.TotalPositiveAttempts.Should().Be(1);
+ }
+
+ [Fact]
+ public async Task AttemptAcquire_ParallelLastPermitAcrossPhysicalPeers_AdmitsExactlyOneRequest()
+ {
+ using var fixture = new AdmissionFixture(visitorPermits: 1);
+ var admitted = await ConcurrentAdmissions(fixture, index => fixture.Request("visitor", $"peer-{index % 2}"));
+
+ admitted.Should().Be(1);
+ fixture.Visitors.Should().ContainSingle();
+ fixture.Visitors.Single().PositiveAttempts.Should().Be(1);
+ fixture.Peer.TotalPositiveAttempts.Should().Be(1);
+ }
+
+ [Fact]
+ public async Task AttemptAcquire_ParallelLastPeerPermitForUnknownVisitors_AdmitsAndAllocatesExactlyOne()
+ {
+ using var fixture = new AdmissionFixture(peerPermits: 1);
+ var admitted = await ConcurrentAdmissions(fixture, index => fixture.Request($"visitor-{index}"));
+
+ admitted.Should().Be(1);
+ fixture.Visitors.Should().ContainSingle();
+ fixture.Visitors.Single().PositiveAttempts.Should().Be(1);
+ fixture.Peer.For("peer").Remaining.Should().Be(0);
+ fixture.Peer.TotalPositiveAttempts.Should().Be(32);
+ }
+
+ [Fact]
+ public async Task AttemptAcquire_ParallelCreationForOneVisitor_OwnsOnlyOneLimiter()
+ {
+ using var fixture = new AdmissionFixture();
+ var admitted = await ConcurrentAdmissions(fixture, _ => fixture.Request("visitor"));
+
+ admitted.Should().Be(32);
+ fixture.Visitors.Should().ContainSingle();
+ fixture.Visitors.Single().PositiveAttempts.Should().Be(32);
+ fixture.Peer.TotalPositiveAttempts.Should().Be(32);
+ }
+
+ [Fact]
+ public async Task AttemptAcquire_RealFixedWindowAutomaticallyReplenishes_AdmitsWithoutReconstructingVisitor()
+ {
+ var allocations = 0;
+ using var fixture = new AdmissionFixture(visitorFactory: () =>
+ {
+ allocations++;
+ return new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = 1,
+ Window = TimeSpan.FromMilliseconds(500),
+ AutoReplenishment = true,
+ QueueLimit = 0,
+ QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
+ });
+ });
+ using var first = fixture.Owner.AttemptAcquire(fixture.Request("visitor"));
+ using var refused = fixture.Owner.AttemptAcquire(fixture.Request("visitor"));
+
+ first.IsAcquired.Should().BeTrue();
+ refused.IsAcquired.Should().BeFalse();
+ RetryAfter(refused).Should().BeGreaterThan(TimeSpan.Zero);
+ fixture.Peer.TotalPositiveAttempts.Should().Be(1, "the exhausted visitor's zero probe refuses before the peer debit");
+
+ // .NET owns quota replenishment through its real timer. The injected
+ // manual clock controls only the admission owner's periodic sweep.
+ using var deadline = new CancellationTokenSource(Timeout);
+ while (true)
+ {
+ using var retry = fixture.Owner.AttemptAcquire(fixture.Request("visitor"));
+ if (retry.IsAcquired) break;
+ await Task.Delay(TimeSpan.FromMilliseconds(10), deadline.Token);
+ }
+
+ allocations.Should().Be(1);
+ fixture.Peer.TotalPositiveAttempts.Should().Be(2);
+ fixture.Clock.GetTimestamp().Should().Be(0);
+ fixture.Timer.Fires.Should().Be(0);
+ }
+
+ [Fact]
+ public void Sweep_ElapsedWallTimeWithSpentQuota_DoesNotReconstructTheVisitorOrRestoreQuota()
+ {
+ using var fixture = new AdmissionFixture(visitorPermits: 1);
+ using var admitted = fixture.Owner.AttemptAcquire(fixture.Request("visitor"));
+ fixture.Clock.Advance(Window + Window);
+
+ fixture.Timer.Fire();
+ using var refused = fixture.Owner.AttemptAcquire(fixture.Request("visitor"));
+
+ refused.IsAcquired.Should().BeFalse();
+ fixture.Visitors.Should().ContainSingle();
+ fixture.Visitors.Single().Disposals.Should().Be(0);
+ fixture.Peer.TotalPositiveAttempts.Should().Be(1);
+ }
+
+ [Fact]
+ public void Sweep_ReplenishedVisitor_RetiresOnlyAfterAWholeFullQuotaIdleWindow()
+ {
+ using var fixture = new AdmissionFixture(visitorPermits: 1);
+ using var admitted = fixture.Owner.AttemptAcquire(fixture.Request("visitor"));
+ var original = fixture.Visitors.Single();
+ fixture.Clock.Advance(Window);
+ original.Replenish();
+ fixture.Clock.Advance(Window - TimeSpan.FromTicks(1));
+
+ fixture.Timer.Fire();
+ original.Disposals.Should().Be(0);
+ fixture.Clock.Advance(TimeSpan.FromTicks(1));
+ fixture.Timer.Fire();
+ original.Disposals.Should().Be(1);
+ using var replacement = fixture.Owner.AttemptAcquire(fixture.Request("visitor"));
+
+ replacement.IsAcquired.Should().BeTrue();
+ fixture.Visitors.Should().HaveCount(2);
+ fixture.Visitors[1].Should().NotBeSameAs(original);
+ }
+
+ [Fact]
+ public async Task Sweep_RegistryLargerThanTwoBatches_VisitsTheWholeCensusInOneTick()
+ {
+ using var fixture = new AdmissionFixture(visitorPermits: 1);
+ for (var index = 0; index < 300; index++)
+ {
+ using var lease = fixture.Owner.AttemptAcquire(fixture.Request($"visitor-{index}"));
+ }
+ var original = fixture.Visitors.ToArray();
+ foreach (var visitor in original) visitor.Replenish();
+ fixture.Clock.Advance(Window);
+ using var release = new ManualResetEventSlim();
+ var entered = Completion();
+ original[0].OnDispose = () =>
+ {
+ entered.TrySetResult();
+ release.Wait(Timeout).Should().BeTrue();
+ };
+ var sweep = Task.Run(fixture.Timer.Fire);
+ try
+ {
+ await entered.Task.WaitAsync(Timeout);
+ original.Sum(visitor => visitor.IdleReads).Should().Be(128,
+ "the first bounded lock batch must end before retired children are disposed");
+ var acquire = Task.Run(() =>
+ {
+ using var lease = fixture.Owner.AttemptAcquire(fixture.Request("between-batches"));
+ return lease.IsAcquired;
+ });
+ (await acquire.WaitAsync(Timeout)).Should().BeTrue(
+ "new admission must progress while the first batch is disposed outside the lock");
+ original.Sum(visitor => visitor.IdleReads).Should().Be(128);
+ }
+ finally
+ {
+ release.Set();
+ await sweep.WaitAsync(Timeout);
+ }
+
+ original.Should().HaveCount(300);
+ original.Should().OnlyContain(visitor => visitor.IdleReads == 1 && visitor.Disposals == 1);
+ fixture.Timer.Fire();
+ original.Should().OnlyContain(visitor => visitor.Disposals == 1);
+ }
+
+ [Fact]
+ public async Task Sweep_ConcurrentTimerCallbacks_DoNotOverlap()
+ {
+ using var fixture = new AdmissionFixture(visitorPermits: 1);
+ using var admitted = fixture.Owner.AttemptAcquire(fixture.Request("visitor"));
+ var visitor = fixture.Visitors.Single();
+ using var release = new ManualResetEventSlim();
+ var entered = Completion();
+ visitor.OnIdleRead = () =>
+ {
+ entered.TrySetResult();
+ release.Wait(Timeout).Should().BeTrue();
+ };
+ var first = Task.Run(fixture.Timer.Fire);
+ try
+ {
+ await entered.Task.WaitAsync(Timeout);
+ var second = Task.Run(fixture.Timer.Fire);
+ await second.WaitAsync(Timeout);
+ visitor.IdleReads.Should().Be(1);
+ }
+ finally
+ {
+ release.Set();
+ await first.WaitAsync(Timeout);
+ }
+ }
+
+ [Fact]
+ public async Task Sweep_RetiredLimiterDisposal_IsOutsideAdmissionLockAndAfterRemoval()
+ {
+ using var fixture = new AdmissionFixture(visitorPermits: 1);
+ using var admitted = fixture.Owner.AttemptAcquire(fixture.Request("visitor"));
+ var original = fixture.Visitors.Single();
+ original.Replenish();
+ fixture.Clock.Advance(Window);
+ using var release = new ManualResetEventSlim();
+ var entered = Completion();
+ original.OnDispose = () =>
+ {
+ entered.TrySetResult();
+ release.Wait(Timeout).Should().BeTrue();
+ };
+ var sweep = Task.Run(fixture.Timer.Fire);
+ try
+ {
+ await entered.Task.WaitAsync(Timeout);
+ var acquire = Task.Run(() =>
+ {
+ using var lease = fixture.Owner.AttemptAcquire(fixture.Request("visitor"));
+ return lease.IsAcquired;
+ });
+ (await acquire.WaitAsync(Timeout)).Should().BeTrue();
+ fixture.Visitors.Should().HaveCount(2);
+ original.PositiveAttempts.Should().Be(1);
+ fixture.Visitors[1].PositiveAttempts.Should().Be(1);
+ }
+ finally
+ {
+ release.Set();
+ await sweep.WaitAsync(Timeout);
+ }
+ original.Disposals.Should().Be(1);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Disposal_ActiveSweep_ClosesAdmissionJoinsCallbacksAndDisposesEveryLimiterOnce(bool asynchronous)
+ {
+ using var fixture = new AdmissionFixture(visitorPermits: 1);
+ var savedRequest = fixture.Request("retiring");
+ using var admitted = fixture.Owner.AttemptAcquire(savedRequest);
+ using var retained = fixture.Owner.AttemptAcquire(fixture.Request("retained"));
+ var retiring = fixture.Visitors[0];
+ retiring.Replenish();
+ fixture.Clock.Advance(Window);
+ using var release = new ManualResetEventSlim();
+ var entered = Completion();
+ retiring.OnDispose = () =>
+ {
+ entered.TrySetResult();
+ release.Wait(Timeout).Should().BeTrue();
+ };
+ var sweep = Task.Run(fixture.Timer.Fire);
+ Task? shutdown = null;
+ Task? concurrentShutdown = null;
+ try
+ {
+ await entered.Task.WaitAsync(Timeout);
+ shutdown = asynchronous
+ ? fixture.Owner.DisposeAsync().AsTask()
+ : Task.Run(fixture.Owner.Dispose);
+ await fixture.Timer.DisposalRequested.Task.WaitAsync(Timeout);
+ concurrentShutdown = asynchronous
+ ? Task.Run(fixture.Owner.Dispose)
+ : fixture.Owner.DisposeAsync().AsTask();
+
+ shutdown.IsCompleted.Should().BeFalse();
+ concurrentShutdown.IsCompleted.Should().BeFalse();
+ var replay = () => fixture.Owner.AttemptAcquire(savedRequest);
+ replay.Should().Throw("closed admission must override cached success");
+ var fresh = () => fixture.Owner.AttemptAcquire(fixture.Request("fresh"));
+ fresh.Should().Throw();
+ }
+ finally
+ {
+ release.Set();
+ await sweep.WaitAsync(Timeout);
+ if (shutdown is not null) await shutdown.WaitAsync(Timeout);
+ if (concurrentShutdown is not null) await concurrentShutdown.WaitAsync(Timeout);
+ }
+
+ fixture.Timer.Fire().Should().BeFalse();
+ fixture.Timer.AsyncDisposals.Should().Be(1);
+ fixture.Peer.Disposals.Should().Be(1);
+ fixture.Visitors.Should().OnlyContain(visitor => visitor.Disposals == 1);
+ }
+
+ private static TimeSpan RetryAfter(RateLimitLease lease)
+ {
+ lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter).Should().BeTrue();
+ return retryAfter;
+ }
+
+ private static TaskCompletionSource Completion() => new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ private static async Task ConcurrentAdmissions(AdmissionFixture fixture, Func request)
+ {
+ using var start = new ManualResetEventSlim();
+ var attempts = Enumerable.Range(0, 32).Select(index => Task.Run(() =>
+ {
+ start.Wait(Timeout).Should().BeTrue();
+ using var lease = fixture.Owner.AttemptAcquire(request(index));
+ return lease.IsAcquired;
+ })).ToArray();
+ start.Set();
+ var results = await Task.WhenAll(attempts).WaitAsync(Timeout);
+ return results.Count(acquired => acquired);
+ }
+
+ private sealed class AdmissionFixture : IDisposable
+ {
+ private readonly ServiceProvider _services;
+
+ public AdmissionFixture(int peerPermits = 600, int visitorPermits = 60, Func? visitorFactory = null)
+ {
+ Clock = new ManualClock();
+ Peer = new PeerBudget(Clock, peerPermits);
+ _services = new ServiceCollection()
+ .AddSingleton(new AnonymousRequestIdentity(new EffectiveHostAccessor(Options.Create(new TrustedHopOptions()))))
+ .BuildServiceProvider();
+ Owner = new NoQueueAdmissionLimiter(Peer, () =>
+ {
+ if (visitorFactory is not null) return visitorFactory();
+ var visitor = new Budget(Clock, visitorPermits);
+ Visitors.Add(visitor);
+ return visitor;
+ }, Window, Clock);
+ }
+
+ public ManualClock Clock { get; }
+ public ManualTimer Timer => Clock.Timer!;
+ public PeerBudget Peer { get; }
+ public NoQueueAdmissionLimiter Owner { get; }
+ public List Visitors { get; } = [];
+
+ public DefaultHttpContext Request(string visitor, string peer = "peer")
+ {
+ var context = new DefaultHttpContext { RequestServices = _services };
+ context.Features.Set(new AnonymousVisitor(peer, visitor, false));
+ return context;
+ }
+
+ public void Dispose()
+ {
+ Owner.Dispose();
+ _services.Dispose();
+ }
+ }
+
+ private sealed class PeerBudget(ManualClock clock, int permits) : PartitionedRateLimiter
+ {
+ private readonly ConcurrentDictionary _budgets = new(StringComparer.Ordinal);
+ public int Disposals { get; private set; }
+ public int TotalPositiveAttempts => _budgets.Values.Sum(budget => budget.PositiveAttempts);
+ public Budget For(string peer) => _budgets.GetOrAdd(peer, _ => new Budget(clock, permits));
+ public override RateLimiterStatistics? GetStatistics(HttpContext resource) => null;
+ protected override RateLimitLease AttemptAcquireCore(HttpContext resource, int permitCount) =>
+ For(resource.Features.Get()!.PeerKey).AttemptAcquire(permitCount);
+ protected override ValueTask AcquireAsyncCore(HttpContext resource, int permitCount, CancellationToken cancellationToken) =>
+ ValueTask.FromResult(AttemptAcquireCore(resource, permitCount));
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing) Disposals++;
+ base.Dispose(disposing);
+ }
+ protected override ValueTask DisposeAsyncCore()
+ {
+ Disposals++;
+ return base.DisposeAsyncCore();
+ }
+ }
+
+ private sealed class Budget(ManualClock clock, int permits) : RateLimiter
+ {
+ private readonly object _gate = new();
+ private readonly int _permitLimit = permits;
+ private long? _idleSince = clock.GetTimestamp();
+ private int _remaining = permits;
+ private int _positiveAttempts;
+ private int _zeroProbes;
+ private int _idleReads;
+ private int _disposals;
+
+ public int Remaining => Volatile.Read(ref _remaining);
+ public int PositiveAttempts => Volatile.Read(ref _positiveAttempts);
+ public int ZeroProbes => Volatile.Read(ref _zeroProbes);
+ public int IdleReads => Volatile.Read(ref _idleReads);
+ public int Disposals => Volatile.Read(ref _disposals);
+ public TimeSpan RetryAfter { get; set; } = Window;
+ public Action? OnIdleRead { get; set; }
+ public Action? OnDispose { get; set; }
+ public List Leases { get; } = [];
+ public override TimeSpan? IdleDuration
+ {
+ get
+ {
+ Interlocked.Increment(ref _idleReads);
+ OnIdleRead?.Invoke();
+ lock (_gate) return _idleSince is { } since ? clock.GetElapsedTime(since) : null;
+ }
+ }
+
+ public void Replenish(int? remaining = null)
+ {
+ lock (_gate)
+ {
+ _remaining = remaining ?? _permitLimit;
+ _idleSince = clock.GetTimestamp();
+ }
+ }
+
+ public override RateLimiterStatistics? GetStatistics() => null;
+ protected override RateLimitLease AttemptAcquireCore(int permitCount)
+ {
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(Disposals != 0, this);
+ if (permitCount == 0) Interlocked.Increment(ref _zeroProbes);
+ else Interlocked.Increment(ref _positiveAttempts);
+ var acquired = _remaining > 0 && _remaining >= permitCount;
+ if (acquired && permitCount > 0)
+ {
+ _remaining -= permitCount;
+ _idleSince = null;
+ }
+ var lease = new BudgetLease(acquired, RetryAfter);
+ Leases.Add(lease);
+ return lease;
+ }
+ }
+ protected override ValueTask AcquireAsyncCore(int permitCount, CancellationToken cancellationToken) =>
+ ValueTask.FromResult(AttemptAcquireCore(permitCount));
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing) DisposeBudget();
+ base.Dispose(disposing);
+ }
+ protected override ValueTask DisposeAsyncCore()
+ {
+ DisposeBudget();
+ return base.DisposeAsyncCore();
+ }
+ private void DisposeBudget()
+ {
+ Interlocked.Increment(ref _disposals);
+ OnDispose?.Invoke();
+ }
+ }
+
+ private sealed class BudgetLease(bool acquired, TimeSpan retryAfter) : RateLimitLease
+ {
+ public int Disposals { get; private set; }
+ public override bool IsAcquired
+ {
+ get
+ {
+ ObjectDisposedException.ThrowIf(Disposals != 0, this);
+ return acquired;
+ }
+ }
+ public override IEnumerable MetadataNames => acquired ? [] : [MetadataName.RetryAfter.Name];
+ public override bool TryGetMetadata(string metadataName, out object? value)
+ {
+ ObjectDisposedException.ThrowIf(Disposals != 0, this);
+ value = !acquired && metadataName == MetadataName.RetryAfter.Name ? retryAfter : null;
+ return value is not null;
+ }
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing) Disposals++;
+ base.Dispose(disposing);
+ }
+ }
+
+ private sealed class ManualClock : TimeProvider
+ {
+ private long _timestamp;
+ public ManualTimer? Timer { get; private set; }
+ public override long TimestampFrequency => TimeSpan.TicksPerSecond;
+ public override long GetTimestamp() => Interlocked.Read(ref _timestamp);
+ public void Advance(TimeSpan duration) => Interlocked.Add(ref _timestamp, duration.Ticks);
+ public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
+ {
+ Timer.Should().BeNull("the admission owner creates one periodic sweeper");
+ dueTime.Should().BeGreaterThan(TimeSpan.Zero);
+ period.Should().BeGreaterThan(TimeSpan.Zero);
+ Timer = new ManualTimer(callback, state);
+ return Timer;
+ }
+ }
+
+ private sealed class ManualTimer(TimerCallback callback, object? state) : ITimer
+ {
+ private readonly object _gate = new();
+ private readonly TaskCompletionSource _joined = Completion();
+ private bool _disposed;
+ private int _callbacks;
+ private int _fires;
+ private int _asyncDisposals;
+ public int Fires => Volatile.Read(ref _fires);
+ public int AsyncDisposals => Volatile.Read(ref _asyncDisposals);
+ public TaskCompletionSource DisposalRequested { get; } = Completion();
+
+ public bool Fire()
+ {
+ lock (_gate)
+ {
+ if (_disposed) return false;
+ _callbacks++;
+ Interlocked.Increment(ref _fires);
+ }
+ try
+ {
+ callback(state);
+ return true;
+ }
+ finally
+ {
+ lock (_gate)
+ {
+ _callbacks--;
+ if (_disposed && _callbacks == 0) _joined.TrySetResult();
+ }
+ }
+ }
+
+ public bool Change(TimeSpan dueTime, TimeSpan period)
+ {
+ lock (_gate) return !_disposed;
+ }
+ public void Dispose()
+ {
+ lock (_gate)
+ {
+ _disposed = true;
+ if (_callbacks == 0) _joined.TrySetResult();
+ }
+ DisposalRequested.TrySetResult();
+ }
+ public ValueTask DisposeAsync()
+ {
+ Interlocked.Increment(ref _asyncDisposals);
+ Dispose();
+ return new ValueTask(_joined.Task);
+ }
+ }
+}
diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/AnonymousLimiterLifecycleTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/AnonymousLimiterLifecycleTests.cs
new file mode 100644
index 00000000..80d0a945
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/AnonymousLimiterLifecycleTests.cs
@@ -0,0 +1,122 @@
+using System.Net;
+using System.Threading.RateLimiting;
+using FluentAssertions;
+using LearnStack.Api.Tenancy;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.RateLimiting;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Xunit;
+
+namespace LearnStack.Tests.Unit.Api.Tenancy;
+
+public sealed class AnonymousLimiterLifecycleTests
+{
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Provider_teardown_disposes_the_global_admission_owner(bool asynchronous)
+ {
+ var provider = Provider();
+ var limiter = Global(provider);
+ if (asynchronous) await provider.DisposeAsync();
+ else provider.Dispose();
+
+ // Keep request services live: a disposed provider must not be the cause
+ // of the expected refusal, masking a global limiter that is still usable.
+ await using var requestServices = Provider();
+ var context = new DefaultHttpContext { RequestServices = requestServices };
+ context.Connection.RemoteIpAddress = IPAddress.Loopback;
+ var acquire = () => limiter.AttemptAcquire(context);
+ acquire.Should().Throw();
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Admission_owner_disposes_each_child_budget_once_on_both_paths(bool asynchronous)
+ {
+ using var provider = Provider();
+ var peer = new TrackedBudget();
+ var visitor = new TrackedVisitorBudget();
+ var owner = new NoQueueAdmissionLimiter(peer, () => visitor, RateLimitingExtensions.Window, TimeProvider.System);
+ var context = new DefaultHttpContext { RequestServices = provider };
+ context.Connection.RemoteIpAddress = IPAddress.Loopback;
+ using var admitted = owner.AttemptAcquire(context);
+ admitted.IsAcquired.Should().BeTrue();
+ if (asynchronous) await owner.DisposeAsync();
+ else owner.Dispose();
+ owner.Dispose();
+ await owner.DisposeAsync();
+ peer.Disposals.Should().Be(1);
+ visitor.Disposals.Should().Be(1);
+ peer.AsyncDisposals.Should().Be(asynchronous ? 1 : 0);
+ visitor.AsyncDisposals.Should().Be(asynchronous ? 1 : 0);
+ }
+
+ private static ServiceProvider Provider()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(new EffectiveHostAccessor(Options.Create(new TrustedHopOptions())));
+ services.AddLearnStackRateLimiting();
+ return services.BuildServiceProvider();
+ }
+
+ private static PartitionedRateLimiter Global(ServiceProvider provider) =>
+ provider.GetRequiredService>().Value.GlobalLimiter!;
+
+ private sealed class TrackedBudget : PartitionedRateLimiter
+ {
+ public int Disposals { get; private set; }
+ public int AsyncDisposals { get; private set; }
+ public override RateLimiterStatistics? GetStatistics(HttpContext resource) => null;
+ protected override RateLimitLease AttemptAcquireCore(HttpContext resource, int permitCount) =>
+ new AcquiredLease();
+ protected override ValueTask AcquireAsyncCore(HttpContext resource, int permitCount, CancellationToken cancellationToken) =>
+ ValueTask.FromResult(new AcquiredLease());
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing) Disposals++;
+ base.Dispose(disposing);
+ }
+ protected override async ValueTask DisposeAsyncCore()
+ {
+ Disposals++;
+ AsyncDisposals++;
+ await base.DisposeAsyncCore();
+ }
+ }
+
+ private sealed class TrackedVisitorBudget : RateLimiter
+ {
+ public int Disposals { get; private set; }
+ public int AsyncDisposals { get; private set; }
+ public override TimeSpan? IdleDuration => null;
+ public override RateLimiterStatistics? GetStatistics() => null;
+ protected override RateLimitLease AttemptAcquireCore(int permitCount) => new AcquiredLease();
+ protected override ValueTask AcquireAsyncCore(int permitCount, CancellationToken cancellationToken) =>
+ ValueTask.FromResult(new AcquiredLease());
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing) Disposals++;
+ base.Dispose(disposing);
+ }
+ protected override async ValueTask DisposeAsyncCore()
+ {
+ Disposals++;
+ AsyncDisposals++;
+ await base.DisposeAsyncCore();
+ }
+ }
+
+ private sealed class AcquiredLease : RateLimitLease
+ {
+ public override bool IsAcquired => true;
+ public override IEnumerable MetadataNames => [];
+ public override bool TryGetMetadata(string metadataName, out object? value)
+ {
+ value = null;
+ return false;
+ }
+ }
+}
diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/AnonymousRequestIdentityTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/AnonymousRequestIdentityTests.cs
new file mode 100644
index 00000000..bb6f9d5e
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/AnonymousRequestIdentityTests.cs
@@ -0,0 +1,124 @@
+using System.Net;
+using FluentAssertions;
+using LearnStack.Api.Tenancy;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Options;
+using Xunit;
+
+namespace LearnStack.Tests.Unit.Api.Tenancy;
+
+public sealed class AnonymousRequestIdentityTests
+{
+ private const string Secret = "a-test-only-visitor-hop-secret-32-characters";
+ private static readonly AnonymousRequestIdentity Identity = new(new EffectiveHostAccessor(Options.Create(
+ new TrustedHopOptions { Networks = ["127.0.0.1/32"], Secrets = [Secret] })));
+
+ [Fact]
+ public void Budgets_match_the_accepted_contract_independently_of_request_loops()
+ {
+ RateLimitingExtensions.AnonymousPermitPerWindow.Should().Be(60);
+ RateLimitingExtensions.PeerPermitPerWindow.Should().Be(600);
+ RateLimitingExtensions.Window.Should().Be(TimeSpan.FromMinutes(1));
+ }
+
+ [Theory]
+ [InlineData("127.0.0.1", "127.0.0.1")]
+ [InlineData("::ffff:127.0.0.1", "127.0.0.1")]
+ [InlineData("0:0:0:0:0:ffff:7f00:1", "127.0.0.1")]
+ [InlineData("2001:0DB8::0001", "2001:db8::1")]
+ [InlineData("::1", "::1")]
+ [InlineData("127.000.0.1", null)]
+ [InlineData("127.1", null)]
+ [InlineData("2130706433", null)]
+ [InlineData("0x7f000001", null)]
+ [InlineData("::ffff:127.000.0.1", null)]
+ [InlineData("2001:db8::1%eth0", null)]
+ [InlineData("[::1]", null)]
+ [InlineData("1.2.3.4:80", null)]
+ [InlineData(" 127.0.0.1", null)]
+ [InlineData("127.0.0.1, 1.2.3.4", null)]
+ [InlineData("", null)]
+ [InlineData(null, null)]
+ public void Only_strict_bounded_IP_literals_are_admitted(string? value, string? expected) =>
+ AnonymousRequestIdentity.ParseAddress(value).Should().Be(expected);
+
+ [Fact]
+ public void Oversized_addresses_are_refused() =>
+ AnonymousRequestIdentity.ParseAddress(new string('1', AnonymousRequestIdentity.MaximumAddressLength + 1)).Should().BeNull();
+
+ [Fact]
+ public void Framework_accepted_mapped_dotted_tail_still_requires_canonical_IPv4_spelling()
+ {
+ const string noncanonical = "::ffff:127.0.0.01";
+ IPAddress.TryParse(noncanonical, out var parsed).Should().BeTrue(
+ "this control must reach the dotted-tail round-trip guard, not framework parse rejection");
+ parsed!.IsIPv4MappedToIPv6.Should().BeTrue(); // Successful TryParse supplies the address.
+ AnonymousRequestIdentity.ParseAddress(noncanonical).Should().BeNull();
+ AnonymousRequestIdentity.ParseAddress("::ffff:127.0.0.1").Should().Be("127.0.0.1");
+ }
+
+ [Fact]
+ public void Trusted_visitor_and_direct_socket_use_one_canonical_namespace()
+ {
+ var direct = Request("::ffff:203.0.113.9");
+ var hop = Request("127.0.0.1", Secret, "0:0:0:0:0:ffff:cb00:7109");
+ Identity.For(direct).VisitorKey.Should().Be("203.0.113.9");
+ Identity.For(hop).VisitorKey.Should().Be(Identity.For(direct).VisitorKey);
+ Identity.For(hop).PeerKey.Should().Be("127.0.0.1");
+ Identity.For(hop).Refuse.Should().BeFalse();
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("127.1")]
+ [InlineData("127.0.0.1, 203.0.113.9")]
+ public void Invalid_trusted_metadata_uses_bounded_peer_fallback_before_refusal(string? visitor)
+ {
+ var result = Identity.For(Request("127.0.0.1", Secret, visitor));
+ result.Should().Be(new AnonymousVisitor("127.0.0.1", "127.0.0.1", true));
+ }
+
+ [Fact]
+ public void Repeated_trusted_metadata_is_not_first_or_last_wins()
+ {
+ var request = Request("127.0.0.1", Secret);
+ request.Request.Headers[AnonymousRequestIdentity.VisitorHeaderName] = new[] { "203.0.113.9", "203.0.113.10" };
+ Identity.For(request).Refuse.Should().BeTrue();
+ }
+
+ [Theory]
+ [InlineData("203.0.113.9", Secret)]
+ [InlineData("127.0.0.1", "wrong")]
+ [InlineData("127.0.0.1", null)]
+ public void Both_network_and_secret_are_required_to_state_a_visitor(string peer, string? secret)
+ {
+ var request = Request(peer, secret, "198.51.100.4");
+ request.Request.Headers["X-Forwarded-For"] = "192.0.2.4";
+ Identity.For(request).Should().Be(new AnonymousVisitor(peer, peer, false));
+ }
+
+ [Fact]
+ public void Repeated_secret_is_untrusted_even_with_valid_visitor()
+ {
+ var request = Request("127.0.0.1", visitor: "203.0.113.9");
+ request.Request.Headers[TrustedHopOptions.SecretHeaderName] = new[] { Secret, Secret };
+ Identity.For(request).Should().Be(new AnonymousVisitor("127.0.0.1", "127.0.0.1", false));
+ }
+
+ [Fact]
+ public void Unknown_peers_share_one_fixed_partition_and_ignore_metadata()
+ {
+ var request = Request(null, Secret, "203.0.113.9");
+ Identity.For(request).Should().Be(new AnonymousVisitor(
+ RateLimitingExtensions.UnknownPeerPartition, RateLimitingExtensions.UnknownPeerPartition, false));
+ }
+
+ private static DefaultHttpContext Request(string? peer, string? secret = null, string? visitor = null)
+ {
+ var request = new DefaultHttpContext();
+ request.Connection.RemoteIpAddress = peer is null ? null : IPAddress.Parse(peer);
+ if (secret is not null) request.Request.Headers[TrustedHopOptions.SecretHeaderName] = secret;
+ if (visitor is not null) request.Request.Headers[AnonymousRequestIdentity.VisitorHeaderName] = visitor;
+ return request;
+ }
+}
diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/EffectiveHostAccessorTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/EffectiveHostAccessorTests.cs
index d5e230d1..bb409c1c 100644
--- a/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/EffectiveHostAccessorTests.cs
+++ b/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/EffectiveHostAccessorTests.cs
@@ -44,6 +44,17 @@ public void The_Right_Peer_With_The_Right_Secret_Is_The_Hop()
Accessor().IsTrustedHop(Request(peer: "10.0.0.5", secret: Secret)).Should().BeTrue();
}
+ [Theory]
+ [InlineData("::ffff:127.0.0.1", true)]
+ [InlineData("::ffff:203.0.113.9", false)]
+ public void IPv4_network_membership_handles_mapped_socket_peers(string peer, bool trusted)
+ {
+ IPNetwork.Parse("127.0.0.1/32").Contains(IPAddress.Parse(peer)).Should().Be(trusted,
+ "the .NET network predicate already handles mapped peers without a parser workaround");
+ Accessor(networks: ["127.0.0.1/32"])
+ .IsTrustedHop(Request(peer: peer, secret: Secret)).Should().Be(trusted);
+ }
+
[Fact]
public void The_Right_Peer_With_The_Wrong_Secret_Is_Not()
{
diff --git a/docs/architecture/04-technical-architecture.md b/docs/architecture/04-technical-architecture.md
index 065a262b..702ee59f 100644
--- a/docs/architecture/04-technical-architecture.md
+++ b/docs/architecture/04-technical-architecture.md
@@ -196,16 +196,15 @@ Full details: [Events & Outbox](15-event-and-outbox.md).
- Next.js (App Router) for public site, admin studio, learner portal, instructor portal.
- Server Components by default; Client Components only where interactivity needs it.
-- Tenant resolution at the edge / middleware layer; tenant context propagated via header into RSC and route handlers.
-- Typed API client generated from OpenAPI.
+- The API resolves tenant/organization scope from the verified host. Web middleware
+ admits public paths using live bootstrap; it carries no tenant/organization ids.
+- Generated OpenAPI types back four injected SDK GET wrappers. The web app's private
+ configured caller owns the trusted API hop.
-> **Open in Phase 02d.** For what Phase 02d builds, whether the edge resolves a tenant
-> at all and whether the frontend ever holds a tenant or organization id are G25, what
-> the middleware carries inward and under which header is G36, and whether the SDK is
-> a hand-written transport over the generated `paths` or a typed client library is
-> G31 — all in
-> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register).
-> The passes that close them edit these bullets with their answers.
+P02d-4 delivers G25/G31's public API and SDK boundary; P02d-5 delivers G35/G36's
+configured caller and middleware admission under
+[ADR-0053](../decisions/0053-trusted-public-server-rendering.md).
+[Frontend Architecture](14-frontend-architecture.md) owns their detailed contracts.
Detailed conventions: [Frontend Architecture](14-frontend-architecture.md) and [Frontend Architecture Standards](../standards/07-frontend-architecture.md).
@@ -230,12 +229,11 @@ the two Dapr containers ([ADR-0035](../decisions/0035-demand-gated-infrastructur
Application projects run **outside** containers during active development for fast iteration. CI runs identical container versions.
-> **Open in Phase 02d.** Where the API and the web app run relative to each other, on a
-> workstation and in the CI job that renders the pages, is G33; what `make demo` starts
-> and waits on over this stack is G45. Both are in
-> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register).
-> They are answered in the decision passes of the packets they block, and those passes
-> edit this section with their answers.
+P02d-5 delivers G33's paired loopback API and native TLS web ingress, with isolated
+real-API production-rendering fixtures. The
+[accepted local setup](../roadmap/phase-02d-walking-skeleton.md#local-setup-and-ingress-details)
+owns host, certificate and private configuration preparation. P02d-7 owns G45's
+`make demo` lifecycle and browser evidence; that gate remains open.
## Observability
diff --git a/docs/architecture/05-mvp-scope.md b/docs/architecture/05-mvp-scope.md
index 67673eae..ae9a8940 100644
--- a/docs/architecture/05-mvp-scope.md
+++ b/docs/architecture/05-mvp-scope.md
@@ -235,14 +235,13 @@ since [Phase 02d](../roadmap/phase-02d-walking-skeleton.md). Phase 10 is therefo
customization aggregate against one real tenant, proving the customization surface is
complete. Genericity is already proven, and re-proven on every CI run.
-> **Open in Phase 02d.** No CI job renders either site today. Whether one re-proves
-> after Phase 02d exits that each host renders its own tenant — an HTTP smoke against
-> `next start`, a narrow browser smoke, or a dated manual record instead — is G38 (c);
-> whether the Lighthouse job runs over both hosts is G44; and what mechanically backs
-> "no production code branches on which tenant it serves" is G20, all in
-> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register).
-> The passes that close those gates edit this paragraph with their answers, and
-> Phase 02d's exit re-verifies it against what shipped.
+P02d-5's required integration check renders two hosts through the production ingress,
+middleware and configured caller against the real API and PostgreSQL. Its explicitly
+test-owned pages prove the transport boundary, not P6's product pages or P7's browser
+demo. P02d-6 owns G38(c)'s page cases; P02d-7 owns G20's production genericity guard,
+G45's demo/browser evidence and G44's Lighthouse activation after P6. The remaining
+harness and assertion decisions stay in the
+[Phase 02d decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register).
## Deferred
diff --git a/docs/architecture/12-localization.md b/docs/architecture/12-localization.md
index 33e9c345..b0c14c49 100644
--- a/docs/architecture/12-localization.md
+++ b/docs/architecture/12-localization.md
@@ -237,8 +237,9 @@ Slugs are **per locale**. Two patterns:
- **Locale-by-host**: `english.learnstack.io` always English, `ingilizce.learnstack.io` always Turkish. Available as a tenant-level configuration.
The accepted P02d-4 bootstrap supplies host-resolved public locale configuration
-without exposing tenant or organization ids. P02d-5 selects the Next.js placement,
-transport and locale redirect (G36); P02d-6 implements the pages. No frontend host
+without exposing tenant or organization ids. Accepted P02d-5/ADR-0053 chooses Node
+middleware behind authenticated native ingress and membership-first redirects;
+Step 3 implements entry/transport. P02d-6 implements the pages. No frontend host
registry lookup is prescribed. See
[ADR-0052](../decisions/0052-anonymous-public-read-boundary.md).
diff --git a/docs/architecture/14-frontend-architecture.md b/docs/architecture/14-frontend-architecture.md
index 82a8d31c..bdebe08c 100644
--- a/docs/architecture/14-frontend-architecture.md
+++ b/docs/architecture/14-frontend-architecture.md
@@ -15,11 +15,18 @@ LearnStack ships **two independent Next.js applications**:
dependency. The operator portal scope lives in
[24-learnstack-hub.md §6](24-learnstack-hub.md) and is not duplicated here.
-This document covers `apps/web`: app shape, tenant resolution at the edge, theming with
+This document covers `apps/web`: app shape, API-owned tenant resolution, theming with
optional per-organization override, rendering strategies, data fetching, the tenant-driven
block resolver, entitlement-aware UI, custom-domain handling, and the path to extracting
independent apps when warranted.
+The [P02d-5 external-review remediation](../roadmap/phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09)
+tracks verified gaps. [ADR-0054](../decisions/0054-bounded-public-renderer-admission.md)
+is **Accepted — 2026-10-09**. P02d-5 delivers coordinated anonymous accounting,
+native method/upgrade admission and pinned URL controls. The remediation record
+owns execution and independent review evidence; the ongoing rules live in
+[Frontend Standards](../standards/07-frontend-architecture.md#native-admission-and-url-identity).
+
> **P02d-4 Accepted design — 2026-10-03.** Step 2 delivers site bootstrap;
> Steps 3–4 deliver Education and contract/SDK controls; P02d-5 owns the server consumer.
> [ADR-0052](../decisions/0052-anonymous-public-read-boundary.md) selects host-resolved
@@ -61,7 +68,7 @@ frontend/
auth/
tenant/
i18n/
- middleware.ts # tenant + organization + locale resolution
+ middleware.ts # ingress verification + locale entry
extensions/ # client-side block resolver, see Page Builder
packages/
ui/ # extracted only once duplication is real
@@ -69,11 +76,11 @@ frontend/
config/ # eslint, tsconfig, tailwind shared bits
```
-> **Open in Phase 02d.** Where composite and primitive components live (G41), where the
-> UI string catalogue lives (G39) and middleware placement (G36) are open
-> in
+> **Phase 02d ownership.** Component placement (G41) and the UI string catalogue
+> (G39) remain P02d-6 decisions. P02d-5/G36 delivers Node middleware in
+> `apps/web/src/middleware.ts`. The ownership and accepted entry rule are recorded in
> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register).
-> The tree records the plan written before them.
+> The tree above is the earlier target layout, not a shipped-directory census.
The operator portal (`operator-portal`) is a **separate Next.js application in the
separate `learnstack-hub` repository**; nothing about it lives under this `frontend/`
@@ -89,12 +96,12 @@ Splitting into separate apps is governed by [ADR 0009 — Frontend Single App Fi
## Tenant + Organization Resolution at the Edge
**Accepted public boundary — 2026-10-03.** Site bootstrap is delivered in Step 2;
-server transport and consumers remain P02d-5/6. The frontend
+P02d-5 Step 3 delivers server transport; page consumers remain P02d-6. The frontend
uses host-resolved site bootstrap, not an edge registry returning tenancy IDs.
[ADR-0052](../decisions/0052-anonymous-public-read-boundary.md) owns the read boundary;
[Frontend Standards](../standards/07-frontend-architecture.md#tenant-resolution)
-owns the ongoing frontend rule. P02d-5/G35/G36 still owns transport and middleware
-placement, and P02d-6 owns public page consumers.
+owns the ongoing frontend rule. P02d-5/G35/G36 delivers the configured transport
+and Node middleware entry; P02d-6 owns public page consumers.
The API computes the effective host once under ADR-0036's direct/trusted-hop rules,
resolves its existing mapping and preserves a factory host ceiling. The configured
@@ -123,7 +130,8 @@ For text renderers:
effective attribution, without tenant/organization IDs or raw settings.
5. Education calls use explicit query locale; headers never select content.
6. P02d-6 renders the bounded DTOs. No site/Education representation cache or edge
- lookup cache is part of this API contract; renderer-cache choices remain G37.
+ lookup cache is part of this API contract; ADR-0053 also forbids shared Next
+ representation caches.
The API host resolver's own cache remains unchanged and never calls Hub. Existing
custom-domain push and invalidation contracts remain under their named phases;
@@ -133,16 +141,32 @@ so a claim cannot enlarge what the host serves.
## Rendering Strategies
-> **Open in Phase 02d.** How tenant-varying `(public)` routes render, and which caches
-> may hold tenant data, is G37 in
-> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register).
-> The `(public)` row below records the plan written before it; that pass rewrites it.
+**P02d-5 Accepted — 2026-10-08.**
+[ADR-0053](../decisions/0053-trusted-public-server-rendering.md) accepts native socket
+provenance, a server-only configured transport and uncached institution rendering.
+Its four-step [delivery plan](../roadmap/phase-02d-walking-skeleton.md#p02d-5-implementation-plan)
+records all four completed steps and both independent review rounds per step.
+Frontend fences, no-skip outcomes and real-API production HTML/RSC proofs pass;
+P02d-5 remains unmerged while external-review remediation continues. P02d-6 owns
+public page consumers.
+
+ADR-0054 retains API-owned tenant authority and membership-first locale entry.
+Its accepted native boundary admits GET/HEAD on every callback path, including
+matcher-exempt health/assets; production upgrades close and development retains
+only validated GET HMR. Signed raw targets remain route/locale authority; redirects
+preserve inert query values, duplicates and ordering with equivalent encoding.
+[Frontend Standards](../standards/07-frontend-architecture.md#native-admission-and-url-identity)
+own the detailed controls and implementation/review boundary. Coordinated API
+admission refuses an exhausted known visitor before peer debit while peer-gating
+unknown visitor allocation; the peer quota bounds successful acquisitions and
+allocations, not total refusal work. Lifecycle and Retry-After/replay obligations
+live in [Security Standards](../standards/11-security.md#rate-limiting).
Per segment:
| Segment | Strategy | Notes |
|---|---|---|
-| `(public)` | SSR with cache (ISR-like) | Pages built on demand, cached by tenant+slug+locale. Revalidated by webhook on publish. |
+| `(public)` | Dynamic SSR, no-store | ADR-0053: no shared Next data/route/ISR cache; fresh API eligibility on each new server request. |
| `(studio)` | SSR, no cache | Always fresh; authentication required at the edge. |
| `(portal)` | SSR for lesson shell, CSR for player | Player benefits from client-side state; shell needs SEO/auth. |
@@ -327,9 +351,9 @@ This document does not restate them.
Studio and Portal have higher budgets because they are authenticated apps and benefit from client-side state.
-CI's Lighthouse job over representative public pages is scaffolded and not yet active.
-Whether it activates in [Phase 02d](../roadmap/phase-02d-walking-skeleton.md), and what
-it asserts, is G44 in
+CI's Lighthouse job over representative public pages remains a disabled placeholder.
+Accepted P02d-5 G44 assigns activation to P02d-7 after P6 pages; P7 selects the
+harness and assertions. The decision is recorded in
[Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register).
## Accessibility
@@ -367,13 +391,13 @@ mechanical.
## Risks
-> **Remaining Phase 02d decision.** Public route caching and its key remain G37 in
-> [the phase register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register).
-> G16(d) now requires contrast refusal, not a warning-only save. Neither decision
-> claims implemented transport or rendering.
+> **Accepted P02d-5.** ADR-0053 / G37 requires dynamic/no-store public rendering;
+> request-local reuse is allowed. P5 delivers transport and its runtime proofs;
+> P6 owns product pages. G16(d) requires contrast refusal before a branding save.
-- **Per-tenant SSR cost** — caching is per `(tenantId, organizationId?, locale, slug)`.
- Cardinality is bounded; budget memory headroom.
+- **Per-request SSR cost** — bootstrap and content reads consume latency and API
+ quota on each request. Measure that cost; only request-local reuse is allowed
+ under [the public rendering policy](../standards/07-frontend-architecture.md#public-site-renderer).
- **Cookie domain scoping** — tenants on custom domains complicate auth cookies. Use
SameSite-Lax + explicit `Domain=` per host; do not share auth cookies across tenants.
Domain registration and TLS flow:
diff --git a/docs/architecture/25-deployment-models.md b/docs/architecture/25-deployment-models.md
index 8212dfcf..3198a347 100644
--- a/docs/architecture/25-deployment-models.md
+++ b/docs/architecture/25-deployment-models.md
@@ -2,7 +2,8 @@
**Derives from:** [ADR-0020](../decisions/0020-triple-deployment-hybrid-license.md),
[ADR-0019](../decisions/0019-learnstack-hub.md),
-[ADR-0035](../decisions/0035-demand-gated-infrastructure.md).
+[ADR-0035](../decisions/0035-demand-gated-infrastructure.md),
+[ADR-0054](../decisions/0054-bounded-public-renderer-admission.md).
LearnStack targets three deployment models from **one codebase, one Helm chart, one set
of container images**. The differentiator across modes is configuration + component
@@ -112,22 +113,26 @@ Internet
connection, saturate the pool, and degrade every other tenant on the instance while
every isolation test stays green.
- What exists today: the API's own anonymous limiter, which runs before host
- classification and partitions every request on its socket peer
+ What exists today: the API's own anonymous limiter runs before host
+ classification. P02d-5 uses a shared 60/min canonical-IP quota for direct peers
+ and authenticated-hop visitors, plus a 600/min physical-peer ceiling
([API Standards § Request and Response Limits](../standards/04-api-design.md#request-and-response-limits)).
APISIX's `limit-req`, keyed on `remote_addr`, fronts the API only once the gateway
lands, which [ADR-0035](../decisions/0035-demand-gated-infrastructure.md) gates to
- [Phase 11](../roadmap/phase-11-production-hardening.md). Each keys on the client, so
- each throttles a noisy client but not a noisy tenant — one tenant behind many IPs is
- unaffected, and many tenants behind one NAT are punished together. A server-side
- renderer is such a NAT: every visitor it renders for, of every tenant, reaches the API
- from one peer.
-
- > **Open in Phase 02d.** How the anonymous limiter treats a request arriving over the
- > trusted hop is G34 in
- > [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register).
- > It is answered in the decision pass of the packet that ships the server-rendering
- > path, and that pass edits this paragraph with its answer.
+ [Phase 11](../roadmap/phase-11-production-hardening.md). Neither policy is
+ per-tenant fairness: many client IPs can serve one tenant, while NAT users share
+ an IP quota. [ADR-0054](../decisions/0054-bounded-public-renderer-admission.md)
+ accounting is **implemented — 2026-10-09; both review rounds passed**. Remediation
+ Step 1 owns visitor limiters under one process-local owner lock.
+ Exhausted known visitors refuse before peer debit; unknown visitor allocation
+ remains peer-gated. The 600/min peer budget bounds successful peer acquisitions
+ and new visitor allocations shared by visitors/tenants, not all attempts or
+ refusal-response work. It is an allocation-rate bound, not a fixed global
+ cardinality cap. This accounting does not bound total incoming
+ network/refusal cost. [Security Standards](../standards/11-security.md#rate-limiting)
+ own lifecycle, replay and Retry-After obligations; the
+ [remediation plan](../roadmap/phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09)
+ owns implementation evidence.
What is required, and where it lives: **resource fairness is
[Phase 11](../roadmap/phase-11-production-hardening.md)** — `statement_timeout` per
diff --git a/docs/architecture/30-api-gateway.md b/docs/architecture/30-api-gateway.md
index 7f0fc8f1..6dadf600 100644
--- a/docs/architecture/30-api-gateway.md
+++ b/docs/architecture/30-api-gateway.md
@@ -35,6 +35,13 @@ the LearnStack core API and the LearnStack Hub API.
> so [Phase 03](../roadmap/phase-03-identity-admin.md) delivers authorization in ASP.NET
> middleware and corrects this route table, but does not stand APISIX up in front of it.
+**Accepted P02d-5 local topology — 2026-10-08.**
+[ADR-0053](../decisions/0053-trusted-public-server-rendering.md) uses native loopback
+HTTPS Next ingress and loopback HTTP API with authenticated host/visitor metadata.
+Step 2 replaces socket-only anonymous partitioning with canonical-IP and peer
+budgets. APISIX remains Phase 11's gated adapter; the topology below is that target,
+not a prerequisite or delivery claim for the walking skeleton.
+
## 1. Topology
```mermaid
diff --git a/docs/decisions/0036-tenant-resolution-trusted-inputs.md b/docs/decisions/0036-tenant-resolution-trusted-inputs.md
index 2d430368..634ce0e1 100644
--- a/docs/decisions/0036-tenant-resolution-trusted-inputs.md
+++ b/docs/decisions/0036-tenant-resolution-trusted-inputs.md
@@ -1040,6 +1040,50 @@ and the [Step 1 delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-4
own the implementation and evidence. Amendment 8 remains historical; the
reconciliation matrix and trusted-input contract are unchanged.
+### 2026-10-08 — Amendment 10: trusted public renderer decision navigation
+
+[ADR-0053](0053-trusted-public-server-rendering.md) is Accepted with the
+[P02d-5 package](../roadmap/phase-02d-walking-skeleton.md#p02d-5-decision-package-2026-10-08).
+It owns three bounded replacements: authenticated visitor-IP limiting with a peer
+ceiling; a server-only configured header setter outside the injected SDK; and the
+mode-independent no-hop startup policy/minimum of 32 characters. All other host
+normalization, reconciliation and assertion-only tenancy rules remain unchanged.
+The original body and prior amendments remain intact; this note is navigation and
+disclosure under ADR-0041, not an in-place fact correction or a changed Decision.
+
+Source/history evidence: `TrustedHopOptions.Secrets` permits overlapping rotation;
+`EffectiveHostAccessor.SecretMatches` requires one presented value and compares all
+configured secrets. `AddLearnStackTenancyEdge` permits both lists empty and refuses
+partial configuration in every mode; commit `b499dc8` records the 2026-08-20 change.
+`TrustedHopOptions.Validate` measures secret length in characters. ADR-0053 explicitly
+adopts that shipped behavior rather than inferring policy from stale wording.
+
+Acceptance carriers: ADR index/0053; CLAUDE and Phase 02d; Frontend Architecture;
+Standards 04/07/11/15/21; the route skill. Other ongoing owner/launch/environment/CI
+carriers are updated before or with their enforcing P5 step, as enumerated in the
+package. No P5 runtime proof is claimed by this decision commit.
+
+### 2026-10-09 — Amendment 11: bounded admission and amendment navigation
+
+The maintainer accepted [ADR-0054](0054-bounded-public-renderer-admission.md) with
+P02d-5's five-step remediation plan on 2026-10-09. Its
+[anonymous accounting](0054-bounded-public-renderer-admission.md#anonymous-accounting)
+replaces ADR-0053 Amendment 3's peer-first debit while preserving authenticated
+visitor inputs, direct/hop shared identity, numeric windows and peer-gated new
+allocation. [ADR-0053 Amendment 7](0053-trusted-public-server-rendering.md#amendment-7--bounded-admission-supersession-2026-10-09)
+records its bounded native-admission and redirect-query supersession. No change
+is made to this ADR's host normalization, reconciliation or assertion-only tenant
+authority. Implementation and its evidence remain pending.
+
+Amendment 10's phrase “navigation and disclosure under ADR-0041” is imprecise.
+[ADR-0041](0041-correcting-false-statements-in-accepted-adrs.md) bounds correction
+of statements false when written; it does not authorize changed decisions.
+ADR-0053 and now ADR-0054 are the new decision vehicles for their bounded
+replacements. These dated notes provide navigation under
+[Documentation Standards § Correcting and Amending ADRs](../standards/13-documentation.md#correcting-and-amending-adrs),
+without applying either false-when-written correction mechanism. Amendment 10,
+the original body, metadata and all earlier amendments are preserved verbatim.
+
## References
- [ADR-0003 Tenant Isolation Defense in
diff --git a/docs/decisions/0052-anonymous-public-read-boundary.md b/docs/decisions/0052-anonymous-public-read-boundary.md
index fc1b34e0..43837d77 100644
--- a/docs/decisions/0052-anonymous-public-read-boundary.md
+++ b/docs/decisions/0052-anonymous-public-read-boundary.md
@@ -2,6 +2,11 @@
## Status
+> **Merge update — 2026-10-08.** P02d-4 is complete and merged through PR #25.
+> [Amendment 13](#amendment-13--merge-closeout-2026-10-08) links final verification.
+> The pending/awaiting-merge statements below record earlier milestones;
+> P02d-5/6 remain the server/page owners.
+
> **Implementation update — 2026-10-05.**
> All four P02d-4 steps are delivered in PR #25, awaiting maintainer review/merge.
> The original pending/not-started statements below are acceptance-time history;
@@ -373,3 +378,17 @@ consumers remain P02d-5/6.
Updated carriers: this Status disclosure, the ADR-0036/0050/0051 delivery notes,
the Education module spec and the P02d-4 delivery record.
+
+## Amendment 13 — merge closeout (2026-10-08)
+
+This delivery note changes no decision. The maintainer merged
+[PR #25](https://github.com/HodeTech/LearnStack/pull/25) on 2026-10-08. All four
+P02d-4 steps, their two-round reviews and the approved required-check rollout
+are complete. The [merge closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-4-merge-and-closeout-2026-10-08)
+owns the final head, merge/tree proof and verification evidence. The original
+Status, Date, Deciders and previous delivery notes remain unchanged as history.
+P02d-5/6 retain server/page delivery; Phase 02d remains in progress.
+
+Updated carriers: this dated Status disclosure, the decisions index, repository
+README and guidance, roadmap index and packet status, and the Education module
+status. No implementation or new decision is introduced by this closeout.
diff --git a/docs/decisions/0053-trusted-public-server-rendering.md b/docs/decisions/0053-trusted-public-server-rendering.md
new file mode 100644
index 00000000..bde7e726
--- /dev/null
+++ b/docs/decisions/0053-trusted-public-server-rendering.md
@@ -0,0 +1,359 @@
+# ADR-0053: Trusted Public Server Rendering
+
+## Status
+
+Accepted — 2026-10-08. The maintainer approved this ADR and the P02d-5 package.
+
+**Date:** 2026-10-08
+**Deciders:** @cemil
+
+The [P02d-5 decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-5-decision-package-2026-10-08)
+contains the exact defaults, entry matrix, delivery steps and proof obligations.
+At acceptance, implementation has not started. The bounded replacements below
+apply; all other Accepted contracts remain authoritative.
+
+## Decision Drivers
+
+- P02d-4 delivers public reads and an injected SDK, but no configured renderer.
+- Middleware cannot prove a visitor IP from client-supplied forwarding headers.
+- Socket-only API limiting makes every renderer visitor share one quota.
+- Public paths contain no tenant identity; path-only rendering caches can mix sites.
+- SSR must preserve API-owned host resolution, public eligibility and READ ONLY.
+- Development must exercise the same ingress and transport as production-build tests.
+
+## Considered Options
+
+1. **Native Node ingress, authenticated transport and dynamic SSR** (recommended).
+ Capture connection provenance before Next, retain the SDK seam and avoid shared
+ tenant representations in the renderer.
+2. **Stock Next with inbound forwarding headers** (rejected). Middleware has no
+ independently verified socket identity; signing a client header at the API hop
+ does not make its visitor address trustworthy.
+3. **One shared renderer quota** (rejected). One visitor can exhaust both sites;
+ per-host quotas alone also multiply under novel-host floods.
+4. **Activate APISIX as a prerequisite** (rejected for this local packet). Its
+ non-development trigger and deployment proof remain Phase 11's responsibility.
+5. **Tenant-keyed ISR now** (rejected). Requires representation ownership,
+ invalidation and revocation contracts before the first renderer demonstrates a
+ need. Existing API definition caches already have their own authority.
+
+## Decision
+
+LearnStack serves institution public requests through a native Node
+ingress that delegates rendering to Next.js, authenticates captured host/address
+provenance to its middleware and server caller, and uses the injected public SDK
+over ADR-0036's network-and-secret API hop. Public representations are dynamically
+rendered without shared Next.js data or route caching. Anonymous limiting uses a
+verified visitor address with a separate socket-peer ceiling. The API remains the
+only tenant/organization authority under ADR-0052.
+
+### Ingress and provenance
+
+- The supported web launcher is the sole listener. It captures the actual
+ `IncomingMessage.socket.remoteAddress` before Next handles the request and
+ canonicalizes IPv4, IPv6 and IPv4-mapped IPv6 to one IP identity.
+- Client tenancy, internal `x-learnstack-*`, forwarding and middleware-control
+ headers are stripped before Next; duplicate/malformed Host and missing socket
+ identity fail closed. No ingress code looks up a tenant or organization.
+- The sole provenance carrier is `X-LearnStack-Ingress-Provenance`, containing
+ exactly one bounded, versioned envelope. A domain-separated HMAC authenticates
+ its captured host, canonical peer, method and request target using a server-only
+ secret. Verifiers refuse duplicate, malformed or conflicting envelopes. Request
+ matching accounts explicitly for the pinned Next version's URL processing.
+- The ingress captures connection inputs, strips client carriers, then mints the
+ envelope before delegating to Next. Middleware verifies it before bootstrap,
+ then rebuilds downstream **request** headers from an allowlist, retaining only
+ the verified envelope as provenance. The server caller verifies it again before
+ SDK use and derives host/peer from its authenticated payload, never unsigned
+ parallel headers. The envelope is neither a response header nor an API-hop header.
+- Direct stock-Next launch, missing or forged stamps and conflicting carriers
+ cannot invoke the configured caller. A plain internal header is never proof.
+- The stamp and private carriers are not response headers, HTML/RSC data, client
+ assets, retained logs or audit values. Visitor IP is limiter metadata, not a
+ tenant selector, user identity or audit actor.
+- P02d-5 uses loopback HTTPS for the web listener and loopback HTTP for the API.
+ Non-development proxy topology, TLS termination and scaling belong to Phase 11;
+ this decision does not claim production deployment readiness.
+
+### API hop and anonymous budgets
+
+- Renderer traffic requires network membership **and** exactly one
+ `X-LearnStack-Hop-Secret` value matching any entry in the configured secret list.
+ The list continues to permit overlapping rotation; the renderer sends one active
+ secret, not the list. The API's no-hop startup remains valid, but the configured
+ renderer requires a complete hop. No API forwarded-header middleware is enabled.
+- Exactly one bounded `X-LearnStack-Visitor-Address` IP literal supplies the
+ anonymous IP key only after that hop predicate succeeds. Other requests ignore
+ this header and all forwarding headers, using the actual socket peer.
+- Both paths use one canonical IP-key namespace: direct and SSR calls do not
+ create separate visitor quotas. Unknown peers share one fixed fallback key.
+- Preserve 60 API requests per fixed one-minute window per IP, no queue and
+ existing `429` Problem Details/`Retry-After`. A second ceiling permits at most
+ 600 API requests per fixed one-minute window per physical socket peer. Neither
+ key includes host, port, cookies, user agent or an attacker-issued identifier.
+- Trusted-hop missing, malformed or repeated visitor metadata consumes the
+ bounded peer fallback budget and then returns masked `404` before host lookup.
+ Invalid metadata never creates a new partition or an unlimited path.
+- Budgets apply before classification/database work. Shared NAT and local
+ loopback visitors still share an IP quota; these are API-call, not page-load,
+ budgets. The socket ceiling is a local backstop, not distributed DDoS protection.
+
+### Configured caller and rendering
+
+- `packages/sdk` remains the pure injected four-GET contract delivered by P02d-4.
+ One server-only adapter in `apps/web` constructs API-hop headers. SDK consumers
+ cannot supply tenant IDs, arbitrary headers, origin or hop credentials.
+- Its API origin is validated private configuration, never derived from a request
+ host or URL. Relative operation paths stay on that origin; HTTP redirects are
+ refused so the hop secret cannot follow a redirect.
+- Outbound headers are an allowlist: JSON Accept, trusted host, hop secret,
+ verified visitor address and validated W3C `traceparent`. No Authorization,
+ cookies, tenant/organization assertion or locale header is forwarded.
+- A ten-second total deadline covers headers **and** body consumption. Caller
+ cancellation remains distinct from timeout/transport failure. No automatic
+ retry hides a refusal or multiplies the anonymous budget. Count decoded response
+ bytes while reading, with an 8 MiB ceiling; timeout/oversize is a transport
+ failure, not a valid API refusal or permission to truncate JSON. This is a
+ consumer resource budget, not a new stored-content or OpenAPI size guarantee.
+- Bootstrap supplies enabled/default locales. The route locale alone supplies
+ Education's query locale; anonymous entry sets no cookies or negotiated default.
+- Tenant-varying public routes use dynamic rendering and no-store transport.
+ No ISR, positive `revalidate`, `generateStaticParams`, `unstable_cache`, shared
+ bootstrap cache or host-blind representation cache is permitted. Request-local
+ reuse may share a validated value only inside the same incoming request.
+ Freshness means the next new server/document request, not an already open page
+ or client Router Cache history. Disable Server Component HMR caching in local
+ development; production evidence uses a production build.
+- P02d-6 owns public page composition; P02d-7 owns the shared demo/browser harness.
+ P02d-5's ingress/adapter tests do not claim those pages are delivered.
+
+### Relationship to existing decisions
+
+This is a bounded replacement of ADR-0036's socket-only anonymous partition rule
+for authenticated renderer traffic, and of its exact SDK-file header-setter rule.
+Its reconciliation matrix, host normalization, network-and-secret predicate and
+assertion-only tenancy inputs remain unchanged. ADR-0052's public DTO, eligibility,
+cursor, no-store and READ ONLY contracts also remain unchanged.
+
+The startup policy is explicit: both hop lists empty are allowed in every API
+mode; partial configuration, invalid CIDRs and blank/short secrets are refused.
+The minimum is 32 characters; the renderer uses generated ASCII entropy and
+requires a complete hop. This adopts shipped behavior in place of ADR-0036's older
+non-Development/empty-list/32-byte wording, not through a silent clarification.
+
+Acceptance appends a dated **Amendment** to ADR-0036 under
+[ADR-0041](0041-correcting-false-statements-in-accepted-adrs.md) and
+[Documentation Standards](../standards/13-documentation.md#correcting-and-amending-adrs).
+It links the bounded replacements, source/history evidence and changed carriers.
+ADR-0053 owns the changed rules; the Amendment supplies navigation and disclosure,
+not a rewrite of the Accepted Decision or historical notes.
+
+Acceptance also reconciles these ongoing carriers explicitly:
+
+- [Standards 07 — Public Site Renderer](../standards/07-frontend-architecture.md#public-site-renderer):
+ replace public `revalidate` with dynamic/no-store rendering; its Tenant Resolution
+ and SDK sections name the single configured adapter outside the pure SDK.
+- [Frontend Architecture — Rendering Strategies](../architecture/14-frontend-architecture.md#rendering-strategies):
+ replace the institution `(public)` ISR-like sketch with the chosen dynamic path.
+- [Standards 15 — Caching](../standards/15-performance.md#caching): distinguish the
+ uncached public renderer from independent API definition caches.
+- [Standards 04 — Request and Response Limits](../standards/04-api-design.md#request-and-response-limits)
+ and [Standards 11 — Rate Limiting](../standards/11-security.md#rate-limiting): replace
+ the anonymous socket-only partition with the authenticated visitor policy and
+ separate peer ceiling; other surface policies retain their existing owners.
+
+## Context
+
+At development `88f52c4`, Next is pinned to 15.5.18. Its installed server preserves
+an existing `x-forwarded-for` value when adding socket metadata; v15 also removes
+`NextRequest.ip`. Middleware forwarding that value would launder attacker input
+into the trusted API hop. Native socket capture addresses the missing source,
+while a verified stamp prevents a bypassed stock launcher accepting plain headers.
+
+P02d-4 already supplies SDK tests, AppError parsing and no-store requests. It also
+forbids hop secrets and authority options in that package. ADR-0036's older exact
+setter path cannot silently override this later seam.
+
+The custom launcher adds maintenance and limits deployment options: Next documents
+lost optimizations and incompatibility with standalone output. Only the ingress
+boundary changes; Next still owns routing and rendering. Reconsider it when Phase
+11 supplies an equivalently proved trusted ingress that can preserve a stock Next
+server. Any replacement must prove peer origin, direct-bypass refusal and secret
+containment before changing the deployment shape.
+
+Local TLS avoids assuming all browsers resolve `.localhost` identically or accept
+Secure cookies on HTTP. Existing seed names and warm database mappings remain
+unchanged. Installing/trusting a development CA is an explicit workstation step,
+not an automatic side effect of repository commands.
+
+## Consequences
+
+### Positive
+
+- The API receives host lookup input and verified rate-limit metadata separately.
+- Forged forwarding headers cannot mint visitor partitions through the renderer.
+- Uncached SSR preserves host isolation and fresh API eligibility on every request.
+- One SDK seam and one configured header setter remain reviewable and enforceable.
+
+### Negative
+
+- The repository owns a native launcher, ingress integrity protocol and TLS setup.
+- Per-IP and socket ceilings can reject legitimate bursts; NAT fairness is unchanged.
+- Repeated bootstrap/content calls spend quota and latency; no ISR hides that cost.
+- Local hosts and CA trust require explicit developer setup; standalone is unavailable.
+
+### Neutral
+
+- No database migration, Hub endpoint, tenant resolver or new authentication surface.
+- Existing API no-hop startup remains usable; a configured renderer is stricter.
+- Distributed limits and production ingress are Phase 11 capabilities, not P5 claims.
+
+## Implementation Notes
+
+The [packet plan](../roadmap/phase-02d-walking-skeleton.md#p02d-5-implementation-plan)
+owns defaults, carrier updates, review rounds and scope by step. Acceptance updates
+ongoing standards and appends ADR-0036 navigation before code. Delivery notes record
+implementation separately; acceptance is not runtime evidence.
+
+## Architecture Tests
+
+These are accepted proof obligations, not passing-test or delivery claims. The
+catalogue records implementation status separately:
+
+- Extend existing hop/anonymous-peer controls for verified visitor metadata,
+ preserved socket origin, one-IP quota and pre-lookup flood bounds.
+- Prove server-only imports, the single fetch/header-setter adapter, no shared
+ public cache and secret/stamp exclusion with nonempty production subjects and
+ clean/planted controls. Real socket tests prove the ingress, not source scans.
+- Extend `No_Architecture_Test_Is_Skippable` to all tested frontend workspaces;
+ actual skipped/todo fixtures and a missing test script must fail the runner.
+- Preserve existing public scope, READ ONLY, SDK drift and contract controls.
+
+## Amendments
+
+### Amendment 1 — Native ingress foundation delivered (2026-10-08)
+
+P02d-5 Step 1 implements the mandatory native HTTPS launcher, socket-derived
+host/address envelope, strict verification and inbound private-header sanitation.
+The root private environment source, optional matching web projection and paired
+loopback API launcher are delivered. The native launcher provides readiness;
+stock Next with forged provenance is refused by the middleware foundation.
+
+The [Step 1 record](../roadmap/phase-02d-walking-skeleton.md#p02d-5-step-1--native-ingress-and-local-topology)
+owns validation and review evidence. Step 2 still owns visitor budgets; Step 3
+owns configured API transport/bootstrap/entry; Step 4 owns mechanical fences and
+complete production integration. TLS socket/production-launch controls are not a
+workstation-browser or P6 page-delivery claim. The original acceptance body remains
+unchanged.
+
+### Amendment 2 — Native ingress review closeout (2026-10-08)
+
+Step 1's two independent review rounds and fix verification are complete.
+Authenticated raw targets are matched against explicit pinned-Next query
+processing; repeated pathname slashes are refused before delegation. Native
+sanitation also removes Next's routing, resumption and revalidation controls.
+Next's documented `httpServer` option points at a non-listening upgrade sink:
+only admitted native TLS upgrades reach it, including development HMR.
+
+The [Step 1 record](../roadmap/phase-02d-walking-skeleton.md#p02d-5-step-1--native-ingress-and-local-topology)
+owns production/socket, mutation and cleanup evidence. Steps 2–4 retain their
+scopes; this note changes no Accepted decision or browser-readiness claim.
+
+### Amendment 3 — API visitor admission and budgets delivered (2026-10-08)
+
+P02d-5 Step 2 implements exactly-one bounded visitor metadata after the existing
+network-and-secret hop predicate, strict canonical IP keys shared with direct
+traffic, and chained 600/physical-peer plus 60/visitor-IP fixed-window budgets.
+The peer ceiling runs first; invalid trusted metadata spends fallback quota and
+receives masked `404` before host classification. Untrusted metadata is ignored.
+
+The [Step 2 record](../roadmap/phase-02d-walking-skeleton.md#p02d-5-step-2--api-visitor-admission-and-budgets)
+owns validation and review evidence. Empty-hop startup, API tenant authority,
+rotation-list support and existing public response contracts remain unchanged.
+Configured frontend transport and full renderer proofs still belong to Steps 3–4.
+
+### Amendment 4 — Configured caller and public entry delivered (2026-10-08)
+
+P02d-5 Step 3 implements the server-only configured SDK adapter, private origin
+and closed hop headers, validated/generated W3C trace context, total ten-second
+deadline and decoded 8 MiB consumer limit. Node middleware verifies provenance
+before live bootstrap, applies membership-first locale entry, preserves inert raw
+query bytes and rebuilds downstream request headers. The public layout is dynamic
+and no-store. Neither bootstrap nor SDK responses enter a shared renderer cache.
+
+The [Step 3 record](../roadmap/phase-02d-walking-skeleton.md#p02d-5-step-3--configured-caller-and-public-entry)
+owns validation and reviews. Step 4 still owns complete production-build integration
+and mechanical frontend fences; P6 owns public page composition. This delivery
+note changes no Accepted decision or browser-readiness claim.
+
+### Amendment 5 — Frontend fences and production integration delivered (2026-10-09)
+
+P02d-5 Step 4 implements source guards for private server imports, the single
+fetch/header adapter, dynamic/no-store policy and raw-authority refusal. The
+workspace runner discovers tested packages and refuses missing scripts/reports,
+empty outcomes, skipped/todo cases and omitted files; actual planted runs prove it.
+
+The disposable production fixture imports the real middleware/caller against the
+real Kestrel API and PostgreSQL as `learnstack_app`. Same-path HTML/RSC requests on
+two hosts stay separate; publication changes affect the next request. A real Client
+Component import fails the build, stock-Next forgeries cannot bootstrap, and private
+values stay out of responses, client assets and logs. Bootstrap/rendering share a
+request-local trace even when incoming trace context is absent or invalid.
+
+The [Step 4 record](../roadmap/phase-02d-walking-skeleton.md#p02d-5-step-4--frontend-fences-and-production-integration)
+owns validation and independent reviews. Test-owned routes are not P6 product
+pages or P7 browser/Lighthouse delivery. The original Accepted body is unchanged.
+
+### Amendment 6 — P02d-5 review and implementation closeout (2026-10-09)
+
+All four P02d-5 steps complete two fresh independent review rounds and verified
+fixes. The final source controls follow bounded static declaration and ESM value
+origins, preserving explicit export precedence and request-local React caching.
+Owned fixture cleanup proves process-group absence even when a leader exits first.
+
+The [packet closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-5-packet-closeout-2026-10-09)
+owns test counts, scope and handoff. P02d-5 is implementation-complete and unmerged;
+maintainer PR review remains. P02d-6 owns product page composition and its decision
+pass; P02d-7 owns the browser demo and Lighthouse. No Accepted decision, original
+metadata, prior amendment or browser-readiness claim changes in this note.
+
+### Amendment 7 — Bounded admission supersession (2026-10-09)
+
+The maintainer accepted [ADR-0054](0054-bounded-public-renderer-admission.md) and
+P02d-5's five-step remediation plan on 2026-10-09. ADR-0054 supersedes only this
+record's anonymous accounting order, native method/upgrade admission and
+redirect-query encoding promise:
+
+- [Anonymous accounting](0054-bounded-public-renderer-admission.md#anonymous-accounting)
+ replaces Amendment 3's peer-first debit, retaining the numeric windows, shared
+ visitor namespace and peer-gated new allocation. The peer budget bounds admitted
+ calls/allocation; known-visitor refusals do not debit it or gain a total-work cap.
+- [Native admission](0054-bounded-public-renderer-admission.md#native-method-and-upgrade-admission)
+ requires GET/HEAD before Next on every listener path and closes every production
+ upgrade; development retains only validated required HMR.
+- [URL identity and redirects](0054-bounded-public-renderer-admission.md#url-identity-and-redirects)
+ replaces Amendment 4's raw-query-byte promise with inert value/duplicate/order
+ preservation through equivalent serializer encoding, retaining signed raw route
+ identity and membership-first entry.
+
+The original Accepted body, metadata and Amendments 1–6 remain historical. All
+other contracts remain authoritative; no API tenant authority, public wire contract
+or local topology changes. The [remediation record](../roadmap/phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09)
+owns current status. Acceptance delivers no runtime correction or new test/CI
+result; each implementation step still owes validation and two independent reviews.
+
+## References
+
+- [ADR-0036](0036-tenant-resolution-trusted-inputs.md)
+- [ADR-0035](0035-demand-gated-infrastructure.md)
+- [ADR-0041](0041-correcting-false-statements-in-accepted-adrs.md)
+- [ADR-0009](0009-frontend-single-app-first.md)
+- [ADR-0052](0052-anonymous-public-read-boundary.md)
+- [Frontend Architecture](../architecture/14-frontend-architecture.md)
+- [Frontend Architecture Standards](../standards/07-frontend-architecture.md)
+- [Documentation Standards](../standards/13-documentation.md)
+- [Node HTTP socket](https://nodejs.org/api/http.html#messagesocket)
+- [Next.js 15 custom server](https://nextjs.org/docs/15/app/guides/custom-server)
+- [NextRequest version history](https://nextjs.org/docs/app/api-reference/functions/next-request)
+- [Next.js 15 route configuration](https://nextjs.org/docs/15/app/api-reference/file-conventions/route-segment-config)
+- [mkcert setup and trust stores](https://github.com/FiloSottile/mkcert)
diff --git a/docs/decisions/0054-bounded-public-renderer-admission.md b/docs/decisions/0054-bounded-public-renderer-admission.md
new file mode 100644
index 00000000..aecfcbac
--- /dev/null
+++ b/docs/decisions/0054-bounded-public-renderer-admission.md
@@ -0,0 +1,333 @@
+# ADR-0054: Bounded Public Renderer Admission
+
+## Status
+
+Accepted — 2026-10-09. The maintainer approved this ADR and the five-step
+P02d-5 remediation plan. Implementation and its review/validation remain pending;
+acceptance is not runtime delivery evidence. ADR-0053 remains Accepted outside
+the bounded supersession below.
+
+**Date:** 2026-10-09
+**Deciders:** @cemil
+**Bounded supersession:** ADR-0053, limited to accounting, native method/upgrade
+admission and redirect-query wording.
+
+## Decision Drivers
+
+- An exhausted visitor must not spend the remaining shared renderer work budget.
+- Unknown visitor identities must still be peer-gated before limiter allocation.
+- Unsupported methods/upgrades need controlled refusal before framework dispatch.
+- Redirect guarantees must describe the URL encoding the supported Next path emits.
+- Preserve local topology, API tenant authority and the existing public contracts.
+
+## Considered Options
+
+1. **Coordinated admission over owned visitor limiters** (chosen): inspect an
+ existing visitor without a debit; charge the peer only when visitor admission
+ remains possible. Gate new visitor allocation on successful peer admission.
+2. **Keep the peer-first chain** (rejected): a single IP can spend 600 peer permits
+ with 60 successful calls and 540 visitor refusals, starving other visitors.
+3. **Reverse the existing chain** (rejected): every unknown identity allocates a
+ visitor partition even after the physical peer is exhausted.
+4. **Add distributed quotas or change the numeric budgets** (rejected here): neither
+ is necessary to repair this accounting defect; Phase 11 owns production fairness.
+5. **Replace Next's redirect pipeline to retain every query byte** (rejected):
+ equivalent percent encoding is sufficient; another response path increases the
+ trusted surface without changing the query's meaning.
+
+## Decision
+
+LearnStack coordinates anonymous admission so an exhausted known visitor is refused
+before any peer debit, while unknown visitor allocation remains peer-gated. The
+public web listener accepts GET/HEAD and closes unsupported production upgrades.
+Redirects retain inert query values and ordering through the supported URL
+serializer, without promising byte-identical percent encoding.
+
+### Anonymous accounting
+
+Keep 60 calls/minute per canonical visitor IP and 600 calls/minute per physical peer,
+one-minute fixed windows, no queue and pre-host/pre-database enforcement. Direct and
+authenticated-hop traffic retain one visitor namespace. Invalid trusted visitor
+metadata retains the current peer-IP fallback and masked refusal behavior.
+
+The admission owner holds the actual visitor limiter instances, rather than a
+parallel cache of guessed membership. One process-local owner lock, shared across
+all visitor keys and physical peers, serializes positive acquisition, creation and
+retirement. No network/database work, `await` or limiter disposal runs under this
+lock; it introduces no rate-limit waiting queue. The acquisition order is:
+
+1. For an existing visitor, make a zero-permit availability probe. If it refuses,
+ return its refusal/Retry-After without acquiring a peer permit.
+2. Acquire one physical-peer permit. If it refuses, return that refusal without
+ creating an unknown visitor limiter or debiting a known visitor.
+3. For a new visitor, create its limiter only now. Acquire one visitor permit.
+ No competing positive visitor debit or retirement can occur between the probe
+ and this debit; replenishment can only increase availability.
+
+Use the selected refusal's Retry-After unchanged: the visitor probe's value when
+it refuses, otherwise the peer refusal's value. If both budgets are exhausted,
+the visitor refusal wins; do not combine their metadata or probe the peer for it.
+
+Do not debit a visitor merely to obtain failure metadata after a statistics check;
+replenishment can race that check. Do not call an opaque partition lookup to test
+membership if the lookup itself creates a partition. Framework retry of the same
+HTTP request reuses both successful and refused admission outcomes: it cannot
+debit again if a later endpoint policy refuses. Each returned lease has independent
+lifetime; cancellation, disposal and Retry-After remain explicit test obligations.
+
+Retire only limiters whose full-quota idle state has lasted at least one complete
+window. One non-overlapping periodic sweep visits the entire registry in bounded
+batches; it cannot repeatedly inspect only the first batch. Final DI-owned disposal
+marks admission closed, joins the sweeper and disposes every remaining instance
+exactly once. Retirement shares the acquisition lock; removal is complete before
+disposal outside that lock, and no request retains a removed limiter. A newly
+reconstructed limiter must never restore quota before the preceding window expires.
+
+This preserves the allocation-rate bound: a peer can create at most 600 new visitor
+limiters in one peer window. It is not a fixed global cardinality cap; retained
+state depends on active peers/windows and is reclaimed when idle. Do not introduce
+an unbounded second identity registry or claim distributed memory/DDoS protection.
+
+The peer budget allows at most 600 successful peer acquisitions and new visitor
+allocations per peer window. Only requests eligible for visitor admission spend
+peer permits; the budget does not bound all attempts or refusal-response work.
+An exhausted known visitor can generate arbitrarily many 429 responses without
+spending peer permits. Each still costs transport parsing, identity verification,
+lock/probe work and response generation. Its admission path performs no registry
+sweep, new limiter allocation or host/database lookup. Background sweep batches
+are bounded; dispose removed instances outside the lock. Neither the old nor the
+chosen limiter bounds incoming network traffic or the total cost of refusals.
+Phase 11 owns upstream transport/edge protection and contention/load measurement;
+this local correction supplies neither total-work protection nor measured throughput.
+
+Ten legitimate visitors can exhaust the peer budget; NAT users still share an IP
+quota. Budgets count API calls: middleware bootstrap consumes one call per admitted
+HTTP request, including prefetch; product-page calls consume additional permits.
+
+### Native method and upgrade admission
+
+Apply GET/HEAD admission to every HTTP path reaching the native listener callback,
+before Next dispatch and independently of middleware matcher exemptions. This
+includes health, assets and scaffolds: OPTIONS, POST and other methods receive
+the existing masked `404` with `Cache-Control: no-store`, and never reach bootstrap
+or Next's Fetch-backed method conversion. HEAD emits no body. Node's HTTP parser
+still owns malformed protocol input before the callback.
+
+The current app route census has one explicit method handler, GET health, and no
+POST handlers or Server Actions. Prove supported RSC/prefetch GET/HEAD and asset/
+health behavior against the real launcher. Development HMR is a separately
+validated GET upgrade, not a POST exemption. Future Server Actions/write routes
+need an explicit admission decision in their owning Phase 02b BFF/auth or Phase 06
+admin-studio work before a consumer ships.
+
+Disable `poweredByHeader` separately to suppress framework identification on
+framework-generated fallback responses as well as ordinary responses. This is
+an identification control; it grants no admission or tenant authority.
+
+Production has no WebSocket consumer: close every upgrade before delegation.
+Development retains only Next's required, validated HMR upgrade path; refuse other
+paths and close failed/unhandled delegation. Future Phase 02b BFF/auth write routes
+or other WebSocket features need explicit admission before their endpoints ship.
+Health/asset exemptions do not grant method, provenance or tenant authority.
+
+### URL identity and redirects
+
+Keep the signed raw request target as the authority for route/locale admission.
+The observed middleware URL is compared only with the explicit projection performed
+by pinned Next 15.5.18, including its full-URL RSC normalization and `_rsc` handling.
+Accounting for that projection does not introduce `.rsc`/segment-suffix route
+aliases or permit a normalized pathname to select a different raw lesson identity.
+
+For redirects, preserve parameter values, duplicates and ordering as inert query
+data; the supported serializer may percent-encode characters such as an apostrophe.
+The query never supplies authority or a redirect destination. Continue using the
+verified ingress host admitted by live bootstrap, accepted HTTPS port and locally
+computed path. Canonical locale redirect status and membership-first precedence
+remain unchanged.
+
+## Context
+
+Review of PR #26 at `07016405` reproduced the accounting defect using current source:
+600 requests from one trusted visitor admit 60, refuse 540, and refuse a fresh
+visitor afterward. ADR-0053 Amendment 3 explicitly records peer-first ordering;
+this is a decision replacement, not an ADR-0041 false-when-written erratum.
+
+An isolated comparison also showed that bare visitor-first ordering allocates 660
+visitor partitions for 660 distinct identities while admitting only 600 calls;
+peer-first allocates 600. Preserve that protection while removing refusal burn.
+
+The native ingress exists because framework headers do not authenticate the original
+socket peer. Its current wire format remains `v1..`: canonical
+unpadded base64url of UTF-8 JSON `[host, peer, method, target]`, authenticated by
+HMAC-SHA256 over `learnstack.public-ingress.v1\0` plus the encoded payload. The
+private paired hop secret is also this MAC key. A separate process key would need
+an additional trusted distribution path to every verifier; this decision adds none.
+
+The envelope has no nonce/expiry and proves origin/integrity, not freshness. The
+supported listener strips submitted stamps and mints its own; direct stock Next
+listeners are unsupported. Runtime diagnostics must never expose the envelope.
+Changing the private secret requires paired verifier/launcher configuration and
+restart; API rotation-list overlap remains available. Phase 11 owns production
+key distribution, replay assessment and ingress lifecycle.
+
+In text: the browser reaches native TLS ingress, then verified middleware/bootstrap
+and the server caller, then API admission/host resolution and the read-only RLS path.
+
+```mermaid
+flowchart LR
+ A[Browser] --> B[Native TLS listener]
+ B --> C[Next middleware: verify then bootstrap]
+ C --> D[Server page and configured caller]
+ D --> E[API: trusted hop and coordinated budgets]
+ E --> F[Host scope, read-only pipeline and RLS]
+```
+
+Middleware bootstrap also uses the configured caller/API path. No browser or web
+component announces tenant/organization scope. Existing traceparent continuation
+is preserved; Phase 11 owns participating/sampling policy. Request-local bootstrap
+reuse and P6 prefetch/page choices are not delivered by accepting this ADR.
+
+## Consequences
+
+### Positive
+
+- Exhausted-visitor floods no longer consume other visitors' remaining peer permits.
+- New-identity allocation still stops at peer exhaustion, before host/database work.
+- Unsupported methods/upgrades have a controlled native outcome.
+- URL matching and redirect promises can be proved against the shipped framework.
+
+### Negative
+
+- The limiter owns synchronization, idle retirement and outcome replay explicitly.
+- Known exhausted visitors' refusal traffic is outside the peer budget; total
+ refusal CPU/network work remains unbounded by this limiter.
+- The single owner lock serializes anonymous admission and bounded sweep batches
+ across peers; contention is a throughput cost to measure in Phase 11.
+- The admitted-call peer cap and fixed-window/NAT tradeoffs remain limitations.
+- Exact redirect-query bytes are no longer guaranteed where URL encoding differs.
+
+### Neutral
+
+- No schema, migration, OpenAPI/SDK wire shape, tenant authority or Hub change.
+- No distributed fairness, production ingress, auth or product-page scope moves.
+
+## Implementation Notes
+
+P02d-5's [remediation plan](../roadmap/phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09)
+owns implementation and review steps. Acceptance appends dated bounded
+supersession/navigation to ADR-0053 for accounting, web method/upgrade admission
+and redirect-query wording, preserving its original body and delivery history.
+[ADR-0036 Amendment 11](0036-tenant-resolution-trusted-inputs.md#2026-10-09--amendment-11-bounded-admission-and-amendment-navigation)
+identifies this decision replacement and its accounting scope, following the
+unchanged [Amendment 10](0036-tenant-resolution-trusted-inputs.md#2026-10-08--amendment-10-trusted-public-renderer-decision-navigation).
+Host normalization, trusted-input resolution and assertion-only tenant authority
+remain unchanged.
+Update ongoing Standards 04/07/11, Architecture 14/25, phase/route guidance and
+catalogue entries with their concrete enforcing tests. Existing
+[G34/G36 acceptance](../roadmap/phase-02d-walking-skeleton.md#p02d-5-accepted-answers)
+is historical; record this replacement explicitly rather than reopening it silently.
+
+In [TrustedVisitorHttpTests](../../backend/tests/LearnStack.Tests.Integration/Database/TrustedVisitorHttpTests.cs),
+replace `Visitor_refusals_charge_the_peer_once_and_preserve_its_remaining_allowance`:
+its current 330 requests spend 330 peer permits, including 270 visitor refusals.
+The replacement must prove those refusals spend no peer permits, leaving 540
+after the first 60 admitted calls, and that later framework retries charge neither
+outcome again. Retain the independent admitted-traffic ceiling proof in
+`Physical_peer_ceiling_bounds_rotating_trusted_visitors_before_lookup`.
+Adapt [AnonymousLimiterLifecycleTests](../../backend/tests/LearnStack.Tests.Unit/Api/Tenancy/AnonymousLimiterLifecycleTests.cs)
+to the new owned registry while retaining both DI teardown paths and exact-once
+child disposal. Update the catalogue's
+[`Anonymous_Requests_Are_Rate_Limited_Per_Peer`](../standards/21-architecture-tests-catalogue.md#anonymous_requests_are_rate_limited_per_peer)
+extension with actual replacement proofs. Prior delivery counts and CI runs remain
+historical; only new executions establish remediation evidence.
+
+## Architecture Tests
+
+Accepted proof obligations, not registered or passing-test claims:
+
+- Exhausted visitor then fresh visitor; no unknown-identity allocation after peer
+ exhaustion; direct/hop shared quota; parallel last-permit admission/creation.
+- Bounded work per known-visitor refusal: no host/database lookup, registry scan
+ or new limiter allocation; peer allowance unchanged after repeated refusals.
+ This proves local work bounds, not a bound on total refusal traffic or CPU.
+- Visitor-first Retry-After selection with distinct refusal values, including
+ both budgets exhausted; same-request successful/refused outcome replay through
+ actual framework/endpoint retry; cancellation and independent lease disposal.
+- Non-overlapping bounded sweep batches with eventual whole-registry coverage;
+ idle retirement/expiry/replenishment without restoring unexpired quota;
+ acquisition/retirement/shutdown races and exact-once disposal on both DI paths.
+- Rejected methods on exempt and ordinary paths before Next; bodyless HEAD;
+ production upgrade closure and retained development HMR with real sockets.
+- Valid query `.rsc` normalization without route aliasing; inert redirect query
+ encoding; framework identification suppressed on fallback responses.
+
+Retain real HTTP/app-role integration evidence alongside deterministic accounting
+tests. Register final rule names with their enforcing implementation; claim delivery
+only after these tests and both independent review rounds pass.
+
+## Amendments
+
+### Amendment 1 — Accounting implementation (2026-10-09)
+
+**Delivery note; independent reviews pending.** P02d-5 remediation Step 1
+implements the anonymous-accounting portion of this Accepted decision, including
+owned visitor limiters, request-result snapshots, bounded idle sweeping and joined
+DI teardown. Native method/upgrade and URL controls remain pending Step 2. The
+original acceptance status records the state when accepted; implementation does
+not rewrite it. Concrete execution and review evidence lives in the
+[Step 1 delivery record](../roadmap/phase-02d-walking-skeleton.md#remediation-step-1--coordinated-anonymous-admission)
+and ongoing [catalogue entry](../standards/21-architecture-tests-catalogue.md#anonymous_requests_are_rate_limited_per_peer).
+
+### Amendment 2 — Native and URL implementation (2026-10-09)
+
+**Delivery note; independent reviews pending.** Remediation Step 2 implements the
+native method/upgrade and URL portions of this decision. Current enforcement and
+concrete execution/review evidence live in [Frontend Standards](../standards/07-frontend-architecture.md#native-admission-and-url-identity)
+and the [Step 2 record](../roadmap/phase-02d-walking-skeleton.md#remediation-step-2--native-ingress-and-url-boundary).
+Step 1's accounting implementation has completed both independent review rounds.
+The accepted decision and prior delivery notes remain unchanged; fixture/tooling
+remediation and final PR closeout remain Steps 3–5.
+
+### Amendment 3 — Fixture proof implementation (2026-10-09)
+
+**Delivery note; independent reviews pending.** Remediation Step 3 implements
+owned fixture lifecycle and falsifiable containment controls. The decision and
+prior delivery notes remain unchanged. Ongoing rules live in [Testing Standards](../standards/06-testing.md#public-renderer-fixture-ownership),
+concrete enforcing tests in the [catalogue](../standards/21-architecture-tests-catalogue.md#p02d-5-public-server-rendering-controls),
+and execution/review evidence in the [Step 3 record](../roadmap/phase-02d-walking-skeleton.md#remediation-step-3--fixture-reliability-and-containment).
+Source/tooling remediation and final PR closeout remain Steps 4–5.
+
+### Amendment 4 — Source and tooling proof implementation (2026-10-09)
+
+**Delivery note; independent reviews pending.** Remediation Step 4 implements the
+bounded source-analysis, runner, lint and CI evidence corrections without changing
+this decision. Current rules live in [Frontend Coding Standards](../standards/03-frontend-coding.md#current-toolchain-and-lint-subjects)
+and [Frontend Architecture Standards](../standards/07-frontend-architecture.md#public-source-fence-scope).
+The [catalogue](../standards/21-architecture-tests-catalogue.md#p02d-5-public-server-rendering-controls)
+names enforcing controls; the [Step 4 record](../roadmap/phase-02d-walking-skeleton.md#remediation-step-4--source-runner-and-tooling-proof)
+owns execution/review evidence. Final corpus/CI/PR closeout remains Step 5.
+
+### Amendment 5 — Remediation delivery navigation (2026-10-09)
+
+**Delivery note.** All four runtime/proof remediation steps are delivered after
+both independent review rounds and verified corrections. The original acceptance
+and earlier delivery notes remain historical. Current implementation, validation,
+reviews and unmerged PR readiness live in the [five-step remediation record](../roadmap/phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09),
+including its [corpus/PR closeout](../roadmap/phase-02d-walking-skeleton.md#remediation-step-5--corpus-and-pr-closeout).
+This adds delivery navigation, not a new decision or a claim that PR #26 is merged.
+
+## References
+
+- [ADR-0053 — Trusted Public Server Rendering](0053-trusted-public-server-rendering.md)
+- [ADR-0052 — Anonymous Public Read Boundary](0052-anonymous-public-read-boundary.md)
+- [ADR-0036 — Tenant Resolution Trusted Inputs](0036-tenant-resolution-trusted-inputs.md)
+- [ADR-0035 — Demand-Gated Infrastructure](0035-demand-gated-infrastructure.md)
+- [Documentation Standards](../standards/13-documentation.md#correcting-and-amending-adrs)
+- [Frontend Architecture](../architecture/14-frontend-architecture.md)
+- [Deployment Models](../architecture/25-deployment-models.md)
+- [API Design Standards](../standards/04-api-design.md)
+- [Frontend Architecture Standards](../standards/07-frontend-architecture.md)
+- [Security Standards](../standards/11-security.md#rate-limiting)
+- [Architecture Tests Catalogue](../standards/21-architecture-tests-catalogue.md)
+- [Frontend Route Workflow](../../.claude/skills/add-frontend-route/SKILL.md)
+- [Phase 11](../roadmap/phase-11-production-hardening.md)
diff --git a/docs/decisions/README.md b/docs/decisions/README.md
index 0c27fbb1..4311ca16 100644
--- a/docs/decisions/README.md
+++ b/docs/decisions/README.md
@@ -66,7 +66,9 @@ an amendment is not a lifecycle status change.
| 0045 | [The Entitlement and Feature-Flag Socket](0045-entitlement-and-feature-flag-socket.md) | **Amendment 1 (2026-09-08)** reads it against the Hub's merged code: the limit vocabulary is the Hub's, `expires_at`/`valid_until` are nullable, the generation guard admits the equal case, and `platform_killswitches` ships **unwritten** because `DenyAllPlatformAdminGate` makes a writer unreachable until Phase 03. **Amendment 2 (2026-09-11):** a registry's membership is the vocabulary the contract names — fourteen Hub features, nine `limits.*`, two tenant flags, three killswitches — and enforcement, not membership, waits for a consumer. **Amendment 3 (2026-09-11):** a key's fail-open/fail-closed class decides only when no projection exists; one past its grace window is read-only, as ADR-0021 decides. **Amendment 4 (2026-09-11):** of the projection's names, `PlanCode` differs from the wire (`tier`) and `ExpiresAt` from the column (`valid_until`) — § 1 had called both wire differences. Declares the port twenty documents name and none define. `IEntitlementProvider.GetAsync(TenantId)` + `RefreshAsync(projection)`, the projection carrying every field `entitlement-v1.schema.json` requires — **`compliance` and `generation` included**, both already `NOT NULL` columns — and the refresh **generation-guarded inside the write statement**, so a stale push cannot resurrect a revoked plan. `IFeatureFlags` is the only module-facing read and **composes over the provider** rather than querying `platform_entitlement_cache`, which is the only reading under which the Phase 02a criterion — swapping the provider changes the answer — can be true. Limits: **`-1` unlimited, `0` denied**, `long` not `long?`; the inverse reading would have made the degraded read-only mode grant unlimited. `NullEntitlementProvider` is registered in **every** deployment mode, per ADR-0035's default-implementation gate rather than ADR-0020's Development-only switch. Killswitches leave `tenant_feature_flags` for a platform-scoped `platform_killswitches`: a foreign key to `tenants` is a constraint no role or `BYPASSRLS` moves, so the sentinel could never have satisfied it. Push-primary, **not** push-only — ADR-0034's `license/verify` fallback stands; the no-Hub absolute belongs to host resolution |
| 0050 | [Publication and Course Content Access](0050-publication-and-course-content-access.md) | Accepted 2026-10-02: publication/access separation, restricted backfill and anonymous denial; supersedes ADR-0048; P02d-2 policy, migration, writers and seed delivered ([Amendment 1](0050-publication-and-course-content-access.md#amendment-1--p02d-2-implementation-delivery-2026-10-02)); P02d-4 public-read enforcement delivered, grants Phase 07 |
| 0051 | [Ordered Text Card Presentation](0051-ordered-text-card-presentation.md) | Accepted 2026-10-02: optional root `x-fields`, localized ordered text cards; compatible extension linked by ADR-0043 Amendment 5; P02d-2 profile parsing, resolution and seed verification delivered ([Amendment 1](0051-ordered-text-card-presentation.md#amendment-1--p02d-2-implementation-delivery-2026-10-02)); P02d-4 public projection delivered, rendering P02d-6 |
-| 0052 | [Anonymous Public Read Boundary](0052-anonymous-public-read-boundary.md) | Accepted 2026-10-03: host-resolved bootstrap, immutable host ceiling, mandatory marked dispatch and read-only ambient frames; ADR-0036/0040 Amendment 8; P02d-4 Steps 1–3 deliver host provenance, physical read-only frames and all institution GET/HEAD reads; both Step 3 review rounds pass; Step 4 delivers OpenAPI/SDK/CI controls; both review rounds and focused fix verification pass; six real required CI jobs and live sixth-check activation verified; PR #25 awaits review/merge |
+| 0052 | [Anonymous Public Read Boundary](0052-anonymous-public-read-boundary.md) | Accepted 2026-10-03: host-resolved bootstrap, immutable host ceiling, mandatory marked dispatch and read-only ambient frames; ADR-0036/0040 Amendment 8; P02d-4 Steps 1–3 deliver host provenance, physical read-only frames and all institution GET/HEAD reads; both Step 3 review rounds pass; Step 4 delivers OpenAPI/SDK/CI controls; both review rounds and focused fix verification pass; six real required CI jobs and live sixth-check activation verified; P02d-4 complete and merged through PR #25 on 2026-10-08 ([Amendment 13](0052-anonymous-public-read-boundary.md#amendment-13--merge-closeout-2026-10-08)) |
+| 0053 | [Trusted Public Server Rendering](0053-trusted-public-server-rendering.md) | Accepted 2026-10-08: P02d-5 delivers native ingress, configured transport, dynamic entry and source/runtime proofs. [ADR-0054](0054-bounded-public-renderer-admission.md) replaces bounded accounting/native/query contracts. [The current packet record](../roadmap/phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09) owns delivery, reviews, CI and PR readiness; P6/P7 remain separate |
+| 0054 | [Bounded Public Renderer Admission](0054-bounded-public-renderer-admission.md) | Accepted 2026-10-09: coordinated anonymous accounting, native GET/HEAD/production-upgrade admission and equivalent redirect-query encoding are delivered. [The five-step remediation record](../roadmap/phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09) owns current review, validation and unmerged PR #26 closeout |
## Proposed ADRs
diff --git a/docs/glossary.md b/docs/glossary.md
index e3ca50d7..5f2fdce7 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -281,6 +281,7 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts:
| Term | Definition |
|------|------------|
+| **Ingress Provenance** | The private, domain-separated HMAC envelope over the native web listener's captured host, canonical socket peer, method and request target. It is not a tenant identity, API-hop header or browser-visible value. P02d-5 Step 1 implements capture/verification; Step 3 delivers downstream caller admission; Step 4 proves the real API/render boundary. See [ADR-0053](decisions/0053-trusted-public-server-rendering.md). |
| **BFF (Backend-for-Frontend)** | The Next.js server-side proxy layer in `app/api/` that holds session cookies, refreshes Keycloak tokens silently, and forwards calls to the .NET API with `Authorization` and tenant + organization headers. The browser never sees refresh tokens. |
| **Idempotency Key** | A client-supplied `Idempotency-Key` header on unsafe operations with external side effects (payments, notification sending, recording start/stop). It is a **nonce inside a tenant's key space, not an identity**: the stored record is addressed by `(tenant, key)` and carries a fingerprint of the organization, principal, method, path, query and body, so the same key presented for a different request is refused rather than replayed. Inbound webhooks do **not** use it — they deduplicate on `(provider, event_id)`. See [ADR-0037](decisions/0037-idempotency-key-contract.md) and [04-api-design.md § Idempotency](standards/04-api-design.md). |
| **Problem Details** | RFC 7807 JSON error envelope used by every LearnStack error response: `type`, `title` (the `lockey_*` key), `status`, `code`, `messageKey`, `instance`, `correlationId`, and `errors` for field-level failures. There is no `detail` — an earlier version of this entry listed one and omitted `messageKey`. The canonical example lives in [09-error-handling.md § API Surface](standards/09-error-handling.md); this entry names the fields rather than restating it. |
diff --git a/docs/modules/education/README.md b/docs/modules/education/README.md
index f910f2bd..07c05ef0 100644
--- a/docs/modules/education/README.md
+++ b/docs/modules/education/README.md
@@ -24,8 +24,10 @@ HTTP/database proofs; both independent review rounds passed. Step 4 adds
OpenAPI/SDK/CI controls; both review rounds and fresh fix verification pass. The
[delivery record](../../roadmap/phase-02d-walking-skeleton.md#p02d-4-step-4-contract-sdk-and-ci)
records verified live rollout and all six successful required jobs.
-[PR #25](https://github.com/HodeTech/LearnStack/pull/25) awaits maintainer review
-and merge. The diagram includes the access column.
+P02d-4 is complete and merged through
+[PR #25](https://github.com/HodeTech/LearnStack/pull/25) on 2026-10-08; its
+[merge closeout](../../roadmap/phase-02d-walking-skeleton.md#p02d-4-merge-and-closeout-2026-10-08)
+records final verification. The diagram includes the access column.
## Overview
diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md
index 535b3ea8..792f04a1 100644
--- a/docs/roadmap/README.md
+++ b/docs/roadmap/README.md
@@ -59,8 +59,14 @@ not deferred to the showcase phase.
[public-read decision package](phase-02d-walking-skeleton.md#p02d-4-decision-package-2026-10-03)
is Accepted — 2026-10-03; all four implementation steps and their review loops
are complete. All six real required CI jobs pass and live sixth-check activation
- is verified. [PR #25](https://github.com/HodeTech/LearnStack/pull/25) awaits
- maintainer review and merge; P02d-5 is next.
+ is verified. P02d-4 is complete and merged through
+ [PR #25](https://github.com/HodeTech/LearnStack/pull/25) on 2026-10-08; its
+ [merge closeout](phase-02d-walking-skeleton.md#p02d-4-merge-and-closeout-2026-10-08)
+ records final verification. P02d-5's decision package and ADR-0053 are Accepted
+ — 2026-10-08. All four steps and both fresh review rounds per step are complete.
+ The [remediation and closeout record](phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09)
+ owns current delivery, review and CI evidence; P02d-5 remains unmerged in PR #26.
+ P02d-6's public-renderer decision pass is next; the P7 browser demo remains later.
- [Phase 02b: Events, Background Jobs, Identity, and Session](phase-02b-events-auth.md)
- [Phase 03: Identity Domain, Authorization, and Admin Foundation](phase-03-identity-admin.md)
- [Phase 04: Headless CMS, Page Builder, and Media Library](phase-04-cms-media-pages.md)
diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md
index e07612b4..595c849e 100644
--- a/docs/roadmap/phase-02b-events-auth.md
+++ b/docs/roadmap/phase-02b-events-auth.md
@@ -1,5 +1,11 @@
# Phase 02b: Events, Background Jobs, Identity, and Session
+**P02d-5 handoff — 2026-10-08.** Accepted ADR-0053 supplies native ingress
+provenance and a server-only public caller, with no anonymous cookies or credential
+forwarding. G12/G14 must re-verify that boundary before adding BFF/session behavior;
+auth routes may not shadow enabled `/{locale}/courses...` paths. P5 implementation
+and P6 pages remain separately recorded; this note accepts no Phase 02b gate.
+
## Goal
Give the platform a signed-in user, a durable cross-module event path, and a place for
@@ -256,7 +262,7 @@ premise a row cites is re-verified at that pass rather than trusted.
| G11 | Which [ADR-0036 matrix](../decisions/0036-tenant-resolution-trusted-inputs.md#the-reconciliation-matrix) rows resolve in this phase, given that row 10 consults a reader that denies everyone — and what row covers a signature-valid token carrying no `tenant_id` claim on a tenant host? | Either mint `organization_id` scoped to the host's organization, which makes row 10 need no membership read, or declare the organization host's authenticated 404 and test it as the expected outcome | A dated **ADR-0036** amendment plus errata beside § Staging across packets and the 2026-09-02 erratum, both of which assign row 10 to this phase without noting that its `M covers (T, O)` term is denied until Phase 03 — which groups it with rows 7 and 14 rather than with 6 and 9 | P02b-5 |
| G12 | Which Keycloak client does the BFF exchange the code with, where does sign-in happen across two tenant hosts, **over what transport** — the seed hosts are `*.learnstack.local` and the frontend's dev script serves plain HTTP, which no browser will return a `Secure` cookie over — and how do the client scope, the mappers and the seed users reach a Keycloak database that has already consumed the import? | A confidential BFF client with one redirect URI per seed host; local TLS for those hosts with a trust step, or hosts under `localhost`, which browsers exempt; and an idempotent reconciler inside `make seed` — a mapper added only to the import reaches no existing workstation | Phase-doc + the realm JSON + `scripts/seed.sh` + `infra/keycloak/README.md`; a new ADR only if the reconciler becomes ADR-0004's `IIdentityProvider` | [Phase 02d](phase-02d-walking-skeleton.md#the-decision-register) G32 (the dev transport and hostnames, closed in the decision pass of the 02d packet that row blocks), P02b-5 (which client the BFF redeems the code with: the API pins `azp` against it), P02b-6 |
| G13 | What is the BFF session — its custody of the refresh token, its store, its idle and absolute lifetimes, its refresh serialisation under concurrent requests, its terminal `invalid_grant` behaviour, its CSRF control and its logout — and which document owns the access-token lifetime? | The `HttpOnly` cookie Security Standards already describes, encrypted by the session adapter, read only by server code, with single-flight refresh and one authority for the TTL | Phase-doc + one reconciled [Security Standards § Authentication](../standards/11-security.md#authentication); a **new ADR** only for a server-side session store | P02b-6 (which document owns the access-token lifetime: the realm export and its reconciler write it), P02b-7 |
-| G14 | What bounds the request types reachable over HTTP while `AuthorizationBehavior` is `return next()`, and what is the shape of the token-keyed rate-limit stage behind a shared BFF connection? | An enumerated read-only routable set held by a catalogue-registered rule, and a limiter stage after `UseAuthentication` keyed on the validated subject — which on its own changes nothing, because the shipped global limiter already partitions every request on the socket peer at the anonymous budget and runs before authentication, so two users behind one BFF share that quota and a request it rejects never reaches a token stage. The record says what the early stage does once a caller is validated, and keeps the pre-classification protection against unknown-host abuse | Phase-doc + catalogue registration; a dated ADR-0032 Amendment for a deny-by-default step instead | P02b-5 (the token-keyed stage and what the early stage does for a validated caller); how the pre-classification stage treats anonymous requests over the trusted hop is [Phase 02d](phase-02d-walking-skeleton.md#the-decision-register)'s G34, closed with the hop that phase wires |
+| G14 | What bounds the request types reachable over HTTP while `AuthorizationBehavior` is `return next()`, and what is the shape of the token-keyed rate-limit stage behind a shared BFF connection? | An enumerated read-only routable set held by a catalogue-registered rule, and a limiter stage after `UseAuthentication` keyed on the validated subject. The early anonymous stage already runs before authentication: P02d-5 delivers 60/min per canonical direct or authenticated-hop visitor IP plus 600/min peer acquisitions/allocations under ADR-0053 and ADR-0054. Exhausted known visitors refuse before peer debit; unknown visitor allocation remains peer-gated. These remain IP/peer quotas, not token quotas; a request they reject never reaches the token stage. Re-verify the [P5 remediation decision](phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09) before choosing authenticated accounting, preserving pre-classification protection against unknown-host abuse | Phase-doc + catalogue registration; a dated ADR-0032 Amendment for a deny-by-default step instead | P02b-5 (the token-keyed stage and the early stage's treatment of validated callers); G34's anonymous-hop policy and ADR-0054's bounded accounting replacement are Accepted and delivered in P02d-5 |
| G15 | What initiates a replay in this phase? | A dispatcher-level operation on the dispatcher's own connection, with the operator surface deferred | Phase-doc; the URL space is its own decision record, per ADR-0036 | — (answered in § The consumer side; recorded so the question travels with the deferral) |
| G16 | What is `LS0002`'s rule name, which project trees does it run over, what does it inspect, and what escalates it to Error? | The name and scope recorded the way Amendment 1 recorded `LS0001`, with the scope reaching the assemblies that actually handle tokens | A dated **ADR-0032** amendment + a catalogue entry + an `AnalyzerReleases` row | P02b-8 |
| G17 | What are the label sets for the event and job metrics, is the tenant axis permitted, and is it spelled `tenant` or `tenant_id`? | One note in the document that owns metric names, no raw tenant label on a per-event series, and an oldest-eligible-row age metric separate from the pending count | [Observability Standards § Required Metrics](../standards/10-observability.md#required-metrics) edit | P02b-2 (the first series ships there, and a label added afterwards touches every emitter already written) |
diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md
index f7f4c6ea..eb200223 100644
--- a/docs/roadmap/phase-02d-walking-skeleton.md
+++ b/docs/roadmap/phase-02d-walking-skeleton.md
@@ -1,6 +1,6 @@
# Phase 02d: Two-Tenant Walking Skeleton
-> **Status (2026-10-04).** Phase 02d **in progress**. The kickoff, `P02d-0`, ships this
+> **Status (2026-10-09).** Phase 02d **in progress**. The kickoff, `P02d-0`, ships this
> plan — the inherited baseline, the packet table, the decision register, criteria that
> name their evidence, and the corrections to the documents that contradicted the phase
> — and no code. Every later packet opens with its decision pass and updates its own
@@ -12,8 +12,8 @@
> | P02d-1 | Education schema and database-level isolation | ✅ complete and merged — 2026-09-14; [merge closeout](#merge-and-closeout-2026-09-14) |
> | P02d-2 | Writers and seed | ✅ complete and merged — 2026-10-02; [merge closeout](#p02d-2-merge-and-closeout-2026-10-02) |
> | P02d-3 | Read internals | ✅ complete and merged — 2026-10-03; [merge closeout](#p02d-3-merge-and-closeout-2026-10-03) |
-> | P02d-4 | Public read API and contract checks | ✅ implementation, review loops and live check rollout complete — 2026-10-04; [PR #25](https://github.com/HodeTech/LearnStack/pull/25) awaits maintainer review and merge; [delivery](#p02d-4-step-4-contract-sdk-and-ci) |
-> | P02d-5 | Server-rendering path | not started |
+> | P02d-4 | Public read API and contract checks | ✅ complete and merged — 2026-10-08; [merge closeout](#p02d-4-merge-and-closeout-2026-10-08) |
+> | P02d-5 | Server-rendering path | Original four steps delivered and reviewed, unmerged; external-review remediation in progress; [approval package](#p02d-5-external-review-remediation-2026-10-09) |
> | P02d-6 | Public renderer | not started |
> | P02d-7 | Demo, full-stack CI and exit | not started |
@@ -70,6 +70,34 @@ verified activation of the sixth required check.
[PR #25](https://github.com/HodeTech/LearnStack/pull/25) awaits maintainer review
and merge. P02d-5 is next; its decision pass has not started.
+**P02d-4 merge complete — 2026-10-08.** The preceding notes record pre-merge
+milestones. P02d-4 is now closed through
+[PR #25](https://github.com/HodeTech/LearnStack/pull/25); its
+[merge closeout](#p02d-4-merge-and-closeout-2026-10-08) records final verification.
+Phase 02d remains in progress. P02d-5 is next; its decision pass has not started.
+
+**P02d-5 preparation — 2026-10-08.** The
+[decision package](#p02d-5-decision-package-2026-10-08) and
+[ADR-0053](../decisions/0053-trusted-public-server-rendering.md) are Proposed after
+baseline review. The preceding merge note records the state at closeout. No P5
+gate is Accepted, no implementation is started and maintainer approval is pending.
+
+**P02d-5 acceptance — 2026-10-08.** The maintainer approved ADR-0053 and the
+[decision package](#p02d-5-decision-package-2026-10-08) after the external-review
+corrections. Its named gate parts close; implementation starts on development,
+with four steps and two fresh review rounds per step. P6/P7 gates remain open.
+
+**P02d-5 implementation complete — 2026-10-09, unmerged.** All four steps and
+their two independent review rounds are complete; confirmed fixes are verified and
+committed. The [packet closeout](#p02d-5-packet-closeout-2026-10-09) records the final
+local verification and scope. P02d-5 awaits maintainer PR review and merge. P02d-6's
+public-renderer decision pass is next; no P6/P7 gate is closed by this delivery.
+
+**External-review remediation — 2026-10-09.** ADR-0054 is Accepted and its runtime
+and proof corrections are delivered. The [current remediation record](#p02d-5-external-review-remediation-2026-10-09)
+owns the five steps, their reviews and final PR/CI readiness. Earlier delivery
+notes remain historical; PR #26 is not merged.
+
## Goal
Put a working education site in a browser — twice, on two hosts, for two tenants in
@@ -354,7 +382,7 @@ premise a row cites is re-verified at that pass rather than trusted.
| G3 | Which of `courses` and `lessons` carry a publication state, with which values and transitions, and what does "published" mean to an anonymous reader — publicly readable, or only listed? Which command sets it, which states does the seed write, and may a course with no lessons, or untranslated in an enabled locale, be published? And for any transition or deletion this phase does not ship (unpublishing a course or lesson, deleting either), which phase owns it? | `courses` `draft` / `published`, meaning publicly readable (Phase 05 adds catalog visibility as its own concept); lessons carry a state and show only when both are published; draft → published only; an empty course may be published, since publish validation is Phase 05's. One review leaned "listed in the catalog" | Contract: a new ADR, or a dated phase-doc statement recording why a two-value, one-transition column is not the state machine Decision Timing reserves for a decision record; no Accepted ADR decides publication ([ADR-0018](../decisions/0018-tenant-driven-customization-model.md) reserves the lifecycle to LearnStack). Detail: the `CHECK` ([Database Standards § Constraints](../standards/05-database.md#constraints)), the Education spec's state diagram, the publish row [Audit Coverage Standards](../standards/18-audit-coverage.md) makes MUST | P02d-1 (column presence and value set), P02d-2 (publishing commands and seeded states; transition contract closed in P02d-1) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): commands and seed states; independent lifecycle retained. Anonymous access superseded by [ADR-0050 / dated G3 record](#g3-supersession-2026-10-02); original P02d-1 answer retained as history |
| G4 | Where does a lesson body's binding to the content-type key and `schema_version` it was validated against live — on `lessons` or on each translation row — and where does the body live: its column, type, per-locale placement, and how non-translatable field values are carried? May a constraint cross into the Customization chain? What becomes of Localization Standards' `isLocalized` marker, which nothing implements? | A value pin `(content_type_key, schema_version)` on `lessons`, as Phase 04 plans for `ContentEntry`, with no foreign key; the field document per locale in `lesson_translations`, every locale validated against the one pin, duplicated non-translatable values accepted until Phase 05's lesson items retire them; the marker removed or given its introducing phase | Detail: this document's § Localization schema, the Education spec, [Localization Standards § Pattern A](../standards/08-localization.md#pattern-a--side-translation-table-default-for-content-shaped-entities) in the same diff. Contract: a dated ADR-0043 amendment if a localization keyword enters the schema profile; its own ADR or amendment if a cross-chain foreign key is chosen, as ADR-0044 § 9 did, with Phase 04 and [Database Standards § Migrations](../standards/05-database.md#migrations) in the same diff | P02d-1 (the first `lessons` and `lesson_translations` DDL; a pin added later needs a backfill that guesses between two Active content types) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): G4 |
| G5 | Before Phase 05's `Level` exists, how does a course or lesson carry the level band criterion 1 shows? Does the reference pin a taxonomy revision, how is a band validated on write, and what renders when the resolved revision no longer declares the stored band? | The reviews split: (a) a nullable, non-translatable `(taxonomy_key, band_key)` on `courses`, resolved against the live revision, because the criterion names the catalog; (b) a revision-pinned triple; (c) no column, the band shown through a lesson-page `x-taxonomy` field, with the criterion reworded. No shipped path validates a band value under any of them | Detail: a phase-doc statement, the Education spec, and a Phase 05 inherited row if a reference ships. Contract: a dated ADR-0010 amendment or a new ADR if an Education table takes a foreign key into Customization | P02d-1 (whether and where a column exists), P02d-2 (validation, seeded references), P02d-4 and P02d-6 (the unresolved-band state) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): column; [Accepted — 2026-10-02](#p02d-2-accepted-answers): validation and seeded references; [Accepted — 2026-10-03](#p02d-4-accepted-answers): unresolved public-band response. Renderer behavior remains P02d-6 |
-| G6 | Locale identity on the content path. (a) What spelling and column type do the satellites' `locale` columns store, and which rule replaces Localization Standards' "Lowercase", which the shipped `LocaleTag` does not follow? (b) Is the `locale` parameter canonicalized before lookup, the membership check and every cache or cursor key, or is a non-canonical spelling refused? (c) What does a non-canonical `/{locale}/` segment get? | (a) `LocaleTag`'s canonical case (`tr-TR`, `zh-Hans`) in `varchar(35)`, as `tenant_locales` stores it — [ADR-0018](../decisions/0018-tenant-driven-customization-model.md)'s 2026-09-04 amendment already makes case variants one locale; (b) well-formedness, then canonicalization, then lookup; (c) a redirect to the canonical segment, decided with G36 | Detail: [Localization Standards § Locale Codes](../standards/08-localization.md#locale-codes) and the Database Standards satellite fence in the same diff. No ADR: ADR-0008 states no casing rule | P02d-1 (a: the first stored rows), P02d-4 (b: validators, cursor binding), P02d-5 (c, with G36) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): (a); [Accepted — 2026-10-03](#p02d-4-accepted-answers): (b); URL-segment redirects (c) remain P02d-5 |
+| G6 | Locale identity on the content path. (a) What spelling and column type do the satellites' `locale` columns store, and which rule replaces Localization Standards' "Lowercase", which the shipped `LocaleTag` does not follow? (b) Is the `locale` parameter canonicalized before lookup, the membership check and every cache or cursor key, or is a non-canonical spelling refused? (c) What does a non-canonical `/{locale}/` segment get? | (a) `LocaleTag`'s canonical case (`tr-TR`, `zh-Hans`) in `varchar(35)`, as `tenant_locales` stores it — [ADR-0018](../decisions/0018-tenant-driven-customization-model.md)'s 2026-09-04 amendment already makes case variants one locale; (b) well-formedness, then canonicalization, then lookup; (c) a redirect to the canonical segment, decided with G36 | Detail: [Localization Standards § Locale Codes](../standards/08-localization.md#locale-codes) and the Database Standards satellite fence in the same diff. No ADR: ADR-0008 states no casing rule | P02d-1 (a: the first stored rows), P02d-4 (b: validators, cursor binding), P02d-5 (c, with G36) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): (a); [Accepted — 2026-10-03](#p02d-4-accepted-answers): (b); URL-segment redirects (c) remain P02d-5; [Accepted — 2026-10-08](#p02d-5-accepted-answers): (c) URL redirects; prior (a,b) answers retained |
| G7 | Organization write scope. (1) Does a lesson carry its course's organization scope? (2) What forces a satellite's — and a lesson's — mirrored `organization_id` to equal its parent's at insert: writer derivation alone, or that plus a database backstop, and which? (3) May an organization-scoped session `INSERT` a tenant-wide row through the `organization_id IS NULL` arm of `WITH CHECK`, which [ADR-0003](../decisions/0003-tenant-isolation-defense-in-depth.md)'s Amendment 5 and Database Standards say it cannot and which it can at `HEAD`? | (1) Identical scope for a course, its lessons and every translation. (2) Writers derive the child's organization from the authorised parent; the reviews split on the backstop — a stored generated scope column with an organization-inclusive composite key, which structural sweeps can see, or a `BEFORE INSERT` trigger reading the parent under the caller's policies — and one review requires database enforcement. A nullable three-column key is already excluded, because `MATCH SIMPLE` skips the check. (3) Tighten, after the pass confirms no audit writer composes a null-organization row under an announced organization | Contract: one dated ADR-0003 amendment for (2) and (3), with an ADR-0041 erratum beside any sentence the pass finds false when it entered the record; the template replaced in place in [Database Standards](../standards/05-database.md) with its disclosure; forward migrations for `tenant_settings` and `audit_log` if (3) tightens. Detail: [Database Standards § Translation satellite tables](../standards/05-database.md#translation-satellite-tables); a catalogue row with a planted offender if a database mechanism is chosen | P02d-1 (policy SQL, the generated column or trigger, aggregate factories), P02d-2 (child derivation in the commands) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): database controls and factory derivation; [Accepted — 2026-10-02](#p02d-2-accepted-answers): command derivation |
| G8 | Which structural guards does the Education chain register, so its tables cannot regress with the suite green: every foreign key between two tables carrying `tenant_id` includes it; every table carrying `organization_id` has the immutability trigger (and how `audit_log`'s append-only guard counts); the Pattern A rule, which would make [ADR-0008](../decisions/0008-localization-schema.md)'s "the migration linter rejects ad-hoc per-locale columns" true? And how does `fn_organization_id_immutable` — which reads `OLD.id` and is declared only in the Tenancy chain — serve satellites that have no `id`? | Three rows, each with a planted-offender companion; the function replaced by a Tenancy-chain migration that reports `OLD.organization_id` or reads the row key through `to_jsonb(OLD)`, which (as in the audit append-only guard's row comparison) never names a column the table may lack, with the cross-chain dependency recorded under Database Standards § Migrations | Detail: Standards 21 rows Registered and Implemented in the packet; the Database Standards immutability fence and § Migrations; `MigrationRollbackTests`. Contract, only if ADR-0008's sentence is left untrue: an ADR-0041 erratum if it was false when entered, otherwise a dated amendment | P02d-1 (a guard shipped with its first new subject is the only point its companion is written against real tables) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): G8 |
| G9 | Education schema detail: the content slug's character shape, normalization, width and database backstop — including whether a GUID-shaped slug is refused, which G26's shared-slot path needs; whether an Education table holds a foreign key into `tenants`, `organizations` or `tenant_locales`; and each runtime role's privileges on the four tables | `UrlSlug`'s shape with its own width constant and a `ck__slug_format` backstop, since restrictive now is the reversible choice (ASCII-only slugs exclude native-script URLs, a product choice); no foreign key into Tenancy; `learnstack_app` `SELECT, INSERT` plus exactly what G11's commands need, `learnstack_platform` `SELECT` | Detail: Localization Standards § Pattern A for the shape; the Database Standards satellite fence and [§ GRANT matrix](../standards/05-database.md#grant-matrix); § Migrations only if a cross-chain key is chosen | P02d-1 (the creating migration writes the `CHECK` and the grants; the grants couple with G11) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): G9 |
@@ -368,8 +396,8 @@ premise a row cites is re-verified at that pass rather than trusted.
| G17 | Does `TenantSetting.Value` carry `[PiiSensitive]`? [Phase 03](phase-03-identity-admin.md) sequences the decision before the first command writing `tenant_settings`, and this phase ships that command | Not marked, provided `tenancy.setting.write` admits only G16's closed key set, so the answer cannot stretch to keys a tenant invents; modelling a sensitive part as its own property stays open to Phase 03 | Contract: a dated phase-doc statement, reflected in `TenantSetting.cs`, the Tenancy spec and `audit.md`. Whole-value redaction of `jsonb` is settled by [ADR-0044](../decisions/0044-audit-write-path.md) Amendment 4 § 1 | P02d-2 (the first MUST-class settings audit row is written by the seed, and rows cannot be redacted retroactively); closes with G16 (a) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): generic whole-value PII redaction before the writer |
| G18 | How is a tenant content type presented? `json_schema` is `jsonb`, which keeps no key order, and the schema profile collects only `x-renderer`, `x-taxonomy` and `x-language`. How are field order, a label per enabled locale and a composite's field roles carried; which registered composite draws a lesson for each seeded type; which primitives does this phase implement, and does `markdown` render; how do types with no primitive row (`integer`, `number`, `boolean`, enums) map; may a rendered type declare a field outside the subset; and is a presentation entry naming a missing property refused at save? | A LearnStack extension — `x-order` and `x-label`, or one ordered `x-fields` list — carrying Pattern B labels, resolved at write like `x-taxonomy`; one composite already in both registries; the reviews split on the subset — `text`, `list` and `link`, with `markdown` without raw HTML, or a placeholder until Phase 05's sanitiser; the seed uses only the subset | Contract: a dated ADR-0043 amendment for a keyword or a save-time refusal; a dated ADR-0018 amendment for a presentation column; a phase-doc statement for `title` plus `required`, which cannot carry two locales. Detail: [Tenant Customization Model § 2](../architecture/32-tenant-customization-model.md) and § 8.1, the Customization spec, the profile's extension and reference-graph skip lists, `composites.ts` | P02d-2 (the seed publishes both content types as `schema_version` 1 with their renderer keys and field kinds; a later answer needs successor revisions) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): ADR-0051 profile and first-render subset. Component placement/fallback remains G41 |
| G19 | URL and markup policy for tenant-authored values on an anonymous page: which schemes (`https` only, or `http` too), credentials and `target`, which media origins, whether the rule is enforced on write — in the Education command, or as a validation gate Phase 04's entries share — whether the public API filters too, and whether URLs inside markdown fall under it. The write-time check constrains structure, not schemes: `format: uri` admits `javascript:` and `data:` | The reviews split on `http`; all refuse `javascript:`, dangerous `data:` and credentials; checked on write by a LearnStack rule and again on render; no third-party media in the seed | Detail: one home for the scheme list — [Security Standards § XSS & Output Encoding](../standards/11-security.md#xss--output-encoding) or [Frontend Architecture Standards § Security](../standards/07-frontend-architecture.md#security), not both; the Education spec's write rules; Tenant Customization Model § 8.1 if checked on write. Contract: a dated ADR-0043 amendment if it becomes a shared validation gate | P02d-2 (the lesson command's validation and the seed values; the render-time check reuses the answer) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): no active sink in the seeded text profile; future URL/markup contracts precede Phase 04/05 sinks |
-| G20 | What mechanically backs "no production code branches on which tenant it serves"? The shipped domain-term scan strips literals and exempts seed data. (a) The mechanism and its literal source; (b) its subjects, matching and the platform built-ins; (c) its exemptions, including development hosts in frontend or infrastructure configuration; (d) whether a ban on production references to `LearnStack.Tools.Seeder` and a behavioural same-code, different-data test accompany it | A Standards 21 sibling row scanning production backend and `frontend/` sources, comments stripped, for exact identity literals read from `SeedData` (slugs, ids, hosts, display names, customization keys), built-ins excluded, with planted offenders; plus the behavioural test. The exemption policy is the owner's judgement | Detail: a Standards 21 row Registered in the first pass that uses it and Implemented before exit; a phase-doc statement in § Genericity proof. No ADR | P02d-2 (a: every seed literal lives where the source reads it), P02d-5 (c: the first host outside `SeedData`), P02d-6 (b: frontend subjects), P02d-7 (Implemented and required) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): (a) SeedData literal source and Registered guard. Subjects, exemptions and behavioral proof remain open for P02d-5/6/7 |
-| G21 | Does the anonymous public path set any cookie — the [Frontend Architecture Standards § Tenant Resolution](../standards/07-frontend-architecture.md#tenant-resolution) flowchart sets them — and may a public page load any cross-origin subresource, such as the CDN-hosted logo and font assets Frontend Architecture describes? | No cookies, since the locale is already in the path and a locale-less request redirects ([Localization Standards § URL Strategy](../standards/08-localization.md#url-strategy)); same-origin subresources only; both asserted by a check. Whether tenant branding may point visitors' browsers at third-party hosts is a data-protection choice for the owner | Detail: a phase-doc statement; the Standards 07 flowchart and Frontend Architecture § Theming reconciled in the deciding pass | P02d-2 (subresources, if G16 admits a URL-valued token), P02d-5 (cookies: the middleware replacement is the first code that could set one) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): subresources. Cookies remain open for P02d-5 |
+| G20 | What mechanically backs "no production code branches on which tenant it serves"? The shipped domain-term scan strips literals and exempts seed data. (a) The mechanism and its literal source; (b) its subjects, matching and the platform built-ins; (c) its exemptions, including development hosts in frontend or infrastructure configuration; (d) whether a ban on production references to `LearnStack.Tools.Seeder` and a behavioural same-code, different-data test accompany it | A Standards 21 sibling row scanning production backend and `frontend/` sources, comments stripped, for exact identity literals read from `SeedData` (slugs, ids, hosts, display names, customization keys), built-ins excluded, with planted offenders; plus the behavioural test. The exemption policy is the owner's judgement | Detail: a Standards 21 row Registered in the first pass that uses it and Implemented before exit; a phase-doc statement in § Genericity proof. No ADR | P02d-2 (a: every seed literal lives where the source reads it), P02d-5 (c: the first host outside `SeedData`), P02d-6 (b: frontend subjects), P02d-7 (Implemented and required) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): (a) SeedData literal source and Registered guard. Subjects, exemptions and behavioral proof remain open for P02d-5/6/7; [Accepted — 2026-10-08](#p02d-5-accepted-answers): (c) exemptions; (b,d) stay P6/P7 |
+| G21 | Does the anonymous public path set any cookie — the [Frontend Architecture Standards § Tenant Resolution](../standards/07-frontend-architecture.md#tenant-resolution) flowchart sets them — and may a public page load any cross-origin subresource, such as the CDN-hosted logo and font assets Frontend Architecture describes? | No cookies, since the locale is already in the path and a locale-less request redirects ([Localization Standards § URL Strategy](../standards/08-localization.md#url-strategy)); same-origin subresources only; both asserted by a check. Whether tenant branding may point visitors' browsers at third-party hosts is a data-protection choice for the owner | Detail: a phase-doc statement; the Standards 07 flowchart and Frontend Architecture § Theming reconciled in the deciding pass | P02d-2 (subresources, if G16 admits a URL-valued token), P02d-5 (cookies: the middleware replacement is the first code that could set one) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): subresources. Cookies remain open for P02d-5; [Accepted — 2026-10-08](#p02d-5-accepted-answers): cookies; prior subresource answer retained |
| G22 | How does the customization definition projection load and stay correct? In the request's ambient transaction, or as a ninth out-of-band tenant-context setter (ADR-0040's set is closed at eight)? In what order are the generation and the rows read; what does an absent generation row mean; how is a cache filled inside a transaction that bumped and rolled back kept unreachable, when the bump is an upsert increment that can reissue a number; what does an absent definition set return; which families are registered, and how does the adapter's exact-tuple `cache.name` mapping match generation-embedded names; what do the TTLs bound; and is the contract batched so a public read issues a bounded number of statements? | Load in the ambient transaction; read the generation first, then the rows; fill only from non-bumping transactions; treat cache faults as misses; restate the module's cache-hit budget; a batched contract, with statement-count assertions cold and warm | Contract: the Customization spec § Primary read flow and a [Tenant Customization Model § 8.2](../architecture/32-tenant-customization-model.md#82-cache-strategy) statement on how a request learns the generation; a dated ADR-0040 amendment and a setters row only if the loader is out-of-band. Detail: the [Infrastructure Stack Standards](../standards/20-infrastructure-stack.md) cache table, the `cache.name` mapping, the Observability Standards metrics family list | P02d-3 | [Accepted — 2026-10-02](#p02d-3-decision-package-2026-10-02) |
| G23 | The typed settings accessor and its freshness. With no `learnstack.tenancy.settings` event until Phase 02b and the seed writing from its own process, what bounds staleness: a TTL with a stated bound, a writer-coupled Tenancy settings generation counter, or no settings cache here? What are the accessor's name and glossary headword; how is a cached read keyed so tenant-wide and organization rows never cross organizations — a settings read depends on `app.organization_id` today, and the policy's tenant-scope read gains a carrier in Phase 03; and does its loader run in the ambient transaction? | The reviews split on freshness — a TTL bound until 02b, a counter, or no cache. For keys: tenant-wide rows loaded with an explicit `organization_id IS NULL` predicate under `CacheKey.ForTenant`, each organization's overrides under `CacheKey.ForOrganization`, merged in memory; an ambient loader. The documented tenant-only key is rejected, because it would serve one organization's overrides to another | Detail: if settings are cached, the Infrastructure Stack Standards cheat-sheet rows and `cache.name` mapping; the Tenancy spec's event row and budget; a glossary headword. Contract only for a counter (the Tenancy spec, Database Standards § Table classes and § GRANT matrix) or an out-of-band loader (an ADR-0040 amendment) | P02d-2 (a counter is bumped inside the setting command's transaction), P02d-3 (name, keys, loader) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): no settings cache in P02d-2/3. [Accepted — 2026-10-02](#p02d-3-decision-package-2026-10-02): typed ambient accessor/scoped merge |
| G24 | Display fallback. Which document owns the chain — [Localization § Fallback Rules](../architecture/12-localization.md#fallback-rules) or [Localization Standards § Locale Model](../standards/08-localization.md#locale-model), which state different chains, while the shipped `LocalizedText.Resolve` narrows one subtag at a time and ends at the first authored value? What is the terminal state of a nullable Pattern A field and of a Pattern B label? Does a response say which locale a fallback value resolved in, so the page can mark its language (WCAG 3.1.2)? | Localization architecture owns the chain and Localization Standards links it, both recording the shipped narrowing and the first-authored terminal for labels; a nullable Pattern A field renders absent; each fallback-capable field reports its resolved locale | Detail: Localization Standards § Locale Model linking its owner, reconciled with `LocalizedText` in the same diff; the Customization contract's signature; the response schema under G26. No ADR | P02d-3 (the first caller that passes a fallback chain), P02d-4 (response fields) | [Accepted — 2026-10-02](#p02d-3-decision-package-2026-10-02): internal fallback; [Accepted — 2026-10-03](#p02d-4-accepted-answers): public response locale fields; renderer language attributes remain P02d-6 |
@@ -378,21 +406,21 @@ premise a row cites is re-verified at that pass rather than trusted.
| G27 | The cache posture of public reads. What directive do anonymous responses carry — the `200`s, the Problem Details `400`s and `404`s, the tenancy edge's unmapped-host `404` — what freshness do a newly published or unpublished course and a not-found have, and do anonymous reads emit an `ETag` and honour `If-None-Match`? [API Standards § Optimistic Concurrency](../standards/04-api-design.md#optimistic-concurrency) says mutable resources expose an `ETag`, and [ADR-0039](../decisions/0039-optimistic-concurrency-token.md) fixes one derivation, which a composite read cannot use without publishing `row_version` | An explicit `Cache-Control: no-store`, asserted by a test, and no `ETag` on anonymous reads — a response without explicit freshness may be cached heuristically by a shared cache. One review proposed no directive, stated | Contract: a phase-doc statement. Detail: API Standards — the directive, and a § Optimistic Concurrency sentence on anonymous read contracts, owed under either answer. A dated ADR-0039 amendment if a body-hash validator ships; [Performance Standards § Caching](../standards/15-performance.md#caching) if the answer caches | P02d-4 (the header-setting code and the headers the snapshot documents) | [Accepted — 2026-10-03](#p02d-4-accepted-answers): no-store without response validators |
| G28 | Public-surface controls. (a) What audit class do `[PublicSurface]` requests register, and does a rule make `Off` the only permitted one? (b) `GET` only, or `GET` and `HEAD`, and what does the catalogue's permitted-methods leg compare a row against? (c) What mechanically stops a marked request from writing — a `READ ONLY` unit of work, a structural scan, or both? (d) What control beyond review keeps a controller dispatching only through `ISender` — a controller taking a module `DbContext` fails loudly, SQL on `IUnitOfWork.Connection` reads zero rows, and code that announces the tenant itself reads real rows? | (a) `Off` for every marked type — a SHOULD or MAY class would make every anonymous `GET` a best-effort write a caller controls — with a sibling rule and companion; (b) one review `GET` only, one the standard's `GET` / `HEAD`; (c) a `READ ONLY` transaction for marked requests, which three shipped setters already open before announcing, and which refuses any in-transaction MUST write, so it is checked against (a); (d) a type-reference rule over controller bodies with a planted offender | Detail: the API Standards § Public surface rows; Standards 21 rows and companions, including the two legs of `PublicSurface_Marker_Set_Is_Enumerated` not yet implemented; an [Error Handling Standards § Controller Mapping](../standards/09-error-handling.md#controller-mapping--resultt--iactionresult) sentence for (d). Contract: a dated ADR-0040 amendment if the unit of work gains a read-only mode | P02d-4 (the first marked query's registration, method attributes and handler; if P02d-3 writes on the read path, (c) closes there) | [Accepted — 2026-10-03](#p02d-4-accepted-answers): Off, GET/HEAD, mandatory dispatch and read-only controls |
| G29 | Which rows do the anonymous reads serve, and what does every hidden row answer? The rule covers course state, lesson state (G3), the lesson's membership in the course its URL names — lesson slugs are unique per tenant, so a lesson resolves without its course segment unless the read checks — and soft deletion. Does every hidden cause (draft, deleted, wrong course, untranslated, other tenant, sibling organization, nonexistent) answer one `not_found` body with no per-cause detail, compared with `instance` and `correlationId` masked? | One eligibility rule used by every read; a lesson resolves only under its eligible parent, only when it belongs to it, only in the requested locale; lists show only eligible entries; deleted rows excluded now; one masked-equal body | Contract: a phase-doc statement whose single record is the Education spec. Detail: the failure constant. Settled and linked: a cross-tenant row is a `404` ([Security Standards § Error Messages](../standards/11-security.md#error-messages)), and an organization-scoped row is served only on its own organization's host ([Localization § Slugs and URLs](../architecture/12-localization.md#slugs-and-urls)) | P02d-4 (handlers, the failure constant, documented `404`s); the fixture rows close with G14 in P02d-2 if they live in the seed | [Accepted — 2026-10-03](#p02d-4-accepted-answers): explicit eligible reads and uniform masking |
-| G30 | The locale error matrix and transport. On each read, what answers an empty, repeated, malformed, over-length, non-canonical, not-enabled or enabled-but-untranslated locale? Does a not-enabled locale answer the Active `unsupported_locale` `400` or the not-found body? Is `X-Locale`, which [Frontend Architecture Standards § Locale Resolution](../standards/07-frontend-architecture.md#locale-resolution) still names as the API carrier, withdrawn, so that locale reaches the API only as the query parameter? | Missing or malformed → `400` `validation_failed` naming `locale`; untranslated → an empty catalog page; the query parameter only. The reviews split on not-enabled — not-found, amending the Error Handling row, or the Active `400`; a uniform answer after the enabled check hides pre-launch rows under either | Detail: a phase-doc statement; the [Error Handling Standards](../standards/09-error-handling.md) table only if not-found; Standards 07 § Locale Resolution; the Frontend Architecture SDK sketch; the API Standards § Pagination example gains `locale` | P02d-4 (validators, OpenAPI parameters and responses), P02d-5 (the server SDK's header set) | [Accepted — 2026-10-03](#p02d-4-accepted-answers): query/error matrix; trusted server header transport remains P02d-5 |
+| G30 | The locale error matrix and transport. On each read, what answers an empty, repeated, malformed, over-length, non-canonical, not-enabled or enabled-but-untranslated locale? Does a not-enabled locale answer the Active `unsupported_locale` `400` or the not-found body? Is `X-Locale`, which [Frontend Architecture Standards § Locale Resolution](../standards/07-frontend-architecture.md#locale-resolution) still names as the API carrier, withdrawn, so that locale reaches the API only as the query parameter? | Missing or malformed → `400` `validation_failed` naming `locale`; untranslated → an empty catalog page; the query parameter only. The reviews split on not-enabled — not-found, amending the Error Handling row, or the Active `400`; a uniform answer after the enabled check hides pre-launch rows under either | Detail: a phase-doc statement; the [Error Handling Standards](../standards/09-error-handling.md) table only if not-found; Standards 07 § Locale Resolution; the Frontend Architecture SDK sketch; the API Standards § Pagination example gains `locale` | P02d-4 (validators, OpenAPI parameters and responses), P02d-5 (the server SDK's header set) | [Accepted — 2026-10-03](#p02d-4-accepted-answers): query/error matrix; trusted server header transport remains P02d-5; [Accepted — 2026-10-08](#p02d-5-accepted-answers): header transport; prior API query-locale answer retained |
| G31 | Contract checks and the SDK surface. The committed OpenAPI snapshot's path, and how the contract suite proves it equals the served document; how the base copy is read; how the first run behaves; which `oasdiff` version and fail level, and which ADR-0024 rows that level detects (a tightened validator or a changed status may be invisible to any diff); what is uploaded on failure. The drift gate's source — the committed snapshot through `LEARNSTACK_OPENAPI`, or a running API — and its job. Whether an activated deferred job keeps its `if: vars.ENABLE_*` condition, given that GitHub treats a skipped required job as passing, or loses it as the integration job's did. And what the SDK surface becomes when regeneration makes `paths` non-empty: a hand-written transport over `paths` or a typed client library, the fate of `createClientSdk`, and whether the package root keeps re-exporting the server entry | A committed snapshot the contract suite asserts equal, diffed against the base ref's copy with `oasdiff` pinned; drift generated from the snapshot in the required `frontend` job; the condition removed on activation; a thin hand-written transport, and no root re-export of the server entry | Detail: [Testing Standards § API Contract Tests](../standards/06-testing.md#api-contract-tests), the API Standards § OpenAPI links, `ci.yml`, [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main) (its activation procedure follows the answer), [Frontend Architecture Standards § SDK](../standards/07-frontend-architecture.md#sdk); a package pin with its licence verdict if a client library is chosen | P02d-4 (the first operation, its snapshot and assertion, the drift gate, and the regenerated types the factories must compile against) | [Accepted — 2026-10-03](#p02d-4-accepted-answers): snapshot, SDK, diff/drift and approved required-check activation |
-| G32 | **The development-transport part of [Phase 02b](phase-02b-events-auth.md#the-decision-register)'s G12.** Which development hostnames and transport serve the two seed tenants — keep `*.learnstack.local`, with a hosts-file step and local TLS with a trust step, or move the seed hosts under `*.localhost`? What does a reviewer do between a clean checkout and both sites, and which carriers move, including host rows already on warm databases, where the host is the primary key and the seeder removes no mapping? | `*.localhost` over HTTP, provided the pass verifies in each browser the team uses and in CI's Chrome that both hosts resolve with no hosts entry and that a `Secure` cookie set on them is stored and returned; otherwise `*.learnstack.local` with local TLS and a named trust step. Tenants are never told apart by port: the effective host strips it | Detail: a dated phase-doc statement in [§ Host-based tenant resolution, end to end](#host-based-tenant-resolution-end-to-end), with `SeedData`, `scripts/seed.sh`, the README Quickstart, the `seed-tenant` and `local-dev-setup` skills and `apps/web`'s dev script and Next configuration in the same packet; Infrastructure Standards only if a TLS proxy publishes a port. No ADR | P02d-5 (the development transport, and the hop configuration a TLS proxy would change), or the first earlier packet that writes a seed-host literal outside `SeedData` | Open |
-| G33 | The server-rendering topology and its evidence. Where do Next.js and the API run relative to each other — the workstation loopback, containers, gated APISIX; which networks are trusted; how does one hop secret reach both processes; what is the server-only API origin — and the same for the CI job that renders the pages? What evidence discharges ADR-0036's "Phase 02d's browser test" and its matrix rows "a direct socket bypassing the hop" and "the Phase 02d anonymous two-host browser render", given that Testing Standards gate this phase on a human? And ADR-0036 § Consequences says the root refuses to start outside Development when the secret list is empty or short, while the shipped rule, in every mode, refuses a half-configured hop, a network entry that is not CIDR and a blank secret or one under 32 characters (characters, not bytes), and admits both lists empty: is that recorded or restored, and is non-development hop configuration this phase's or Phase 11's? | Both processes on the loopback, networks `127.0.0.1/32` and `::1/128`, one generated secret from a single source and never under `NEXT_PUBLIC_`, with networks and secrets arriving together (committing networks without secrets breaks every Development-environment fixture); the API called directly; a per-run secret in CI. Evidence: request-level hop tests as `learnstack_app` — no `X-Tenant-Id`, a non-hop peer, a wrong secret, a repeated header — beside the human walkthrough, with one review adding an automated browser smoke. Record the shipped startup rule; non-development hop configuration is Phase 11's | Contract: one dated [ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md) amendment if the evidence departs from the ADR's words, recording the startup rule, and carrying G34 if G34 changes the key or budget. Detail: `.env.example`, `apps/web/.env.local.example`, `appsettings.Development.json` or the demo recipe; a Standards 21 row for the hop runtime test; a Testing Standards § End-to-End Tests sentence only if ownership moves; a Phase 11 scope row | P02d-5 (the hop configuration and the fixture shape — in-process test hosts have no socket peer), P02d-7 (the CI part) | Open |
-| G34 | How does the pre-classification anonymous limiter treat a request arriving over the authenticated trusted hop? Every server-rendered call reaches the API from the renderer's peer, so every visitor of both tenants shares one partition, and one client sending random `Host` values through the renderer can starve both sites. The partition key, the budget and how the renderer derives any visitor identity it states — while unknown-host floods stay bounded before database work, and a direct peer is still limited per peer | The reviews differ: a visitor address stated over the hop, in a dedicated single-valued header or through `X-Forwarded-For` with the peer captured first, as ADR-0036 anticipates; a separate hop budget with limiting in the renderer; or an explicitly sized shared quota. A per-host ceiling as the only backstop multiplies under a random-`Host` flood | Contract: a dated ADR-0036 amendment if the hop changes the key or the budget, stating the new input's trust rule and whether it may be logged or audited; otherwise a phase-doc statement. Detail: API Standards § Request and Response Limits, [Security Standards § Rate Limiting](../standards/11-security.md#rate-limiting), the catalogue's per-peer rule. `P02b-0` re-verifies Phase 02b's G14 against the answer | P02d-5 | Open |
-| G35 | The server SDK transport. Its options — visitor host, locale, an optional assertion, never a tenant id as selector; where the host comes from; how the API origin is configured; the server-only guard, timeouts and cancellation; and whether it forwards W3C `traceparent`. With it: which parts of [Observability Standards § Frontend Observability](../standards/10-observability.md#frontend-observability) — Next.js error capture, web vitals — ship here, and which phase owns the rest, since no phase names them | Host and locale plus an optional assertion; hop headers and origin from server configuration, never from request input; a `server-only` guard; Problem Details mapped to the `AppError` union [Error Handling Standards](../standards/09-error-handling.md) already fixes; `traceparent` forwarded; Next.js error capture and web vitals assigned to Phase 11, whose § Observability lists error tracking | Detail: Frontend Architecture Standards § SDK and the Frontend Architecture SDK sketch; a pin and licence verdict if a guard package is added; an exclusion row naming frontend observability's owner, with an Observability Standards sentence | P02d-5 (the transport's headers and configuration read); the ownership half closes by exit, because a deferral names its phase | Open |
-| G36 | The edge middleware and entry behaviour. Does the middleware resolve anything (with G25)? What does it carry inward, and under which header name — the Frontend Architecture sketch reuses `x-learnstack-host`, the hop header's own name? Which inbound internal headers are removed or overwritten, including `x-organization-id` when resolution has none — a deny-strip or an allowlist rebuild? What does the matcher exclude? What do `/` and a locale-less path answer, with which status, target and default-locale source; what do a disabled, malformed or non-canonical locale segment (G6 c), a platform host and an unknown host answer; does this run in middleware or the route tree, and may an i18n library own the middleware? Does this phase build the locale-less redirect [Localization Standards § URL Strategy](../standards/08-localization.md#url-strategy) requires, or keep the standards index's i18n-runtime carve-out while Phase 06 claims redirect handling? | Normalise the host; strip every client-supplied `x-tenant-id`, `x-organization-id`, `x-locale` and `x-learnstack-*`; drop the scaffold's `503` guard and TODOs; `/` redirects to the tenant's default locale; a disabled locale is a `404` before any content call; a platform or unknown host gets a `404` with no platform text; the redirect built minimally here, with Phase 06's rows reworded to "deepens" | Detail: a phase-doc statement; the Standards 07 § Tenant Resolution flowchart and the Frontend Architecture middleware sketch; Phase 06's rows in the same diff; if less is built, a dated narrowing of the standards index row for Localization Standards naming the owning phase | P02d-5 (the middleware replacement rewrites the scaffold's locale fallback, so every placement answer changes it first) | Open |
-| G37 | How do tenant-varying `(public)` routes render, and which Next.js caches may hold tenant data — the full-route cache, the fetch data cache, `unstable_cache`, `generateStaticParams` — so one host's page is never served on the other? A public URL carries no tenant, and both tenants send the same request line over the hop, so a path-keyed cache leaks. What freshness does a page have after a customization or content write? | All three reviews: dynamic rendering with uncached SDK fetches — no `revalidate`, no `generateStaticParams`, no `unstable_cache` — relying on the API's generation-keyed cache; no ISR here | Detail: [Frontend Architecture Standards § Public Site Renderer](../standards/07-frontend-architecture.md#public-site-renderer), which prescribes `revalidate` today, and [Frontend Architecture § Rendering Strategies](../architecture/14-frontend-architecture.md#rendering-strategies), rewritten with the `add-frontend-route` skill; [Performance Standards § Caching](../standards/15-performance.md#caching) if the answer caches; an ADR if the pass judges the Standards 07 change non-trivial | P02d-5 (one mechanism: a host-bearing rewrite target is middleware code and fetch cache options are transport code, both written before the renderer) | Open |
-| G38 | The frontend test set. (a) Which predicates of the middleware and the server SDK does Vitest cover, and in which packages — `pnpm -r test` runs only packages with a test script, which `packages/sdk` lacks? (b) How are async Server Component pages and field components covered below the browser, given vendor guidance that Vitest does not render async Server Components? (c) What automated evidence, if any, re-proves the page-level two-host claim after exit — an HTTP smoke against `next start`, one narrow Playwright smoke pulled forward from Phase 06, or a dated manual record? (d) By what mechanism does the `frontend` job refuse skipped and todo cases, which it does not today, since `No_Architecture_Test_Is_Skippable`'s runner leg reads only the backend `.trx` files? | (a) host normalization and header stripping, locale parsing and entry answers, hop headers with no tenant selector, `not_found` mapping — each with an inversion companion; (b) async pages covered through their synchronous children; (c) the reviews split three ways; (d) a reporter-output check or a lint ban on disabled tests, failing also on a package with tests and no script, proven with a planted skip | Detail: a phase-doc statement; the test files; the Standards 21 entry `No_Architecture_Test_Is_Skippable` extended rather than a second name; `ci.yml`. Contract for (c) only if a browser smoke moves: a Testing Standards § End-to-End Tests edit citing this row, with its carriers | P02d-5 (a; d at the latest), P02d-6 (b, c), P02d-7 (c's job) | Open |
+| G32 | **The development-transport part of [Phase 02b](phase-02b-events-auth.md#the-decision-register)'s G12.** Which development hostnames and transport serve the two seed tenants — keep `*.learnstack.local`, with a hosts-file step and local TLS with a trust step, or move the seed hosts under `*.localhost`? What does a reviewer do between a clean checkout and both sites, and which carriers move, including host rows already on warm databases, where the host is the primary key and the seeder removes no mapping? | `*.localhost` over HTTP, provided the pass verifies in each browser the team uses and in CI's Chrome that both hosts resolve with no hosts entry and that a `Secure` cookie set on them is stored and returned; otherwise `*.learnstack.local` with local TLS and a named trust step. Tenants are never told apart by port: the effective host strips it | Detail: a dated phase-doc statement in [§ Host-based tenant resolution, end to end](#host-based-tenant-resolution-end-to-end), with `SeedData`, `scripts/seed.sh`, the README Quickstart, the `seed-tenant` and `local-dev-setup` skills and `apps/web`'s dev script and Next configuration in the same packet; Infrastructure Standards only if a TLS proxy publishes a port. No ADR | P02d-5 (the development transport, and the hop configuration a TLS proxy would change), or the first earlier packet that writes a seed-host literal outside `SeedData` | [Accepted — 2026-10-08](#p02d-5-accepted-answers): local hosts and TLS |
+| G33 | The server-rendering topology and its evidence. Where do Next.js and the API run relative to each other — the workstation loopback, containers, gated APISIX; which networks are trusted; how does one hop secret reach both processes; what is the server-only API origin — and the same for the CI job that renders the pages? What evidence discharges ADR-0036's "Phase 02d's browser test" and its matrix rows "a direct socket bypassing the hop" and "the Phase 02d anonymous two-host browser render", given that Testing Standards gate this phase on a human? And ADR-0036 § Consequences says the root refuses to start outside Development when the secret list is empty or short, while the shipped rule, in every mode, refuses a half-configured hop, a network entry that is not CIDR and a blank secret or one under 32 characters (characters, not bytes), and admits both lists empty: is that recorded or restored, and is non-development hop configuration this phase's or Phase 11's? | Both processes on the loopback, networks `127.0.0.1/32` and `::1/128`, one generated secret from a single source and never under `NEXT_PUBLIC_`, with networks and secrets arriving together (committing networks without secrets breaks every Development-environment fixture); the API called directly; a per-run secret in CI. Evidence: request-level hop tests as `learnstack_app` — no `X-Tenant-Id`, a non-hop peer, a wrong secret, a repeated header — beside the human walkthrough, with one review adding an automated browser smoke. Record the shipped startup rule; non-development hop configuration is Phase 11's | Contract: one dated [ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md) amendment if the evidence departs from the ADR's words, recording the startup rule, and carrying G34 if G34 changes the key or budget. Detail: `.env.example`, `apps/web/.env.local.example`, `appsettings.Development.json` or the demo recipe; a Standards 21 row for the hop runtime test; a Testing Standards § End-to-End Tests sentence only if ownership moves; a Phase 11 scope row | P02d-5 (the hop configuration and the fixture shape — in-process test hosts have no socket peer), P02d-7 (the CI part) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): local topology, shared source and ingress |
+| G34 | How does the pre-classification anonymous limiter treat a request arriving over the authenticated trusted hop? Every server-rendered call reaches the API from the renderer's peer, so every visitor of both tenants shares one partition, and one client sending random `Host` values through the renderer can starve both sites. The partition key, the budget and how the renderer derives any visitor identity it states — while unknown-host floods stay bounded before database work, and a direct peer is still limited per peer | The reviews differ: a visitor address stated over the hop, in a dedicated single-valued header or through `X-Forwarded-For` with the peer captured first, as ADR-0036 anticipates; a separate hop budget with limiting in the renderer; or an explicitly sized shared quota. A per-host ceiling as the only backstop multiplies under a random-`Host` flood | Contract: a dated ADR-0036 amendment if the hop changes the key or the budget, stating the new input's trust rule and whether it may be logged or audited; otherwise a phase-doc statement. Detail: API Standards § Request and Response Limits, [Security Standards § Rate Limiting](../standards/11-security.md#rate-limiting), the catalogue's per-peer rule. `P02b-0` re-verifies Phase 02b's G14 against the answer | P02d-5 | [Accepted — 2026-10-08](#p02d-5-accepted-answers): authenticated visitor and peer budgets |
+| G35 | The server SDK transport. Its options — visitor host, locale, an optional assertion, never a tenant id as selector; where the host comes from; how the API origin is configured; the server-only guard, timeouts and cancellation; and whether it forwards W3C `traceparent`. With it: which parts of [Observability Standards § Frontend Observability](../standards/10-observability.md#frontend-observability) — Next.js error capture, web vitals — ship here, and which phase owns the rest, since no phase names them | Host and locale plus an optional assertion; hop headers and origin from server configuration, never from request input; a `server-only` guard; Problem Details mapped to the `AppError` union [Error Handling Standards](../standards/09-error-handling.md) already fixes; `traceparent` forwarded; Next.js error capture and web vitals assigned to Phase 11, whose § Observability lists error tracking | Detail: Frontend Architecture Standards § SDK and the Frontend Architecture SDK sketch; a pin and licence verdict if a guard package is added; an exclusion row naming frontend observability's owner, with an Observability Standards sentence | P02d-5 (the transport's headers and configuration read); the ownership half closes by exit, because a deferral names its phase | [Accepted — 2026-10-08](#p02d-5-accepted-answers): configured caller, deadlines and observability owners |
+| G36 | The edge middleware and entry behaviour. Does the middleware resolve anything (with G25)? What does it carry inward, and under which header name — the Frontend Architecture sketch reuses `x-learnstack-host`, the hop header's own name? Which inbound internal headers are removed or overwritten, including `x-organization-id` when resolution has none — a deny-strip or an allowlist rebuild? What does the matcher exclude? What do `/` and a locale-less path answer, with which status, target and default-locale source; what do a disabled, malformed or non-canonical locale segment (G6 c), a platform host and an unknown host answer; does this run in middleware or the route tree, and may an i18n library own the middleware? Does this phase build the locale-less redirect [Localization Standards § URL Strategy](../standards/08-localization.md#url-strategy) requires, or keep the standards index's i18n-runtime carve-out while Phase 06 claims redirect handling? | Normalise the host; strip every client-supplied `x-tenant-id`, `x-organization-id`, `x-locale` and `x-learnstack-*`; drop the scaffold's `503` guard and TODOs; `/` redirects to the tenant's default locale; a disabled locale is a `404` before any content call; a platform or unknown host gets a `404` with no platform text; the redirect built minimally here, with Phase 06's rows reworded to "deepens" | Detail: a phase-doc statement; the Standards 07 § Tenant Resolution flowchart and the Frontend Architecture middleware sketch; Phase 06's rows in the same diff; if less is built, a dated narrowing of the standards index row for Localization Standards naming the owning phase | P02d-5 (the middleware replacement rewrites the scaffold's locale fallback, so every placement answer changes it first) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): membership-first entry and redirects |
+| G37 | How do tenant-varying `(public)` routes render, and which Next.js caches may hold tenant data — the full-route cache, the fetch data cache, `unstable_cache`, `generateStaticParams` — so one host's page is never served on the other? A public URL carries no tenant, and both tenants send the same request line over the hop, so a path-keyed cache leaks. What freshness does a page have after a customization or content write? | All three reviews: dynamic rendering with uncached SDK fetches — no `revalidate`, no `generateStaticParams`, no `unstable_cache` — relying on the API's generation-keyed cache; no ISR here | Detail: [Frontend Architecture Standards § Public Site Renderer](../standards/07-frontend-architecture.md#public-site-renderer), which prescribes `revalidate` today, and [Frontend Architecture § Rendering Strategies](../architecture/14-frontend-architecture.md#rendering-strategies), rewritten with the `add-frontend-route` skill; [Performance Standards § Caching](../standards/15-performance.md#caching) if the answer caches; an ADR if the pass judges the Standards 07 change non-trivial | P02d-5 (one mechanism: a host-bearing rewrite target is middleware code and fetch cache options are transport code, both written before the renderer) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): dynamic no-store renderer policy |
+| G38 | The frontend test set. (a) Which predicates of the middleware and the server SDK does Vitest cover, and in which packages — `pnpm -r test` runs only packages with a test script, which `packages/sdk` lacks? (b) How are async Server Component pages and field components covered below the browser, given vendor guidance that Vitest does not render async Server Components? (c) What automated evidence, if any, re-proves the page-level two-host claim after exit — an HTTP smoke against `next start`, one narrow Playwright smoke pulled forward from Phase 06, or a dated manual record? (d) By what mechanism does the `frontend` job refuse skipped and todo cases, which it does not today, since `No_Architecture_Test_Is_Skippable`'s runner leg reads only the backend `.trx` files? | (a) host normalization and header stripping, locale parsing and entry answers, hop headers with no tenant selector, `not_found` mapping — each with an inversion companion; (b) async pages covered through their synchronous children; (c) the reviews split three ways; (d) a reporter-output check or a lint ban on disabled tests, failing also on a package with tests and no script, proven with a planted skip | Detail: a phase-doc statement; the test files; the Standards 21 entry `No_Architecture_Test_Is_Skippable` extended rather than a second name; `ci.yml`. Contract for (c) only if a browser smoke moves: a Testing Standards § End-to-End Tests edit citing this row, with its carriers | P02d-5 (a; d at the latest), P02d-6 (b, c), P02d-7 (c's job) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): (a,d) tests and non-skippability; (b,c) stay P6 |
| G39 | Is [ADR-0027](../decisions/README.md#open-adr-drafts), the frontend i18n library, Accepted in this phase rather than Phase 04, or do this phase's pages meet a library-neutral message contract under the standards index's carve-out? Where does the one UI string catalogue live — the carriers name three paths? | The reviews differ: Accept at first use (`next-intl` composed with the tenant middleware, with its pin and licence verdict), or move only the minimum slice — catalogue loading, lookup, layout locale, `lang`. A third option, no platform-authored text, is hard for a skip link or a not-found page | Contract: ADR-0027 Accepted, with the decisions index row, Phase 04's ADR-0027 lines and the standards index row in the same diff — which, under [the decisions index SLA](../decisions/README.md#open-adr-drafts), makes it an exit blocker here — or a narrowed carve-out plus a phase-doc statement. Detail: [Localization Standards § Strings in Code](../standards/08-localization.md#strings-in-code), [Localization § UI String Catalogue](../architecture/12-localization.md#ui-string-catalogue), the `add-i18n-key` and `add-frontend-route` skills | P02d-6 (the first platform string, message loading and catalogue files) | Open |
| G40 | Route files, page states and site chrome. What happens to `(public)/page.tsx`'s platform placeholder, and to `/studio` and `/portal` on tenant hosts; is the `courses` segment fixed, and which phase owns localized section names? Does this phase ship `(public)` loading, error and not-found files — [Frontend Architecture Standards § Routing](../standards/07-frontend-architecture.md#routing) requires `loading.tsx` and `error.tsx` per route group, and a not-found page is this phase's own choice; how do SDK `not_found`, a cursor `validation_failed`, unavailable and `429` map to page states; what do an empty catalog and a course with no eligible lesson show; does the catalog render a next-page link? Does a minimal chrome ship? Are `hreflang`, canonical and `og:locale`, which [Localization Standards § SEO](../standards/08-localization.md#seo) requires on translated public pages, built here or carved out? | The placeholder leaves the public tree; a fixed `courses` segment with the section-name owner named; minimal state files in tenant tokens; `not_found` → an HTTP `404` page; unavailable or `429` → the route group's error page without disclosure; an explicit empty state; a next-page link over enough seeded courses; in-page links rather than chrome, keeping Phase 06's navigation rows true | Detail: a phase-doc statement; Phase 06's § What Phase 02d already shipped rows reworded; a dated carve-out in the standards index row for Frontend Architecture Standards or Localization Standards, naming the owning phase, for whatever this phase builds less of | P02d-6 | Open |
| G41 | How does the lesson page draw what the subset does not implement — an out-of-subset `x-renderer`, a missing optional field, an array of objects, a stored value whose type differs from its declaration, an `x-taxonomy` value and its missing band? And where do composite and primitive components live — Frontend Architecture Standards names `packages/blocks`, Frontend Architecture names `components/blocks/`, and the registry sits in `apps/web/src/lib/customization/`? | A safe placeholder, never an exception and never raw JSON; a missing optional field renders nothing; `integer`, `boolean` and enums as text; an `x-taxonomy` value as the band's display name in the requested locale. No review addresses the component home | Detail: Tenant Customization Model § 2 (the implemented subset, not a second list) and § 8.1; Frontend Architecture Standards § Public Site Renderer and the Frontend Architecture tree. A new primitive would be an ADR-0018 release, which this row must not assume, since Phase 04 owns the field-type set | P02d-6 | Open |
| G42 | How do validated branding tokens reach the server-rendered HTML — a `style` attribute on the root element, which needs `unsafe-inline` or `unsafe-hashes`; a nonce-compatible `