From 88f52c45004057116f8144b69c204000c00373e3 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 8 Oct 2026 18:37:01 +0300 Subject: [PATCH 01/40] docs(roadmap): close P02d-4 after PR merge Record verified merge and CI evidence so completed public API work is clearly separated from upcoming server rendering and browser delivery. Keep accepted decision history and earlier delivery records intact. ADR: 0052 --- CLAUDE.md | 7 +- README.md | 6 +- .../0052-anonymous-public-read-boundary.md | 19 +++++ docs/decisions/README.md | 2 +- docs/modules/education/README.md | 6 +- docs/roadmap/README.md | 6 +- docs/roadmap/phase-02d-walking-skeleton.md | 69 ++++++++++++++++++- 7 files changed, 103 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9cff7359..8d984c79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,9 +103,10 @@ 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 is next; its decision pass is not started. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` (core + 7 modules × 4 projects + 4 test projects including the diff --git a/README.md b/README.md index ad672b34..3f033e83 100644 --- a/README.md +++ b/README.md @@ -85,8 +85,10 @@ 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 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/README.md b/docs/decisions/README.md index 0c27fbb1..ae68a506 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -66,7 +66,7 @@ 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)) | ## Proposed ADRs 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..90119234 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -59,8 +59,10 @@ 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 is next; its decision pass is not started. - [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-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index f7f4c6ea..b5c91012 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-08).** 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,7 +12,7 @@ > | 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-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 | not started | > | P02d-6 | Public renderer | not started | > | P02d-7 | Demo, full-stack CI and exit | not started | @@ -70,6 +70,12 @@ 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. + ## Goal Put a working education site in a browser — twice, on two hosts, for two tenants in @@ -2639,6 +2645,65 @@ delivery claims, phase ownership and all 590 relative links/anchors in the six changed Markdown files. The complete local regression above verifies the code fix; this closeout adds delivery evidence only. +### P02d-4 merge and closeout (2026-10-08) + +The maintainer merged [PR #25](https://github.com/HodeTech/LearnStack/pull/25) at +2026-10-08 15:25:18 UTC. GitHub records final PR head +`f8adb110ff7fca2a15bbd3f87c1546c62852dc4c` and merge commit +`b83175538cf6f9c5415294a8f77dadd9c82588dd`. The merge's second parent is that +head, and both commits have the identical tree. Development remains the working +branch; the fetched main ref verifies the merge without changing branches. + +- All six required checks pass on the + [final PR head](https://github.com/HodeTech/LearnStack/actions/runs/37794924088). + Downloaded TRX artifacts confirm **2847 passed, zero failed or skipped**: + 1646 unit, 198 architecture, four contract, 171 Docker-free integration and + 828 Docker integration. Frontend CI passes typecheck, lint, build, SDK drift + verification and **73 tests** (60 SDK, thirteen web). +- All six required checks also pass in the + [merge-commit run](https://github.com/HodeTech/LearnStack/actions/runs/37800631591). + Independently downloaded merge-run TRX confirms the same 2847 passing cases + and zero failures/skips. Its OpenAPI artifact matches the merge SHA and + snapshot hash. Read-only protection verification confirms all six Actions + contexts with `app_id=15368` and `strict: true`; no setting is changed. +- OpenAPI artifacts match the final head, verified base and committed snapshot + hash. All **60 policy controls** pass (39 breaking, 21 compatible). The final + workflow correction adds six actual-event selection controls to the eight + admission/bootstrap controls: **14 Python tests** pass locally and in CI. + Four local selector mutants fail. Zero-before pushes select the head's parent; + ordinary push, PR and manual refs retain their existing contracts. +- The final review corrections retain unchanged ADR decisions and align delivery + navigation. Two fresh focused reviewers approve each final follow-up + (`7e07613..7ad4b91` and `7ad4b91..f8adb11`). CodeRabbit succeeds at the final + head; all five inline threads are resolved. The four implementation steps' + two-round review records above remain unchanged. + +**P02d-4 is closed.** Host-scoped anonymous site/catalog/course/lesson GET/HEAD, +physical read-only frames, exact enabled-locale/access eligibility, bounded public +projections and cursors, the served OpenAPI baseline, typed SDK and compatibility +gate are delivered. No database migration is added by P02d-4. + +**Next: P02d-5 — Server-rendering path.** Its implementation and decision pass +have not started. The [packet gate table](#packets-and-decision-gates) owns its +complete prerequisites; the [decision register](#the-decision-register) still +leaves development transport/hosts, trusted-hop topology and rate limiting, +server SDK transport, middleware/locale entry, rendering/cache policy and the +frontend test/skip fence open. P02d-4 supplies the completed API prerequisite; +it does not accept those later decisions. + +P02d-6 owns the three public pages, text-card renderer, safe theme injection, +language attributes and accessibility. P02d-7 owns `make demo`, the two-host +full-stack evidence and phase exit. G44 retains the Lighthouse activation +decision. Phase 02d remains in progress; no browser delivery, authenticated +learner grants or Course Marketplace commerce is claimed by this closeout. + +**Documentation-only closeout validation.** Five local corpus-consistency tests +pass with zero failures/skips. The seven changed Markdown files pass the local +relative-link/fragment audit (825 link occurrences, 342 fragments), added-prose +width and diff checks. ADR-0052 changes are insertion-only; previous P02d-4 +delivery notes and the P02d-1 suffix remain byte-identical. No production code +changes or new decisions are introduced. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved From b04c44efb2ecfd2c8a99440c4ffcee95ad83bcc2 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 8 Oct 2026 22:12:02 +0300 Subject: [PATCH 02/40] docs: accept P02d-5 trusted rendering decisions Record maintainer approval before implementation so the native ingress, visitor budgets and uncached rendering have one explicit authority. Preserve accepted history and separate pending proofs from delivery. ADR: 0036, 0041, 0052, 0053 --- .claude/skills/add-frontend-route/SKILL.md | 73 +++-- .claude/skills/implement-task/SKILL.md | 4 + .claude/skills/run-tests-locally/SKILL.md | 6 +- .github/CONTRIBUTING.md | 7 +- .github/workflows/ci.yml | 6 +- CLAUDE.md | 5 +- docs/architecture/12-localization.md | 5 +- docs/architecture/14-frontend-architecture.md | 14 +- docs/architecture/30-api-gateway.md | 7 + .../0036-tenant-resolution-trusted-inputs.md | 23 ++ .../0053-trusted-public-server-rendering.md | 246 ++++++++++++++++ docs/decisions/README.md | 1 + docs/roadmap/phase-02b-events-auth.md | 6 + docs/roadmap/phase-02d-walking-skeleton.md | 266 +++++++++++++++++- docs/standards/04-api-design.md | 2 +- docs/standards/07-frontend-architecture.md | 21 +- docs/standards/08-localization.md | 12 +- docs/standards/10-observability.md | 11 +- docs/standards/11-security.md | 23 +- docs/standards/15-performance.md | 4 +- .../21-architecture-tests-catalogue.md | 48 +++- docs/standards/README.md | 2 +- 22 files changed, 682 insertions(+), 110 deletions(-) create mode 100644 docs/decisions/0053-trusted-public-server-rendering.md diff --git a/.claude/skills/add-frontend-route/SKILL.md b/.claude/skills/add-frontend-route/SKILL.md index b4d9de2f..6d182ab9 100644 --- a/.claude/skills/add-frontend-route/SKILL.md +++ b/.claude/skills/add-frontend-route/SKILL.md @@ -76,22 +76,20 @@ 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 { createServerSdk, type PublicFetch } from '@learnstack/sdk/server'; -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(transport: PublicFetch, locale: string) { + return createServerSdk(transport).getCourses({ locale }); } ``` -> **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 owns 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 pass supplies the server caller before public page consumers are added. Rules: @@ -106,11 +104,13 @@ 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. The current +`src/middleware.ts` remains a scaffold that copies the raw host into `x-tenant-id` +and sets `x-locale`; it sets no `x-organization-id`. Its replacement and the +configured transport are recorded as G35 and G36 in [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register). Don't read `host` directly inside a page. @@ -178,17 +178,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 +210,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 +231,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/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/run-tests-locally/SKILL.md b/.claude/skills/run-tests-locally/SKILL.md index 02fb6aa8..b8eb8f92 100644 --- a/.claude/skills/run-tests-locally/SKILL.md +++ b/.claude/skills/run-tests-locally/SKILL.md @@ -254,6 +254,10 @@ 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 @@ -262,7 +266,7 @@ dotnet test --blame-hang --blame-hang-timeout 5min - 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. diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index fdfc7d08..97d16a8e 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -61,9 +61,10 @@ 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. + - `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) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8dda58e8..416e764a 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 @@ -346,11 +346,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: diff --git a/CLAUDE.md b/CLAUDE.md index 8d984c79..0600521d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,7 +106,10 @@ 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 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 is next; its decision pass is not started. +records final verification. P02d-5 is next; its +[decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-5-decision-package-2026-10-08) +and ADR-0053 are Accepted — 2026-10-08. The approved four-step implementation and +two-round review loop now run on development; no P02d-5 delivery is claimed yet. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` (core + 7 modules × 4 projects + 4 test projects including the 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..c419d8e8 100644 --- a/docs/architecture/14-frontend-architecture.md +++ b/docs/architecture/14-frontend-architecture.md @@ -123,7 +123,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 +134,17 @@ 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) +is starting; acceptance is not runtime evidence. P02d-6 owns public page consumers. 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. | 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..d6784611 100644 --- a/docs/decisions/0036-tenant-resolution-trusted-inputs.md +++ b/docs/decisions/0036-tenant-resolution-trusted-inputs.md @@ -1040,6 +1040,29 @@ 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. + ## References - [ADR-0003 Tenant Isolation Defense in 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..71e393f7 --- /dev/null +++ b/docs/decisions/0053-trusted-public-server-rendering.md @@ -0,0 +1,246 @@ +# 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. + +## 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/README.md b/docs/decisions/README.md index ae68a506..215c12a6 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -67,6 +67,7 @@ an amendment is not a lifecycle status change. | 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; 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: native ingress provenance, authenticated visitor/peer budgets and uncached institution SSR; bounded ADR-0036 replacements; P02d-5 implementation starting, delivery not claimed | ## Proposed ADRs diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index e07612b4..7f509bfe 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 diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index b5c91012..b05a200a 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -13,7 +13,7 @@ > | 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 | ✅ complete and merged — 2026-10-08; [merge closeout](#p02d-4-merge-and-closeout-2026-10-08) | -> | P02d-5 | Server-rendering path | not started | +> | P02d-5 | Server-rendering path | [decision package Accepted](#p02d-5-decision-package-2026-10-08); four-step implementation starting | > | P02d-6 | Public renderer | not started | > | P02d-7 | Demo, full-stack CI and exit | not started | @@ -76,6 +76,17 @@ milestones. P02d-4 is now closed through [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. + ## Goal Put a working education site in a browser — twice, on two hosts, for two tenants in @@ -360,7 +371,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 | @@ -374,8 +385,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 | @@ -384,21 +395,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 `