diff --git a/.claude/skills/add-frontend-route/SKILL.md b/.claude/skills/add-frontend-route/SKILL.md
index 9dc4623c..f695a9c8 100644
--- a/.claude/skills/add-frontend-route/SKILL.md
+++ b/.claude/skills/add-frontend-route/SKILL.md
@@ -41,7 +41,7 @@ SDK contract per
| Input | Required | Description |
|-------|----------|-------------|
-| Path | Yes | URL path (`/courses/[slug]`, `/dashboard/users`, `/lesson/[id]`). |
+| Path | Yes | Public URL `/{locale}/courses/{slug}`; future authenticated paths follow their owning phase. |
| Route group | Yes | `(public)` / `(studio)` / `(portal)`. |
| Auth | Yes | Anonymous (public) / authenticated tenant / org-scoped. |
| Permission key | If guarded | `{module}.{resource}.{action}` from the closed set. |
@@ -59,6 +59,14 @@ SDK contract per
### Step 2: Create the route folder
+The shipped public tree is `(public)/[locale]/courses/`, with course detail at
+`[slug]/page.tsx` and lesson detail at `[slug]/lessons/[lessonSlug]/page.tsx`.
+`courses` and `lessons` are fixed section segments. The only localized status
+namespace is `[locale]/status/not-found`. A new public segment also needs an
+explicit update to the closed `contentPath` / `publicEntry` admission policy and
+its tests; adding a Next folder alone leaves it refused. Phase 06 owns broader
+localized section names.
+
```
frontend/apps/web/src/app/
(studio)/
@@ -75,24 +83,35 @@ unless the screen genuinely needs one.
### Step 3: Server Component shell
```tsx
-// page.tsx (Server Component by default)
-import { createConfiguredPublicClient } from '@/server/configured-public-client';
-
-// The configured server caller supplies this transport; never a tenant-ID option.
-export function loadCourses(envelope: string | null) {
- const client = createConfiguredPublicClient(envelope);
- if (!client) throw new Error('Invalid public ingress');
- return client.getCourses(); // Locale comes from the authenticated route.
+// app/(public)/[locale]/courses/page.tsx
+import { notFound } from 'next/navigation';
+import { PublicCatalog } from '@/components/public/catalog';
+import { PublicState } from '@/components/public/state';
+import { requirePublicResource } from '@/server/public-resource';
+import { getPublicUi } from '@/server/public-ui';
+
+export default async function CoursesPage() {
+ const resource = await requirePublicResource();
+ const ui = await getPublicUi();
+ if (resource.kind === 'failure') {
+ return ;
+ }
+ if (resource.kind !== 'catalog') notFound();
+ return ;
}
```
> **P02d-4 delivered.** `@learnstack/sdk/server` exports an injected
> `createServerSdk(transport)` with four typed public GET wrappers; no global `sdk`
-> object, tenant-ID option or module namespace exists. The example is a loader,
-> not a complete route. P02d-5/G35 delivers the configured trusted transport in
+> object, tenant-ID option or module namespace exists. P02d-5/G35 delivers the
+> configured trusted transport in
> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register);
-> that caller precedes P02d-6 public page consumers. Read the verified envelope
-> from request-local `headers()`; never create or expose a provenance stamp in a page.
+> that caller precedes P02d-6 public page consumers. Pages use the shared resource
+> loader; they do not read envelope headers or create another bootstrap/client.
+> Keep loader helpers outside `page.tsx`; Next page exports are restricted to
+> supported route exports such as the default component and `generateMetadata`.
Rules:
@@ -170,6 +189,7 @@ is authoritative.
### Step 6: Feature gating (entitlement-aware UI)
+The hook/package below is a future feature-UI sketch, not a shipped SDK export.
For features gated by plan-projected `FeatureKey`:
```tsx
@@ -187,23 +207,41 @@ Hide, don't disable. The hook reads the entitlement projection. See
### Step 7: Localisation
+**P02d-6 G39 foundation delivered — Step 1, 2026-10-10.**
+[ADR-0027](../../../docs/decisions/0027-frontend-i18n.md) selects exact `next-intl`
+4.14.9, now installed with complete English/Turkish catalogues. The async Server
+Component pattern is:
+
```tsx
-import { useTranslations } from "next-intl"; // or react-intl per the i18n ADR
+import { getPublicUi } from "@/server/public-ui";
-export default function CoursesPage() {
- const t = useTranslations("courses");
- return
{t("title")}
;
+export default async function CoursesPage() {
+ const { t } = await getPublicUi();
+ return
{t("catalog.title")}
;
}
```
-Translation keys live under `frontend/apps/web/src/i18n//courses.json`.
+Messages live in `frontend/apps/web/src/i18n/messages//.json`;
+P02d-6 supplies complete `en/public.json` and `tr/public.json`. General UI keys
+are dotted feature identifiers, distinct from backend `lockey_*` wire keys. The
+web app owns closed page-outcome mappings; the SDK supplies no translations.
See [add-i18n-key](../add-i18n-key/SKILL.md).
-> **Open in Phase 02d.** No i18n library is installed and no catalogue exists. Whether
-> ADR-0027 picks the library in Phase 02d, and where the one UI string catalogue
-> lives — the corpus names three paths — are G39 in
-> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register);
-> the pass that closes it edits this step and add-i18n-key.
+`src/i18n/request.ts` uses the same server-only, request-cached verified admission
+loader as document/layout/page consumers. It re-verifies the ingress envelope,
+reads the canonical locale from the signed target and checks live enabled-locale
+membership. The loader neither imports next-intl nor reads messages. No i18n
+routing middleware, preference cookie, locale header or callsite override supplies
+authority. Only whole-catalogue fallback is allowed for unauthored UI languages;
+missing used keys in supported catalogues fail validation. Preserve exact content
+locale, document language and actual resolved-label language.
+
+The guarded frontend suite supplies ICU, argument and callsite checks. No
+`lint:i18n` command or screenshot/axe tooling exists. The
+[Step 1 delivery record](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation)
+owns foundation validation; the
+[packet closeout](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10)
+records the delivered product proof and scoped manual accessibility checks.
### Step 8: Public-site SSR caching
@@ -214,21 +252,57 @@ public rendering and no-store API transport; no positive `revalidate`, ISR,
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.
+from a page header or add hop options to the injected SDK.
+
+**Accepted P02d-6 G40 — 2026-10-09; foundation delivered in Step 1.** Use ordinary
+same-host relative anchors for public navigation and pagination, without automatic
+prefetch or reliance on retained client Router Cache. Request-local metadata,
+layout and page share verified admission and resource loaders; each page honors
+their result before emitting a shell. No loading boundary may flush before
+redirect admission.
+
+Parse owned pagination values only from the verified raw signed target, never
+observed Next `searchParams`. Catalog uses `cursor`/`limit`; outline uses
+`lessonCursor`/`lessonLimit`, default 20 and API bounds. Refuse duplicate, empty,
+malformed or oversized owned values; never decode opaque cursors or fabricate a
+previous cursor. Paginated metadata is noindex with a cursor-free canonical.
+Canonical/alternate URLs use the verified live host and eligible API slugs.
### Step 9: Loading + error boundaries
-Every route ships its own:
-
-- `loading.tsx` — skeleton shell, not a blank page. No "loading…" spinners for
- expected-fast resources (<250 ms).
-- `error.tsx` — graceful boundary; 404 page renders the tenant's brand if a
- tenant was resolved.
+Each route retains `loading.tsx` and a graceful `error.tsx`; P02d-6's accepted
+status composition is distinct from the framework's thrown `notFound()` behavior:
+
+- A missing/hidden content resource returns local HTTP 307 to the same host's
+ `/{locale}/status/not-found`. The browser URL changes; the original response is
+ not a direct 404.
+- Middleware admits that fixed status namespace through the same live host and
+ locale checks, then supplies HTTP 404. Its ordinary server-rendered document has
+ localized language/direction, safe theme/chrome, noindex metadata and a catalog
+ recovery link. It never queries Education or echoes the original slug/query.
+- Unknown-host/provenance refusals remain direct masked responses. Fresh status
+ bootstrap failure retains neutral 404/429/503; fallback UI cannot invent tenant
+ admission. HEAD is bodyless throughout.
+- Known content-call failures are controlled translated HTTP 200 noindex states:
+ invalid cursor with reset link, retry-later for 429, unavailable for transport,
+ invalid responses or unavailable API. They do not claim HTTP 400/429/503.
+ Closed outcome mapping never exposes backend keys, titles, field errors or
+ parameters as lookup identifiers or visible copy.
+- Unexpected framework errors keep pre-stream 500 / post-stream 200 behavior;
+ `error.tsx` cannot set arbitrary status.
+
+ADR-0055's implemented single bootstrap costs two API calls per completed product
+document, one per fixed status/scaffold document and three per followed missing
+document. Metadata/layout/page/UI share the admitted snapshot without another
+bootstrap or cross-request cache. HEAD/RSC/prefetch and Flight fallback require
+separately proven counts. The
+[Step 3 record](../../../docs/roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout)
+records passing production replacement proofs, completed reviews and verified fixes.
### Step 10: Tests
-- Component tests (`frontend/apps/web/src/app/(studio)/dashboard/users/page.test.tsx`)
- with Testing Library, per
+- Synchronous view/mapping tests use Vitest/Testing Library; async public pages use
+ the real production HTML/RSC fixture, per
[Testing Standards § Frontend Test Types](../../../docs/standards/06-testing.md#frontend-test-types).
Automated `axe-core` runs through Playwright, owned by
[Phase 06](../../../docs/roadmap/phase-06-renderer-admin-studio.md) per
@@ -236,8 +310,11 @@ Every route ships its own:
the manual keyboard and contrast checks
[Accessibility Standards § Tooling](../../../docs/standards/16-accessibility.md#tooling)
and [§ Testing](../../../docs/standards/16-accessibility.md#testing) require are
- recorded in the PR description. The phase that ships a route names its test set in
- its decision register.
+ recorded in the PR description. P02d-6 also requires actual manual screen-reader,
+ keyboard, focus, 320 CSS px reflow/zoom, long-string and contrast evidence on both
+ hosts/locales. If unavailable, evidence stays pending and completion is not
+ claimed. HTTP/RSC assertions are not browser or assistive-technology proof.
+ P02d-7 owns browser/demo and Lighthouse; Phase 11 owns web-vitals telemetry.
- Lighthouse budget check on representative public routes — CI's `lighthouse budget`
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
diff --git a/.claude/skills/add-i18n-key/SKILL.md b/.claude/skills/add-i18n-key/SKILL.md
index 19289723..6ca14750 100644
--- a/.claude/skills/add-i18n-key/SKILL.md
+++ b/.claude/skills/add-i18n-key/SKILL.md
@@ -1,7 +1,8 @@
---
name: add-i18n-key
description: >
- Add a translation key under `frontend/apps/web/src/i18n//.json`
+ Add a translation key under
+ `frontend/apps/web/src/i18n/messages//.json`
with the project's dotted-feature namespace + ICU MessageFormat conventions.
USE FOR: adding a user-facing string, renaming a key (with deprecation), removing
a key. DO NOT USE FOR: backend `LocalizedMessage` keys (those follow the
@@ -19,14 +20,16 @@ Manage user-facing translations in `apps/web` consistently per
[08-localization.md](../../../docs/standards/08-localization.md) +
[ADR-0008 Localization Schema](../../../docs/decisions/0008-localization-schema.md).
-> **Open in Phase 02d.** None of the machinery below exists today: no
-> `frontend/apps/web/src/i18n/` tree, no i18n library, no `pnpm lint:i18n` task, no
-> `no-literal-strings` rule, no `_deprecated.json`, and no screenshot or `axe-core`
-> test. Where the one UI string catalogue lives — this skill, Localization Standards
-> and Localization architecture name three different paths — and whether ADR-0027
-> picks the library in Phase 02d are G39 in
-> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register);
-> the pass that closes it edits this skill.
+> **G39 foundation delivered — P02d-6 Step 1, 2026-10-10.**
+> [ADR-0027](../../../docs/decisions/0027-frontend-i18n.md) selects exact
+> `next-intl` 4.14.9 and the catalogue home below. Step 1 installs the runtime,
+> complete English/Turkish catalogues and guarded ICU/callsite checks. Its
+> [delivery record](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation)
+> owns foundation validation; the
+> [packet closeout](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10)
+> records the scoped product and manual accessibility evidence. No
+> `pnpm lint:i18n`, `no-literal-strings`, `_deprecated.json`, screenshot or
+> `axe-core` task exists today; do not claim those checks run.
## When to use
@@ -37,7 +40,9 @@ Manage user-facing translations in `apps/web` consistently per
## When not to use
- Backend `LocalizedMessage` keys returned by the API. Those have their own
- `lockey_*` namespace (the SDK maps API codes to client-side resources).
+ `lockey_*` namespace. The SDK normalizes API codes to `AppError`; it has no
+ translation resource map. A UI feature owns any supported backend-message
+ resources separately from general UI copy.
- Notification template content. Lives in `TenantTemplateLibrary` rows (data, not
code).
- Debug-only strings or developer error messages.
@@ -50,31 +55,29 @@ Manage user-facing translations in `apps/web` consistently per
|-------|----------|-------------|
| Key | Yes | Dotted-feature namespace: `enrollment.list.empty_state.title`. |
| Default locale value | Yes | The English (or product-default) translation. |
-| Other locales | If translations exist | Each locale's translation. |
+| Other locales | Yes for supported UI languages | Complete `en` and `tr` initially. |
| Pluralisation / interpolation | If applicable | Use ICU MessageFormat. |
## Workflow
### Step 1: Pick the namespace
-Translation files are split by **feature**:
+The accepted app-local catalogue layout is:
-```
+```text
frontend/apps/web/src/i18n/
- en/
- auth.json
- catalog.json
- classroom.json
- common.json
- enrollment.json
- notifications.json
- portal.json
- studio.json
- tr/
- ...
- ...
+ request.ts
+ messages/
+ en/public.json
+ tr/public.json
```
+P02d-6 supplies only `public`; later features add a namespace beneath each locale.
+`catalogues.ts` statically imports the two public files, and `request.ts` explicitly
+mounts them as `messages.public`. There is no automatic file discovery. JSON nesting
+supplies dotted identifiers: `public.catalog.course_count` is
+`catalog.course_count` within the `public` translator.
+
Keys are dotted: `..`. Examples:
```
@@ -96,16 +99,20 @@ Rules:
- Lowercase, dotted, snake_case for multi-word segments.
- Feature-namespaced — `auth.signin.errors.invalid_credentials`, not
`errors.invalid_credentials` (no global error namespace).
-- API error codes are **not** repeated here — the SDK has its own resource map
- for `LocalizedMessage` keys.
+- General UI copy does not copy the backend `lockey_*` namespace. The SDK returns
+ normalized outcomes and message keys as data, not translations.
+ [ADR-0027](../../../docs/decisions/0027-frontend-i18n.md#message-and-test-contract)
+ assigns P02d-6 page-outcome-to-UI-key mapping to the web app; arbitrary backend
+ message keys never become general UI lookup identifiers.
### Step 2: Add the key in every locale
-The convention: a key MUST exist in the default locale (en) before any non-default
-locale. Don't ship a key with translations missing from `en`.
+Every used key MUST exist in each supported bundled UI catalogue. P02d-6 requires
+complete `en` and `tr` `public` messages. These later enrollment examples show the
+same layout; they do not introduce an enrollment catalogue in P02d-6.
```jsonc
-// frontend/apps/web/src/i18n/en/enrollment.json
+// frontend/apps/web/src/i18n/messages/en/enrollment.json
{
"list": {
"empty_state": {
@@ -118,7 +125,7 @@ locale. Don't ship a key with translations missing from `en`.
```
```jsonc
-// frontend/apps/web/src/i18n/tr/enrollment.json
+// frontend/apps/web/src/i18n/messages/tr/enrollment.json
{
"list": {
"empty_state": {
@@ -132,6 +139,12 @@ locale. Don't ship a key with translations missing from `en`.
### Step 3: ICU MessageFormat for plural / select / number
+For a new namespace, adding JSON files is insufficient. Wire their static imports
+and locale map into `catalogues.ts`, mount the namespace in `request.ts`, and add
+its typed translator and callsite-census coverage. Do not pass a catalogue to a
+Client Component. For public keys with new ICU arguments, update
+`PublicMessageArguments` alongside the messages and their callsites.
+
```jsonc
{
"list": {
@@ -144,16 +157,33 @@ locale. Don't ship a key with translations missing from `en`.
Usage:
```tsx
-const t = useTranslations("enrollment.list");
-return
{t("count", { count: learners.length })}
;
+import { getPublicUi } from "@/server/public-ui";
+
+export default async function CatalogCount({ count }: { count: number }) {
+ const { t } = await getPublicUi();
+ return
{t("catalog.course_count", { count })}
;
+}
```
### Step 4: Variable interpolation
-ICU placeholders: `{name}`, `{count}`, `{date, date, short}`. The frontend i18n
-library (next-intl / react-intl — ADR-0027, reserved in
-[the decisions index](../../../docs/decisions/README.md#open-adr-drafts)) handles ICU
-natively.
+ICU placeholders: `{name}`, `{count}`, `{date, date, short}`. The accepted
+`next-intl` 4.14.9 runtime handles ICU using the selected UI catalogue's locale.
+Dynamic values enter as plain text parameters, never HTML or rich-text callbacks.
+
+`src/i18n/request.ts` uses the same server-only request-cached verified admission
+loader as document/layout/page consumers. That loader neither imports next-intl
+nor reads messages. The canonical locale comes from the signed target and live
+site membership, never next-intl middleware `requestLocale`, callsite overrides,
+query data, cookies or `Accept-Language`. Do not install i18n routing middleware.
+
+Select one whole UI catalogue by exact canonical tag, successive rightmost-subtag
+removal, then platform `en`. An unauthored UI language does not refuse or redirect
+an enabled content locale. API content locale and document language remain the
+admitted locale; fallback UI groups carry their actual language and direction.
+A missing used key within a supported catalogue fails validation; it never
+triggers per-key fallback. See
+[ADR-0027's contract](../../../docs/decisions/0027-frontend-i18n.md#message-and-test-contract).
### Step 5: Don't branch on locale
@@ -171,15 +201,15 @@ it as data (date formats, currency, plural rules ICU already knows).
1. Add the new key with the same value as the old key.
2. Update all call sites to use the new key.
-3. Mark the old key in a tracking file (`frontend/apps/web/src/i18n/_deprecated.json`)
- with the planned removal date (≥ 1 release window).
+3. Record the planned removal date (≥ 1 release window). The sketched
+ `_deprecated.json` is not implemented; do not assume a tracking file exists.
4. After the window, remove the old key from every locale file.
### Step 7: Remove a key
-1. Confirm zero call sites: `grep -rn "" frontend/apps/web/src/`.
+1. Confirm zero call sites: `rg "" frontend/apps/web/src/`.
2. Remove the key from every locale's JSON file.
-3. Remove the tracking entry from `_deprecated.json`.
+3. Remove the deprecation tracking entry, if present.
### Step 8: Trailer
@@ -192,25 +222,44 @@ I18n: enrollment.list.empty_state.title, enrollment.list.empty_state.cta_label
## Validation
-- `pnpm lint:i18n` (custom lint task) flags missing keys per locale.
-- `pnpm test` is green; tests that depend on a key surface a clear failure if
- it's missing.
-- Visual / screenshot tests show the key resolving in every locale, not the
- raw key string.
-- `axe-core` accessibility test passes (translations don't break ARIA labels).
+Run the checked catalogue suite from `frontend/`:
+
+```bash
+pnpm --filter @learnstack/web test src/i18n/catalogues.test.ts
+```
+
+Also run the complete guarded frontend suite before completion; the focused
+command does not replace production HTML/RSC or source-boundary proofs.
+
+P02d-6 implements these accepted obligations in the guarded frontend suite; the
+acceptance record alone is not passing evidence:
+
+- Nonempty catalogues with equal key sets and valid ICU messages, including
+ matching argument names/types across supported languages.
+- Typed callsite checking or a checked census rejects absent-from-all keys and
+ misspelled identifiers. Planted missing-key, unknown-callsite, malformed-ICU
+ and mismatched-argument controls must fail.
+- Formatter failures select the bounded translated unavailable state without raw
+ keys, parameters or library diagnostics in the document or logs.
+- Production HTML/RSC proofs cover locale fallback, language/direction and
+ concurrent hosts/locales without unnecessary client catalogue payloads.
+- Record actual implemented commands and results. Screenshot/axe tooling is not
+ supplied by acceptance; Phase 06 owns the full Playwright/axe suite.
## Common pitfalls
-- **Hardcoded English in JSX.** Lint rule `no-literal-strings` will reject. Move
- to a translation key.
+- **Hardcoded English in JSX.** Move platform copy to a translation key. No
+ `no-literal-strings` rule is installed by the acceptance record.
- **Per-locale branching.** If you find yourself doing
`if (locale === "tr") ...`, encode the behaviour as data via ICU.
-- **Missing translation for non-default locale.** The build falls back to the
- default locale — and the user sees mixed languages. Tests catch this.
+- **Confusing language fallback with a missing key.** ADR-0027 selects a
+ whole fallback catalogue only for an unauthored UI language; missing keys in
+ supported bundled catalogues must fail validation, not mix languages per key.
- **Renaming without deprecation window.** Stale references break the build for
every consumer.
-- **Putting backend `LocalizedMessage` keys here.** Those have their own
- `lockey_*` prefix and live with the API contract, not the i18n bundle.
+- **Mixing backend keys with general UI copy.** `LocalizedMessage` keys retain
+ their `lockey_*` API contract. A feature rendering them owns explicit supported
+ error resources; the SDK does not supply translations.
- **Long keys.** A key over ~80 chars is a sign the namespace is wrong. Split.
- **`I18n:` trailer missing.** Without it, `git log --grep` for translation
changes is broken.
diff --git a/.claude/skills/add-page-block/SKILL.md b/.claude/skills/add-page-block/SKILL.md
index 836f5ee6..55605b50 100644
--- a/.claude/skills/add-page-block/SKILL.md
+++ b/.claude/skills/add-page-block/SKILL.md
@@ -60,17 +60,18 @@ and [17-page-builder.md](../../../docs/architecture/17-page-builder.md).
| **Composite renderer** | C# composite registry + React renderer (`default-card`, `content-list`, `card-grid`). | LearnStack engineering. | Tenants compose this in `TenantPageBlock` rows. |
| **Tenant block** | `tenant_page_blocks` row (data only). | Tenant admin via Studio editor. | Tenant-specific shape (`vocabulary-list` for English, `asana-card` for yoga). |
-> **Open in Phase 02d.** The code paths in this step and the next two describe intended
-> shape. At HEAD the frontend registry is `frontend/apps/web/src/lib/customization/` —
-> `PRIMITIVE_KEYS` and `COMPOSITE_KEYS` as closed key lists with `resolveRendererKey`,
-> no component and no `registerPrimitive` / `registerComposite` — and the backend's
-> closed sets are `PrimitiveRendererKey` and `CompositeRendererKey` in
-> `LearnStack.Modules.Customization.Domain`; no `PageBlockRegistry` exists. Where
-> primitive and composite components live — `components/blocks/` here, `packages/blocks`
-> in Frontend Architecture Standards — is G41 in
-> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register),
-> and the pass that closes it, which ships the lesson renderer's first components, edits
-> this skill with its answer.
+> **P02d-6 G41 subset implemented — Step 3, 2026-10-10.** The first
+> ordered plain-string `default-card` views live in
+> `frontend/apps/web/src/components/public/`, with bounded unavailable fallbacks
+> and no active URL sink. The wider registry and component sketches below remain
+> Phase 04/05 and Phase 06 targets. The existing key lists in
+> `src/lib/customization/` are not renderer implementations. See the
+> [accepted package](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-accepted-answers).
+
+`RegisterPrimitive`, `BlockRenderer`, `resolver.registerPrimitive` and
+`useContentEntries` below are illustrative future APIs, not existing symbols to
+import. G41 chooses app-local `components/public` for the shipped subset; it does
+not create `packages/blocks` or the wider registry.
### Step 2: Primitive block (path A)
@@ -208,25 +209,24 @@ the page.
- JSON Schema validation test for the new primitive / tenant schema.
- Renderer snapshot test.
-- Accessibility test (`axe-core` violations fail). No test runs `axe-core` yet:
- [Testing Standards § End-to-End Tests](../../../docs/standards/06-testing.md#end-to-end-tests)
- puts automated axe through Playwright in
- [Phase 06](../../../docs/roadmap/phase-06-renderer-admin-studio.md). Which
- accessibility checks fail a build on Phase 02d's pages, jsdom axe among the options,
- is G43 in
- [that phase's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register),
- whose pass edits this bullet with its answer.
-- Budgets in [Performance Standards](../../../docs/standards/15-performance.md) for
- representative pages embedding the block — judged by reading while CI's
- `lighthouse budget` job remains a placeholder. Whether it activates in Phase 02d
- is G44 in
- [that phase's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register);
- the pass that closes it edits this bullet.
+- Full automated `axe-core` through Playwright belongs to
+ [Phase 06](../../../docs/roadmap/phase-06-renderer-admin-studio.md), per
+ [Testing Standards](../../../docs/standards/06-testing.md#end-to-end-tests).
+ Accepted P02d-6/G43 requires applicable jsx-a11y error rules with actual-config
+ planted controls, localized product-page semantics and real manual keyboard,
+ focus, reflow, contrast and screen-reader evidence. Step 3 implements the
+ applicable lint controls and synchronous lesson semantics. The
+ [Step 4 record](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout)
+ records product proofs and the scoped manual browser/screen-reader checks.
+- [Performance Standards](../../../docs/standards/15-performance.md) governs pages
+ embedding the block. Accepted G44 assigns Lighthouse activation and assertions
+ to P02d-7 after P6; the job remains disabled until that harness ships.
## Validation
- `dotnet build` and `pnpm build` pass.
-- Architecture test `Block_Schemas_Are_Immutable_After_Publish` is green.
+- The future block-registry implementation must supply its schema-immutability
+ proof; `Block_Schemas_Are_Immutable_After_Publish` is not a shipped test today.
- For a primitive: the block appears in the Studio block picker.
- For a composite: tenant admins can reference it from their `TenantPageBlock`
editor.
@@ -236,7 +236,7 @@ the page.
and UI colours pass, per
[Accessibility Standards § Testing](../../../docs/standards/16-accessibility.md#testing),
recorded in the PR description. Automated `axe-core` is not a gate yet — Step 7 names
- its owner and G43, the Phase 02d gate open on it.
+ its Phase 06 owner and Accepted G43's earlier P6 obligations.
## Common pitfalls
diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md
index 5340890d..9aad9d03 100644
--- a/.claude/skills/code-review/SKILL.md
+++ b/.claude/skills/code-review/SKILL.md
@@ -83,7 +83,7 @@ Walk every change against the project's
| **Authn / authz** | Every new endpoint has `[Authorize(Policy=…)]` or `[AllowAnonymous]` with a comment. Policy keys exist in the permission registry. Resource-scope handlers (`instructor` edits own course only) present where needed. |
| **Tenant id from the resolved context only** | Resolved per [ADR-0036](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md): from the host lookup of the effective host, or on a platform host from validated claims confirmed by live membership; off HTTP, from the job parameters or the integration-event envelope. Never from a request body, query string, cookie or tenant header — `X-Tenant-Id` and `X-Organization-Id` are assertions that can only reject, on every hop. Check command and DTO surfaces. |
| **SQL injection** | EF Core LINQ or `FromSqlInterpolated`; never raw string concatenation. Raw SQL has parameters. |
-| **XSS** | No `dangerouslySetInnerHTML` outside a sanitisation wrapper; CSP nonces in place; markdown via allowlist. Nothing sets a CSP today: the [standards index](../../../docs/standards/README.md#honest-status-today) row for Security Standards carves § HTTP Headers out to [Phase 11](../../../docs/roadmap/phase-11-production-hardening.md#security), so a missing nonce is not a Blocker until that phase sets one. How Phase 02d's theme tokens reach the page without constraining that nonce-based target is G42 in [its decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register), and the pass that closes it edits this row. |
+| **XSS** | No `dangerouslySetInnerHTML` outside a sanitisation wrapper; CSP nonces in place; markdown via allowlist. Nothing sets a CSP today: the [standards index](../../../docs/standards/README.md#honest-status-today) row for Security Standards carves § HTTP Headers out to [Phase 11](../../../docs/roadmap/phase-11-production-hardening.md#security), so a missing nonce is not a Blocker until that phase sets one. Accepted P02d-6 G42 requires atomic four-color validation and a fixed style element, with whole-palette fallback; output must be safe without CSP. [The accepted package](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-accepted-answers) owns its proof obligations; acceptance alone is not delivery. |
| **CSRF** | Server Actions / Auth.js session check; non-Action mutating fetches carry CSRF tokens. |
| **Secrets** | No secret in source / appsettings / env-file-committed; reads through `ISecretProvider`. No log of token / password / national id. |
| **PII redaction** | `[PiiSensitive]` fields carry `SensitiveTokenCatalog.RedactedValue` in audit snapshots — the property stays, so the diff still records *that* it changed; logs don't carry raw PII. |
@@ -185,7 +185,7 @@ change.
| Outbox round-trip test for new integration event | Always |
| Permission denied test for every new permission key | Always |
| Boundary tests for every DSL band threshold | Scoring / completion rules |
-| Lighthouse / axe-core for public-route changes | Accepted P02d-5 G44 assigns Lighthouse activation to P02d-7/G44/G45 after P6 pages. It is still a disabled placeholder; no passing audit is claimed. P02d-6/G43 selects the skeleton's accessibility gate; the broader axe/Playwright suite belongs to Phase 06. |
+| Lighthouse / axe-core for public-route changes | Accepted P02d-5 G44 assigns Lighthouse activation to P02d-7/G44/G45 after P6 pages. It is still a disabled placeholder; no passing audit is claimed. Accepted P02d-6/G43 requires applicable jsx-a11y error rules with planted controls, actual-page semantics and real manual keyboard/focus/reflow/contrast/screen-reader evidence; the broader axe/Playwright suite belongs to Phase 06. Pending manual evidence prevents packet completion. |
A change without tests is incomplete; flag as Blocker unless the user
explicitly deferred the test.
diff --git a/.claude/skills/run-tests-locally/SKILL.md b/.claude/skills/run-tests-locally/SKILL.md
index bfeb6d4c..f77fd761 100644
--- a/.claude/skills/run-tests-locally/SKILL.md
+++ b/.claude/skills/run-tests-locally/SKILL.md
@@ -166,15 +166,38 @@ packet/PR validation uses the guarded workspace command. Backend Docker integrat
also runs P5's disposable production Next/TLS fixture against the real API and
PostgreSQL; Node, pnpm, installed frontend dependencies and OpenSSL are required.
It owns ports 3000/3011 while running and refuses occupied ports without stopping
-an existing user process. Product-page browser/a11y tests remain P6/P7.
+an existing user process. P6 adds separate production product modes for foundation,
+pagination, presentation, concurrent isolation and freshness/failure states. Those
+HTTP/HTML/RSC proofs do not claim browser or screen-reader execution; P6's manual
+accessibility closeout is recorded with its tested scope in the
+[packet closeout](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10).
+New UI changes require their own applicable manual checks; P7 owns the browser demo.
+
+ADR-0055's separate admission fixture adds a narrow stock-Next browser navigation
+probe and development HMR admission controls. Its Docker integration run requires
+an installed stock Chromium browser: Chrome or Brave at the supported macOS
+application paths, or Chrome/Chromium at the supported Linux executable paths.
+Missing browser installation fails the proof; it never skips. The required CI
+integration job checks `/usr/bin/google-chrome` and its version before running.
+The fixture launches headless with its own disposable profile and trust limited
+to its certificate's public-key fingerprint; it does not use a personal profile.
+Browser observations cover a refused Flight followed by stock Next's fresh document
+navigation and separately counted API calls. HMR controls cover source recompilation
+and a fresh live site snapshot in the same development process. The
+[Step 3 record](../../../docs/roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout)
+owns actual execution, completed reviews and verified fixes. This probe does not
+deliver P7's browser demo, full Playwright/axe or Lighthouse.
> **`pnpm test:a11y` and `pnpm test:e2e` do not exist yet.** `package.json`
> defines `dev`, `build`, `start`, `lint`, `typecheck` and `test`, and neither
> `axe-core` nor `@playwright/test` is a dependency. Both arrive in **Phase 06**, per
> [Testing Standards § End-to-End Tests](../../../docs/standards/06-testing.md).
> Accepted P02d-5 G44 assigns Lighthouse to P02d-7/G44/G45 after P6 pages; the job
-> remains disabled. P02d-6/G43 selects the skeleton's accessibility gate. P5's
-> production HTML/RSC fixture proves transport, not a browser/a11y audit.
+> remains disabled. Accepted P02d-6/G43 requires failing applicable `jsx-a11y`
+> rules, localized page semantics and a real manual screen-reader smoke. These
+> lint and product-semantic controls are implemented. Passing manual evidence is
+> recorded in the P6 delivery record. P5's production HTML/RSC mode proves transport;
+> P6's separate product modes still do not claim a browser/a11y audit.
### Step 7: Single-test focus
@@ -274,10 +297,11 @@ The entry decision does not provide a Lighthouse command or passing audit.
The P5 native renderer fixtures require free 3000/3011 and fail on collisions.
- A failing test message points at the specific rule / scenario it violates.
- For frontend changes, `pnpm test`, `pnpm lint` and `pnpm typecheck` are clean.
- The axe suite joins this list in Phase 06. Whether route tests or `jsx-a11y`
- findings fail a build 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.
+ The axe suite joins this list in Phase 06. Accepted P02d-6/G43 requires failing
+ applicable `jsx-a11y` rules and localized page-semantics checks, plus manual
+ keyboard, focus, reflow, contrast and screen-reader evidence. Acceptance does
+ not claim those controls pass; implementation records their actual results in
+ [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-accepted-answers).
## Common pitfalls
@@ -295,9 +319,9 @@ The entry decision does not provide a Lighthouse command or passing audit.
- **Assuming an accessibility gate exists.**
[16-accessibility.md](../../../docs/standards/16-accessibility.md) makes WCAG
2.2 AA binding, and Phase 06 brings the axe suite that enforces it. Reading the
- standard is the gate today; whether a route test or lint rule fails a build
- earlier, on Phase 02d's pages, is 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 pitfall.
+ standard does not prove implementation. Accepted P02d-6/G43 requires earlier
+ failing lint/semantics controls and actual manual assistive-technology evidence;
+ DOM assertions cannot substitute for a screen reader. Delivery is recorded in
+ [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-accepted-answers).
- **CI-only failures.** Usually a race or timing assumption. Use `--blame-hang`
+ `--blame-crash` locally.
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 836335bc..46a9832c 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -66,7 +66,19 @@ Configure these in **GitHub → Settings → Branches → Branch protection rule
integration also builds a disposable Next app against the real API/PostgreSQL;
Node 22.23.1/pnpm, frozen frontend dependencies and OpenSSL are required by
both that job and the frontend native-ingress proof.
- Its test-owned HTML/RSC routes do not claim P6 pages or a browser audit.
+ P5 keeps its synthetic transport probe; P6 adds unchanged product-page modes
+ for HTML/RSC isolation, pagination, presentation, freshness and recovery.
+ These socket/DOM proofs do not claim a browser or manual accessibility audit.
+ ADR-0055 adds a separate narrow stock-Next navigation probe and development
+ HMR admission controls. Docker integration requires an installed stock
+ Chromium browser; CI explicitly checks `/usr/bin/google-chrome` and its
+ version before the suite. Local supported binaries are Chrome/Brave on macOS
+ and Chrome/Chromium on Linux; a missing browser fails rather than skips.
+ The fixture uses an isolated disposable profile and certificate-specific
+ public-key trust. Its Flight-to-document navigation and fresh HMR admission
+ scope does not deliver P7's browser demo, full Playwright/axe or Lighthouse.
+ The [Step 3 record](../docs/roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout)
+ owns actual execution and remaining reviews.
- `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;
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f0579a46..ab6f6500 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -203,6 +203,13 @@ jobs:
working-directory: frontend
run: pnpm install --frozen-lockfile
+ # ADR-0055's narrow stock-Next Flight fallback proof cannot silently skip.
+ # The hosted Ubuntu image supplies Chrome; do not use a user's browser profile.
+ - name: Verify stock browser for admission navigation proof
+ run: |
+ test -x /usr/bin/google-chrome
+ /usr/bin/google-chrome --version
+
- name: Restore
working-directory: backend
# CI=true here too, not only on the build: NuGet's audit reports at RESTORE, and
diff --git a/CLAUDE.md b/CLAUDE.md
index f576ce2d..a0632c14 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -58,8 +58,9 @@ the phase against its own exit decision: thirty catalogue entries name it as the
that implemented them — module boundaries in all four legs, domain genericity across names,
schema, slugs, keys and the frontend tree, the port and entitlement-key bans, ADR-0040's
two behavioural properties, and three named isolation proofs as `learnstack_app`. The
-standards were re-stated against what enforces them today (nineteen `Active`, three
-`Adopted`), and four guards now hold the corpus to its own claims, so a status header, a
+standards were re-stated against enforcement at Packet 10 closeout (nineteen
+`Active`, three `Adopted`), and four guards now hold the corpus to its own claims,
+so a status header, a
catalogue entry, a published count or a planted `Skip` fails the build rather than a
reader. The whole .NET suite runs with **zero skips**, which the runner now refuses to
let change.
@@ -110,10 +111,41 @@ records final verification. P02d-5 delivers trusted public server rendering unde
Accepted ADR-0053 and ADR-0054: native HTTPS ingress, coordinated visitor/peer
admission, the configured bounded server caller, live-host/locale entry and
source/runtime proof controls. The [remediation and closeout record](docs/roadmap/phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09)
-is the current owner of implementation steps, independent reviews, validation and
-PR readiness. P02d-5 remains unmerged in [PR #26](https://github.com/HodeTech/LearnStack/pull/26).
-Work stays on development. P02d-6's decision pass and product pages are next;
-P02d-7 owns the browser demo and Lighthouse. Neither packet is delivered by P5.
+owns implementation steps, independent reviews and validation. **P02d-5 is complete
+and merged** through [PR #26](https://github.com/HodeTech/LearnStack/pull/26) on
+2026-10-09. Its
+[merge closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-5-merge-and-closeout-2026-10-09)
+records final verification.
+Work stays on development. **P02d-6's decision pass is Accepted — 2026-10-09**:
+[ADR-0027](docs/decisions/0027-frontend-i18n.md) and its
+[decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09)
+authorize four implementation steps and two independent review rounds per step.
+Step 1 implements the pinned i18n and document foundation, verified request-local
+admission/content loaders, atomic color theming, chrome and controlled status/error/
+loading components. Its
+[delivery record](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation)
+owns its validation and review evidence. Step 2 adds actual catalog/course pages,
+independent pagination and metadata from the shared resource loader. Its
+[delivery record](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-2-catalog-and-course-pages)
+owns current verification and review status. Step 3 adds the ordered plain-text
+lesson page, eligible lesson metadata and 31 blocking JSX accessibility rules
+with actual-config planted controls. Its
+[delivery record](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-3-ordered-lesson-presentation)
+owns verification and review status. Step 4 adds concurrent host/locale, theme,
+freshness and content-fault product proofs; its
+[delivery record](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout)
+records both completed review rounds. **P02d-6 implementation is complete —
+2026-10-10, unmerged**: the
+[packet closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10)
+records passing manual browser checks and the maintainer's VoiceOver smoke.
+Maintainer PR review and merge remain pending. ADR-0055 remediation delivers
+single-bootstrap admission and passing production replacement proofs. All three
+steps completed both independent review rounds;
+the [Step 3 record](docs/roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout)
+owns completed Step 3 reviews, verified fixes and delivery evidence. The five new
+catalogue rules are Implemented.
+P02d-7 owns the browser demo and Lighthouse; Phase 06 retains
+the full renderer/Studio expansion.
**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 1a7cfe13..ffe643e6 100644
--- a/README.md
+++ b/README.md
@@ -52,9 +52,9 @@ The planned surfaces serve the people on each side of that experience:
| **Learner portal** | Enrolled learners | Work through lessons, track progress and join live sessions. |
| **Optional Course Marketplace** | Learners and participating institutions | Shared discovery and central checkout; endorsed target, architecture approval pending. |
-These are planned product capabilities. Today, the frontend contains route scaffolds
-for the first three surfaces; the status below separates delivered foundations from the
-remaining product work.
+The public catalog, course and lesson pages are implemented. Studio and learner
+portal remain scaffolds; the status below separates delivered work from remaining
+product and validation obligations.
**Built for different ways of teaching.** Content types and level taxonomies already
have a validated customization model. The wider design covers page blocks, lesson
@@ -93,8 +93,16 @@ Its four steps deliver the read-only host boundary, anonymous site/Education
GET/HEAD reads and OpenAPI/SDK/CI controls. Every step completed both review
rounds; final fix verification and all six real required CI jobs pass. The sixth
required OpenAPI check is active, with all other live protection settings preserved.
-Trusted server rendering is delivered by P02d-5; public product pages and the
-browser demo remain P02d-6–7. The
+Trusted server rendering is delivered by P02d-5. P02d-6 implements localized
+catalog/course/lesson pages, ordered text fields, safe themes and metadata; its
+[product closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout)
+records automated verification/reviews and scoped browser/VoiceOver observations.
+The original P02d-6 implementation is complete. ADR-0055 remediation delivers
+single-bootstrap admission and passing production replacement proofs; all three
+steps completed both independent review rounds, and
+the [Step 3 record](docs/roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout)
+owns completed Step 3 reviews, verified fixes and delivery evidence. Maintainer
+PR review and merge remain pending. The browser demo remains P02d-7. The
[P02d-4 decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-4-decision-package-2026-10-03)
is Accepted — 2026-10-03. All four implementation steps and their review loops
are complete. P02d-5 supplies trusted server rendering; P02d-6 supplies public
@@ -105,9 +113,9 @@ page consumers and P02d-7 the two-host browser demo.
| **Tenancy** | Tenant provisioning, organizations, locales, typed settings/branding reads, host resolution and database isolation | User membership and permissions in [Phase 03](docs/roadmap/phase-03-identity-admin.md) |
| **Customization** | Content types, level taxonomies, exact-definition and generation-cached batched display readers, text-card metadata validation and tenant-authored seeds | Remaining authoring capabilities across [Phases 04–08a](docs/roadmap/README.md) |
| **Audit** | Classified write path and transactional durability for business changes | Operational hardening in [Phase 11](docs/roadmap/phase-11-production-hardening.md) |
-| **Education** | Course and Lesson aggregates, translations, protected-content policy, scoped authoring commands, complete demo seeds and isolated public GET/HEAD reads | Public page consumers in [P02d-6](docs/roadmap/phase-02d-walking-skeleton.md) |
+| **Education** | Course and Lesson aggregates, translations, protected-content policy, scoped authoring commands, complete demo seeds and isolated public GET/HEAD reads | Full versioned curriculum in [Phase 05](docs/roadmap/phase-05-education-learning-content.md) |
| **API foundation** | Error contracts, validation, tenancy, concurrency and observability infrastructure | Authentication and durable event processing in [Phase 02b](docs/roadmap/phase-02b-events-auth.md) |
-| **Frontend** | Native TLS ingress, live locale entry, trusted server caller, route scaffolds and generated typed SDK | Public pages and two-tenant browser demo in [P02d-6–7](docs/roadmap/phase-02d-walking-skeleton.md) |
+| **Frontend** | Native TLS ingress, live locale entry, trusted server caller, localized catalog/course/lesson pages, safe themes, accessibility controls/manual smoke and generated typed SDK | Two-tenant browser demo and Lighthouse in [P02d-7](docs/roadmap/phase-02d-walking-skeleton.md) |
**Four modules contain domain implementations:** Tenancy, Customization, Audit and
Education. Identity, Content and Media remain scaffolded.
@@ -185,14 +193,14 @@ this launcher. Both processes read the same private root source; a web `.env.loc
projection is optional and must match. No shell evaluates the values. Ordinary
no-hop API startup remains supported outside this paired renderer recipe.
-**P02d-5 implements ingress, visitor budgets and public entry.** The web
+**P02d-5 is complete: ingress, visitor budgets and public entry.** The web
bootstraps the captured host through the API, redirects using enabled/default
locales and refuses unavailable scope. The server caller enforces no-store,
deadline/body limits and the closed authenticated hop. All four steps completed
both independent review rounds and fix verification. Source fences, the no-skip
test runner and real-API production HTML/RSC proofs pass. The
[remediation and closeout record](docs/roadmap/phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09)
-owns current review/CI evidence. Public pages are P02d-6;
+owns current review/CI evidence. P02d-6 implements public pages;
the two-host browser demo with `make demo` is P02d-7. See
[local setup](.claude/skills/local-dev-setup/SKILL.md) and
[Compose documentation](infra/compose/README.md) for troubleshooting.
@@ -267,7 +275,8 @@ backend/
tests/ Unit, architecture, contract and integration suites
analyzers/ LearnStack Roslyn analyzers
frontend/
- apps/web/ Public site, Admin Studio and learner portal scaffolds
+ apps/web/ Public catalog, course and lesson pages
+ Admin Studio and learner portal scaffolds
packages/ Shared config, UI and generated API SDK
infra/ Compose stack and service configuration
scripts/ Seed and verification helpers
diff --git a/backend/src/LearnStack.Api/Common/ProblemDetailsFactory.cs b/backend/src/LearnStack.Api/Common/ProblemDetailsFactory.cs
index c7c0b956..f97acce4 100644
--- a/backend/src/LearnStack.Api/Common/ProblemDetailsFactory.cs
+++ b/backend/src/LearnStack.Api/Common/ProblemDetailsFactory.cs
@@ -17,7 +17,8 @@ namespace LearnStack.Api.Common;
///
/// intentionally carries the
/// lockey_* localization key (matches the Standards 09 § API Surface
-/// example). The frontend resolves the key against its i18n catalogue; the
+/// example). Consumers own explicit supported mappings, separately from general
+/// dotted UI catalogues (ADR-0027); arbitrary keys are not UI lookups. The
/// wire value is stable across locales so support staff debugging in
/// Insomnia / curl can match the lockey back to the catalogue entry. A
/// future LocalizedMessage → text projector may compose a human-readable
diff --git a/backend/src/LearnStack.SharedKernel/Localization/LocalizedMessage.cs b/backend/src/LearnStack.SharedKernel/Localization/LocalizedMessage.cs
index 43480224..25dffd86 100644
--- a/backend/src/LearnStack.SharedKernel/Localization/LocalizedMessage.cs
+++ b/backend/src/LearnStack.SharedKernel/Localization/LocalizedMessage.cs
@@ -6,13 +6,13 @@ namespace LearnStack.SharedKernel.Localization;
/// Localization-key carrier for every user-facing message LearnStack
/// returns to the frontend. The backend never returns raw English text;
/// it returns a whose
-/// resolves to a translation on the client.
+/// is stable wire data. Each consuming feature owns its supported UI mapping.
///
///
///
/// The lockey_ prefix invariant is enforced at the constructor:
-/// every key must match the format the frontend's
-/// i18n bundles ship under. Mis-prefixed keys fail loud at the
+/// every backend message key uses this namespace, separately from dotted
+/// general UI keys (ADR-0027). Mis-prefixed keys fail loud at the
/// point of construction rather than silently resolving to "missing
/// translation" at render time. Per Phase 02a Packet 2.
///
@@ -28,8 +28,8 @@ namespace LearnStack.SharedKernel.Localization;
public sealed record LocalizedMessage
{
///
- /// The required prefix for every localization key. The frontend's
- /// translation catalogues use the same prefix.
+ /// The required prefix for backend message keys. General frontend UI
+ /// catalogues use a separate dotted namespace.
///
public const string RequiredPrefix = "lockey_";
diff --git a/backend/tests/LearnStack.Tests.Architecture/CorpusConsistencyTests.cs b/backend/tests/LearnStack.Tests.Architecture/CorpusConsistencyTests.cs
index cd9572be..168c3261 100644
--- a/backend/tests/LearnStack.Tests.Architecture/CorpusConsistencyTests.cs
+++ b/backend/tests/LearnStack.Tests.Architecture/CorpusConsistencyTests.cs
@@ -199,11 +199,11 @@ public void The_Corpus_Guards_Can_Actually_Fail()
- **Status:** **Implemented** — `SomeTests.cs`.
- #### `A_Rule_Whose_Entry_Names_The_Class`
+ ### `A_Rule_Whose_Entry_Names_The_Class`
- **Status:** **Implemented** (Packet 6 step 4, `SomeTests`).
- #### `A_Rule_That_Does_Not`
+ ### `A_Rule_That_Does_Not`
- **Status:** **Registered.**
@@ -211,15 +211,16 @@ public void The_Corpus_Guards_Can_Actually_Fail()
- **Status:** **Implemented** (`LearnStack.Tests.Integration`, `OtherTests`).
- #### `An-Analyzer-Rule`
+ ### `An-Analyzer-Rule`
- **Status:** **Implemented** — `SomeTests.cs`.
""").Should().Equal([
"A_Rule_That_Runs",
"A_Rule_Whose_Entry_Names_The_Class",
"An-Analyzer-Rule",
- ], "a hyphen in a rule name is a rule name: excluding it dropped the `LS0001` "
- + "analyzer's entry from every count here, prose and recount alike");
+ ], "both third- and fourth-level rule headings count; a hyphen in a rule name "
+ + "is a rule name: excluding it dropped the `LS0001` analyzer's entry from "
+ + "every count here, prose and recount alike");
// The class reader the orphan check rests on: a `…Tests` token in backticks, with or
// without its extension, and nothing else.
@@ -424,7 +425,7 @@ private static Dictionary> DeclaredMethods() =>
// name is `LearnStackException-DomainExceptionThrow`. Excluding it dropped that entry from
// every count here, and because the prose and the recount read through this same pattern
// they agreed with each other while both were short by one.
- [GeneratedRegex(@"#### `(?[A-Za-z0-9_\-]+)`(?.*?)(?=\n#{2,4} |\z)", RegexOptions.Singleline)]
+ [GeneratedRegex(@"^#{3,4} `(?[A-Za-z0-9_\-]+)`(?.*?)(?=\n#{2,4} |\z)", RegexOptions.Singleline | RegexOptions.Multiline)]
private static partial Regex CatalogueEntry();
// Three spellings, because the catalogue writes all three: the bare class, the file with
diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicAdmissionRenderingTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicAdmissionRenderingTests.cs
new file mode 100644
index 00000000..17f2691f
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicAdmissionRenderingTests.cs
@@ -0,0 +1,528 @@
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO.Compression;
+using System.Net;
+using System.Security.Cryptography;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using FluentAssertions;
+using LearnStack.Tools.Seeder;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Hosting.Server;
+using Microsoft.AspNetCore.Hosting.Server.Features;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Serilog.Core;
+using Serilog.Events;
+using Xunit;
+
+namespace LearnStack.Tests.Integration.Database;
+
+/// One copied production build; fresh real API budgets for each bounded proof phase.
+[Collection(PublicReadTestGroup.Name)]
+[Trait(RequiresDocker.Key, RequiresDocker.Value)]
+public sealed class PublicAdmissionRenderingTests(PublicReadFixture fixture)
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+ private const string PrivateMarker = "admission-fixture-private-provider-value";
+ private static readonly string[] ValidLocales = ["en"];
+ private static readonly string[] DuplicateLocales = ["en", "en"];
+ private sealed record WireCase(string Name, string Kind, int ApiStatus, string? RetryAfter,
+ string Method, int Status, string? ExpectedRetryAfter = null, string Code = "dependency_unavailable");
+ private static readonly WireCase[] WireCases =
+ [
+ new("missing-get", "problem", 404, null, "GET", 404, Code: "not_found"),
+ new("missing-head", "problem", 404, null, "HEAD", 404, Code: "not_found"),
+ new("rate-zero", "problem", 429, "0", "GET", 429, "0", "rate_limited"),
+ new("rate-head", "problem", 429, "60", "HEAD", 429, "60", "rate_limited"),
+ new("unavailable-zero", "problem", 503, "0", "GET", 503, "0"),
+ new("unavailable-head", "problem", 503, "60", "HEAD", 503, "60"),
+ new("audit-retry", "problem", 503, "60", "GET", 503, "60", "audit_unavailable"),
+ new("retry-over", "problem", 429, "61", "GET", 429, Code: "rate_limited"),
+ new("retry-negative", "problem", 429, "-1", "GET", 429, Code: "rate_limited"),
+ new("retry-fraction", "problem", 429, "0.5", "GET", 429, Code: "rate_limited"),
+ new("retry-date", "problem", 503, "Wed, 21 Oct 2026 07:28:00 GMT", "GET", 503),
+ new("retry-malformed", "problem", 503, "invalid", "GET", 503),
+ new("retry-unknown", "problem", 503, "60", "GET", 503, Code: "unknown_fixture_code"),
+ new("retry-other-status", "problem", 500, "60", "GET", 503),
+ new("malformed-json", "malformed", 200, null, "GET", 503),
+ new("malformed-rate-problem", "malformed-problem", 429, "60", "GET", 503),
+ new("malformed-unavailable-problem", "malformed-problem", 503, "60", "GET", 503),
+ new("wrong-content-type", "wrong-content-type", 200, null, "GET", 503),
+ new("empty-body", "empty", 200, null, "GET", 503),
+ new("invalid-shape", "invalid-shape", 200, null, "GET", 503),
+ new("invalid-site", "invalid-site", 200, null, "GET", 503),
+ new("invalid-membership", "invalid-membership", 200, null, "GET", 503),
+ new("oversized", "oversized", 200, null, "GET", 503),
+ new("decoded-oversized", "compressed", 200, null, "GET", 503),
+ new("transport", "transport", 0, null, "GET", 503),
+ new("header-deadline", "hold", 0, null, "GET", 503),
+ new("body-deadline", "body-hold", 200, null, "GET", 503),
+ ];
+
+ [Fact]
+ public async Task Native_admission_preserves_wire_failures_request_lifetimes_and_browser_fallback()
+ {
+ var secret = Convert.ToHexString(RandomNumberGenerator.GetBytes(24)).ToLowerInvariant();
+ var root = RepositoryRoot();
+ using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(10));
+ ApiHost? host = null;
+ using var process = RendererProcess(root);
+ var started = false;
+ var verified = false;
+ var position = 0;
+ var hmrChanged = false;
+ var hmrReads = 0;
+ var phases = new List();
+ var firstCourse = SeedData.English.Curriculum!.Courses.First(row => row.Status == "Published"
+ && row.ContentAccess == "public" && row.OrganizationId is null);
+ var english = firstCourse.Translations.Single();
+ try
+ {
+ host = StartApi(secret);
+ started = process.Start();
+ started.Should().BeTrue();
+ var errors = BoundedErrorsAsync(process.StandardError, deadline.Token);
+ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new
+ {
+ apiOrigin = host.Origin,
+ secret,
+ privateMarker = PrivateMarker,
+ locale = english.Locale,
+ courseSlug = english.Slug,
+ tenants = new[]
+ {
+ new { host = SeedData.English.Host, name = SeedData.English.DisplayName, locale = english.Locale,
+ theme = SeedData.English.Curriculum!.Theme.Value },
+ new { host = SeedData.Yoga.Host, name = SeedData.Yoga.DisplayName,
+ locale = SeedData.Yoga.Curriculum!.Locales.Single(row => row.IsDefault).Locale,
+ theme = SeedData.Yoga.Curriculum.Theme.Value },
+ },
+ wireCases = WireCases.Select(row => new { row.Name, row.Method, row.Status, retryAfter = row.ExpectedRetryAfter }),
+ }, JsonOptions));
+ while (await process.StandardOutput.ReadLineAsync(deadline.Token) is { } checkpoint)
+ {
+ checkpoint.Length.Should().BeLessThan(64, "only closed control tokens leave the renderer");
+ string? nextOrigin = null;
+ if (checkpoint == "build-complete") host.Observation.Requests.Should().BeEmpty();
+ else if (checkpoint == "hmr-before")
+ {
+ host.Observation.Requests.Should().HaveCount(position);
+ hmrReads = fixture.Observation.Reads;
+ }
+ else if (checkpoint == "hmr-change")
+ {
+ await fixture.ExecuteAsync(SeedData.English, "UPDATE tenants SET display_name=@name WHERE id=@tenant",
+ new Dictionary { ["name"] = SeedData.English.DisplayName + " HMR refresh" });
+ hmrChanged = true;
+ }
+ else if (checkpoint == "hmr-after")
+ {
+ AssertCalls(host, ref position, 2, 2);
+ fixture.Observation.Reads.Should().Be(hmrReads + 2);
+ }
+ else if (checkpoint == "hmr-restore")
+ {
+ await fixture.ExecuteAsync(SeedData.English, "UPDATE tenants SET display_name=@name WHERE id=@tenant",
+ new Dictionary { ["name"] = SeedData.English.DisplayName });
+ hmrChanged = false;
+ }
+ else if (checkpoint.StartsWith("phase-", StringComparison.Ordinal))
+ {
+ checkpoint.Should().BeOneOf("phase-wire", "phase-lifetime", "phase-browser");
+ phases.Add(checkpoint);
+ if (checkpoint != "phase-wire")
+ {
+ VerifyPhase(host, position);
+ await host.DisposeAsync();
+ host = StartApi(secret);
+ }
+ position = 0;
+ nextOrigin = host.Origin;
+ }
+ else if (checkpoint.StartsWith("arm-", StringComparison.Ordinal))
+ {
+ host.Observation.Requests.Length.Should().Be(position, "each earlier API call belongs to an asserted checkpoint");
+ var name = checkpoint[4..];
+ if (name == "overlap") host.Observation.ArmGate("/api/v1/public/site", 4);
+ else if (name == "shutdown") host.Observation.ArmGate("/api/v1/public/courses", 2);
+ else if (name is "bootstrap" or "content")
+ host.Observation.Arm(new WireCase(name, "hold", 0, null, "GET", 503), name == "bootstrap" ? "/api/v1/public/site" : "/api/v1/public/courses");
+ else if (name is "browser-429" or "browser-503")
+ {
+ var status = name == "browser-429" ? 429 : 503;
+ host.Observation.Arm(new WireCase(name, "problem", status, "0", "GET", status,
+ Code: status == 429 ? "rate_limited" : "dependency_unavailable"));
+ }
+ else host.Observation.Arm(WireCases.Single(row => row.Name == name));
+ }
+ else if (checkpoint == "release-overlap") host.Observation.ReleaseGate();
+ else if (checkpoint is "held-overlap" or "held-shutdown")
+ await host.Observation.WaitForGateAsync(deadline.Token);
+ else if (checkpoint.StartsWith("held-", StringComparison.Ordinal))
+ await host.Observation.WaitForHeldAsync(deadline.Token);
+ else if (checkpoint.StartsWith("aborted-", StringComparison.Ordinal))
+ {
+ if (checkpoint == "aborted-shutdown")
+ {
+ await host.Observation.WaitForGateAbortAsync(deadline.Token);
+ AssertCalls(host, ref position, 4, 2);
+ host.Observation.VerifyGate();
+ }
+ else
+ {
+ await host.Observation.WaitForAbortAsync(deadline.Token);
+ var expected = checkpoint == "aborted-bootstrap" ? 1 : 2;
+ AssertCalls(host, ref position, expected, 1);
+ host.Observation.VerifyFault();
+ }
+ }
+ else if (checkpoint.StartsWith("done-", StringComparison.Ordinal))
+ {
+ var name = checkpoint[5..];
+ var wire = WireCases.SingleOrDefault(row => row.Name == name);
+ if (wire is not null)
+ {
+ AssertCalls(host, ref position, 1, 1);
+ if (wire.Kind is "hold" or "body-hold") await host.Observation.WaitForAbortAsync(deadline.Token);
+ host.Observation.VerifyFault();
+ }
+ else if (name is "browser-429" or "browser-503")
+ {
+ var calls = AssertCalls(host, ref position, 3, 2);
+ calls.Select(row => row.Status).Should().Equal(name == "browser-429" ? 429 : 503, 200, 200);
+ host.Observation.VerifyFault();
+ }
+ else if (name.StartsWith("context-", StringComparison.Ordinal) || name == "configuration")
+ AssertCalls(host, ref position, 0, 0);
+ else if (name is "html-overlap" or "rsc-overlap" or "prefetch-overlap" or "mixed-overlap")
+ {
+ AssertCalls(host, ref position, 8, 4);
+ host.Observation.VerifyGate();
+ }
+ else if (name == "scaffold-overlap")
+ {
+ AssertCalls(host, ref position, 4, 4);
+ host.Observation.VerifyGate();
+ }
+ else if (name == "keep-alive") AssertCalls(host, ref position, 4, 2);
+ else if (name == "late-head" || name == "browser-initial" || name == "entry-refusal")
+ AssertCalls(host, ref position, 1, 1);
+ else if (name is "healthy-wire" or "healthy-lifetime" or "head" or "hmr-before" or "hmr-after")
+ AssertCalls(host, ref position, 2, 1);
+ else throw new InvalidOperationException("Unexpected admission proof checkpoint.");
+ }
+ else if (checkpoint == "verified")
+ {
+ VerifyPhase(host, position);
+ verified = true;
+ }
+ else throw new InvalidOperationException("Unexpected admission proof checkpoint.");
+ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new { kind = "continue", apiOrigin = nextOrigin }, JsonOptions));
+ }
+ await process.WaitForExitAsync(deadline.Token);
+ process.ExitCode.Should().Be(0, await errors);
+ verified.Should().BeTrue();
+ phases.Should().Equal("phase-wire", "phase-lifetime", "phase-browser");
+ }
+ finally
+ {
+ if (started && !process.HasExited)
+ {
+ process.StandardInput.Close();
+ using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(20));
+ try { await process.WaitForExitAsync(cleanup.Token); }
+ catch (OperationCanceledException)
+ {
+ process.Kill(entireProcessTree: true);
+ await process.WaitForExitAsync(CancellationToken.None);
+ }
+ }
+ if (hmrChanged)
+ await fixture.ExecuteAsync(SeedData.English, "UPDATE tenants SET display_name=@name WHERE id=@tenant",
+ new Dictionary { ["name"] = SeedData.English.DisplayName });
+ if (host is not null) await host.DisposeAsync();
+ }
+ }
+
+ private ApiHost StartApi(string secret)
+ {
+ var observation = new AdmissionObservation(secret);
+ var factory = fixture.WithWebHostBuilder(builder =>
+ {
+ builder.UseSetting("Tenancy:TrustedHop:Networks:0", "127.0.0.1/32");
+ builder.UseSetting("Tenancy:TrustedHop:Secrets:0", secret);
+ builder.ConfigureTestServices(services =>
+ {
+ services.AddSingleton(observation);
+ services.AddSingleton(observation);
+ });
+ });
+ factory.UseKestrel(options => options.Listen(IPAddress.Loopback, 0));
+ var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
+ var address = factory.Services.GetRequiredService().Features.Get()!.Addresses.Single();
+ return new(factory, client, address, observation, fixture.Observation.Reads);
+ }
+
+ private void VerifyPhase(ApiHost host, int position)
+ {
+ host.Observation.Requests.Should().HaveCount(position);
+ host.Observation.Requests.Should().HaveCountLessThanOrEqualTo(60, "each phase uses the unchanged single-visitor budget");
+ host.Observation.Requests.Should().OnlyContain(row => row.ValidHop);
+ host.Observation.PrivateLog.Should().BeFalse();
+ host.Observation.Logs.Should().BeGreaterThan(0, "API containment observes nonempty real structured logs");
+ fixture.Observation.Reads.Should().BeGreaterThan(host.InitialReads, "positive controls execute the production reader as READ ONLY learnstack_app");
+ }
+
+ private static ObservedRequest[] AssertCalls(ApiHost host, ref int position, int count, int sites)
+ {
+ var rows = host.Observation.Requests.Skip(position).ToArray();
+ rows.Should().HaveCount(count);
+ rows.Count(row => row.Path == "/api/v1/public/site").Should().Be(sites);
+ rows.Count(row => row.Path.StartsWith("/api/v1/public/courses", StringComparison.Ordinal)).Should().Be(count - sites);
+ if (count > 0) rows.Should().OnlyContain(row => row.ValidHop);
+ position += rows.Length;
+ return rows;
+ }
+
+ private sealed record ApiHost(WebApplicationFactory Factory, HttpClient Client, string Origin,
+ AdmissionObservation Observation, int InitialReads) : IAsyncDisposable
+ {
+ public async ValueTask DisposeAsync()
+ {
+ Client.Dispose();
+ await Factory.DisposeAsync();
+ }
+ }
+
+ private sealed class ObservedRequest(string path, bool validHop)
+ {
+ public string Path { get; } = path;
+ public bool ValidHop { get; } = validHop;
+ public int Status { get; set; }
+ }
+
+ /// Explicit one-shot wire faults; healthy operations keep the real API pipeline.
+ private sealed class AdmissionObservation(string secret) : IStartupFilter, ILogEventSink
+ {
+ private readonly ConcurrentQueue _requests = new();
+ private Fault? _fault;
+ private Fault? _armed;
+ private Gate? _gate;
+ private int _privateLog;
+ private int _logs;
+ public ObservedRequest[] Requests => _requests.ToArray();
+ public bool PrivateLog => Volatile.Read(ref _privateLog) != 0;
+ public int Logs => Volatile.Read(ref _logs);
+ public void ArmGate(string path, int count)
+ {
+ _gate.Should().BeNull();
+ _gate = new(path, count);
+ }
+ public Task WaitForGateAsync(CancellationToken token) => _gate!.Entered.Task.WaitAsync(TimeSpan.FromSeconds(15), token);
+ public Task WaitForGateAbortAsync(CancellationToken token) => _gate!.Aborted.Task.WaitAsync(TimeSpan.FromSeconds(15), token);
+ public void ReleaseGate() => _gate!.Release.TrySetResult();
+ public void VerifyGate()
+ {
+ _gate!.Arrivals.Should().Be(_gate.Count, "identical requests count separately in the overlap multiset");
+ _gate.Entered.Task.IsCompletedSuccessfully.Should().BeTrue();
+ _gate = null;
+ }
+ public void Arm(WireCase value, string path = "/api/v1/public/site")
+ {
+ _armed.Should().BeNull();
+ _armed = new(value, path);
+ Volatile.Write(ref _fault, _armed);
+ }
+ public Task WaitForHeldAsync(CancellationToken token) => _armed!.Entered.Task.WaitAsync(TimeSpan.FromSeconds(15), token);
+ public Task WaitForAbortAsync(CancellationToken token) => _armed!.Aborted.Task.WaitAsync(TimeSpan.FromSeconds(15), token);
+ public void VerifyFault()
+ {
+ _fault.Should().BeNull("the exact one-shot API fault was consumed");
+ _armed!.Entered.Task.IsCompletedSuccessfully.Should().BeTrue();
+ _armed = null;
+ }
+ public void Emit(LogEvent logEvent)
+ {
+ Interlocked.Increment(ref _logs);
+ using var output = new StringWriter(CultureInfo.InvariantCulture);
+ new Serilog.Formatting.Compact.RenderedCompactJsonFormatter().Format(logEvent, output);
+ var value = output.ToString();
+ if (value.Contains(secret, StringComparison.Ordinal) || value.Contains(PrivateMarker, StringComparison.Ordinal)
+ || value.Contains("x-learnstack-", StringComparison.OrdinalIgnoreCase)
+ || Regex.IsMatch(value, @"(? Configure(Action next) => app =>
+ {
+ app.Use(async (context, continuation) =>
+ {
+ if (!context.Request.Path.StartsWithSegments("/api/v1/public")) { await continuation(); return; }
+ var headers = context.Request.Headers;
+ var validHop = headers["X-LearnStack-Hop-Secret"].Count == 1
+ && headers["X-LearnStack-Hop-Secret"] == secret
+ && headers.Accept == "application/json"
+ && headers["traceparent"].Count == 1
+ && Regex.IsMatch(headers["traceparent"].ToString(), "^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1))
+ && headers["X-LearnStack-Visitor-Address"] == "127.0.0.1"
+ && headers["X-LearnStack-Host"].Count == 1
+ && (headers["X-LearnStack-Host"] == SeedData.English.Host + ":3000" || headers["X-LearnStack-Host"] == SeedData.Yoga.Host + ":3000")
+ && !headers.ContainsKey("X-LearnStack-Ingress-Provenance")
+ && !headers.ContainsKey("Authorization") && !headers.ContainsKey("Cookie")
+ && !headers.ContainsKey("X-Tenant-Id") && !headers.ContainsKey("X-Organization-Id")
+ && !headers.ContainsKey("X-Locale") && !headers.ContainsKey("X-Forwarded-For");
+ var row = new ObservedRequest(context.Request.Path.Value!, validHop);
+ _requests.Enqueue(row);
+ if (Volatile.Read(ref _gate) is { } gate && context.Request.Path == gate.Path)
+ {
+ try { await gate.ArriveAsync(context.RequestAborted); }
+ catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) { return; }
+ }
+ var fault = Volatile.Read(ref _fault);
+ if (fault is not null && context.Request.Path == fault.Path
+ && Interlocked.CompareExchange(ref _fault, null, fault) == fault)
+ {
+ fault.Entered.TrySetResult();
+ try { await RespondAsync(context, fault.Value); }
+ finally
+ {
+ if (context.RequestAborted.IsCancellationRequested) fault.Aborted.TrySetResult();
+ }
+ }
+ else await continuation();
+ row.Status = context.Response.StatusCode;
+ });
+ next(app);
+ };
+ private static async Task RespondAsync(HttpContext context, WireCase fault)
+ {
+ if (fault.Kind == "transport") { context.Abort(); return; }
+ if (fault.Kind is "hold" or "body-hold")
+ {
+ if (fault.Kind == "body-hold")
+ {
+ context.Response.StatusCode = 200;
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync("{\"displayName\":", context.RequestAborted);
+ await context.Response.Body.FlushAsync(context.RequestAborted);
+ }
+ try { await Task.Delay(Timeout.InfiniteTimeSpan, context.RequestAborted); }
+ catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) { }
+ return;
+ }
+ context.Response.StatusCode = fault.ApiStatus;
+ context.Response.ContentType = fault.Kind is "problem" or "malformed-problem" ? "application/problem+json"
+ : fault.Kind == "wrong-content-type" ? "text/plain" : "application/json";
+ context.Response.Headers.CacheControl = "no-store";
+ if (fault.RetryAfter is not null) context.Response.Headers.RetryAfter = fault.RetryAfter;
+ var body = fault.Kind switch
+ {
+ "problem" => JsonSerializer.Serialize(new
+ {
+ type = "https://fixture.invalid/private",
+ title = PrivateMarker,
+ status = fault.ApiStatus,
+ detail = PrivateMarker,
+ code = fault.Code,
+ messageKey = "lockey_fixture_private",
+ correlationId = "fixture-private-correlation",
+ instance = "/fixture-private"
+ }, JsonOptions),
+ "malformed" => "{\"private\":",
+ "malformed-problem" => "{\"status\":\"invalid\",\"detail\":\"" + PrivateMarker + "\"}",
+ "wrong-content-type" => JsonSerializer.Serialize(new
+ {
+ displayName = PrivateMarker,
+ enabledLocales = ValidLocales,
+ defaultLocale = "en",
+ theme = (object?)null,
+ showPlatformAttribution = true
+ }, JsonOptions),
+ "empty" => "",
+ "invalid-shape" => "[]",
+ "invalid-site" => "{\"displayName\":42}",
+ "invalid-membership" => JsonSerializer.Serialize(new
+ {
+ displayName = PrivateMarker,
+ enabledLocales = DuplicateLocales,
+ defaultLocale = "en",
+ theme = (object?)null,
+ showPlatformAttribution = true
+ }, JsonOptions),
+ "oversized" or "compressed" => "{\"displayName\":\"" + new string('x', 8 * 1024 * 1024) + "\"}",
+ _ => throw new InvalidOperationException("Unknown admission fixture response."),
+ };
+ if (fault.Kind == "compressed")
+ {
+ context.Response.Headers.ContentEncoding = "gzip";
+ await using var gzip = new GZipStream(context.Response.Body, CompressionLevel.Fastest, leaveOpen: true);
+ await gzip.WriteAsync(System.Text.Encoding.UTF8.GetBytes(body), context.RequestAborted);
+ }
+ else await context.Response.WriteAsync(body, context.RequestAborted);
+ }
+ private sealed record Fault(WireCase Value, string Path)
+ {
+ public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public TaskCompletionSource Aborted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ }
+ private sealed class Gate(string path, int count)
+ {
+ private int _arrivals;
+ private int _aborted;
+ public string Path { get; } = path;
+ public int Count { get; } = count;
+ public int Arrivals => Volatile.Read(ref _arrivals);
+ public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public TaskCompletionSource Aborted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public async Task ArriveAsync(CancellationToken token)
+ {
+ if (Interlocked.Increment(ref _arrivals) == Count) Entered.TrySetResult();
+ try { await Release.Task.WaitAsync(token); }
+ catch (OperationCanceledException) when (token.IsCancellationRequested)
+ {
+ if (Interlocked.Increment(ref _aborted) == Count) Aborted.TrySetResult();
+ throw;
+ }
+ }
+ }
+ }
+
+ private static Process RendererProcess(string root)
+ {
+ var start = new ProcessStartInfo("node")
+ {
+ WorkingDirectory = root,
+ UseShellExecute = false,
+ RedirectStandardInput = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true
+ };
+ start.ArgumentList.Add(Path.Combine(root, "frontend/apps/web/scripts/verify-public-admission.mjs"));
+ start.Environment.Clear();
+ start.Environment["PATH"] = Environment.GetEnvironmentVariable("PATH");
+ start.Environment["NODE_TLS_REJECT_UNAUTHORIZED"] = "1";
+ return new() { StartInfo = start };
+ }
+ private static string RepositoryRoot()
+ {
+ for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent)
+ if (File.Exists(Path.Combine(directory.FullName, "frontend/pnpm-lock.yaml"))) return directory.FullName;
+ throw new InvalidOperationException("Cannot find admission rendering fixture source.");
+ }
+ private static async Task BoundedErrorsAsync(StreamReader stream, CancellationToken token)
+ {
+ var output = new System.Text.StringBuilder();
+ var buffer = new char[1024];
+ int read;
+ while ((read = await stream.ReadAsync(buffer, token)) > 0)
+ if (output.Length + read <= 4096) output.Append(buffer, 0, read);
+ var text = output.ToString();
+ return text.Split('\n', StringSplitOptions.RemoveEmptyEntries).All(line => line.StartsWith("Public admission fixture ", StringComparison.Ordinal))
+ ? text : "Public admission fixture exited unexpectedly.";
+ }
+}
diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicReadFixture.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicReadFixture.cs
index 2e507fd9..f52dd5d0 100644
--- a/backend/tests/LearnStack.Tests.Integration/Database/PublicReadFixture.cs
+++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicReadFixture.cs
@@ -16,7 +16,10 @@
namespace LearnStack.Tests.Integration.Database;
-[CollectionDefinition(Name)]
+// Production Serilog owns a process-global logger. Other collections' API hosts
+// must not replace/dispose it while these fixtures inspect real emitted logs.
+// Concurrent HTTP lanes inside each renderer proof remain concurrent.
+[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class PublicReadTestGroup : ICollectionFixture
{
public const string Name = "Public read HTTP";
diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs
index 37c4ac6c..440309b5 100644
--- a/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs
+++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicServerRenderingTests.cs
@@ -6,7 +6,10 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using FluentAssertions;
+using LearnStack.SharedKernel.Entitlements;
+using LearnStack.SharedKernel.Identifiers;
using LearnStack.Tools.Seeder;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
@@ -23,7 +26,8 @@ namespace LearnStack.Tests.Integration.Database;
///
/// Production Next/native TLS/SDK/API/PostgreSQL proof. The disposable renderer
-/// owns its route; no diagnostic page is added to the shipped application.
+/// separates the P5 transport probe from unchanged P6 product routes.
+/// Pagination, presentation revisions and product closeout use fresh-budget modes.
///
[Collection(PublicReadTestGroup.Name)]
[Trait(RequiresDocker.Key, RequiresDocker.Value)]
@@ -31,12 +35,29 @@ public sealed class PublicServerRenderingTests(PublicReadFixture fixture)
{
private const string SuppliedTrace = "00-1234567890abcdef1234567890abcdef-1234567890abcdef-01";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+ private static readonly Guid PresentationRevisionId = Guid.Parse("01930000-0000-7000-8000-000000006003");
+ private static readonly PresentationField[] PresentationFields =
+ [
+ new("fixture_script", new("Script text", "en"), ""),
+ new("fixture_markup", new("İşaretleme metni", "tr-TR"), "Authored markup"),
+ new("fixture_javascript", new("نص البرمجية", "ar"), "javascript:alert('fixture-presentation')"),
+ new("fixture_data", new("Data address", "en"), "data:text/html,"),
+ new("fixture_http", new("HTTP address", "en"), "http://fixture-presentation.invalid/content")
+ ];
+ private static readonly PresentationLabel PresentationTypeLabel = new("Deneme kartı", "tr-TR");
- [Fact]
- public async Task Production_rendering_is_host_isolated_fresh_and_private_through_the_real_public_API()
+ [Theory]
+ [InlineData("transport")]
+ [InlineData("foundation")]
+ [InlineData("foundation-pagination")]
+ [InlineData("presentation")]
+ [InlineData("product-isolation")]
+ [InlineData("product-freshness")]
+ public async Task Production_rendering_is_host_isolated_fresh_and_private_through_the_real_public_API(string mode)
{
var secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
var observed = new RenderingObservation(secret);
+ var product = mode.StartsWith("product-", StringComparison.Ordinal);
await using var factory = fixture.WithWebHostBuilder(builder =>
{
builder.UseSetting("Tenancy:TrustedHop:Networks:0", "127.0.0.1/32");
@@ -47,6 +68,7 @@ public async Task Production_rendering_is_host_isolated_fresh_and_private_throug
// Production Serilog reads sinks from DI; keep its real
// configuration/enrichers and observe the same emitted events.
services.AddSingleton(observed);
+ if (product) services.AddSingleton();
});
});
factory.UseKestrel(options => options.Listen(IPAddress.Loopback, 0));
@@ -64,17 +86,32 @@ public async Task Production_rendering_is_host_isolated_fresh_and_private_throug
var sharedSlug = firstTranslation.Slug;
var secondCourse = SeedData.Yoga.Curriculum!.Courses.Single(course =>
course.Translations.Any(translation => translation.Locale == locale && translation.Slug == sharedSlug));
+ var firstSlugs = SeedData.English.Curriculum.Courses.SelectMany(course => course.Translations)
+ .Where(translation => translation.Locale == locale).Select(translation => translation.Slug).ToHashSet();
+ var foreignCourse = SeedData.Yoga.Curriculum.Courses
+ .Where(course => course.Status == "Published" && (course.OrganizationId is null
+ || (SeedData.Yoga.MapHostToDefaultOrganization && course.OrganizationId == SeedData.Yoga.DefaultOrganization.OrganizationId)))
+ .SelectMany(course => course.Translations).First(translation => translation.Locale == locale && !firstSlugs.Contains(translation.Slug));
var tenants = new[]
{
TenantInput(SeedData.English, firstCourse, locale),
TenantInput(SeedData.Yoga, secondCourse, locale)
};
+ var productRows = new List<(SeedTenant Tenant, SeedCourse Course)>();
+ var productInputs = product
+ ? new[] { ProductInput(SeedData.English, firstCourse, 1), ProductInput(SeedData.Yoga, secondCourse, 2) }
+ : [];
+ var malformedTheme = false;
var root = RepositoryRoot();
using var process = RendererProcess(root);
using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(8));
var started = false;
var draft = false;
var verified = false;
+ var foundationPosition = 0;
+ var foundationLocales = false;
+ var paginationRows = false;
+ var presentationRevision = false;
var tracePosition = 0;
var beforeProtocol = -1;
var beforeStock = -1;
@@ -82,17 +119,42 @@ public async Task Production_rendering_is_host_isolated_fresh_and_private_throug
var checkpoints = new List();
try
{
+ if (mode == "foundation")
+ {
+ // Test-owned membership extends only this scenario. Product reads
+ // still execute as learnstack_app through the real read-only API.
+ await fixture.ExecuteAsync(SeedData.English,
+ "INSERT INTO tenant_locales(tenant_id,locale,is_default,is_enabled,sort) VALUES(@tenant,'ar',false,true,8),(@tenant,'tr-TR',false,true,9),(@tenant,'tr',false,true,10)");
+ foundationLocales = true;
+ }
+ if (mode == "foundation-pagination")
+ {
+ await AddPaginationRowsAsync(firstCourse);
+ paginationRows = true;
+ }
+ if (product)
+ {
+ foreach (var (tenant, course, index) in new[] { (SeedData.English, firstCourse, 1), (SeedData.Yoga, secondCourse, 2) })
+ {
+ await AddProductTranslationsAsync(tenant, course, index);
+ productRows.Add((tenant, course));
+ }
+ }
started = process.Start();
started.Should().BeTrue();
var errors = BoundedErrorsAsync(process.StandardError, deadline.Token);
await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new
{
+ mode,
apiOrigin = origin,
secret,
locale,
courseSlug = sharedSlug,
+ foreignCourse = new { foreignCourse.Slug, foreignCourse.Title },
traceparent = SuppliedTrace,
- tenants
+ tenants,
+ product = productInputs,
+ presentation = new { label = PresentationTypeLabel, fields = PresentationFields }
}, JsonOptions));
while (await process.StandardOutput.ReadLineAsync(deadline.Token) is { } checkpoint)
{
@@ -107,9 +169,9 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new
case "trace-missing":
case "trace-malformed":
var requests = observed.Requests.Skip(tracePosition).ToArray();
- requests.Should().HaveCount(3, "middleware bootstrap, page bootstrap and content all use the real API");
- requests.Take(2).Should().OnlyContain(request => request.Path == "/api/v1/public/site");
- requests[2].Path.Should().StartWith("/api/v1/public/courses");
+ requests.Should().HaveCount(2, "one middleware bootstrap and one content operation use the real API");
+ requests[0].Path.Should().Be("/api/v1/public/site");
+ requests[1].Path.Should().StartWith("/api/v1/public/courses");
requests.Select(request => request.TraceId).Distinct().Should().ContainSingle("one incoming render has one distributed trace");
requests.Should().OnlyContain(request => request.TraceId.Length == 32 && request.TraceId != new string('0', 32));
if (checkpoint == "trace-supplied") requests[0].TraceId.Should().Be(SuppliedTrace.Substring(3, 32));
@@ -142,6 +204,183 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new
await SetStatusAsync(firstCourse, firstCourse.Status);
draft = false;
break;
+ case "foundation-normal":
+ case "foundation-repeat":
+ case "foundation-missing":
+ var foundationRequests = observed.Requests.Skip(foundationPosition).ToArray();
+ foundationRequests.Should().HaveCount(checkpoint == "foundation-missing" ? 3 : 2,
+ "metadata, document/layout and page share one request-local admission and content operation");
+ foundationRequests.Count(request => request.Path == "/api/v1/public/site").Should()
+ .Be(checkpoint == "foundation-missing" ? 2 : 1);
+ foundationRequests.Count(request => request.Path.StartsWith("/api/v1/public/courses", StringComparison.Ordinal)).Should().Be(1);
+ foundationRequests.Single(request => request.Path.StartsWith("/api/v1/public/courses", StringComparison.Ordinal))
+ .Locale.Should().Be(locale, "UI configuration cannot change the exact content API locale");
+ foundationRequests.Single(request => request.Path != "/api/v1/public/site").Status.Should()
+ .Be(checkpoint == "foundation-missing" ? 404 : 200);
+ foundationPosition = observed.Requests.Length;
+ break;
+ case "foundation-catalog-english":
+ case "foundation-catalog-yoga":
+ case "foundation-catalog-turkish":
+ case "foundation-course-yoga":
+ case "foundation-course-turkish":
+ case "foundation-restricted":
+ case "foundation-empty":
+ case "pagination-catalog-first":
+ case "pagination-catalog-next":
+ case "pagination-catalog-restart":
+ case "pagination-outline-first":
+ case "pagination-outline-next":
+ case "pagination-outline-restart":
+ var productRequests = observed.Requests.Skip(foundationPosition).ToArray();
+ productRequests.Should().HaveCount(2, "an unchanged page uses one middleware admission and one shared content operation");
+ productRequests.Count(request => request.Path == "/api/v1/public/site").Should().Be(1);
+ productRequests.Count(request => request.Path.StartsWith("/api/v1/public/courses", StringComparison.Ordinal)).Should().Be(1);
+ productRequests.Single(request => request.Path != "/api/v1/public/site").Status.Should().Be(200);
+ productRequests.Single(request => request.Path.StartsWith("/api/v1/public/courses", StringComparison.Ordinal))
+ .Locale.Should().Be(checkpoint switch
+ {
+ "foundation-catalog-turkish" or "foundation-course-turkish" =>
+ SeedData.Yoga.Curriculum!.Locales.Single(row => row.IsDefault).Locale,
+ "foundation-empty" => "ar",
+ _ => locale
+ }, "only the admitted content locale reaches Education");
+ foundationPosition = observed.Requests.Length;
+ break;
+ case "foundation-cross-tenant":
+ var crossTenantRequests = observed.Requests.Skip(foundationPosition).ToArray();
+ crossTenantRequests.Should().HaveCount(3, "missing cross-tenant detail and followed status use the approved redirect chain");
+ crossTenantRequests.Count(request => request.Path == "/api/v1/public/site").Should().Be(2);
+ crossTenantRequests.Count(request => request.Path.StartsWith("/api/v1/public/courses", StringComparison.Ordinal)).Should().Be(1);
+ var hiddenCourseRequest = crossTenantRequests.Single(request => request.Path != "/api/v1/public/site");
+ hiddenCourseRequest.Path.Should().Be($"/api/v1/public/courses/{foreignCourse.Slug}");
+ hiddenCourseRequest.Locale.Should().Be(locale);
+ hiddenCourseRequest.Status.Should().Be(404, "the seeded foreign course exists but is hidden on this host");
+ foundationPosition = observed.Requests.Length;
+ break;
+ case "foundation-status":
+ case "foundation-head":
+ case "foundation-refused":
+ case "foundation-canonical":
+ var statusRequests = observed.Requests.Skip(foundationPosition).ToArray();
+ statusRequests.Should().HaveCount(checkpoint switch
+ {
+ "foundation-status" => 4,
+ "foundation-head" => 1,
+ "foundation-canonical" => 2,
+ _ => 2
+ });
+ statusRequests.Should().OnlyContain(request => request.Path == "/api/v1/public/site",
+ "status pages and admission refusals must never query Education");
+ foundationPosition = observed.Requests.Length;
+ break;
+ case "presentation-english":
+ case "presentation-yoga-en":
+ case "presentation-yoga-tr":
+ case "presentation-rsc-english":
+ case "presentation-rsc-yoga-tr":
+ case "presentation-exact-pin":
+ case "presentation-swapped":
+ case "presentation-rsc-swapped":
+ case "presentation-yoga-unchanged":
+ case "presentation-empty":
+ case "presentation-unavailable":
+ case "presentation-restored":
+ var presentationRequests = observed.Requests.Skip(foundationPosition).ToArray();
+ presentationRequests.Should().HaveCount(2,
+ "the unchanged lesson route shares one middleware admission and one exact content read across metadata, document and page");
+ presentationRequests.Count(request => request.Path == "/api/v1/public/site").Should().Be(1);
+ var lessonRequest = presentationRequests.Single(request => request.Path != "/api/v1/public/site");
+ lessonRequest.Path.Should().Contain("/lessons/");
+ lessonRequest.Locale.Should().Be(checkpoint is "presentation-yoga-tr" or "presentation-rsc-yoga-tr" or "presentation-yoga-unchanged"
+ ? SeedData.Yoga.Curriculum!.Locales.Single(row => row.IsDefault).Locale : locale);
+ foundationPosition = observed.Requests.Length;
+ break;
+ case "presentation-cross-host":
+ case "presentation-protected":
+ var hiddenLessonRequests = observed.Requests.Skip(foundationPosition).ToArray();
+ hiddenLessonRequests.Should().HaveCount(3,
+ "one refused lesson document and its localized status redirect use the real bootstrap chain");
+ hiddenLessonRequests.Count(request => request.Path == "/api/v1/public/site").Should().Be(2);
+ hiddenLessonRequests.Single(request => request.Path != "/api/v1/public/site").Locale.Should()
+ .Be(checkpoint == "presentation-protected" ? SeedData.Yoga.Curriculum!.Locales.Single(row => row.IsDefault).Locale : locale);
+ hiddenLessonRequests.Single(request => request.Path != "/api/v1/public/site").Status.Should().Be(404);
+ foundationPosition = observed.Requests.Length;
+ break;
+ case "presentation-publish-revision":
+ observed.Requests.Length.Should().Be(foundationPosition, "fixture setup makes no public API calls");
+ await AddPresentationRevisionAsync();
+ presentationRevision = true;
+ break;
+ case "presentation-pin-revision":
+ observed.Requests.Length.Should().Be(foundationPosition);
+ await SetPresentationLessonAsync(firstCourse, SeedData.English.Curriculum!.ContentType.SchemaVersion + 1,
+ JsonSerializer.Serialize(PresentationFields.ToDictionary(field => field.Name, field => field.Value), JsonOptions));
+ break;
+ case "presentation-make-empty":
+ observed.Requests.Length.Should().Be(foundationPosition);
+ await SetPresentationLessonAsync(firstCourse, SeedData.English.Curriculum!.ContentType.SchemaVersion + 1, "{}");
+ break;
+ case "presentation-make-unavailable":
+ observed.Requests.Length.Should().Be(foundationPosition);
+ await SetPresentationLessonAsync(firstCourse, SeedData.English.Curriculum!.ContentType.SchemaVersion + 1,
+ "{\"fixture_script\":42,\"fixture_hidden\":\"unavailable-private-body-canary\"}");
+ break;
+ case "presentation-restore-lesson":
+ observed.Requests.Length.Should().Be(foundationPosition);
+ await RestorePresentationLessonAsync(firstCourse);
+ break;
+ case "product-arm-html":
+ case "product-arm-rsc":
+ observed.Requests.Length.Should().Be(foundationPosition);
+ observed.ArmBarrier(productInputs.SelectMany(input => new[]
+ {
+ new ContentTarget(input.Host + ":3000", "en", $"/api/v1/public/courses/{sharedSlug}/lessons/{input.EnglishLessonSlug}"),
+ new ContentTarget(input.Host + ":3000", "ar", $"/api/v1/public/courses/{input.CourseSlug}/lessons/{input.LessonSlug}")
+ }).ToArray());
+ break;
+ case "product-html":
+ case "product-rsc":
+ AssertProductReads(observed, foundationPosition, siteCalls: 4, contentCalls: 4);
+ observed.VerifyBarrier();
+ var concurrent = observed.Requests.Skip(foundationPosition).Where(request => request.Path != "/api/v1/public/site").ToArray();
+ concurrent.Select(request => (request.Host, request.Locale)).Should().BeEquivalentTo(
+ productInputs.SelectMany(input => new[] { (input.Host + ":3000", "en"), (input.Host + ":3000", "ar") }));
+ concurrent.Should().OnlyContain(request => request.Status == 200 && request.Path.Contains("/lessons/"));
+ foundationPosition = observed.Requests.Length;
+ break;
+ case "product-malform-theme":
+ await SetProductThemeAsync("{\"primary\":\"#abcdef\",\"background\":\"#ffffff\",\"foreground\":\"#111827\",\"muted\":\"fixture-invalid-color\"}");
+ malformedTheme = true;
+ break;
+ case "product-restore-theme":
+ await SetProductThemeAsync(SeedData.English.Curriculum!.Theme.Value);
+ malformedTheme = false;
+ break;
+ case "product-arm-429":
+ observed.ArmFault(new(SeedData.Yoga.Host + ":3000", SeedData.Yoga.Curriculum!.Locales.Single(row => row.IsDefault).Locale,
+ "/api/v1/public/courses/" + secondCourse.Translations.Single(row => row.Locale == "tr-TR").Slug), 429);
+ break;
+ case "product-arm-503":
+ observed.ArmFault(new(SeedData.English.Host + ":3000", "ar",
+ $"/api/v1/public/courses/{productInputs[0].CourseSlug}/lessons/{productInputs[0].LessonSlug}"), 503);
+ break;
+ case var name when name.StartsWith("product-", StringComparison.Ordinal):
+ var missing = name is "product-draft-course" or "product-draft-lesson" or "product-protected-en" or "product-protected-tr";
+ AssertProductReads(observed, foundationPosition, siteCalls: missing ? 2 : 1, contentCalls: 1);
+ var productContent = observed.Requests.Skip(foundationPosition).Single(request => request.Path != "/api/v1/public/site");
+ productContent.Status.Should().Be(missing ? 404 : name switch
+ {
+ "product-bad-cursor" => 400,
+ "product-content-429" => 429,
+ "product-content-503" => 503,
+ _ => 200
+ });
+ if (name == "product-bad-cursor")
+ productContent.Query.Should().Contain("lessonCursor=bad-cursor", "the syntactically accepted cursor must reach the real API validator");
+ if (name is "product-content-429" or "product-content-503") observed.VerifyFault();
+ foundationPosition = observed.Requests.Length;
+ break;
case "verified":
verified = true;
break;
@@ -155,7 +394,40 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new
var errorOutput = await errors;
process.ExitCode.Should().Be(0, errorOutput);
verified.Should().BeTrue("the production fixture must execute every scenario");
- checkpoints.Should().Equal("build-complete", "trace-supplied", "trace-missing", "trace-malformed", "protocol-before", "protocol-after", "stock-before", "stock-after", "make-draft", "restore-published", "verified");
+ if (mode == "transport") checkpoints.Should().Equal("build-complete", "trace-supplied", "trace-missing", "trace-malformed", "protocol-before", "protocol-after", "stock-before", "stock-after", "make-draft", "restore-published", "verified");
+ else if (mode == "foundation") checkpoints.Should().Equal("build-complete", "foundation-normal", "foundation-repeat",
+ "foundation-catalog-english", "foundation-catalog-yoga", "foundation-catalog-turkish",
+ "foundation-course-yoga", "foundation-course-turkish",
+ "foundation-restricted", "foundation-empty", "foundation-cross-tenant", "foundation-missing",
+ "foundation-status", "foundation-head", "foundation-refused", "foundation-canonical", "stock-before", "stock-after", "verified");
+ else if (mode == "foundation-pagination") checkpoints.Should().Equal("build-complete", "pagination-catalog-first", "pagination-catalog-next",
+ "pagination-catalog-restart", "pagination-outline-first", "pagination-outline-next",
+ "pagination-outline-restart", "verified");
+ else if (mode == "presentation") checkpoints.Should().Equal("build-complete", "presentation-english", "presentation-yoga-en", "presentation-yoga-tr",
+ "presentation-rsc-english", "presentation-rsc-yoga-tr", "presentation-publish-revision", "presentation-exact-pin",
+ "presentation-pin-revision", "presentation-swapped", "presentation-rsc-swapped", "presentation-yoga-unchanged",
+ "presentation-make-empty", "presentation-empty", "presentation-make-unavailable", "presentation-unavailable",
+ "presentation-restore-lesson", "presentation-restored", "presentation-cross-host", "presentation-protected", "verified");
+ else if (mode == "product-isolation") checkpoints.Should().Equal("build-complete", "product-arm-html", "product-html", "product-arm-rsc", "product-rsc",
+ "product-malform-theme", "product-theme-fallback", "product-theme-other", "product-restore-theme", "product-theme-restored",
+ "product-protected-en", "product-protected-tr", "verified");
+ else if (mode == "product-freshness") checkpoints.Should().Equal("build-complete", "product-warm-catalog", "product-warm-course", "product-warm-lesson",
+ "make-draft", "product-draft-catalog", "product-draft-course", "product-draft-lesson", "product-draft-other", "restore-published",
+ "product-restored-catalog", "product-restored-course", "product-restored-lesson", "product-bad-cursor", "product-cursor-recovery",
+ "product-arm-429", "product-content-429", "product-429-recovery", "product-arm-503", "product-content-503", "product-503-recovery", "verified");
+ if (mode == "transport") observed.Requests.Should().HaveCount(39,
+ "sixteen HTML/RSC representations use two calls each, the root redirect uses one and six protocol probes use one each");
+ if (mode == "product-isolation") observed.Requests.Should().HaveCount(28,
+ "eight concurrent representations and three theme documents use two calls each, plus two followed missing chains use three each");
+ if (mode == "product-freshness") observed.Requests.Should().HaveCount(34,
+ "fourteen actual product representations use two calls each and two followed missing chains use three each");
+ if (product) foundationPosition.Should().Be(observed.Requests.Length, "every product request belongs to an asserted checkpoint");
+ if (mode == "foundation") observed.Requests.Should().HaveCount(33,
+ "nine product documents use two calls each, two followed missing chains use three each and nine status/entry requests use one each");
+ if (mode == "foundation-pagination") observed.Requests.Should().HaveCount(12,
+ "six actual catalog/outline documents each make exactly two API calls");
+ if (mode == "presentation") observed.Requests.Should().HaveCount(30,
+ "twelve actual lesson HTML/RSC representations use two calls each and two followed hidden lessons use three each");
observed.Requests.Length.Should().BeInRange(1, 59, "the fixture stays within one real anonymous visitor budget");
observed.Requests.Should().OnlyContain(request => request.ValidHop, "the real caller uses the closed authenticated hop");
observed.Logs.Should().BeGreaterThan(0, "API log containment needs a nonempty real logging subject");
@@ -175,7 +447,17 @@ await process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(new
catch (OperationCanceledException) { process.Kill(entireProcessTree: true); await process.WaitForExitAsync(CancellationToken.None); }
}
}
- finally { if (draft) await SetStatusAsync(firstCourse, firstCourse.Status); }
+ finally
+ {
+ if (draft) await SetStatusAsync(firstCourse, firstCourse.Status);
+ if (malformedTheme) await SetProductThemeAsync(SeedData.English.Curriculum!.Theme.Value);
+ foreach (var (tenant, course) in productRows) await RemoveProductTranslationsAsync(tenant, course);
+ if (foundationLocales)
+ await fixture.ExecuteAsync(SeedData.English,
+ "DELETE FROM tenant_locales WHERE tenant_id=@tenant AND locale IN ('ar','tr-TR','tr')");
+ if (paginationRows) await RemovePaginationRowsAsync(firstCourse);
+ if (presentationRevision) await RemovePresentationRevisionAsync(firstCourse);
+ }
}
}
@@ -274,28 +556,307 @@ public void Api_log_containment_recognizes_private_values_without_matching_ordin
observation.PrivateLog.Should().Be(expected);
}
+ private sealed record ProductTenant(string Host, string CourseSlug, string CourseTitle, string CourseSummary,
+ string LessonSlug, string LessonTitle, string EnglishLessonSlug, PresentationContent Content,
+ JsonElement Theme, bool ShowAttribution, string[] AllCanaries, string[] ProtectedCanaries);
+ private sealed record PresentationContent(PresentationLabel Label, PresentationField[] Fields);
+
+ private static ProductTenant ProductInput(SeedTenant tenant, SeedCourse course, int index)
+ {
+ var curriculum = tenant.Curriculum!;
+ var defaultLocale = curriculum.Locales.Single(row => row.IsDefault).Locale;
+ var type = curriculum.ContentType;
+ using var schema = JsonDocument.Parse(type.JsonSchema);
+ using var theme = JsonDocument.Parse(curriculum.Theme.Value);
+ var fields = schema.RootElement.GetProperty("x-fields").EnumerateArray().Select((field, fieldIndex) =>
+ new PresentationField(field.GetProperty("name").GetString()!,
+ new(field.GetProperty("label").GetProperty(defaultLocale).GetString()!, defaultLocale),
+ $"محتوى عربي للمؤسسة {index} والحقل {fieldIndex + 1}")).ToArray();
+ static string[] LessonMarkers(SeedLesson lesson) => lesson.Translations.SelectMany(translation =>
+ {
+ using var body = JsonDocument.Parse(translation.Body);
+ return new[] { translation.Title }.Concat(body.RootElement.EnumerateObject().Select(field => field.Value.GetString()!)).ToArray();
+ }).ToArray();
+ bool Eligible(SeedCourse row, SeedLesson lesson) => row.ContentAccess == "public" && row.Status == "Published" && lesson.Status == "Published"
+ && (row.OrganizationId is null || (tenant.MapHostToDefaultOrganization && row.OrganizationId == tenant.DefaultOrganization.OrganizationId));
+ var allCanaries = curriculum.Courses.SelectMany(row => row.Translations.SelectMany(translation =>
+ new[] { translation.Title, translation.Summary ?? "" }).Concat(row.Lessons.SelectMany(LessonMarkers)))
+ .Where(value => value.Length > 0).Distinct().ToArray();
+ // A shared authored sentence is not an identity marker. Assert every
+ // unique hidden title/body marker, while visible controls prove content.
+ var visibleMarkers = curriculum.Courses.SelectMany(row => row.Lessons.Where(lesson => Eligible(row, lesson)).SelectMany(LessonMarkers)).ToHashSet();
+ var protectedCanaries = curriculum.Courses.SelectMany(row => row.Lessons.Where(lesson => !Eligible(row, lesson)).SelectMany(LessonMarkers))
+ .Where(value => !visibleMarkers.Contains(value)).Distinct().ToArray();
+ protectedCanaries.Should().NotBeEmpty();
+ return new(tenant.Host, "p6-ar-course", $"دورة المؤسسة {index}", $"وصف الدورة العربية للمؤسسة {index}",
+ "p6-ar-lesson", $"درس المؤسسة {index}", course.Lessons.First(row => row.Status == "Published").Translations.Single(row => row.Locale == "en").Slug,
+ new(new(type.DisplayName[defaultLocale], defaultLocale), fields), theme.RootElement.Clone(),
+ tenant.TenantId != SeedData.English.TenantId, allCanaries, protectedCanaries);
+ }
+
+ /// Owner-only test setup for exact-locale rows; this does not exercise application writers.
+ private Task AddProductTranslationsAsync(SeedTenant tenant, SeedCourse course, int index)
+ {
+ var input = ProductInput(tenant, course, index);
+ return fixture.ExecuteOwnerFixtureAsync(tenant,
+ """
+ INSERT INTO tenant_locales(tenant_id,locale,is_default,is_enabled,sort) VALUES(@tenant,'ar',false,true,8);
+ INSERT INTO course_translations(course_id,tenant_id,organization_id,locale,title,summary,slug)
+ SELECT id,tenant_id,organization_id,'ar',@title,@summary,@slug FROM courses WHERE tenant_id=@tenant AND id=@course;
+ INSERT INTO lesson_translations(lesson_id,tenant_id,organization_id,locale,title,slug,body)
+ SELECT id,tenant_id,organization_id,'ar',@lessonTitle,@lessonSlug,CAST(@body AS jsonb)
+ FROM lessons WHERE tenant_id=@tenant AND id=@lesson;
+ """,
+ new Dictionary
+ {
+ ["course"] = course.Id,
+ ["lesson"] = course.Lessons.First(row => row.Status == "Published").Id,
+ ["title"] = input.CourseTitle,
+ ["summary"] = input.CourseSummary,
+ ["slug"] = input.CourseSlug,
+ ["lessonTitle"] = input.LessonTitle,
+ ["lessonSlug"] = input.LessonSlug,
+ ["body"] = JsonSerializer.Serialize(input.Content.Fields.ToDictionary(field => field.Name, field => field.Value), JsonOptions)
+ }, course.OrganizationId);
+ }
+
+ private Task RemoveProductTranslationsAsync(SeedTenant tenant, SeedCourse course) => fixture.ExecuteOwnerFixtureAsync(tenant,
+ """
+ DELETE FROM lesson_translations WHERE tenant_id=@tenant AND lesson_id=@lesson AND locale='ar';
+ DELETE FROM course_translations WHERE tenant_id=@tenant AND course_id=@course AND locale='ar';
+ DELETE FROM tenant_locales WHERE tenant_id=@tenant AND locale='ar';
+ """, new Dictionary
+ {
+ ["course"] = course.Id,
+ ["lesson"] = course.Lessons.First(row => row.Status == "Published").Id
+ }, course.OrganizationId);
+
+ private Task SetProductThemeAsync(string value) => fixture.ExecuteAsync(SeedData.English,
+ "UPDATE tenant_settings SET value=CAST(@value AS jsonb) WHERE tenant_id=@tenant AND key='branding.theme'",
+ new Dictionary { ["value"] = value });
+
+ private static void AssertProductReads(RenderingObservation observed, int position, int siteCalls, int contentCalls)
+ {
+ var requests = observed.Requests.Skip(position).ToArray();
+ requests.Should().HaveCount(siteCalls + contentCalls, "only the actual middleware admission and shared content operations reach the API");
+ requests.Count(request => request.Path == "/api/v1/public/site").Should().Be(siteCalls);
+ requests.Count(request => request.Path != "/api/v1/public/site").Should().Be(contentCalls);
+ requests.Where(request => request.Path == "/api/v1/public/site").Should().OnlyContain(request => request.Status == 200);
+ }
+
private Task SetStatusAsync(SeedCourse course, string status) => fixture.ExecuteOwnerFixtureAsync(
SeedData.English,
"UPDATE courses SET status=@status WHERE tenant_id=@tenant AND id=@id",
new Dictionary { ["id"] = course.Id, ["status"] = status.ToLowerInvariant() });
+ private Task AddPresentationRevisionAsync()
+ {
+ var type = SeedData.English.Curriculum!.ContentType;
+ var fields = PresentationFields.Select(field => new
+ {
+ name = field.Name,
+ label = new Dictionary { [field.Label.Locale] = field.Label.Value }
+ }).Append(new { name = "fixture_optional", label = new Dictionary { ["en"] = "Omitted optional field" } }).ToArray();
+ // This new optional-string profile satisfies ADR-0051. Its order differs
+ // from JSONB key order; the immutable seeded revision is never rewritten.
+ var schema = JsonSerializer.Serialize(new Dictionary
+ {
+ ["$schema"] = "https://json-schema.org/draft/2020-12/schema",
+ ["type"] = "object",
+ ["properties"] = fields.ToDictionary(field => field.name, _ => new { type = "string" }),
+ ["additionalProperties"] = false,
+ ["x-fields"] = fields
+ }, JsonOptions);
+ return fixture.ExecuteOwnerFixtureAsync(SeedData.English,
+ """
+ UPDATE tenant_content_types SET status='Deprecated'
+ WHERE tenant_id=@tenant AND id=@original AND status='Active';
+ INSERT INTO tenant_content_types(id,tenant_id,key,schema_version,status,display_name,
+ json_schema,renderer_key,created_at,created_by)
+ VALUES(@revision,@tenant,@key,@version,'Active',CAST(@label AS jsonb),
+ CAST(@schema AS jsonb),'default-card',now(),@actor);
+ UPDATE customization_generations SET generation=generation+1 WHERE tenant_id=@tenant;
+ """,
+ new Dictionary
+ {
+ ["original"] = type.Id,
+ ["revision"] = PresentationRevisionId,
+ ["key"] = type.Key,
+ ["version"] = type.SchemaVersion + 1,
+ ["label"] = JsonSerializer.Serialize(new Dictionary
+ {
+ [PresentationTypeLabel.Locale] = PresentationTypeLabel.Value
+ }, JsonOptions),
+ ["schema"] = schema
+ });
+ }
+
+ private Task SetPresentationLessonAsync(SeedCourse course, int version, string body) => fixture.ExecuteOwnerFixtureAsync(
+ SeedData.English,
+ """
+ UPDATE lessons SET content_type_schema_version=@version WHERE tenant_id=@tenant AND id=@lesson;
+ UPDATE lesson_translations SET body=CAST(@body AS jsonb)
+ WHERE tenant_id=@tenant AND lesson_id=@lesson AND locale='en';
+ """,
+ new Dictionary
+ {
+ ["lesson"] = course.Lessons.First(row => row.Status == "Published").Id,
+ ["version"] = version,
+ ["body"] = body
+ });
+
+ private Task RestorePresentationLessonAsync(SeedCourse course)
+ {
+ var lesson = course.Lessons.First(row => row.Status == "Published");
+ return SetPresentationLessonAsync(course, lesson.ContentTypeSchemaVersion,
+ lesson.Translations.Single(row => row.Locale == "en").Body);
+ }
+
+ private async Task RemovePresentationRevisionAsync(SeedCourse course)
+ {
+ // Also runs after any failed checkpoint; no test-owned pin/body, lifecycle
+ // or generation increment survives into the shared fixture's next case.
+ await RestorePresentationLessonAsync(course);
+ await fixture.ExecuteOwnerFixtureAsync(SeedData.English,
+ """
+ DELETE FROM tenant_content_types WHERE tenant_id=@tenant AND id=@revision;
+ UPDATE tenant_content_types SET status='Active' WHERE tenant_id=@tenant AND id=@original;
+ UPDATE customization_generations SET generation=generation-1 WHERE tenant_id=@tenant;
+ """,
+ new Dictionary
+ {
+ ["revision"] = PresentationRevisionId,
+ ["original"] = SeedData.English.Curriculum!.ContentType.Id
+ });
+ }
+
+ private Task AddPaginationRowsAsync(SeedCourse course)
+ {
+ var lesson = course.Lessons.First(row => row.Status == "Published");
+ return fixture.ExecuteOwnerFixtureAsync(SeedData.English,
+ """
+ INSERT INTO courses(id,tenant_id,organization_id,slug_key,content_access,status,created_at,created_by)
+ SELECT md5('p6-fixture-course-' || n)::uuid,@tenant,NULL,'p6-fixture-course-' || n,
+ 'public','published',now() + n * interval '1 millisecond',@actor
+ FROM generate_series(1,21) AS n;
+ INSERT INTO course_translations(course_id,tenant_id,organization_id,locale,title,summary,slug)
+ SELECT md5('p6-fixture-course-' || n)::uuid,@tenant,NULL,'en',
+ 'Fixture course ' || lpad(n::text,2,'0'),'Fixture catalog continuation',
+ 'p6-fixture-course-' || n
+ FROM generate_series(1,21) AS n;
+ INSERT INTO lessons(id,tenant_id,organization_id,course_id,sort,content_type_key,
+ content_type_schema_version,status,created_at,created_by)
+ SELECT md5('p6-fixture-lesson-' || n)::uuid,@tenant,NULL,@course,1000+n,
+ @contentType,@contentVersion,'published',now(),@actor
+ FROM generate_series(1,21) AS n;
+ INSERT INTO lesson_translations(lesson_id,tenant_id,organization_id,locale,title,slug,body)
+ SELECT md5('p6-fixture-lesson-' || n)::uuid,@tenant,NULL,'en',
+ 'Fixture lesson ' || lpad(n::text,2,'0'),'p6-fixture-lesson-' || n,
+ CAST(@body AS jsonb)
+ FROM generate_series(1,21) AS n;
+ """,
+ new Dictionary
+ {
+ ["course"] = course.Id,
+ ["contentType"] = lesson.ContentTypeKey,
+ ["contentVersion"] = lesson.ContentTypeSchemaVersion,
+ ["body"] = lesson.Translations.Single(row => row.Locale == "en").Body
+ });
+ }
+
+ private Task RemovePaginationRowsAsync(SeedCourse course) => fixture.ExecuteOwnerFixtureAsync(
+ SeedData.English,
+ """
+ DELETE FROM lesson_translations WHERE tenant_id=@tenant AND slug LIKE 'p6-fixture-lesson-%';
+ DELETE FROM lessons WHERE tenant_id=@tenant AND course_id=@course
+ AND id IN (SELECT md5('p6-fixture-lesson-' || n)::uuid FROM generate_series(1,21) AS n);
+ DELETE FROM course_translations WHERE tenant_id=@tenant AND slug LIKE 'p6-fixture-course-%';
+ DELETE FROM courses WHERE tenant_id=@tenant AND slug_key LIKE 'p6-fixture-course-%';
+ """,
+ new Dictionary { ["course"] = course.Id });
+
private static object TenantInput(SeedTenant tenant, SeedCourse course, string locale)
{
var lesson = course.Lessons.First(row => row.Status == "Published");
var translation = lesson.Translations.Single(row => row.Locale == locale);
+ var defaultLocale = tenant.Curriculum!.Locales.Single(row => row.IsDefault).Locale;
+ var defaultCourse = course.Translations.Single(row => row.Locale == defaultLocale);
+ var defaultLesson = lesson.Translations.Single(row => row.Locale == defaultLocale);
+ var visible = tenant.Curriculum.Courses.Where(row => row.Status == "Published"
+ && (row.OrganizationId is null || (tenant.MapHostToDefaultOrganization
+ && row.OrganizationId == tenant.DefaultOrganization.OrganizationId))).ToArray();
+ var hidden = tenant.Curriculum.Courses.Except(visible).ToArray();
+ var restricted = visible.FirstOrDefault(row => row.ContentAccess == "enrollment_required");
+ var restrictedTranslation = restricted?.Translations.Single(row => row.Locale == locale);
+ var restrictedDefault = restricted?.Translations.Single(row => row.Locale == defaultLocale);
+ var catalogEn = visible.Select(row => row.Translations.Single(translation => translation.Locale == locale))
+ .Select(row => new { row.Title, row.Slug }).ToArray();
+ var catalogDefault = visible.Select(row => row.Translations.Single(translation => translation.Locale == defaultLocale))
+ .Select(row => new { row.Title, row.Slug }).ToArray();
using var body = JsonDocument.Parse(translation.Body);
return new
{
host = tenant.Host,
name = tenant.DisplayName,
courseTitle = course.Translations.Single(row => row.Locale == locale).Title,
+ courseSummary = course.Translations.Single(row => row.Locale == locale).Summary,
lessonSlug = translation.Slug,
lessonTitle = translation.Title,
lessonText = body.RootElement.EnumerateObject().First().Value.GetString(),
- defaultLocale = tenant.Curriculum!.Locales.Single(row => row.IsDefault).Locale
+ defaultLocale,
+ defaultCourseSlug = defaultCourse.Slug,
+ defaultCourseTitle = defaultCourse.Title,
+ defaultCourseSummary = defaultCourse.Summary,
+ defaultLessonSlug = defaultLesson.Slug,
+ defaultLessonTitle = defaultLesson.Title,
+ content = SeedPresentation(tenant, translation, locale),
+ defaultContent = SeedPresentation(tenant, defaultLesson, defaultLocale),
+ restrictedSlug = restrictedTranslation?.Slug,
+ restrictedDefaultSlug = restrictedDefault?.Slug,
+ restrictedDefaultLessonSlug = restricted?.Lessons.First(row => row.Status == "Published")
+ .Translations.Single(row => row.Locale == defaultLocale).Slug,
+ restrictedLessonSlug = restricted?.Lessons.First(row => row.Status == "Published").Translations.Single(row => row.Locale == locale).Slug,
+ restrictedTitle = restrictedTranslation?.Title,
+ restrictedSummary = restrictedTranslation?.Summary,
+ restrictedCanaries = LessonCanaries(restricted, locale),
+ restrictedDefaultCanaries = LessonCanaries(restricted, defaultLocale),
+ catalogEn,
+ catalogDefault,
+ hiddenEn = hidden.Select(row => row.Translations.Single(translation => translation.Locale == locale).Title).ToArray(),
+ hiddenDefault = hidden.Select(row => row.Translations.Single(translation => translation.Locale == defaultLocale).Title).ToArray()
+ };
+ }
+
+ private static object SeedPresentation(SeedTenant tenant, SeedLessonTranslation translation, string locale)
+ {
+ var type = tenant.Curriculum!.ContentType;
+ using var schema = JsonDocument.Parse(type.JsonSchema);
+ using var body = JsonDocument.Parse(translation.Body);
+ return new
+ {
+ label = new PresentationLabel(type.DisplayName[locale], locale),
+ fields = schema.RootElement.GetProperty("x-fields").EnumerateArray().Select(field =>
+ {
+ var name = field.GetProperty("name").GetString()!;
+ return new PresentationField(name,
+ new PresentationLabel(field.GetProperty("label").GetProperty(locale).GetString()!, locale),
+ body.RootElement.GetProperty(name).GetString()!);
+ }).ToArray()
};
}
+ private static string[] LessonCanaries(SeedCourse? course, string locale) => course?.Lessons
+ .SelectMany(row => row.Translations.Where(item => item.Locale == locale))
+ .SelectMany(item =>
+ {
+ using var content = JsonDocument.Parse(item.Body);
+ return new[] { item.Title, item.Slug }.Concat(content.RootElement.EnumerateObject()
+ .Select(field => field.Value.GetString()!)).ToArray();
+ }).Distinct(StringComparer.Ordinal).ToArray() ?? [];
+
+ private sealed record PresentationLabel(string Value, string Locale);
+ private sealed record PresentationField(string Name, PresentationLabel Label, string Value);
+
private static Process RendererProcess(string root, string? temporaryRoot = null)
{
var start = new ProcessStartInfo("node")
@@ -339,9 +900,48 @@ private static async Task BoundedErrorsAsync(StreamReader stream, Cancel
? result.ToString() : "Production rendering fixture exited unexpectedly.";
}
- private sealed record ObservedRequest(string Path, string TraceId, bool ValidHop);
+ private sealed record ObservedRequest(string Path, string Locale, string TraceId, bool ValidHop, string Host, string Query)
+ {
+ public int Status { get; set; }
+ }
+
+ private sealed record ContentTarget(string Host, string Locale, string Path);
+
+ private sealed record ContentFault(ContentTarget Target, int Status);
+
+ /// Four exact content calls must arrive before any enters the real API pipeline.
+ private sealed class ContentBarrier(ContentTarget[] targets)
+ {
+ private readonly HashSet _remaining = targets.ToHashSet();
+ private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private int _arrivals;
+ public int Arrivals => Volatile.Read(ref _arrivals);
+ public bool Released => _release.Task.IsCompletedSuccessfully;
+
+ public async Task ArriveAsync(ContentTarget target, CancellationToken cancellationToken)
+ {
+ lock (_remaining)
+ {
+ if (!targets.Contains(target)) return;
+ _remaining.Remove(target).Should().BeTrue("each held host/locale content operation runs exactly once");
+ Interlocked.Increment(ref _arrivals);
+ if (_remaining.Count == 0) _release.SetResult();
+ }
+ await _release.Task.WaitAsync(TimeSpan.FromSeconds(8), cancellationToken);
+ }
+ }
- /// Observes the real Kestrel request without changing API resolution or transactions.
+ private sealed class ProductEntitlementProvider : IEntitlementProvider
+ {
+ public Task GetAsync(TenantId tenantId, CancellationToken ct = default) =>
+ Task.FromResult(new EntitlementProjection(tenantId, "fixture-product",
+ new Dictionary { [FeatureKeys.WhiteLabelBranding.Value] = tenantId == SeedData.English.TenantId },
+ new Dictionary(), ComplianceCaps.None, null, null, 1));
+ public Task RefreshAsync(EntitlementProjection projection, CancellationToken ct = default) =>
+ Task.FromResult(EntitlementRefreshOutcome.IgnoredAsStale);
+ }
+
+ /// Observes real Kestrel requests; optional content boundary faults never replace site bootstrap.
private sealed class RenderingObservation(string secret) : IStartupFilter, ILogEventSink
{
// Match the complete unpadded wire shape, rather than ordinary v1 route
@@ -352,12 +952,42 @@ private sealed class RenderingObservation(string secret) : IStartupFilter, ILogE
RegexOptions.CultureInvariant,
TimeSpan.FromSeconds(1));
private readonly ConcurrentQueue _requests = new();
+ private ContentBarrier? _barrier;
+ private ContentFault? _fault;
+ private int _faults;
+ private int _verifiedFaults;
private int _logs;
private int _privateLog;
public ObservedRequest[] Requests => _requests.ToArray();
public int Logs => Volatile.Read(ref _logs);
public bool PrivateLog => Volatile.Read(ref _privateLog) != 0;
+ public void ArmBarrier(ContentTarget[] targets)
+ {
+ _barrier.Should().BeNull();
+ Volatile.Write(ref _barrier, new ContentBarrier(targets));
+ }
+
+ public void VerifyBarrier()
+ {
+ _barrier.Should().NotBeNull();
+ _barrier!.Arrivals.Should().Be(4, "all host/locale lanes reached the held content boundary before any was released");
+ _barrier.Released.Should().BeTrue();
+ _barrier = null;
+ }
+
+ public void ArmFault(ContentTarget target, int status)
+ {
+ _fault.Should().BeNull();
+ Volatile.Write(ref _fault, new(target, status));
+ }
+
+ public void VerifyFault()
+ {
+ _fault.Should().BeNull("the exact content fault is consumed once");
+ Volatile.Read(ref _faults).Should().Be(++_verifiedFaults);
+ }
+
public void Emit(LogEvent logEvent)
{
using var text = new StringWriter(CultureInfo.InvariantCulture);
@@ -375,6 +1005,7 @@ public Action Configure(Action next) =
{
app.Use(async (context, continuation) =>
{
+ ObservedRequest? observedRequest = null;
if (context.Request.Path.StartsWithSegments("/api/v1/public"))
{
var headers = context.Request.Headers;
@@ -386,9 +1017,39 @@ public Action Configure(Action next) =
&& !headers.ContainsKey("Authorization") && !headers.ContainsKey("Cookie")
&& !headers.ContainsKey("X-Tenant-Id") && !headers.ContainsKey("X-Organization-Id")
&& !headers.ContainsKey("X-Locale") && !headers.ContainsKey("X-Forwarded-For");
- _requests.Enqueue(new(context.Request.Path.Value!, Activity.Current?.TraceId.ToHexString() ?? "", validHop));
+ observedRequest = new(context.Request.Path.Value!, context.Request.Query["locale"].ToString(),
+ Activity.Current?.TraceId.ToHexString() ?? "", validHop, headers["X-LearnStack-Host"].ToString(), context.Request.QueryString.Value ?? "");
+ _requests.Enqueue(observedRequest);
+ var target = new ContentTarget(observedRequest.Host, observedRequest.Locale, observedRequest.Path);
+ if (Volatile.Read(ref _barrier) is { } barrier) await barrier.ArriveAsync(target, context.RequestAborted);
+ var fault = Volatile.Read(ref _fault);
+ if (fault is not null && target == fault.Target && context.Request.Method == "GET"
+ && Interlocked.CompareExchange(ref _fault, null, fault) == fault)
+ {
+ // A one-shot content boundary fault, not a real handler or
+ // limiter failure. The middleware site bootstrap runs normally.
+ Interlocked.Increment(ref _faults);
+ context.Response.StatusCode = fault.Status;
+ context.Response.ContentType = "application/problem+json";
+ context.Response.Headers.CacheControl = "no-store";
+ await context.Response.WriteAsJsonAsync(new
+ {
+ type = "https://fixture.invalid/fixture-private-problem-type",
+ title = "fixture-private-problem-title",
+ status = fault.Status,
+ detail = "fixture-private-problem-detail",
+ instance = "/fixture-private-instance",
+ code = fault.Status == 429 ? "rate_limited" : "service_unavailable",
+ messageKey = "lockey_fixture_private_problem",
+ correlationId = "fixture-private-correlation",
+ messageParams = new { value = "fixture-private-parameter" }
+ }, options: JsonOptions, contentType: "application/problem+json", cancellationToken: context.RequestAborted);
+ observedRequest.Status = context.Response.StatusCode;
+ return;
+ }
}
await continuation();
+ if (observedRequest is not null) observedRequest.Status = context.Response.StatusCode;
});
next(app);
};
diff --git a/docs/architecture/12-localization.md b/docs/architecture/12-localization.md
index b0c14c49..c1aa3f1f 100644
--- a/docs/architecture/12-localization.md
+++ b/docs/architecture/12-localization.md
@@ -14,9 +14,17 @@ Localisation covers:
- **System messages** — Problem Details `title`/`detail` for human-facing errors.
- **Slugs and routes** — locale-specific URLs (`/tr/kurslar/...`, `/en/courses/...`).
-Out of scope for the initial implementation:
-
-- RTL languages (Arabic, Hebrew). The schema supports them but layout-level RTL is deferred until a tenant requires it.
+Initial renderer scope:
+
+- Document direction and logical layout properties apply from the first public
+ renderer, including an enabled RTL locale; they are not deferred until a tenant
+ requests a redesign. Accepted P02d-6 G39/G43 selects the mapping and proofs;
+ Step 1 implements direction selection and fallback chrome; Step 2 adds
+ catalog/course language attribution; Step 3 adds ordered lesson field labels.
+ Step 4 records the product proof and scoped manual accessibility observations.
+- P02d-6 accepts initial English and Turkish platform UI catalogues in
+ [ADR-0027](../decisions/0027-frontend-i18n.md). Additional UI translations are
+ added when authored; their absence must not narrow enabled content locales.
- Plural forms beyond ICU MessageFormat defaults (Turkish has different rules than English; ICU handles both).
## Locale Identifiers
@@ -207,8 +215,8 @@ Pattern B is cheaper for short fields where joining a translation table is overk
[ADR-0008](../decisions/0008-localization-schema.md); the standard links here.
[P02d-3's decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02)
records acceptance. Step 1 implements locale-carrying resolution; both review
-rounds passed. P02d-4 Step 3 supplies public response fields; language attributes
-remain P02d-6.
+rounds passed. P02d-4 Step 3 supplies public response fields; P02d-6 delivers
+language attributes.
When the requested locale is unavailable:
@@ -275,8 +283,9 @@ redirect/slug registry.
[Localization Standards § Education slug grammar](../standards/08-localization.md#education-slug-grammar)
owns the accepted storage grammar. Its URL-segment restrictions leave translated
content fully Unicode. The [Education spec](../modules/education/README.md#localization-and-url-identity)
-owns the module's identity rules; public route templates and request normalization
-remain later packet decisions.
+owns the module's identity rules; P02d-4/6 deliver the public route templates and
+request normalization recorded in
+[Frontend Standards](../standards/07-frontend-architecture.md#routing).
The routing consequences follow directly, and are behaviour rather than defects:
@@ -292,32 +301,59 @@ The routing consequences follow directly, and are behaviour rather than defects:
## UI String Catalogue
-UI strings (button labels, validation messages, empty-state copy) live in JSON catalogues under the frontend.
+**P02d-6 Step 1 G39 foundation implemented — 2026-10-10.**
+[ADR-0027](../decisions/0027-frontend-i18n.md) selects server-first `next-intl`
+4.14.9 and one app-local catalogue home:
```text
-apps/web/locales/
- en/
- common.json
- portal.json
- studio.json
- tr/
- common.json
- portal.json
- studio.json
+apps/web/src/i18n/
+ request.ts
+ messages/
+ en/public.json
+ tr/public.json
```
-Keys are dotted, namespaced by feature, ICU MessageFormat for plural/select. The
-frontend uses a lightweight i18n library (e.g. `next-intl` or `react-intl`); the choice
-is ADR-0027, reserved and not yet made — see
-[the decisions index](../decisions/README.md#open-adr-drafts).
-
-> **Open in Phase 02d.** Where the catalogue lives — this tree,
-> [Localization Standards § Strings in Code](../standards/08-localization.md#strings-in-code)
-> and the `add-i18n-key` skill each name a different path — and whether ADR-0027 is
-> Accepted in that phase are G39 in
-> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register).
-
-API responses do **not** localise system-level identifiers, only human-facing strings. Error codes are stable English strings; human-readable messages are localised by the consumer when needed, using the locale from the JWT or request.
+Only `public` ships first; later feature namespaces use the same locale directory.
+Shared-package extraction follows ADR-0009's measured duplication trigger.
+General UI keys are lowercase dotted feature names with snake_case segments, such
+as `public.catalog.course_count`; JSON nesting supplies the dots. Values use ICU
+MessageFormat with plain text parameters. Backend `lockey_*` message resources
+remain separate; the SDK validates/normalizes wire errors and owns no translations.
+The web app maps closed page outcomes to its own keys, with bounded unavailable
+copy for unknown outcomes, never arbitrary backend lookup or visible diagnostics.
+
+Request configuration uses the same server-only request-cached admission source as
+document/layout/page consumers: verified ingress, signed target locale and live
+enabled membership. It adds no bootstrap call, i18n routing middleware, locale
+header, rewrite, cookie or `Accept-Language` authority. Messages/configuration stay
+server-only; interactive consumers receive only needed translated labels.
+
+UI fallback selects a whole catalogue by exact tag, narrowed rightmost subtags,
+then platform `en`. Missing required keys in bundled supported languages fail the
+build, not per-key fallback. ICU uses the selected UI language's plural rules.
+Checked key/ICU/argument/callsite contracts and planted controls are required by
+[Localization Standards](../standards/08-localization.md#strings-in-code).
+
+The admitted content locale independently selects API reads and document `lang`;
+runtime internationalization data supplies its direction, with deterministic `ltr`
+when undescribed. UI groups carry their actual selected language/direction, and
+Pattern B labels carry their resolved locale. A test-owned enabled `ar` route
+proves RTL content with English fallback UI, without altering seed inventory or
+claiming an Arabic catalogue. Pattern A content never gains cross-locale fallback.
+Step 1 implements the pinned dependency/plugin, catalogues, request runtime and
+key/ICU/callsite controls. The
+[delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation)
+owns foundation verification and review evidence. Step 2 adds catalog/course
+consumers; Step 3 adds ordered lesson labels and metadata. The
+[Step 4 closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout)
+records product proof and scoped manual accessibility evidence. Phase 04 inherits
+the foundation for CMS/Studio; Phase 06
+owns the full Studio/portal consumers.
+
+API error codes remain stable machine identifiers. Problem Details carries
+backend `lockey_*` message data, not translated text; supported consumer resources
+own its resolution. Public pages instead use the closed UI-outcome mapping above.
+See [Error Handling Standards](../standards/09-error-handling.md#mapping-problem-details--ui).
## Notification Template Localisation
diff --git a/docs/architecture/14-frontend-architecture.md b/docs/architecture/14-frontend-architecture.md
index bdebe08c..33cc9f5a 100644
--- a/docs/architecture/14-frontend-architecture.md
+++ b/docs/architecture/14-frontend-architecture.md
@@ -27,6 +27,32 @@ native method/upgrade admission and pinned URL controls. The remediation record
owns execution and independent review evidence; the ongoing rules live in
[Frontend Standards](../standards/07-frontend-architecture.md#native-admission-and-url-identity).
+**P02d-6 decisions Accepted — 2026-10-09; Steps 1–4 implemented.**
+[ADR-0027](../decisions/0027-frontend-i18n.md) selects the UI foundation; the
+[decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09)
+selects pages, status/navigation, safe theme and product/accessibility proofs.
+Step 1 installs the pinned runtime and supplies shared request-local loaders,
+document language/direction, atomic theme injection, chrome and controlled
+status/error/loading components. Step 2 adds actual catalog/course pages,
+independent opaque pagination and metadata. Step 3 adds ordered lesson fields,
+language attribution and eligible metadata. Step 4 adds concurrent host/locale,
+theme, freshness and content-fault product proofs. The
+[delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout)
+owns validation, completed reviews and passing manual accessibility closeout.
+
+[ADR-0055](../decisions/0055-public-renderer-bootstrap-failures.md) is
+**Accepted — 2026-10-10; implementation delivered and production proofs pass**:
+it replaces the duplicate RSC bootstrap with one middleware-owned live read and
+an immutable same-request snapshot, preserving exact bootstrap 429/503 before
+rendering. The
+[Step 3 record](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout)
+owns passing production replacement proofs and completed Step 3 reviews. Both
+review rounds for all three steps and focused fix verification are complete. The
+[review decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-systematic-review-decision-package-2026-10-10)
+separately records approval for Open Graph projection and bounded Next security
+maintenance. The isolated context-propagation experiment establishes feasibility;
+it is not production security/lifecycle proof or runtime delivery.
+
> **P02d-4 Accepted design — 2026-10-03.** Step 2 delivers site bootstrap;
> Steps 3–4 deliver Education and contract/SDK controls; P02d-5 owns the server consumer.
> [ADR-0052](../decisions/0052-anonymous-public-read-boundary.md) selects host-resolved
@@ -61,13 +87,18 @@ frontend/
sessions/
api/ # only thin BFF proxies, see "Data Fetching"
components/
+ public/ # P6 public views and states
blocks/ # built-in primitive page blocks
ui/ # design-system primitives
lib/
api/
auth/
tenant/
- i18n/
+ i18n/ # ADR-0027 foundation implemented in P6 Step 1
+ request.ts
+ messages/
+ en/public.json
+ tr/public.json
middleware.ts # ingress verification + locale entry
extensions/ # client-side block resolver, see Page Builder
packages/
@@ -76,8 +107,11 @@ frontend/
config/ # eslint, tsconfig, tailwind shared bits
```
-> **Phase 02d ownership.** Component placement (G41) and the UI string catalogue
-> (G39) remain P02d-6 decisions. P02d-5/G36 delivers Node middleware in
+> **Phase 02d ownership.** Accepted G41 places synchronous plain-string views in
+> `apps/web/src/components/public/`; G39 selects the app-local i18n home above.
+> Step 1 implements i18n and chrome/state views; Step 2 adds catalog/course views.
+> Step 3 adds ordered plain-text lesson views and metadata.
+> P02d-5/G36 delivers Node middleware in
> `apps/web/src/middleware.ts`. The ownership and accepted entry rule are recorded in
> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register).
> The tree above is the earlier target layout, not a shipped-directory census.
@@ -96,7 +130,8 @@ Splitting into separate apps is governed by [ADR 0009 — Frontend Single App Fi
## Tenant + Organization Resolution at the Edge
**Accepted public boundary — 2026-10-03.** Site bootstrap is delivered in Step 2;
-P02d-5 Step 3 delivers server transport; page consumers remain P02d-6. The frontend
+P02d-5 Step 3 delivers server transport; P02d-6 delivers the public page consumers.
+The frontend
uses host-resolved site bootstrap, not an edge registry returning tenancy IDs.
[ADR-0052](../decisions/0052-anonymous-public-read-boundary.md) owns the read boundary;
[Frontend Standards](../standards/07-frontend-architecture.md#tenant-resolution)
@@ -147,8 +182,10 @@ provenance, a server-only configured transport and uncached institution renderin
Its four-step [delivery plan](../roadmap/phase-02d-walking-skeleton.md#p02d-5-implementation-plan)
records all four completed steps and both independent review rounds per step.
Frontend fences, no-skip outcomes and real-API production HTML/RSC proofs pass;
-P02d-5 remains unmerged while external-review remediation continues. P02d-6 owns
-public page consumers.
+P02d-5 and its external-review remediation are complete and merged through
+[PR #26](https://github.com/HodeTech/LearnStack/pull/26); the
+[merge closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-5-merge-and-closeout-2026-10-09)
+records final verification. P02d-6 owns public page consumers.
ADR-0054 retains API-owned tenant authority and membership-first locale entry.
Its accepted native boundary admits GET/HEAD on every callback path, including
@@ -172,22 +209,63 @@ Per segment:
Static export is not used; tenants are resolved at request time and the renderer needs per-request context.
+### Accepted P02d-6 public composition
+
+The three locale-prefixed catalog/course/lesson routes use minimal tenant chrome,
+ordinary same-host relative anchors and no automatic prefetch. Both opaque
+pagination surfaces parse owned parameters only from the signed raw target. A new
+document re-reads API state; metadata/layout/page share request-local admission and
+content loaders. ADR-0055's implemented boundary uses one live middleware bootstrap
+and a native same-request immutable snapshot: two API calls per product document,
+one per fixed status/scaffold document and three per followed missing-detail
+chain. Metadata/layout/page/UI reuse adds no bootstrap. HEAD/RSC/prefetch and
+Flight fallback require separately proven counts. No retry or shared DTO cache is
+added.
+
+Missing/hidden resources return local **307** to the same host's fixed
+`/{locale}/status/not-found`, followed by a branded **404** document; the browser
+URL changes. Live host/locale admission applies again, with no Education read or
+original slug/query/cursor echo. Required loading/error files cannot flush a shell
+before redirect admission. Known content-call failures use translated **HTTP 200
+noindex** states for invalid cursor, retry-later and unavailable responses;
+bootstrap retains real neutral 404/429/503. Framework exceptions retain pre-stream
+500/post-stream 200 behavior. [Standards 07](../standards/07-frontend-architecture.md#routing)
+owns the detailed status and navigation rules.
+
+Metadata uses verified live host and validated local segments, with actual eligible
+alternate slugs. Pagination is noindex with a cursor-free first-page canonical;
+errors are noindex without misleading resource alternates. Restricted marketing,
+empty catalog/public outline, empty ready content and unavailable content remain
+distinct. The lesson view renders only ADR-0051's ordered plain-string
+`default-card` fields; unsupported presentation has a bounded fallback. No active
+URL sink, HTML, Markdown, linkification or richer primitive is introduced. Full
+menus, media, preview, authored error pages and Studio remain Phase 06.
+
+UI configuration shares verified request-local admission without i18n routing
+middleware or another locale authority. Admission/content loaders use a private
+weak memo keyed by Next's exact request-store headers object, including error
+rendering; header values never key shared representations. `getPublicUi` uses
+React cache only for selected messages/translators inside an RSC render. UI fallback
+does not change API content
+locale; document/UI/label language and RTL rules live in
+[Localization](12-localization.md#ui-string-catalogue).
+
## Theming
**P02d-4 Step 2 public projection delivered.** Bootstrap returns
only the whole typed four-color theme or null; frontend safe CSS defaults remain
owned here, without backend duplication. Baseline colors apply independently of
plan. Effective WhiteLabelBranding removes LearnStack attribution only. Public
-responses expose no setting keys, raw/partial JSON or organization merge. G42
-still owns document injection in P02d-6.
+responses expose no setting keys, raw/partial JSON or organization merge. Accepted
+G42 document injection is implemented in P02d-6 Step 1.
**P02d-2/3 delivered foundation — 2026-10-03.** The theme writer and typed settings
-accessor are implemented. Step 2 delivers the anonymous projection; renderer
-injection remains P02d-6. The
+accessor are implemented. P02d-4 Step 2 delivers the anonymous projection;
+P02d-6 Step 1 implements renderer injection. The
[whole-theme contract](../modules/tenancy/README.md#whole-theme-setting-and-public-boundary)
selects only tenant-wide color values and no remote subresource. Organization merges,
logo/font URLs and Studio below are Phase 06 targets, not this packet's behavior.
-G42 still selects safe HTML injection before P02d-6.
+G42 accepts atomic four-color validation and fixed server-generated CSS.
A tenant's branding flows from the API as design tokens, and the renderer applies them
as CSS custom properties in the SSR'd page. The variable names are the `--ls-*` set
@@ -196,18 +274,20 @@ names and the shared Tailwind preset reads; this document keeps no second vocabu
The accepted [Tenancy contract](../modules/tenancy/README.md#whole-theme-setting-and-public-boundary)
owns P02d-2's admitted tokens and values. P02d-4 accepts their public projection and
attribution rule; Step 2 delivers this API projection.
-How the tokens reach the document, and how that mechanism stays compatible with the
-nonce-based policy that
-[Security Standards § HTTP Headers](../standards/11-security.md#http-headers) sets as
-the target, is G42 in the same register.
+The emitter revalidates the complete `#rrggbb` palette before producing only four
+fixed color properties in a style element. A null/malformed value retains the
+entire existing CSS default palette; no per-token merge, style attribute, URL or
+font is admitted.
+[Standards 07](../standards/07-frontend-architecture.md#tenant-branding) owns the exact
+tokens. Output is safe without CSP; Phase 11 owns its delivery.
In Phase 06's planned organization override, when the request carries an organization id
and that organization has a
`BrandingOverride`, the override merges on top of the tenant defaults before injection —
the merged token set is the source of truth for the SSR'd page.
-P02d-6's target is a themed first paint through safe SSR injection; no renderer
-injection is implemented yet.
+P02d-6 Step 1 implements safe SSR injection for themed first paint; the delivery
+record owns production verification and review evidence.
Logo/font assets and uploads are Phase 06 targets, requiring safe media and
subresource contracts before their writers or consumers. P02d-2's accepted color-only
@@ -364,6 +444,16 @@ harness and assertions. The decision is recorded in
[Testing Standards § End-to-End Tests](../standards/06-testing.md#end-to-end-tests)
names the owning phase.
- Keyboard navigation and focus order are reviewed before any block ships.
+- Accepted P02d-6/G38(b,c)/G43 requires actual product HTML/DOM proofs and applicable
+ jsx-a11y error rules with planted controls. Manual keyboard, focus, reflow/zoom,
+ contrast and real screen-reader evidence for catalog → course → lesson names
+ commit, environment and both hosts/locales. Automated product proofs and blocking
+ lint controls are implemented; the
+ [packet closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10)
+ records passing manual browser checks and maintainer-reported VoiceOver evidence.
+ See
+ [Testing Standards](../standards/06-testing.md#frontend-test-types); HTTP/RSC
+ proofs do not claim browser E2E or full Playwright/axe delivery.
- Color contrast is verified for every branded theme, including the merged tenant and
organization token set, per
[Accessibility Standards § Color and Contrast](../standards/16-accessibility.md#color-and-contrast).
diff --git a/docs/architecture/32-tenant-customization-model.md b/docs/architecture/32-tenant-customization-model.md
index 9f84a860..23faa835 100644
--- a/docs/architecture/32-tenant-customization-model.md
+++ b/docs/architecture/32-tenant-customization-model.md
@@ -49,17 +49,26 @@ organization-scoped where it makes sense.
[ADR-0051](../decisions/0051-ordered-text-card-presentation.md) adds optional root
ordered `x-fields` metadata and a plain-string `default-card` profile. The two seed
types opt in; the parser and exact reader validate metadata, and legacy schemas remain
-valid. Browser rendering belongs to P02d-6. The wider renderer set below is a
-target, not implemented Phase 02d coverage. P02d-6 implements only the approved subset.
-
-> **Accepted subset; renderer placement remains open.** ADR-0051 selects plain-string
-> text cards with authored order and labels; Phase 02d adds no Markdown or active sink.
-> ADR-0018's wider closed set below is unchanged and is not implemented coverage.
-> G41 still decides unsupported-content fallbacks and component placement. The folder
-> in the sketch does not exist; the key registry in
-> `frontend/apps/web/src/lib/customization/` does not settle that gate. See
+valid. P02d-6 Step 3 implements the bounded lesson view. The wider renderer set
+below is a target, not implemented Phase 02d coverage. P02d-6 implements only the
+approved subset.
+
+> **P02d-6 G41 subset implemented — Step 3, 2026-10-10.** ADR-0051 selects
+> ordered plain-string `default-card` fields with authored language-bearing labels.
+> Synchronous views live in `frontend/apps/web/src/components/public/`. Unsupported
+> renderers/unresolved presentation use bounded placeholders, with no raw JSON,
+> HTML, Markdown, linkification, active URL sink or richer primitive. ADR-0018's
+> wider closed set below remains a target; registry keys are not implementation.
+> The sketch's primitive folder does not exist. See
> [Phase 02d's register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register).
+P02d-6 omits absent optional fields and renders script/HTML/URL-like values as
+escaped inert text. Empty ready content differs from unavailable presentation;
+unknown/unavailable content emits at most one count/state diagnostic per request,
+without field values, keys or private context. Exact schema swaps and inert-value
+proofs use unchanged product routes. Integer/boolean/enum/taxonomy and richer
+primitive expansion remain Phase 04/05 and Phase 06, not this accepted subset.
+
The frontend ships a **fixed, closed set** of primitive renderers:
```typescript
@@ -773,7 +782,7 @@ arrives with the aggregate it edits, per [§ 12](#12-phasing).
> P02d-4's accepted G16(f/g) contract applies baseline colors independently of plan;
> effective `tenancy.white_label_branding` removes LearnStack attribution only.
> Public theme/attribution projection is delivered by P02d-4 Step 2;
-> browser rendering remains P02d-6. Advanced Studio branding remains
+> browser rendering is delivered by P02d-6. Advanced Studio branding remains
> Phase 06 scope, separate from the four-color contract.
## 11. Hard architectural invariants
diff --git a/docs/decisions/0027-frontend-i18n.md b/docs/decisions/0027-frontend-i18n.md
new file mode 100644
index 00000000..96ce1e45
--- /dev/null
+++ b/docs/decisions/0027-frontend-i18n.md
@@ -0,0 +1,366 @@
+# ADR-0027: Frontend UI Localization with next-intl
+
+## Status
+
+Accepted — 2026-10-09.
+
+**Date:** 2026-10-09
+**Deciders:** @cemil
+
+The maintainer accepted this reserved frontend-i18n decision and P02d-6 G39 before
+implementation. At acceptance, no implementation is delivered; later delivery is
+recorded in dated amendments. P02d-6 owns the first consumer, and Phase 04 consumes
+that foundation. The
+[P02d-6 decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09)
+owns the page plan and approval boundary.
+
+## Decision Drivers
+
+- P02d-6 introduces platform-authored navigation, empty/error states, attribution
+ and accessibility labels; English literals cannot serve the bilingual tenant.
+- Async Server Components need server-side messages without shipping the entire
+ catalogue or a provider to every browser.
+- Tenant-enabled content locales and the smaller set of translated UI catalogues
+ have different owners. UI support must not narrow content admission.
+- Competing unimplemented catalogue sketches need one location, key grammar and
+ mechanically checked translation contract at the first consumer.
+- Host/locale admission, exact content locale and trusted transport are already
+ accepted in ADR-0052/0053/0054. An i18n library must not replace those boundaries.
+- Air-gapped rendering must use bundled messages and runtime locale data, without
+ translation services, remote downloads or credentials.
+
+## Considered Options
+
+1. **next-intl, server-first, without its routing middleware** (chosen).
+ Direct async App Router APIs fit the first consumers. Routing remains owned by
+ the verified public entry, with one app-local request configuration.
+2. **react-intl / FormatJS**. A viable ICU and React alternative, including its
+ documented App Router support. Not selected because next-intl directly supplies
+ the async server message APIs needed here; no broader formatting requirement
+ currently outweighs that fit.
+3. **Lingui**. A viable RSC alternative with extraction and compile-time tooling.
+ Not selected because the first explicit JSON catalogues do not require its
+ additional macro/extraction pipeline.
+4. **A library-neutral lookup dictionary until Phase 04**. Not selected because
+ the first plural/select messages, error states and translation checks would
+ create a temporary parallel runtime at the first real consumer.
+
+These are alternatives to the decision, not assertions that the other
+libraries cannot support Server Components. Reconsider the selection if a pinned
+production-build proof fails, its licence changes incompatibly, or actual Studio
+requirements demonstrate a material gap in extraction or formatting.
+
+## Decision
+
+LearnStack uses `next-intl` for platform-authored frontend UI messages, with
+app-local JSON catalogues and server-first formatting. The existing verified
+public entry owns host and content-locale admission; the library neither resolves
+tenants nor redirects requests. UI catalogue fallback never changes the admitted
+content locale.
+
+Acceptance authorizes the contracts below; it does not claim implementation or
+test delivery.
+
+## Context
+
+ADR-0008 decides content storage, not the frontend UI runtime. P02d-4 delivers
+exact enabled-locale content reads and locale-bearing fallback labels. P02d-5
+delivers membership-first entry through mandatory native ingress. P02d-6 is the
+first consumer that needs translated platform copy, earlier than the original
+Phase 04 reservation.
+
+The documentation currently sketches `packages/i18n`, `apps/web/locales` and
+`apps/web/src/i18n//.json`, with differing namespace layouts in
+the frontend skills. None exists as an implemented catalogue. The single
+home is:
+
+```text
+frontend/apps/web/src/i18n/
+ request.ts
+ messages/
+ en/public.json
+ tr/public.json
+```
+
+Only the `public` namespace ships in P02d-6. A later feature adds its own namespace
+under the same locale directory. Shared-package extraction follows ADR-0009's
+measured duplication trigger; an empty `packages/i18n` is not created.
+
+### Dependency and execution boundary
+
+Pin `next-intl` **4.14.9** exactly in the web app and commit the lockfile. The npm
+metadata checked on 2026-10-09 declares **MIT**, Next 15 and React 19 peer support.
+Those ranges include the repository's exact **Next 15.5.18 / React 19.0.0** pins.
+That is compatibility evidence, not a passing build; installation, licence-file
+inspection, type checking and a production build are implementation obligations.
+No paid service or network runtime is introduced.
+
+Use the App Router plugin and request configuration without `next-intl` routing
+middleware. Keep catalogue loading and server formatting behind `server-only`.
+Async Server Components use the server APIs. A Client Component receives only the
+translated labels its interaction needs, never the full catalogue by default.
+The error boundary's retry control is such a bounded consumer; a provider is not
+required merely to render static translated text.
+
+`src/i18n/request.ts` calls a server-only, request-cached admission loader. That
+loader re-verifies the existing ingress envelope, takes the canonical route locale
+from its signed target, and checks live site enabled-locale membership. It is the
+same loader used by document/layout/page consumers; it neither imports next-intl
+nor reads messages, avoiding a configuration cycle or another bootstrap call.
+After admission, a pure selector chooses the UI catalogue and the configuration
+returns its locale and `{public: messages}`. The callback does not read next-intl's
+middleware-derived `requestLocale` or use a callsite locale override as authority.
+It adds no locale header, i18n middleware, rewrite, cookie or `Accept-Language`
+selection. Route params and observed query data cannot replace the signed source.
+
+The accepted `/{locale}/status/not-found` namespace uses that same live admission;
+it never loads Education content. Admitted `/studio` and `/portal` scaffolds have
+no content locale and retain platform English until Phase 06 supplies their UI.
+A failed admission cannot select a tenant document through English fallback.
+Production tests prove this integration without next-intl routing middleware,
+including concurrent hosts/locales and configuration use before page rendering.
+
+### Content locale, UI messages and document language
+
+The verified route locale remains the exact locale sent to the Education API.
+Neither `Accept-Language`, a cookie nor a message-catalogue lookup selects it.
+The route is admitted only through the live site's enabled-locale membership.
+
+For platform UI copy, select an authored catalogue by exact canonical tag, then
+remove one rightmost subtag at a time, then use the platform `en` catalogue.
+P02d-6 supplies complete `en` and `tr` catalogues. A tenant-enabled `ar` or `tr-TR`
+route remains admissible even when only fallback UI copy exists. Do not redirect
+it or substitute the content language. Fallback selects a whole UI catalogue when
+that UI language is absent; a missing required key within any supported bundled
+catalogue is a build failure, never per-key fallback or a raw-key response.
+
+ICU formatting uses the selected catalogue's locale: English fallback copy uses
+English plural rules even on a `zh` content route. The API locale and document
+language remain `zh`.
+
+The document's `lang` is the admitted content locale and its direction follows
+that locale using runtime internationalization data. If that data cannot describe
+an admitted tag, the deterministic direction fallback is `ltr`; it never refuses
+the content locale. Platform UI groups whose catalogue language differs carry
+their actual `lang` and direction. Pattern B labels use the API's resolved locale;
+Pattern A titles, summaries and bodies do not gain cross-locale fallback.
+
+### Message and test contract
+
+General platform UI identifiers use lowercase dotted feature namespaces and
+snake_case segments, without `lockey_`. For example, `en/public.json` contains:
+
+```json
+{
+ "catalog": {
+ "course_count": "{count, plural, one {# course} other {# courses}}"
+ }
+}
+```
+
+The request configuration mounts that file under `public`. The `public`
+translator resolves `catalog.course_count` with `{count}`; the full identifier is
+`public.catalog.course_count`. JSON nesting supplies the dot separators.
+Values use ICU MessageFormat; dynamic content enters as plain
+text parameters. Do not use rich-text translation callbacks, HTML messages,
+authored URL attributes or client input as a message identifier.
+
+Backend `LocalizedMessage.Key` and Problem Details `messageKey` retain their
+`lockey_` wire contract, including backend error-localization resources. They are
+distinct from general UI identifiers. The SDK validates those payloads and maps
+known machine codes to `AppError`; it owns no translated resources. P02d-6's web
+app maps supported page outcomes to explicitly owned feature UI keys. It never
+uses arbitrary backend message keys, titles, field errors or parameters as lookup
+identifiers or visible copy; unknown outcomes select the bounded unavailable
+state. This does not remove the backend-message localization contract for future
+form consumers, whose UI owns its supported error resources.
+
+The frontend suite checks a nonempty catalogue, equal key sets across supported
+UI languages and valid ICU messages, including matching argument names/types.
+Typed callsite-to-catalogue checking or a checked callsite census also rejects a
+used key missing from every catalogue and a misspelled callsite identifier;
+catalogue equality alone cannot prove coverage. Planted per-locale missing-key,
+absent-from-all, unknown-callsite, malformed-ICU and mismatched-argument controls
+must fail. Override library error/fallback behavior so a formatter failure yields
+a bounded translated unavailable state, never a raw identifier, namespace,
+parameter value or library diagnostic in the document or logs; exercise that path.
+Request tests distinguish route locale, UI fallback and resolved label locale,
+including region/script tags, unsupported-but-enabled languages and RTL. A
+test-owned enabled `ar` locale and eligible content exercise `lang="ar"` /
+`dir="rtl"`, with English fallback UI labelled `lang="en"` / `dir="ltr"`.
+Do not rewrite the historical seed or claim an Arabic UI catalogue. The production
+fixture proves document/visible-text language and that catalogues and server
+configuration do not cross into an unnecessary client payload.
+
+## Consequences
+
+### Positive
+
+- The first public pages have translated copy and accessible labels from day one.
+- One catalogue home and runtime replace the competing unimplemented sketches.
+- UI translation coverage cannot accidentally become tenant content eligibility.
+- Bundled messages preserve the offline deployment boundary.
+
+### Negative
+
+- The web app acquires a pinned runtime dependency and an App Router integration.
+- Each platform-authored message needs both initial translations and checked ICU
+ arguments. Falling back to English for an untranslated UI language remains a
+ visible limitation, labelled with its actual language.
+- Server and interactive error-state translation need separate bounded interfaces.
+
+### Neutral
+
+- Content satellites, `LocalizedText`, public DTOs, admission and API error codes
+ retain their accepted contracts.
+- Tenant-authored translations/editors remain Phase 04; full Studio/portal UI
+ consumers and renderer expansion remain Phase 06.
+- This chooses no date/number/currency product policy and adds no preference cookie.
+
+## Implementation Notes
+
+The acceptance commit moves ADR-0027 to Active ADRs with P02d-6 as its first-consumer
+gate and reconciles these carriers:
+
+- [Standards 03](../standards/03-frontend-coding.md) for UI keys/checks,
+ [07](../standards/07-frontend-architecture.md) for the `packages/i18n` sketch and
+ [08](../standards/08-localization.md#strings-in-code) for its
+ `packages/i18n/locales/{locale}.json` sketch.
+- [Standards 09](../standards/09-error-handling.md#mapping-problem-details--ui)
+ and the [glossary](../glossary.md#cross-cutting-concerns) for backend wire keys,
+ SDK normalization and UI-owned message resolution.
+- [Localization architecture](../architecture/12-localization.md),
+ [Frontend architecture](../architecture/14-frontend-architecture.md),
+ the [standards index](../standards/README.md) and
+ [Phase 04](../roadmap/phase-04-cms-media-pages.md) for location and ownership.
+- The [add-i18n-key](../../.claude/skills/add-i18n-key/SKILL.md) and
+ [add-frontend-route](../../.claude/skills/add-frontend-route/SKILL.md) skills
+ for the same catalogue home, namespace grammar and server request integration.
+
+Phase 04 consumes the installed foundation and still owns its CMS/Studio message
+coverage. The decision commit makes no installation or implementation claim.
+
+P02d-6 implements catalogues, server configuration, bounded interactive labels and
+the checks above. Each implementation step follows the packet's commit and two
+independent review rounds. This acceptance changes no previously Accepted ADR.
+
+## Architecture Tests
+
+The acceptance commit registers these non-skippable rules with planted controls
+in [Standards 21](../standards/21-architecture-tests-catalogue.md#p02d-6-public-ui-localization-controls),
+before implementation:
+
+- [Ui_Catalogues_Cover_Public_Call_Sites](../standards/21-architecture-tests-catalogue.md#ui_catalogues_cover_public_call_sites)
+- [Public_Ui_Locale_Does_Not_Change_Content_Admission](../standards/21-architecture-tests-catalogue.md#public_ui_locale_does_not_change_content_admission)
+- [Public_Theme_Emits_Only_Validated_Color_Tokens](../standards/21-architecture-tests-catalogue.md#public_theme_emits_only_validated_color_tokens)
+- [Public_Pages_Preserve_Approved_Response_States](../standards/21-architecture-tests-catalogue.md#public_pages_preserve_approved_response_states)
+- [Public_Pages_Expose_Localized_Accessible_Semantics](../standards/21-architecture-tests-catalogue.md#public_pages_expose_localized_accessible_semantics)
+
+All five are Registered at acceptance, not passing implementation claims. Status
+becomes Implemented only with the actual checks.
+Existing [public source-boundary controls](../standards/21-architecture-tests-catalogue.md#p02d-5-public-server-rendering-controls)
+and [No_Architecture_Test_Is_Skippable](../standards/21-architecture-tests-catalogue.md#no_architecture_test_is_skippable)
+remain mandatory; no second skip-refusal rule is created.
+
+## Amendments
+
+### Amendment 1 — P02d-6 document foundation delivery (2026-10-10)
+
+P02d-6 Step 1 implements the exact next-intl 4.14.9 dependency/lockfile and App
+Router plugin, complete app-local English/Turkish `public` catalogues, server-only
+request configuration and key/ICU/argument/callsite controls with planted failures.
+UI fallback preserves content locale; formatter failures select bounded copy.
+
+Admission/content loaders use a private weak identity memo keyed only by Next's
+exact request-store headers object to retain same-request work across framework
+error rendering. Header values, host, locale and the ingress envelope are not
+cache keys; no representation crosses incoming requests or gains a new authority
+carrier. `getPublicUi` uses React cache for selected messages/translators within
+an RSC render, separately from admission/content-read deduplication.
+
+Step 1 also supplies document language/direction, atomic four-color style-element
+injection, tenant chrome, bounded interactive error labels, loading/state views
+and the live-admitted fixed status page, which performs no Education read.
+Catalog/course pages remain Step 2; lesson presentation remains Step 3. The
+[Step 1 delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-1-localization-and-document-foundation)
+owns current validation, review and product/accessibility proof status. The
+Decision, Status, Date and Deciders remain unchanged.
+
+### Amendment 2 — P02d-6 pages and automated product proof (2026-10-10)
+
+Steps 2–3 implement localized catalog, course and ordered plain-text lesson pages,
+independent opaque pagination and eligible metadata from shared request-local
+loaders. Known content failures use bounded translated HTTP 200/noindex views;
+missing or hidden details use the approved local 307 to a fixed localized 404.
+Resolved API label language remains distinct from UI fallback language.
+
+Step 4 adds separate real API/production Next product modes for concurrent
+host/locale isolation, authored RTL content, atomic theme fallback, publication
+freshness and content-failure recovery. The
+[delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout)
+owns execution and review evidence. Actual manual keyboard, focus, reflow,
+contrast and screen-reader evidence remains pending; P6 completion and
+Accessibility promotion are not claimed. This delivery note changes no decision
+or historical amendment. Phase 04 consumes the implemented UI foundation;
+Phase 06 owns the full renderer/Studio and Playwright/axe expansion, and P02d-7
+owns the browser demo and Lighthouse activation.
+
+### Amendment 3 — P02d-6 manual accessibility closeout (2026-10-10)
+
+P02d-6 completes its four implementation steps and two independent review rounds
+per step. The [packet closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10)
+records keyboard, focus, 320 CSS px desktop reflow and calculated contrast
+observations, followed by the maintainer's passing real VoiceOver checks on the
+English and Turkish catalog → course → lesson flows and missing/invalid-link
+recovery views. The earlier pending amendments remain historical.
+
+Accessibility Standards becomes Active for delivered public-page enforcement;
+the five P6 catalogue entries become Implemented with named existing tests.
+Implementation is complete; maintainer PR review and merge remain separate.
+This delivery note changes no accepted decision and claims no full WCAG audit,
+Arabic screen-reader check or zoom-conformance result. P02d-7 still owns the
+browser demo/Lighthouse and Phase 06 owns full Playwright/axe expansion.
+
+### Amendment 4 — Request-local bootstrap supersession (2026-10-10)
+
+The maintainer accepted
+[ADR-0055](0055-public-renderer-bootstrap-failures.md). It replaces only this
+record's admission-loader execution contract: middleware performs the sole live
+site bootstrap, then publishes an immutable validated snapshot into its native
+request context. The shared admission loader re-verifies provenance and consumes
+that same request's snapshot without a second `getSite()` call. Signed-route
+locale selection, enabled-locale checks, UI fallback and catalogue ownership are
+unchanged. The original acceptance metadata, body and prior amendments remain
+historical; this is bounded supersession navigation, not a correction.
+
+Acceptance delivers no runtime implementation or passing production proof. The
+[three-step plan](../roadmap/phase-02d-walking-skeleton.md#adr-0055-acceptance-and-implementation-plan-2026-10-10)
+owns implementation, two independent review rounds per step and delivery status.
+
+### Amendment 5 — Single-bootstrap loader delivery (2026-10-10)
+
+ADR-0055's replacement admission loader is implemented. Middleware publishes one
+validated immutable snapshot; provenance-checked request, resource and UI consumers
+share it only during that native request. Content locale and UI fallback remain
+separate. The [Step 3 record](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout)
+owns actual production proof, validation and independent review status. The original
+acceptance metadata, body and earlier amendments remain historical. This delivery
+note changes no decision or manual accessibility claim.
+
+## References
+
+- [ADR-0008 — Localization Schema](0008-localization-schema.md)
+- [ADR-0009 — Single Next.js App First](0009-frontend-single-app-first.md)
+- [ADR-0052 — Anonymous Public Read Boundary](0052-anonymous-public-read-boundary.md)
+- [ADR-0053 — Trusted Public Server Rendering](0053-trusted-public-server-rendering.md)
+- [ADR-0054 — Bounded Public Renderer Admission](0054-bounded-public-renderer-admission.md)
+- [Localization architecture](../architecture/12-localization.md)
+- [Localization Standards](../standards/08-localization.md)
+- [Frontend Architecture Standards](../standards/07-frontend-architecture.md)
+- [next-intl — App Router setup](https://next-intl.dev/docs/getting-started/app-router)
+- [next-intl — Server and Client Components](https://next-intl.dev/docs/environments/server-client-components)
+- [next-intl — request configuration](https://next-intl.dev/docs/usage/configuration)
+- [next-intl — error files](https://next-intl.dev/docs/environments/error-files)
+- [next-intl package metadata](https://www.npmjs.com/package/next-intl/v/4.14.9)
+- [FormatJS — React Intl](https://formatjs.github.io/docs/react-intl/)
+- [Lingui — React Server Components](https://lingui.dev/tutorials/react-rsc)
diff --git a/docs/decisions/0051-ordered-text-card-presentation.md b/docs/decisions/0051-ordered-text-card-presentation.md
index ce8151a4..05fee628 100644
--- a/docs/decisions/0051-ordered-text-card-presentation.md
+++ b/docs/decisions/0051-ordered-text-card-presentation.md
@@ -2,6 +2,13 @@
## Status
+> **Implementation update — 2026-10-10.**
+> P02d-6 delivers ordered plain-text lesson rendering, bounded fallbacks and
+> inert HTML/script/URL-like value proofs. Product HTML/RSC checks and manual
+> accessibility smoke are complete; PR review and merge remain separate. See
+> [Amendment 4](#amendment-4--p02d-6-renderer-delivery-2026-10-10).
+> Earlier delivery disclosures and the acceptance-time statement are historical.
+
> **Implementation update — 2026-10-05.**
> P02d-4 delivers ordered public descriptor/content projection after eligibility,
> including bounded unavailable results. The dated 2026-10-02 disclosure below
@@ -220,3 +227,18 @@ The Decision and acceptance-time status are unchanged. A new dated disclosure
inside Status points to this amendment without rewriting earlier records.
Updated carriers: this Status disclosure, the corresponding ADR-0050/0052
delivery disclosures, the Education module spec and the P02d-4 delivery record.
+
+### Amendment 4 — P02d-6 renderer delivery (2026-10-10)
+
+P02d-6 completes ordered plain-string lesson rendering with authored field order,
+resolved label language/direction, omitted absent optional fields and bounded
+unavailable states. HTML/script/URL-like values remain inert text. Unit and
+production HTML/RSC cases cover the actual product routes; the
+[packet closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10)
+records automated evidence, two review rounds per step and passing manual
+browser/maintainer VoiceOver smoke. PR review and merge remain separate.
+
+The new Status disclosure points to this delivery note. Earlier amendments,
+Decision, acceptance Status, Date and Deciders remain unchanged. Richer
+primitives, media, Markdown/HTML sanitization and the full registry retain their
+Phase 04/05/06 owners; P02d-7 still owns the browser demo and Lighthouse.
diff --git a/docs/decisions/0053-trusted-public-server-rendering.md b/docs/decisions/0053-trusted-public-server-rendering.md
index bde7e726..8191bc2b 100644
--- a/docs/decisions/0053-trusted-public-server-rendering.md
+++ b/docs/decisions/0053-trusted-public-server-rendering.md
@@ -342,6 +342,46 @@ or local topology changes. The [remediation record](../roadmap/phase-02d-walking
owns current status. Acceptance delivers no runtime correction or new test/CI
result; each implementation step still owes validation and two independent reviews.
+### Amendment 8 — Merge closeout (2026-10-09)
+
+P02d-5 and its ADR-0054 remediation are complete and merged through
+[PR #26](https://github.com/HodeTech/LearnStack/pull/26). The
+[merge closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-5-merge-and-closeout-2026-10-09)
+owns the final head, merge verification and CI evidence. Original acceptance
+metadata and prior amendments remain historical. This delivery note changes no
+decision; P02d-6 product pages and P02d-7 browser/demo/Lighthouse remain separate.
+
+### Amendment 9 — Single-bootstrap ownership supersession (2026-10-10)
+
+The maintainer accepted
+[ADR-0055](0055-public-renderer-bootstrap-failures.md). It replaces this record's
+duplicate middleware/RSC site-read contract with one live middleware bootstrap
+and an immutable validated snapshot bound to the native request lifetime. RSC
+re-verifies provenance before consuming that request's snapshot; it performs no
+second site read. Middleware owns exact bootstrap 404/429/503 before rendering.
+Normal product documents use two API calls; followed missing-detail chains use
+three. Content eligibility remains live at the API, and a new request reads a new
+site snapshot. API tenant authority, signed route identity, no-store transport,
+numeric quotas and content-error/missing-detail status policies are unchanged.
+
+This is bounded supersession navigation. The original body, metadata and earlier
+delivery notes remain historical. Acceptance delivers no implementation or
+passing production proof; the
+[three-step plan](../roadmap/phase-02d-walking-skeleton.md#adr-0055-acceptance-and-implementation-plan-2026-10-10)
+owns execution and review evidence.
+
+### Amendment 10 — Single-bootstrap production delivery (2026-10-10)
+
+ADR-0055's native request context, middleware publication and checked RSC consumers
+are implemented. Actual production Next/API/PostgreSQL proofs preserve ingress,
+isolation, content states and the two/one/three-call contract; the additional
+fixture covers exact bootstrap refusals, concurrent snapshots, native lifetime,
+stock browser fallback and fresh development admission. The
+[Step 3 record](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout)
+owns execution and independent review status. This delivery note leaves the
+original acceptance metadata, body and earlier amendments unchanged; it adds no
+P7 browser-demo, full accessibility or production deployment claim.
+
## References
- [ADR-0036](0036-tenant-resolution-trusted-inputs.md)
diff --git a/docs/decisions/0054-bounded-public-renderer-admission.md b/docs/decisions/0054-bounded-public-renderer-admission.md
index aecfcbac..a1fed57c 100644
--- a/docs/decisions/0054-bounded-public-renderer-admission.md
+++ b/docs/decisions/0054-bounded-public-renderer-admission.md
@@ -316,6 +316,54 @@ reviews and unmerged PR readiness live in the [five-step remediation record](../
including its [corpus/PR closeout](../roadmap/phase-02d-walking-skeleton.md#remediation-step-5--corpus-and-pr-closeout).
This adds delivery navigation, not a new decision or a claim that PR #26 is merged.
+### Amendment 6 — Merge closeout (2026-10-09)
+
+All five remediation steps are complete and merged through
+[PR #26](https://github.com/HodeTech/LearnStack/pull/26). The
+[merge closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-5-merge-and-closeout-2026-10-09)
+owns the final head, merge verification and CI evidence. The accepted decision,
+metadata and earlier delivery notes remain unchanged. This note records delivery
+only; P02d-6/7 and Phase 11 retain their named work.
+
+
+### Amendment 7 — Next security maintenance (2026-10-10)
+
+The current verified runtime pin is Next 15.5.27 with matching eslint-config-next.
+The signed raw target, full-URL RSC projection and equivalent redirect-query
+contract are unchanged. The unused image optimizer is disabled. P02d-6's
+[remediation record](../roadmap/phase-02d-walking-skeleton.md#remediation-group-3--approved-security-maintenance)
+owns the replacement native/production proofs and dependency triage; historical
+15.5.18 observations remain unchanged.
+
+### Amendment 8 — Renderer bootstrap and Retry-After qualification (2026-10-10)
+
+The maintainer accepted
+[ADR-0055](0055-public-renderer-bootstrap-failures.md). It selects the request-local
+bootstrap reuse left undelivered in this record's Context. Middleware performs one
+live site read and owns bootstrap refusal responses; RSC consumes the immutable
+same-request snapshot. Implementation and production proofs remain pending under
+the [three-step plan](../roadmap/phase-02d-walking-skeleton.md#adr-0055-acceptance-and-implementation-plan-2026-10-10).
+
+ADR-0055 qualifies only the renderer forwarding boundary of
+[Anonymous accounting](#anonymous-accounting)'s instruction to use the selected
+refusal's Retry-After unchanged. The renderer forwards an eligible parsed integer
+from zero through sixty seconds unchanged for its closed supported 429/503 cases;
+it omits malformed, negative, HTTP-date or out-of-range values without clamping or
+inventing a duration. API refusal selection, original API metadata, visitor/peer
+accounting and numeric quotas remain unchanged. This is bounded supersession
+navigation, not a correction; original metadata, body and earlier amendments stay
+historical. Acceptance is not runtime delivery evidence.
+
+### Amendment 9 — Renderer bootstrap qualification delivery (2026-10-10)
+
+ADR-0055's single-bootstrap reuse and bounded renderer Retry-After qualification
+are implemented. Real wire proofs cover supported 404/429/503, eligible zero/sixty
+boundaries, omitted invalid values and bodyless HEAD. API accounting, original API
+metadata and numeric budgets remain unchanged. The
+[Step 3 record](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout)
+owns execution and independent review status. This delivery note preserves the
+original acceptance metadata, body and all earlier amendments.
+
## References
- [ADR-0053 — Trusted Public Server Rendering](0053-trusted-public-server-rendering.md)
diff --git a/docs/decisions/0055-public-renderer-bootstrap-failures.md b/docs/decisions/0055-public-renderer-bootstrap-failures.md
new file mode 100644
index 00000000..fe6acd11
--- /dev/null
+++ b/docs/decisions/0055-public-renderer-bootstrap-failures.md
@@ -0,0 +1,345 @@
+# ADR-0055: Handle Public Renderer Bootstrap Failures in Middleware
+
+## Status
+
+> **Delivery navigation — 2026-10-10:** [Amendment 1](#amendment-1--production-implementation-and-proof-2026-10-10)
+> records implemented runtime and production proof. The pending statements below
+> describe the acceptance baseline; the linked delivery record owns current reviews.
+
+Accepted — 2026-10-10. The maintainer approved the revised decision after the
+isolated Next 15.5.27 context-propagation experiment and its two review rounds.
+
+**Date:** 2026-10-10
+**Deciders:** @cemil
+
+This replaces the unaccepted neutral-500 and native-response-gate proposals.
+Approval accepts the single live read, request-local DTO handoff, revised counts
+and bounded renderer Retry-After policy. It is separate from the earlier Open
+Graph/security approvals. The isolated experiment establishes feasibility only;
+production implementation, its security/lifecycle proofs and both review rounds
+per step remain pending. The decision-only acceptance commit precedes that code.
+
+## Decision Drivers
+
+- Bootstrap failure needs an HTTP response owner before RSC rendering begins.
+- Preserve exact 404/429/503 and bounded Retry-After without intercepting Next's
+ streaming writes, adding a preflight or relying on arbitrary RSC status control.
+- A second failing site read creates a refusal the RSC owner cannot express exactly.
+- Keep API-owned tenant authority, authenticated ingress and fresh admission on
+ every new incoming request; no shared representation or serialized DTO carrier.
+- Failed bootstrap permits no Education read or newly emitted tenant theme.
+- Make ownership, freshness and quota changes explicit; do not preserve redundant reads.
+
+## Considered Options
+
+1. **One live middleware bootstrap with a request-local validated snapshot**
+ (chosen). Middleware already owns a `NextResponse` before RSC.
+ RSC re-verifies provenance and consumes only that incoming request's snapshot.
+ Shared runtime/context propagation is supported by the isolated feasibility
+ experiment; actual production integration remains a mandatory proof gate.
+2. **Native response gate around a second RSC read** (rejected). Intercepting
+ commitment, early hints, compression, backpressure and terminal stream behavior
+ adds a fragile version-sensitive response adapter to preserve a duplicate read.
+3. **Native-owned bootstrap before Next** (viable larger alternative). The native
+ listener can emit exact refusals before delegation, but must acquire configured
+ transport/SDK build support and route/locale/redirect ownership currently in
+ middleware. It still needs a validated request-local handoff to RSC. Prefer this
+ only through a separate concrete decision if the chosen boundary fails.
+4. **RSC exceptions or error components** (rejected). They cannot promise arbitrary
+ 429/503 and Retry-After; framework error rendering can already have committed 200.
+5. **Extra preflight or two serial native reads** (rejected). Another read consumes
+ quota and creates another race. Retaining a count is not a reason for duplication.
+6. **DTO header or shared site cache** (rejected). A serialized carrier adds a new
+ trust protocol; a shared representation changes freshness and isolation scope.
+
+## Decision
+
+LearnStack performs one live public site bootstrap in its existing Node middleware
+HTTP response owner. After verified successful admission, middleware publishes a
+bounded, immutable validated site snapshot into a native-created request-local
+context. RSC independently re-verifies provenance and consumes that exact request's
+snapshot without another site call. Middleware emits bootstrap 404/429/503 before
+RSC starts. This bounded replacement supersedes the duplicate-read and request-count
+contracts in ADR-0053, ADR-0027's admission-loader execution contract and P02d-6
+G40, and qualifies ADR-0054's renderer Retry-After forwarding boundary only.
+
+### Native context and middleware admission
+
+1. The native listener retains method admission, socket capture, carrier stripping
+ and provenance minting before Next. It creates a fresh `AsyncLocalStorage`
+ context bound to the captured canonical host, peer, method, signed raw target
+ and native request lifetime, then delegates through the existing Next handler.
+2. Middleware verifies provenance and its existing explicit Next URL projection.
+ It also requires the active native context to match the verified original
+ host/peer/method/target. No client header, URL or trace ID looks up a context.
+3. Middleware makes one live `getSite()` through the existing configured caller,
+ then evaluates the existing membership-first route/locale entry policy.
+ Failure returns its controlled response; redirects retain their current policy.
+4. Only a successful continuation publishes the validated snapshot and advances
+ the native context from pending to ready. Refusal/redirect is terminal and
+ publishes no site snapshot. Missing, disposed, mismatched or conflicting
+ context cannot continue into RSC; a context invariant failure returns neutral 503.
+5. Middleware retains its downstream header allowlist, including the verified
+ provenance envelope. The snapshot/context is never serialized into request or
+ response headers, HTML/Flight payloads, cookies or client assets.
+
+The native context contains only bounded ownership/binding state, cancellation
+capability and the approved site snapshot. It is not a registry: there is no map
+keyed by host, path, envelope, client identifier or framework request metadata.
+Identical simultaneous requests have distinct native lifetimes and stores.
+
+### Snapshot and RSC consumption
+
+The snapshot contains only the validated public Site DTO, not a raw body, Response,
+SDK client, provider error or tenant/organization ID. The existing ten-second total
+transport deadline and **8 MiB decoded-response ceiling** apply before parsing.
+Retain one snapshot, freeze nested values and do not retain the raw response.
+
+RSC re-verifies the authenticated provenance envelope and checks the active native
+context's binding and ready state before consuming the snapshot. The signed raw
+request target remains the route authority; Next's transformed metadata does not
+select the host, tenant, organization or locale. The snapshot is a rendering value
+from the API's host-resolved read, not a tenant-authority grant or an API bypass.
+All Education calls still use the verified configured caller and independently
+pass live API admission, public eligibility, READ ONLY and RLS enforcement.
+
+The existing request-local admission/resource loaders remain the shared consumer
+for metadata, layout, page and i18n configuration. Their admission loader no longer
+calls `getSite()`. Each consumer honors its result before Education or theme use;
+layouts alone do not serialize child execution. Content clients carry the native
+cancellation signal and retain the existing transport controls.
+
+While the native request remains active, a missing, disposed or mismatched RSC
+snapshot is an internal lifecycle error, not API 404 or permission to fetch another
+site or reuse data. Fail closed before Education/theme with a sanitized framework
+failure. Arbitrary framework-error statuses are not guaranteed; production proofs
+must prevent these active-request context failures. Expected late refusal follows
+the separate completion policy below.
+
+### Freshness, accounting and response scope
+
+Every matched tenant-capable HTTP request starts with a new context and live
+bootstrap; health/assets retain their exemptions. No site value survives for
+another request, host, navigation or HMR render. Consumers share
+one admitted snapshot: the second site revalidation is removed. Changes between
+middleware and RSC appear on the next request; content API eligibility remains live.
+
+| Incoming request or followed chain | Public API calls |
+|---|---:|
+| Completed admitted product document GET | 2: site + shared content operation |
+| Completed fixed status/scaffold document GET | 1: site only |
+| Bootstrap refusal reaching the API | 1: site attempt, no Education |
+| Missing-detail GET followed to fixed localized 404 | 3: original site/content + destination site |
+
+HEAD, RSC and prefetch have one mandatory site call; content is called only when
+Next executes the shared resource loader. Prove their actual counts separately.
+Invalid provenance/context rejected before transport makes no API call. A locale/
+entry redirect uses one site call; following it is a new counted request. Admitted
+invalid-cursor states may omit content transport as under the existing policy.
+Metadata/layout/page/UI reuse cannot add calls. Preserve 60-call visitor and
+600-call peer budgets; these remain API-call budgets, not page quotas.
+
+The exact-status guarantee covers valid execution and bootstrap failures handled
+by middleware before RSC. It does not promise 429/503 for arbitrary later framework
+errors or rewrite an already streamed status. Content-error HTTP 200/noindex,
+cursor SEO and missing-detail 307-to-fixed-404 remain accepted.
+
+### Refusal and Retry-After policy
+
+| Bootstrap outcome | Original HTTP response |
+|---|---|
+| Invalid provenance, client verification refusal, API 404 or entry 404 | Neutral 404 |
+| Valid API 429 | Neutral 429; eligible bounded Retry-After |
+| API 503 or other non-404/non-429 site failure | Neutral 503; eligible bounded Retry-After |
+| Timeout, transport/invalid response, oversized body, configuration failure or entry 503 | Neutral 503; no invented Retry-After |
+| Middleware context invariant failure | Neutral 503; no invented Retry-After |
+| Client disconnect | Cancel work; no response-delivery guarantee |
+
+Refusals retain `Cache-Control: no-store`, fixed plain-text content type/copy and
+bodyless HEAD. No site snapshot, Education operation or new tenant-themed failure
+response follows failed bootstrap. No raw Problem Details, submitted/private value
+or provider diagnostic enters response output or retained logs.
+
+One renderer sanitizer accepts optional parsed Retry-After integer delta-seconds
+**0 through 60 inclusive**, only on 429/503 from the closed supported rate-limit/
+unavailable error cases. Preserve eligible values; omit malformed, HTTP-date,
+negative and out-of-range values without clamping or inventing a retry duration.
+The SDK's current safe-integer parser alone does not implement this accepted bound.
+
+This qualifies ADR-0054's
+[Anonymous accounting](0054-bounded-public-renderer-admission.md#anonymous-accounting)
+instruction to preserve the selected refusal's Retry-After unchanged at the
+renderer forwarding boundary only: forward an eligible value unchanged, otherwise
+omit it. The API's visitor-first refusal selection and original response metadata
+remain unchanged. Record this bounded renderer qualification explicitly in
+ADR-0054's dated navigation note; it is not a replacement of API accounting.
+
+### Lifecycle, runtime and navigation
+
+Cancellation reaches bootstrap/content transport, without promising forced
+cancellation of arbitrary React work. Native `finish`/`close` or shutdown closes
+the store exactly once, releases its snapshot/listeners and prevents late use.
+Do not call ALS `disable()` per request or dispose on a delegating promise's return.
+Response completion does not prove React work has ended. Check disposal before
+publication or downstream work and again after awaits. Clearing the store's
+snapshot reference does not synchronously reclaim references held by already
+running consumers. Late continuations must fail closed without republishing,
+starting another bootstrap or Education read, or exposing the context/DTO in
+diagnostics. Production proofs must cover valid HEAD and late component work.
+Expected refusal after completion is distinct from an active-request context
+defect and cannot change the already completed HTTP response.
+
+Native startup installs the sole ALS holder as a versioned, non-enumerable
+`globalThis[Symbol.for('learnstack.public-admission.v1')]`. Server-only facades
+retrieve and validate that instance; bundles never create a fallback. Only native
+ingress calls `run()` with a fresh store. This is a same-process Node contract;
+workers, Edge or serverless need a new decision. Prove actual singleton/context
+reuse through middleware, RSC and error rendering on Next 15.5.27. Missing context
+fails closed; no header/cache/extra-read fallback or Next API guarantee is implied.
+
+Product navigation retains plain anchors. Stock Next can turn a non-success Flight
+response into full-document navigation, causing another genuine request and quota
+charge; prove and count that chain separately. A recovered follow-up may differ.
+Failed Flight cannot erase the old document's painted theme: the guarantee is no
+new tenant-themed failure response, not removal of existing browser DOM.
+
+Fixed status and studio/portal share admission without new authentication. Preserve
+tenant-free health/asset exemptions and missing-asset fallbacks, native GET/HEAD,
+production upgrade closure and HMR rules; HMR must not reuse old snapshots.
+
+## Context
+
+At `69b382a`, middleware discards its site read; RSC reads again and collapses
+failures to `null`, which `public-resource.ts` turns into `notFound()`. G40 spends
+three calls on a normal document and five on a followed missing document. This
+decision replaces that ownership/count choice instead of intercepting streaming.
+
+This is the request-local bootstrap reuse explicitly left undelivered by
+[ADR-0054's Context](0054-bounded-public-renderer-admission.md#context).
+It changes the admission loader described in
+[ADR-0027's execution boundary](0027-frontend-i18n.md#dependency-and-execution-boundary):
+the loader consumes this request's validated live snapshot instead of calling
+`getSite()` again. Provenance verification, signed-route locale selection and
+enabled-locale membership checks remain required before UI or content consumers.
+
+Historical source inspection of Next 15.5.18 shows `pipe-readable.js` flushing
+headers before body writes, `app-render.js` mapping ordinary errors to 500 and
+Node middleware executing through its adapter. Official custom-server/streaming
+and Node ALS documentation establish the available primitives, not this shared
+context's correctness. Delivery requires production proofs on Next **15.5.27**;
+15.5.18 source observations are not runtime evidence for the patched baseline.
+
+### Isolated feasibility observation (2026-10-10)
+
+An isolated native HTTPS / production Next 15.5.27 experiment on Node 22.23.1,
+independently replayed by the root agent, confirms the same ALS holder and store
+references through bundled Node middleware, metadata, layout and page, including
+after awaits. It covers 37 requests: eight overlapping identical requests each
+for HTML, Flight and prefetch; eight host/locale HTML/Flight combinations; HEAD;
+and HTML/Flight not-found/error cases. Prefetch executes middleware without dynamic
+RSC consumers in this fixture. Unbound and duplicate-ALS controls return 503 with
+no snapshot publication or RSC consumer. Publication is synthetic, not `getSite()`.
+
+On a valid HEAD, an already-started not-found component resumes after native
+`finish`. ALS still carries the correct disposed store; its snapshot is cleared
+and the facade refuses late access. The wire remains bodyless 200. This supports
+the disposal checks above; it does not prove all React work or local references
+have ended. The fixture's bounded error is logged by Next, so production must
+also verify neutral diagnostics during this path.
+
+This establishes the same-process propagation premise only. Actual provenance,
+route binding, API/RLS, call accounting, abort/shutdown, keep-alive, HMR and browser
+navigation remain production proof obligations. The experiment does not accept
+this decision or satisfy those obligations. At the experiment's completion,
+maintainer approval remained pending; Status records the subsequent acceptance.
+
+## Consequences
+
+- **Positive:** bootstrap failure has an existing pre-render HTTP owner; one site
+ call eliminates the second-read failure race and reduces quota use.
+- **Negative:** a new validated request-local DTO handoff and cross-bundle runtime
+ dependency require proof; the second within-request revalidation is removed.
+- **Neutral:** no response-write interceptor, new listener, endpoint/schema/SDK wire
+ change, shared cache or authority change. Stock Flight fallback can add requests.
+
+## Implementation Notes
+
+P02d-6 remediation owns the accepted replacement of G40's duplicate RSC bootstrap,
+response ownership and three/five-call contract. Approval explicitly accepts the
+request-local DTO handoff and two/three-call replacement. Record bounded dated
+supersession/navigation in ADR-0053, ADR-0027 and the G40/current guidance, plus
+ADR-0054's renderer Retry-After qualification and deferred-reuse delivery boundary;
+retain Accepted bodies and historical delivery records unchanged. The acceptance
+decision must be committed before dependent production implementation.
+
+Scope covers native context/lifecycle, runtime facade, middleware publication,
+RSC/resource/i18n consumers and proofs. Failed feasibility requires a new decision,
+not duplicated reads, bootstrap 500/200, shared data or DTO headers. Native-owned
+bootstrap needs its own ownership/build design.
+
+## Architecture Tests
+
+The acceptance commit registers the following canonical rules and planned owning
+test paths in the
+[architecture-test catalogue](../standards/21-architecture-tests-catalogue.md#p02d-6-request-local-bootstrap-admission-controls):
+
+- `Public_Admission_Uses_Only_The_Active_Native_Context`
+- `Public_Bootstrap_Refusals_Preserve_Exact_Http_Responses`
+- `Public_Admission_Reuses_One_Immutable_Snapshot_Per_Request`
+- `Public_Admission_Ends_With_The_Native_Request`
+- `Public_Admission_Preserves_Production_Ingress_Boundaries`
+
+All five are Registered, not passing-test or Implemented claims. Mark a rule
+Implemented only after its executable proof and planted failing/passing controls
+pass. Together they own these mandatory obligations:
+
+- Actual Next 15.5.27 production context propagation and same-request snapshot reuse;
+ missing, duplicated, disposed or mismatched context fails closed before consumers.
+- First-wire 404/429/503, no-store, bodyless HEAD and Retry-After boundaries/omissions
+ for API refusal, transport/timeout, malformed/oversized response and invalid site.
+- No Education, new tenant theme, private marker or raw error after failed bootstrap;
+ planted broken admission/context controls must fail the proof.
+- Exact two/one/three-call paths and zero additional site calls across concurrent
+ metadata/layout/page/UI/error consumers; recovered next request re-reads live data.
+- Same/different hosts/locales and identical targets/envelopes under concurrency;
+ snapshot binding, deep immutability and no cross-request retention or lookup.
+- Product/status/scaffold HTML, RSC, prefetch and navigation; separately counted
+ Flight-to-document fallback; retained content 200/noindex and missing 307-to-404.
+- Abort/late publication, normal finish, early close, shutdown and keep-alive next
+ request; zero active contexts/snapshots after completion and no HMR reuse.
+- Invalid host/locale/stamp, stock launcher bypass, exempt asset/health fallback,
+ native method/upgrade refusal, no build-time bootstrap and retained HMR behavior.
+
+## Amendments
+
+### Amendment 1 — Production implementation and proof (2026-10-10)
+
+The three-step implementation delivers native-owned request context/lifetime,
+middleware's sole live bootstrap and provenance-checked snapshot consumers.
+The six existing production modes retain their isolation, content-state and
+freshness controls with revised accounting. The additional real API/PostgreSQL
+fixture covers exact wire failures, snapshot concurrency, cancellation, late HEAD,
+shutdown, keep-alive, stock browser Flight fallback and development source/API
+refresh. Public success and Problem Details media types are enforced before SDK
+parsing under the existing API contract.
+
+The [Step 3 record](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout)
+owns executed checks, verified corrections and independent review status. Five
+catalogue rules are Implemented with named executable owners. The original
+acceptance metadata, decision and prior feasibility observations remain historical;
+this delivery note changes no decision or P7/Phase 06 scope.
+
+## References
+
+- [ADR-0027 — Frontend UI Localization](0027-frontend-i18n.md)
+- [ADR-0036 — Tenant Resolution Trusted Inputs](0036-tenant-resolution-trusted-inputs.md)
+- [ADR-0052 — Anonymous Public Read Boundary](0052-anonymous-public-read-boundary.md)
+- [ADR-0053 — Trusted Public Server Rendering](0053-trusted-public-server-rendering.md)
+- [ADR-0054 — Bounded Public Renderer Admission](0054-bounded-public-renderer-admission.md)
+- [P02d-6 review decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-systematic-review-decision-package-2026-10-10)
+- [Native launcher](../../frontend/apps/web/scripts/public-server.mjs)
+- [Middleware](../../frontend/apps/web/src/middleware.ts)
+- [RSC admission loader](../../frontend/apps/web/src/server/public-request.ts)
+- [Next 15 custom server](https://nextjs.org/docs/15/app/guides/custom-server)
+- [Next 15 streaming status codes](https://nextjs.org/docs/15/app/api-reference/file-conventions/loading#status-codes)
+- [Node asynchronous context](https://nodejs.org/api/async_context.html#asynclocalstoragerunstore-callback-args)
diff --git a/docs/decisions/README.md b/docs/decisions/README.md
index 4311ca16..6083b93f 100644
--- a/docs/decisions/README.md
+++ b/docs/decisions/README.md
@@ -46,6 +46,7 @@ an amendment is not a lifecycle status change.
| 0022 | [Custom Domain & TLS](0022-custom-domain-tls.md) | Hub-owned custom domain admin; DNS-01 + HTTP-01 + Let's Encrypt; APISIX hot-reload |
| 0023 | [Strongly-Typed ID Source Generator — Vogen](0023-strongly-typed-id-source-generator.md) | Vogen as the source generator for both IDs and value objects; `[ValueObject]` annotation; EF + JSON + ASP.NET + OpenAPI emitters out of the box (Amendment 1: Vogen 7.0.0 pin + architecture-test placement, 2026-05-21; Amendment 2: `UserId` / `TenantId` / `OrganizationId` are cross-cutting and live in `LearnStack.SharedKernel`, 2026-08-10; **Amendment 3: `IStronglyTypedId` gains `IsInitialized()` and is no longer a pure marker, 2026-08-10**; Amendments 4–7, 2026-08-27 to 2026-09-01: `idempotency_keys` mints no id, `gen_uuid_v7()` is not a PostgreSQL function, the retroactive disclosure of the 2026-05-21 edit, and an id's own formatting is not a wire format; **Amendment 8, 2026-09-07: a module-local identifier crosses a cross-module contract as `Guid`**) |
| 0024 | [API Versioning Policy](0024-api-versioning-policy.md) | URL-based `/v{N}/`; 6-month deprecation window; RFC 8594 `Sunset` + `Deprecation` headers; OpenAPI `deprecated` + `x-sunset` extensions; 410 Gone with RFC 7807 on sunset |
+| 0027 | [Frontend UI Localization with next-intl](0027-frontend-i18n.md) | Accepted 2026-10-09: next-intl 4.14.9, server-first app-local ICU catalogues, dotted UI keys and verified request-locale source; P02d-6 completes the foundation, catalog/course/lesson pages, automated product proofs and manual accessibility ([Amendment 3](0027-frontend-i18n.md#amendment-3--p02d-6-manual-accessibility-closeout-2026-10-10)); Phase 04 consumes the foundation |
| 0028 | [`audit_log` Partition Management — Hangfire Recurring Job](0028-audit-log-partition-management.md) | Daily `learnstack:audit:partition-management` Hangfire job; create-ahead 2 months; drop only on platform-max retention horizon; row-level purge separate; no `pg_partman` dependency (Amendment 2026-09-07: Packet 9 creates a **plain, unpartitioned** table and executes none of this ADR's DDL; `Partition_Manager_Job_Is_Registered_AtStartup` moves to Phase 11 with the job it guards) |
| 0029 | [Object Storage — SeaweedFS](0029-object-storage-seaweedfs.md) | Self-hosted SeaweedFS behind the existing `IStorageProvider` S3 contract; partially supersedes ADR-0002's MinIO row |
| 0030 | [Redis-compatible Store — Valkey](0030-redis-compatible-store-valkey.md) | Valkey (Linux Foundation, BSD-3-Clause) for the cache + Dapr state-store backend; RESP-protocol drop-in; partially supersedes ADR-0002's Redis row |
@@ -65,10 +66,11 @@ an amendment is not a lifecycle status change.
| 0044 | [The Audit Write Path](0044-audit-write-path.md) | **Amendments 1–5 (2026-09-08), 6–7 (2026-09-11).** The eleven questions ADR-0033 left open, decided before the first audit file: the reserved **non-nil** platform sentinel tenant id (the nil uuid is refused by three shipped guards that read all-zero as *no tenant*), and a `tenants` CHECK that keeps it unprovisionable; the row's `tenant_id` is the tenant the **transaction announced**, so a provisioning command audits under the tenant it creates and `make seed` still commits; **N intents per request**, one per audited resource, because two shipped commands already write two — and only the **owning** unit-of-work frame writes them or reports the commit boundary, which is what stops a nested `Send` from claiming durability nothing committed; `outcome` closes at four values (`indeterminate` included) and the writer always supplies `timestamp`, so ADR-0033's deliberate duplicate-id pair is legal under `(id, timestamp)` rather than a `23505`; the catalogue is **code** keyed by request type and the matrix is prose, with a test asserting they agree, and every request must be classified — there is no `RequestKind.Other`; the interceptor captures **every** tracked entity minus a named exclusion list, because the `AuditableEntity<>` predicate was blind to `platform_host_to_tenant`; `[PiiSensitive]` and an elision record that preserves the column's JSON type; `audit_log` is org-scoped, carries **no** FK to `tenants`, and both standalone writers announce **two** GUCs — measured: an org-scoped `denied` row is refused without the second. Amendment 1 makes `audit_config` tenant-**wide**; Amendment 2 corrects the redaction sentinel to the shipped constant and the reason `FORCE` needs a trigger (the policy binds the owner by tenant, not by immutability — measured); Amendment 3 gives the matrix↔catalogue join two directions with two domains, because 23 of 30 shipped matrix slugs have no request type and the rule as written was red on arrival, and moves the audit value types into `SharedKernel.Audit` to break the same project cycle `AuditEntryId` had; Amendment 4 makes `[PiiSensitive]` property-granular, `jsonb` included, ships `audit_config` unwritten and binds the matrix rule to modules with code; Amendment 5 guards the sentinel where it is announced and fills `entity_type` / `entity_id` from the declared aggregate; Amendment 6 — from an external review that reproduced four defects against real PostgreSQL — has a handler designate the subject when it writes two instances of its aggregate, carries the actor and correlation id on the intent, keeps each intent's own request's result, captures a contained entity with its aggregate, and instance-qualifies `changes` pointers; Amendment 7 corrects § 7's count of plain entities — seven today, five of them MUST — and names the Tenancy matrix alone as the one that singles out the host mapping |
| 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 |
+| 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; P02d-6 completes ordered plain-text rendering, automated product proofs and manual accessibility smoke |
| 0052 | [Anonymous Public Read Boundary](0052-anonymous-public-read-boundary.md) | Accepted 2026-10-03: host-resolved bootstrap, immutable host ceiling, mandatory marked dispatch and read-only ambient frames; ADR-0036/0040 Amendment 8; P02d-4 Steps 1–3 deliver host provenance, physical read-only frames and all institution GET/HEAD reads; both Step 3 review rounds pass; Step 4 delivers OpenAPI/SDK/CI controls; both review rounds and focused fix verification pass; six real required CI jobs and live sixth-check activation verified; P02d-4 complete and merged through PR #25 on 2026-10-08 ([Amendment 13](0052-anonymous-public-read-boundary.md#amendment-13--merge-closeout-2026-10-08)) |
-| 0053 | [Trusted Public Server Rendering](0053-trusted-public-server-rendering.md) | Accepted 2026-10-08: P02d-5 delivers native ingress, configured transport, dynamic entry and source/runtime proofs. [ADR-0054](0054-bounded-public-renderer-admission.md) replaces bounded accounting/native/query contracts. [The current packet record](../roadmap/phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09) owns delivery, reviews, CI and PR readiness; P6/P7 remain separate |
-| 0054 | [Bounded Public Renderer Admission](0054-bounded-public-renderer-admission.md) | Accepted 2026-10-09: coordinated anonymous accounting, native GET/HEAD/production-upgrade admission and equivalent redirect-query encoding are delivered. [The five-step remediation record](../roadmap/phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09) owns current review, validation and unmerged PR #26 closeout |
+| 0053 | [Trusted Public Server Rendering](0053-trusted-public-server-rendering.md) | Accepted 2026-10-08: P02d-5 delivers native ingress, configured transport, dynamic entry and source/runtime proofs. [ADR-0054](0054-bounded-public-renderer-admission.md) replaces bounded accounting/native/query contracts. P02d-5 is complete and merged through PR #26 on 2026-10-09; the [merge closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-5-merge-and-closeout-2026-10-09) owns final verification ([Amendment 8](0053-trusted-public-server-rendering.md#amendment-8--merge-closeout-2026-10-09)); P6/P7 remain separate |
+| 0054 | [Bounded Public Renderer Admission](0054-bounded-public-renderer-admission.md) | Accepted 2026-10-09: coordinated anonymous accounting, native GET/HEAD/production-upgrade admission and equivalent redirect-query encoding are delivered. All five remediation steps are complete and merged through PR #26; the [merge closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-5-merge-and-closeout-2026-10-09) owns final verification ([Amendment 6](0054-bounded-public-renderer-admission.md#amendment-6--merge-closeout-2026-10-09)) |
+| 0055 | [Handle Public Renderer Bootstrap Failures in Middleware](0055-public-renderer-bootstrap-failures.md) | Accepted 2026-10-10: one live middleware bootstrap and an immutable native same-request snapshot; exact bootstrap 404/429/503, bounded renderer Retry-After and two/one/three-call accounting implemented. Production replacement proofs pass; all three steps completed both review rounds and focused fix verification. [Step 3](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) owns delivery evidence, completed reviews and verified fixes; five new catalogue rules are Implemented |
## Proposed ADRs
@@ -137,7 +139,6 @@ table records the phase commitment so reviewers can flag late drafts.
|---|---|---|---|
| 0025 | Scoring + completion DSL sandbox engine (CEL vs restricted Lua vs custom; sandbox boundary; allowed function set) | **Phase 05** — `TenantCompletionRule` runtime evaluator lights up here; Phase 08a's assessment scoring depends on it | [ADR-0018](0018-tenant-driven-customization-model.md), [phase-05-education-learning-content.md](../roadmap/phase-05-education-learning-content.md), [phase-08a-assessment-notifications.md](../roadmap/phase-08a-assessment-notifications.md) |
| 0026 | Release-tag scheme (`vYYYY.MM.DD.` vs SemVer; SaaS continuous-deploy reconciliation; Self-Hosted release cadence) | **Phase 11** — production hardening checklist owns this | [14-git-workflow.md § Tagging and Releases](../standards/14-git-workflow.md) |
-| 0027 | Frontend i18n library pick (`next-intl` vs `react-intl` vs `lingui`) | **Phase 04** — the first CMS / page-builder surface ships locale-aware copy | [12-localization.md](../architecture/12-localization.md), [08-localization.md](../standards/08-localization.md) |
| 0046 | Event delivery, the inbox, and the subscriber failure contract (inbox table identity and deduplication key; the dead-letter store; who claims a delivery; per-subscription attempts, terminal state and replay as one state machine) | **Phase 02b** — the consumer packet cannot start without it, and two committed designs currently contradict each other | [phase-02b-events-auth.md](../roadmap/phase-02b-events-auth.md), [15-event-and-outbox.md](../architecture/15-event-and-outbox.md), [ADR-0006](0006-events-and-outbox.md) |
| 0047 | Background job runtime (storage under the closed four-role model; the single tenant-context writer; the job frame's transaction owner; the enqueue-site rule; the retry and dead-letter contract; the ADR-0035 classification; the queue grammar; package pins and licence verdict) | **Phase 02b** — Hangfire is attributed to ADR-0002 and ADR-0006, neither of which mentions it, and no compliant first boot exists today | [phase-02b-events-auth.md](../roadmap/phase-02b-events-auth.md), [20-infrastructure-stack.md § Background Jobs and Hangfire](../standards/20-infrastructure-stack.md), [09-tenant-isolation.md](../architecture/09-tenant-isolation.md) |
@@ -145,6 +146,10 @@ table records the phase commitment so reviewers can flag late drafts.
draft lands, take its reserved number; do not let another ADR claim it. If the
decision is dropped, leave the number unused — never recycle.
+ADR-0027 is Accepted on 2026-10-09 and appears in Active ADRs. P02d-6 now owns
+its first consumer and G39 is closed; Phase 04 consumes that foundation. The
+acceptance record does not claim dependency installation or implementation.
+
**SLA:** any draft whose target phase is currently in progress without an Accepted ADR
is a blocker on the phase exit checklist for that phase. The roadmap's Phase Exit
Decision lines reference this list.
diff --git a/docs/glossary.md b/docs/glossary.md
index 5f2fdce7..becf8322 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -147,7 +147,7 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts:
|---|---|
| **`IExactCustomizationDefinitionReader`** | Contextual uncached application reader for exact content-type/taxonomy revision values, with NewBinding versus ExistingPin eligibility; [Customization spec](modules/customization/README.md#p02d-2-accepted-exact-write-contract). |
| **`ICustomizationDefinitionProjectionReader`** | Accepted P02d-3 internal batched display reader for exact tenant-owned revision pins, with immutable generation-keyed definition families. Step 3 implements coherent loading, generation caching and scope-safe bypass; both review rounds passed. Not a public API; the [decision package](roadmap/phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02) records acceptance on 2026-10-02. |
-| **`ResolvedLocalizedText`** | An immutable display label paired with its actual authored canonical locale, following [Localization § Fallback Rules](architecture/12-localization.md#fallback-rules). P02d-3 delivers internal metadata; P02d-4 Step 3 delivers its public `{value, locale}` projection. P02d-6 owns page language attributes. |
+| **`ResolvedLocalizedText`** | An immutable display label paired with its actual authored canonical locale, following [Localization § Fallback Rules](architecture/12-localization.md#fallback-rules). P02d-3 delivers internal metadata; P02d-4 Step 3 delivers its public `{value, locale}` projection. P02d-6 implements page language/direction attributes, independently of the UI fallback language. |
| **`ITenantSettingsAccessor`** | Accepted P02d-3 typed, uncached ambient settings reader with registered scope/grammar and explicit organization precedence. Step 1 implements the reader; the [Tenancy contract](modules/tenancy/README.md#p02d-3-accepted-typed-settings-contract) owns its scope. Both review rounds passed. |
| **`IPublicTenantConfigurationReader`** | P02d-4's typed internal live-scope and locale configuration reader, on the announced read-only transaction. Valid emptiness, unavailable scope and invalid stored configuration stay distinct; [Tenancy contract](modules/tenancy/README.md#p02d-4-public-configuration-contract). |
| **`ITenantLocaleEligibilityReader`** | Contextual uncached Tenancy contract for canonical enabled locale membership and valid locale configuration; [Tenancy spec](modules/tenancy/README.md#p02d-2-accepted-locale-and-branding-contract). |
@@ -199,8 +199,8 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts:
| Term | Definition |
|------|------------|
-| **TenantBranding** | The tenant's presentation tokens. Not an aggregate of its own: the values are tenant settings held in `tenant_settings` ([Frontend Architecture Standards § Tenant Branding](standards/07-frontend-architecture.md#tenant-branding)). G16(a–e) is Accepted and delivered in P02d-2; the [Tenancy contract](modules/tenancy/README.md#p02d-2-accepted-locale-and-branding-contract) owns the keys and value grammar. Anonymous projection and attribution are delivered G16(f/g) in P02d-4 Step 2, and server-rendered injection remains G42, in [Phase 02d's decision register](roadmap/phase-02d-walking-skeleton.md#the-decision-register). |
-| **`branding.theme`** | P02d-2 delivers the writer for a single tenant-wide TenantSetting document with four validated color fields; one root/version protects contrast during concurrent replacement. The [Tenancy spec](modules/tenancy/README.md#whole-theme-setting-and-public-boundary) owns the command-local registry; other generic setting keys remain legal. Anonymous projection is delivered by P02d-4 Step 2; rendering remains P02d-6. |
+| **TenantBranding** | The tenant's presentation tokens. Not an aggregate of its own: the values are tenant settings held in `tenant_settings` ([Frontend Architecture Standards § Tenant Branding](standards/07-frontend-architecture.md#tenant-branding)). G16(a–e) is Accepted and delivered in P02d-2; the [Tenancy contract](modules/tenancy/README.md#p02d-2-accepted-locale-and-branding-contract) owns the keys and value grammar. Anonymous projection and attribution are delivered G16(f/g) in P02d-4 Step 2, and P02d-6 Step 1 delivers G42 server-rendered injection, recorded in [Phase 02d's decision register](roadmap/phase-02d-walking-skeleton.md#the-decision-register). |
+| **`branding.theme`** | P02d-2 delivers the writer for a single tenant-wide TenantSetting document with four validated color fields; one root/version protects contrast during concurrent replacement. The [Tenancy spec](modules/tenancy/README.md#whole-theme-setting-and-public-boundary) owns the command-local registry; other generic setting keys remain legal. Anonymous projection is delivered by P02d-4 Step 2; P02d-6 Step 1 implements atomic document color injection. |
| **OrganizationBranding** | A planned per-organization design-token override owned by [Phase 06](roadmap/phase-06-renderer-admin-studio.md). Its partial token merge is not implemented by P02d-2/3; the generic settings scope model does not authorize an organization override of `branding.theme`. |
## Module-Loading Contracts
@@ -293,7 +293,8 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts:
|------|------------|
| **`Result`** | The sealed record in `LearnStack.SharedKernel.Results` implementing `IResultBase`: carries `IsSuccess`, `Value`, optional `Error`, and an optional `SuccessMessage` (a `LocalizedMessage`). Primary constructor is `internal`; callers go through `Ok` (throws on null value) / `Fail`. Application + Domain layer methods return `Result` for **expected** outcomes (validation failed, not found, forbidden, business-rule violation). Exceptions are reserved for **unexpected** bugs / infrastructure faults. `Result` is the payload-less success shape. See [09-error-handling.md § Two-Track Model](standards/09-error-handling.md) and [ADR-0032](decisions/0032-exception-handling-logging-and-observability.md). |
| **`Error`** | The sealed record `(LocalizedMessage Message, IReadOnlyDictionary>? Details)` that travels inside `Result.Fail(error)`. `Code` is the **unprefixed stable identifier** (e.g. `"validation_failed"`) derived by stripping the `lockey_` prefix from `Message.Key` — so routing logic (`Result.ToActionResult()`, Problem Details writers) and frontend localization read consistent values without manual sync. Field-level `Details` flow as `LocalizedMessage` lists, keeping the prefix invariant uniform across the entire error payload. See [09-error-handling.md § Result Type](standards/09-error-handling.md). |
-| **`LocalizedMessage`** | The sealed record `(string Key, IReadOnlyDictionary? Params)` in `LearnStack.SharedKernel.Localization`. `Key` MUST begin with `lockey_` — enforced at the constructor — so the frontend's translation catalogues (keyed under the same prefix) resolve every user-facing message without raw English on the wire. Equality is structural (Key + Params); ctor defensively copies Params; empty params normalised to null. `Params` values must be plain text — the frontend resolves messages via React text nodes, never `dangerouslySetInnerHTML`. Per [Phase 02a Packet 2](roadmap/phase-02a-kernel-tenancy.md). |
+| **`LocalizedMessage`** | The sealed record `(string Key, IReadOnlyDictionary? Params)` in `LearnStack.SharedKernel.Localization`. `Key` MUST begin with `lockey_`, enforced at construction; this is the backend-message wire namespace, distinct from general frontend UI identifiers. Consumers own supported error resources; the SDK normalizes errors and contains no translations. See [Error Handling § Mapping Problem Details → UI](standards/09-error-handling.md#mapping-problem-details--ui). Equality is structural (Key + Params); ctor defensively copies Params; empty params normalised to null. `Params` values must be plain text — resolved messages use React text nodes, never `dangerouslySetInnerHTML`. Per [Phase 02a Packet 2](roadmap/phase-02a-kernel-tenancy.md). |
+| **UI Catalogue** | App-owned platform messages under `frontend/apps/web/src/i18n/messages//.json`. Accepted [ADR-0027](decisions/0027-frontend-i18n.md) selects exact next-intl 4.14.9 and complete `en`/`tr` `public` catalogues, implemented by P02d-6 Step 1 with the server request runtime and catalogue controls. General UI identifiers are dotted feature keys, distinct from backend `lockey_*`. Whole-catalogue language fallback never changes admitted content locale; missing used keys in supported catalogues fail validation. The SDK contains no translations. |
| **`None`** | The `readonly record struct` value used as `Result` when a command/query succeeds without returning data — replaces the `Result` with `IsSuccess = true` and `Value = null` shape Standards 09 § Forbidden bans. |
| **`IClock` / `IRandom` / `IGuidFactory`** | The three deterministic-test abstractions in `LearnStack.SharedKernel`. Production code never reads `DateTime.UtcNow`, instantiates `System.Random`, or calls `Guid.NewGuid()` directly — those calls go through the abstractions so tests pin the values via `FixedClock` / `FixedRandom` / `FixedGuidFactory`. Per Standards 02 § Time. |
| **`UserId`** | The cross-cutting strongly-typed actor identifier in `LearnStack.SharedKernel.Identifiers` (Vogen `[ValueObject]`). Audit columns on `AuditableEntity` reference users by `UserId` so the "no raw `Guid` on the public surface" rule (Standards 02) holds even though the Identity module lands in Phase 02b. Identity consumes the same type when it ships. |
diff --git a/docs/modules/customization/README.md b/docs/modules/customization/README.md
index edcccc51..e2a3940c 100644
--- a/docs/modules/customization/README.md
+++ b/docs/modules/customization/README.md
@@ -198,7 +198,8 @@ nondeleted definitions are eligible; missing individual pins remain distinguisha
without failing unrelated members or substituting another revision. Labels resolve
per call with actual locale metadata from the caller's display-locale context.
P02d-4 Step 3 delivers the public response/refusal contract;
-page rendering remains P02d-6. The coherent loader supplies
+P02d-6 Steps 2–3 deliver the catalog, course and lesson views. The coherent loader
+supplies
generation-keyed families; dirty or rollback-only
scopes bypass their cache. The writer reader remains uncached.
@@ -235,7 +236,8 @@ binding purpose (`NewBinding` or `ExistingPin`), never an inferred live version.
if it is concurrently deprecated. Strict commit-time eligibility is not selected.
- Body validation uses `IJsonSchemaValidator` against that returned exact schema.
Generation-keyed read caching is delivered in P02d-3; P02d-4 public shape is
- delivered by Step 3. Renderer fallback remains P02d-6. These are
+ delivered by Step 3. P02d-6 Step 3 implements the bounded ordered-text renderer
+ and unavailable fallback. These are
separate from the exact write contract.
- Module-owned contextual verification queries give the seeder exact IDs, revision
data, labels/bands and state. They are audit Off and introduce no setter exception.
diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md
index ecd75022..c4e3eee8 100644
--- a/docs/modules/tenancy/README.md
+++ b/docs/modules/tenancy/README.md
@@ -182,9 +182,8 @@ replacement yields a concurrency conflict; competing creates use the existing
tenant/scope/key uniqueness. No retry merges colors from different candidates.
Invalid existing override projects as a whole null in P02d-4 Step 2; safe CSS
defaults remain the renderer's responsibility. Never emit raw JSON or partial unsafe
-colors. Whole-theme/null and attribution-only semantics are delivered; G42 still
-owns injection. Authoring
-this baseline theme is
+colors. Whole-theme/null and attribution-only semantics are delivered; P02d-6
+Step 1 implements G42 atomic CSS injection. Authoring this baseline theme is
not gated by `tenancy.white_label_branding` in P02d-2.
Generic `TenantSetting.Value` carries `[PiiSensitive]`; audit capture redacts the whole
@@ -203,7 +202,7 @@ No raw string-key/JSON export, settings HTTP surface or caller-supplied scope is
admitted. The first production registration is tenant-wide `branding.theme`,
reusing its four-color grammar and contrast validator above. It returns a complete
typed palette or bounded absent/invalid outcome. P02d-4 public allowlisting is
-delivered by Step 2; CSS injection remains P02d-6.
+delivered by Step 2; P02d-6 Step 1 delivers atomic CSS injection.
Registrations are values in an explicit server-owned `TenantSettingRegistry`.
The composition extension installs its default only when no registry was already
diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md
index 792f04a1..e9e837d8 100644
--- a/docs/roadmap/README.md
+++ b/docs/roadmap/README.md
@@ -65,8 +65,20 @@ not deferred to the showcase phase.
records final verification. P02d-5's decision package and ADR-0053 are Accepted
— 2026-10-08. All four steps and both fresh review rounds per step are complete.
The [remediation and closeout record](phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09)
- owns current delivery, review and CI evidence; P02d-5 remains unmerged in PR #26.
- P02d-6's public-renderer decision pass is next; the P7 browser demo remains later.
+ owns delivery and review evidence. P02d-5 is complete and merged through
+ [PR #26](https://github.com/HodeTech/LearnStack/pull/26) on 2026-10-09; its
+ [merge closeout](phase-02d-walking-skeleton.md#p02d-5-merge-and-closeout-2026-10-09)
+ records final verification.
+ P02d-6's ADR-0027 and decision package are Accepted — 2026-10-09. Its localized
+ catalog/course/lesson pages and product proofs are implemented; the
+ [closeout record](phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout)
+ records completed reviews and scoped browser/VoiceOver observations.
+ The original implementation is complete. ADR-0055 remediation delivers
+ single-bootstrap admission and passing production replacement proofs. All three
+ steps completed both independent review rounds;
+ the [Step 3 record](phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout)
+ owns completed Step 3 reviews, verified fixes and delivery evidence. Maintainer
+ PR review and merge remain pending. P7 owns the browser/demo.
- [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 eb200223..e7073dd5 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-09).** Phase 02d **in progress**. The kickoff, `P02d-0`, ships this
+> **Status (2026-10-10).** 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
@@ -13,8 +13,8 @@
> | 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 | Original four steps delivered and reviewed, unmerged; external-review remediation in progress; [approval package](#p02d-5-external-review-remediation-2026-10-09) |
-> | P02d-6 | Public renderer | not started |
+> | P02d-5 | Server-rendering path | ✅ complete and merged — 2026-10-09; [merge closeout](#p02d-5-merge-and-closeout-2026-10-09) |
+> | P02d-6 | Public renderer | ✅ implementation complete — 2026-10-10; all four steps and both review rounds per step complete; scoped browser/VoiceOver smoke passed; [packet closeout](#p02d-6-packet-closeout-2026-10-10); maintainer PR review/merge pending |
> | P02d-7 | Demo, full-stack CI and exit | not started |
**Acceptance update — 2026-10-02.** P02d-1 remains merged. The maintainer accepted
@@ -98,6 +98,34 @@ and proof corrections are delivered. The [current remediation record](#p02d-5-ex
owns the five steps, their reviews and final PR/CI readiness. Earlier delivery
notes remain historical; PR #26 is not merged.
+**P02d-5 merge complete — 2026-10-09.** The preceding notes record pre-merge
+milestones. P02d-5 is now closed through
+[PR #26](https://github.com/HodeTech/LearnStack/pull/26); its
+[merge closeout](#p02d-5-merge-and-closeout-2026-10-09) records the final head,
+verification and merge. Phase 02d remains in progress. P02d-6's public-renderer
+decision pass is next; P6/P7 implementation has not started.
+
+**P02d-6 preparation — 2026-10-09.** The
+[decision package](#p02d-6-decision-package-2026-10-09) and
+[Proposed ADR-0027](../decisions/0027-frontend-i18n.md) are ready for maintainer
+review. All P6 gate parts remain unaccepted. The package explicitly proposes the
+404 navigation/status tradeoff exposed by the pinned framework experiment;
+implementation waits for approval, not an assumed framework guarantee.
+
+**P02d-6 acceptance — 2026-10-09.** The maintainer approved revised ADR-0027 and
+the complete [decision package](#p02d-6-decision-package-2026-10-09), including
+the local 307→404 URL change, HTTP 200/noindex content-failure states and four
+implementation steps. The acceptance commit closes its named gate parts and
+reconciles their carriers before code. Every step uses commits and two fresh
+independent review rounds. Development remains the working branch.
+
+**P02d-6 implementation complete — 2026-10-10, unmerged.** All four steps and
+both independent review rounds per step are complete. The
+[packet closeout](#p02d-6-packet-closeout-2026-10-10) records automated verification,
+manual browser observations and the maintainer's passing VoiceOver smoke.
+P02d-6 awaits maintainer PR review and merge. P02d-7's demo, browser/Lighthouse
+harness and phase-exit decision pass are next; they have not started.
+
## Goal
Put a working education site in a browser — twice, on two hosts, for two tenants in
@@ -232,9 +260,11 @@ owner; where a choice is still open, it names the register row that answers it.
P02d-1 implements Education's roots, translations, migration and isolation.
P02d-2 implements its six commands and seeded data. P02d-3 supplies internal reads;
-P02d-4 supplies public endpoints and contract/SDK controls. What remains unimplemented:
-every renderer component; the configured trusted server SDK transport;
-any trusted-hop configuration; any Lighthouse tooling; and a `make demo` target.
+P02d-4 supplies public endpoints and contract/SDK controls. P02d-5 supplies native
+HTTPS ingress, coordinated anonymous admission, the configured trusted server
+caller and live-host/locale entry. Public product pages, text-card components,
+theme injection and accessibility remain P02d-6; the browser/demo/Lighthouse
+harness and phase exit remain P02d-7.
### Explicitly not in this phase
@@ -330,9 +360,9 @@ Decisions made or referenced in this phase:
(lesson-body validation on the command path; no compiled-validator cache)
- [ADR-0045 Entitlement and Feature-Flag Socket](../decisions/0045-entitlement-and-feature-flag-socket.md)
(`tenancy.white_label_branding`, whose meaning G16 asks)
-- The reserved, undrafted **ADR-0027** — the frontend i18n library, target Phase 04 in
- [decisions/README § Open ADR Drafts](../decisions/README.md#open-adr-drafts); G39 asks
- whether this phase's renderer Accepts it
+- **Accepted [ADR-0027](../decisions/0027-frontend-i18n.md)** — the frontend i18n
+ library and catalogue contract; P02d-6 first consumer, accepted with G39 on
+ 2026-10-09. Phase 04 consumes the foundation; implementation is separate.
## Scope
@@ -381,22 +411,22 @@ premise a row cites is re-verified at that pass rather than trusted.
| G2 | Which aggregate does `Lesson` belong to, and what is its parent: an entity inside `Course`, its own root referencing `Course`, or a minimal `CourseVersion` and default `Module` now? With it: how the satellites are mapped (base type, markers, `deleted_at`), the `sort` invariant and tie-breaker, and what the shape obliges Phase 05 to preserve — course and lesson ids, published slugs, order, organization scope, the inline body | The reviews split. One brings the version spine forward so Phase 05 enriches rather than re-parents; two keep this phase thin and record the preservation obligations, with Phase 05 designing the move. Between the thin shapes: inside `Course` means `ON DELETE CASCADE` and one audit row, but a lesson edit mutates `Course` structurally, which [Domain Model § Education Catalog](../architecture/02-domain-model.md#education-catalog) says a published course never is; its own root means `RESTRICT`, its own `row_version` and its own audit subject | Contract: a dated phase-doc statement; no Accepted ADR holds the `Course` / `CourseVersion` hierarchy, so a new ADR only if the answer needs a cross-root write ([ADR-0042](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md)). Detail: the Education spec's data model; an interim note in [Domain Model § Learning Content](../architecture/02-domain-model.md#learning-content) where the answer departs from it; the class count in [Database Standards § Foreign keys between tenant-owned tables](../standards/05-database.md#foreign-keys-between-tenant-owned-tables) if `Lesson` cascades | P02d-1 (the lessons foreign-key target, `ON DELETE`, `row_version`, the root mapping and satellite `deleted_at`) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): G2 |
| 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 |
+| 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. [Accepted — 2026-10-09](#p02d-6-accepted-answers): renderer state |
| 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 |
| G10 | What is the catalog's default order and tie-breaker, and what is the cursor it mints: its payload and version; what it binds (tenant, organization, locale, sort, filters, endpoint); its integrity (none, a MAC with a key version, or server-side state); its direction; what happens when a row changes between pages; which list parameters the endpoint binds; where it is decoded; whether the codec is this endpoint's or the kernel's; and which cursor classes answer `400`? | The reviews split between a keyless versioned payload with a binding fingerprint, decoded at binding so a garbage cursor opens no transaction, and an HMAC-authenticated cursor with key rotation. Both keep tenant and organization out of the cursor, and bind `CursorPaginationRequest` rather than `ListRequest`, whose `q` is Phase 04's search | Contract: a phase-doc statement if the codec is endpoint-local and keyless; a new ADR if it becomes a kernel rule later lists follow, or a MAC adds a secret and a rotation posture. Detail: [API Standards § Pagination](../standards/04-api-design.md#pagination), which drops "Nothing validates its *shape* yet"; Standards 21 rows | P02d-1 (the order part: an ordering column, publication timestamp or collation), P02d-4 (the codec part) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): order; [Accepted — 2026-10-03](#p02d-4-accepted-answers): resource-local cursor codec |
| G11 | The write surface the seed needs. Which Education commands write courses, lessons and their translations; is a translation written separately from create; is publishing its own command; which command reports a slug collision as `business_rule_violation` rather than a raw unique violation, and does Localization Standards' "from the publish command" still hold? What shape do the Tenancy commands raising `tenancy.locale.write` and `tenancy.setting.write` take? How are the non-baseline writes classified, and how does a re-run converge? | Create course, write course translation, add lesson, write lesson translation, publish course (MUST); one locale command over `Tenant.AddLocale` and `SetDefaultLocale`; a create-or-update setting command keyed on context scope and key; ordering taxonomy → content type → course → lessons; idempotent by conflict, with an ownership check per act and a second-run test. None has a route | Contract: a phase-doc statement plus the Education spec (README write sequence, `audit.md`, `permissions.md` as a forward declaration on [the Tenancy precedent](../modules/tenancy/permissions.md)). Detail: catalogue sources, the Tenancy `audit.md` and `permissions.md`, Localization Standards § Pattern A if the collision sentence changes. An ADR only if a handler must write two roots | P02d-2 (commands, handlers, catalogue sources, seeder acts) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): commands, failures, audit classifications and convergence |
-| G12 | Through which `Customization.Application.Contracts` surface does an Education write obtain the schema a body is validated against — exact `(key, schema_version)` including Deprecated revisions, or a key that binds the Active one — and is it an interface or a MediatR query, classified how? Which revisions may a writer bind, and what refusal answers an absent, cross-tenant or ineligible one? On the read side: what the cache keys on, whether the lesson response carries the binding or resolved field descriptors, and what the API and the page show when a binding cannot be resolved | One exact-revision query, Deprecated included, never falling back to Active; only Active revisions bindable for new writes, since a Draft's body can still change; absent and cross-tenant refused indistinguishably as `validation_failed` naming the binding; resolved descriptors in the response; an unresolvable binding shows a bounded placeholder with a warning log, never a `500` and never another revision's fields ([ADR-0013](../decisions/0013-page-block-schema-versioning.md)'s placeholder rule) | Detail: the Customization spec's contract and § Primary read flow, the Education spec's invariants, a phase-doc statement. No ADR: ADR-0010 settles the mechanism. A dated ADR-0013 amendment only if the unresolvable outcome departs from the placeholder rule | P02d-2 (the contract and write eligibility: the lesson writer is its first caller), P02d-3 (the cache key), P02d-4 (descriptors, the unresolvable outcome), P02d-6 (the page state) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): contract and write eligibility. [Accepted — 2026-10-02](#p02d-3-decision-package-2026-10-02): cache key; [Accepted — 2026-10-03](#p02d-4-accepted-answers): public response; page behavior remains P02d-6 |
+| G12 | Through which `Customization.Application.Contracts` surface does an Education write obtain the schema a body is validated against — exact `(key, schema_version)` including Deprecated revisions, or a key that binds the Active one — and is it an interface or a MediatR query, classified how? Which revisions may a writer bind, and what refusal answers an absent, cross-tenant or ineligible one? On the read side: what the cache keys on, whether the lesson response carries the binding or resolved field descriptors, and what the API and the page show when a binding cannot be resolved | One exact-revision query, Deprecated included, never falling back to Active; only Active revisions bindable for new writes, since a Draft's body can still change; absent and cross-tenant refused indistinguishably as `validation_failed` naming the binding; resolved descriptors in the response; an unresolvable binding shows a bounded placeholder with a warning log, never a `500` and never another revision's fields ([ADR-0013](../decisions/0013-page-block-schema-versioning.md)'s placeholder rule) | Detail: the Customization spec's contract and § Primary read flow, the Education spec's invariants, a phase-doc statement. No ADR: ADR-0010 settles the mechanism. A dated ADR-0013 amendment only if the unresolvable outcome departs from the placeholder rule | P02d-2 (the contract and write eligibility: the lesson writer is its first caller), P02d-3 (the cache key), P02d-4 (descriptors, the unresolvable outcome), P02d-6 (the page state) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): contract and write eligibility. [Accepted — 2026-10-02](#p02d-3-decision-package-2026-10-02): cache key; [Accepted — 2026-10-03](#p02d-4-accepted-answers): public response; [Accepted — 2026-10-09](#p02d-6-accepted-answers): page state |
| G13 | May an Education translation be written for a locale absent from, or disabled in, `tenant_locales`, and how is membership checked across the module boundary? Does a read resolve under a disabled locale? What does a tenant with no locale rows serve — [Localization § Tenant Locale Configuration](../architecture/12-localization.md#tenant-locale-configuration) promises platform `en`, and nothing implements it? Does a platform registry bound the enabled set, as Localization Standards names one in a namespace that does not exist? What happens to translations when `RemoveLocale` runs? | A Tenancy application contract checks membership on write; a read resolves only an enabled locale, checked once per request; no cross-chain foreign key; no platform registry in this phase; a tenant with no locale rows serves nothing until it has one | Contract: a phase-doc statement over ADR-0010's application-contract mechanism. Detail: the Tenancy and Education specs; Localization architecture and Localization Standards § Locale Model reconciled in the same diff; Database Standards § Migrations only if a key is chosen | P02d-2 (the translation command's check and the locale command the seed uses; the read half is written to the same answer in P02d-4) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): write eligibility, no platform registry and no implicit no-row locale; public reader implementation remains P02d-4 |
| G14 | Seed inventory. At what scope is each seeded row class written — courses, lessons, translations, branding settings — and from what seeder context, given that `SeedTenantContext` requires an organization? Where do the rows the criteria need live — a sibling-organization course, an organization-scoped course on the tenant host, a `(locale, slug)` held in both tenants, draft and wrong-course rows, more courses than one catalog page, a disabled locale holding translations — `make seed` or test-owned data? Which key the yoga taxonomy uses, which tenant is bilingual, what state do the built-in `card` / `plain` keep, which record holds it all, and how do the Packet 7 fixture's raw settings rows coexist with seeded ones? | English content tenant-wide; the yoga studio gets a tenant-wide, a Studio One and a Studio Two course; a seed context that announces no organization; branding tenant-wide; rows in the seed with `SeedData` as the record; built-ins stay Active and are never selected implicitly; expectations recomputed as enumerated sets. An English organization-scoped row is still needed for the tenant-host criterion, seeded or test-owned — the demo database's contents are the owner's preference | Detail: a phase-doc statement, the `SeedData` remarks, the `seed-tenant` skill, the writers delivery record. No ADR: [Security Standards § Forbidden](../standards/11-security.md#forbidden) already makes scope come from context | P02d-2 (seeder steps, the seed-context constructor, `SeedData`, `SeederTests`; moving placement later rewrites the seed and every request-level case) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): inventory, ownership and test-owned controls |
| G15 | `SeedRunner` calls `IUnitOfWork.SetTenantContextAsync` on its own transaction, and neither [ADR-0040](../decisions/0040-ambient-unit-of-work.md)'s closed setter set nor [Security Standards § The out-of-band setters](../standards/11-security.md#the-out-of-band-setters) lists it. Is that method's caller set mechanically closed, and is the seeder's call reconciled by routing its ownership check through `ISender`, or by admitting the seeder? | Route the ownership check through `ISender`, and add a source scan that admits `TransactionBehavior` (and Phase 02b's transport) with a planted offender | Contract: a dated ADR-0040 amendment plus a setters-table row only if the seeder is admitted. Detail: a Standards 21 source-scan row with its companion | P02d-2 (the Education seed acts reach the ownership check's refusal arm today) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): contextual verification and Registered caller fence |
-| G16 | The branding token contract. (a) Where does the settings key registry live, what does a descriptor carry, and does `tenancy.setting.write` refuse keys outside it? (b) Which branding keys exist — per-token keys or one theme document — and is a layout option among them? (c) What value does each accept, fonts and logos included, and what happens to a stored value that fails it? (d) Does a failed contrast check refuse the write or record a warning — [Accessibility Standards § Color and Contrast](../standards/16-accessibility.md#color-and-contrast) says a Studio warning? (e) What does an organization-scoped branding row do here — refused, ignored or applied? (f) Which tokens may leave an anonymous response? (g) Does `tenancy.white_label_branding` — which reads true under `NullEntitlementProvider`, whose projection grants every registered feature, falls back to its catalog default `false` from a projection that omits it, and which the Hub's Starter plan sets false — govern applying theme tokens or only removing LearnStack attribution? | (a) a registry beside `FeatureKeys` and `LimitKeys`, as `Tenant.SetFeatureFlag` already refuses unregistered keys; (b) per-token keys, at most one enumerated layout option or none; (c) `#rrggbb` colours, one font key from a closed self-hosted set, no remote logo; (d) refuse; (e) tenant-wide only, keeping Phase 06's override and ADR-0017's `OrganizationBranding` true; (f) a closed projection of publicly readable keys; (g) not gated — tokens are baseline presentation, and the key's meaning is agreed with the Hub. That token values are tenant settings is settled by [Frontend Architecture Standards § Tenant Branding](../standards/07-frontend-architecture.md#tenant-branding) | Contract: a phase-doc statement plus Frontend Architecture Standards § Tenant Branding; a new ADR if the registry becomes an admission rule for every `tenant_settings` key; a dated ADR-0017 amendment if (e) applies overrides; Accessibility Standards if (d) replaces the warning. Detail: the Tenancy spec and permission matrix, [Frontend Architecture § Theming](../architecture/14-frontend-architecture.md#theming), the `FeatureKeys` descriptor with a matching note in the Hub repository for (g) | P02d-2 (a–e: validation and the seeded keys, which Phase 06's editor later edits), P02d-4 (f, g: the anonymous projection the OpenAPI baseline freezes), P02d-6 (g: whether rendering consults the flag) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): (a–e); [Accepted — 2026-10-03](#p02d-4-accepted-answers): public projection and attribution (f/g); injection remains G42/P02d-6 |
+| G16 | The branding token contract. (a) Where does the settings key registry live, what does a descriptor carry, and does `tenancy.setting.write` refuse keys outside it? (b) Which branding keys exist — per-token keys or one theme document — and is a layout option among them? (c) What value does each accept, fonts and logos included, and what happens to a stored value that fails it? (d) Does a failed contrast check refuse the write or record a warning — [Accessibility Standards § Color and Contrast](../standards/16-accessibility.md#color-and-contrast) says a Studio warning? (e) What does an organization-scoped branding row do here — refused, ignored or applied? (f) Which tokens may leave an anonymous response? (g) Does `tenancy.white_label_branding` — which reads true under `NullEntitlementProvider`, whose projection grants every registered feature, falls back to its catalog default `false` from a projection that omits it, and which the Hub's Starter plan sets false — govern applying theme tokens or only removing LearnStack attribution? | (a) a registry beside `FeatureKeys` and `LimitKeys`, as `Tenant.SetFeatureFlag` already refuses unregistered keys; (b) per-token keys, at most one enumerated layout option or none; (c) `#rrggbb` colours, one font key from a closed self-hosted set, no remote logo; (d) refuse; (e) tenant-wide only, keeping Phase 06's override and ADR-0017's `OrganizationBranding` true; (f) a closed projection of publicly readable keys; (g) not gated — tokens are baseline presentation, and the key's meaning is agreed with the Hub. That token values are tenant settings is settled by [Frontend Architecture Standards § Tenant Branding](../standards/07-frontend-architecture.md#tenant-branding) | Contract: a phase-doc statement plus Frontend Architecture Standards § Tenant Branding; a new ADR if the registry becomes an admission rule for every `tenant_settings` key; a dated ADR-0017 amendment if (e) applies overrides; Accessibility Standards if (d) replaces the warning. Detail: the Tenancy spec and permission matrix, [Frontend Architecture § Theming](../architecture/14-frontend-architecture.md#theming), the `FeatureKeys` descriptor with a matching note in the Hub repository for (g) | P02d-2 (a–e: validation and the seeded keys, which Phase 06's editor later edits), P02d-4 (f, g: the anonymous projection the OpenAPI baseline freezes), P02d-6 (g: whether rendering consults the flag) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): (a–e); [Accepted — 2026-10-03](#p02d-4-accepted-answers): public projection and attribution (f/g); [Accepted — 2026-10-09](#p02d-6-accepted-answers): injection and attribution (g) |
| 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; [Accepted — 2026-10-08](#p02d-5-accepted-answers): (c) exemptions; (b,d) stay P6/P7 |
+| 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. [Accepted — 2026-10-08](#p02d-5-accepted-answers): (c) exemptions; [Accepted — 2026-10-09](#p02d-6-accepted-answers): (b) frontend subjects; implementation and behavioral proof remain 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 |
@@ -414,12 +444,12 @@ premise a row cites is re-verified at that pass rather than trusted.
| 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 `' },
+ { ...palette, muted: '#64748b' },
+ { ...palette, url: 'https://private.invalid' },
+ { ...palette, font: 'private' },
+ ])('rejects the whole malformed theme without partial tokens: %j', (theme) => {
+ expect(publicThemeCss(theme)).toBeNull();
+ const { container } = render();
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('provides one main/skip target, inert institution text and labelled fallback chrome', () => {
+ const ui = createPublicTranslator('ar');
+ const { container } = render(
+
+
عنوان
+ ,
+ );
+ expect(screen.getAllByRole('main')).toHaveLength(1);
+ const main = screen.getByRole('main');
+ expect(main.id).toBe('main-content');
+ expect(main).toHaveAttribute('tabindex', '-1');
+ expect(screen.getByRole('link', { name: 'Skip to content' })).toHaveAttribute(
+ 'href',
+ '#main-content',
+ );
+ const nav = screen.getByRole('navigation');
+ expect(nav).toHaveAttribute('lang', 'en');
+ expect(nav).toHaveAttribute('dir', 'ltr');
+ expect(screen.getByRole('link', { name: 'Course catalog' })).toHaveAttribute(
+ 'href',
+ '/ar/courses',
+ );
+ expect(screen.getByText(site.displayName)).toBeInTheDocument();
+ expect(container.querySelector('script')).toBeNull();
+ expect(screen.getByText('Powered by LearnStack')).toBeInTheDocument();
+ });
+
+ it('uses only the effective attribution value independently of the safe theme', () => {
+ const ui = createPublicTranslator('tr');
+ render(
+
+