From 83471f4eb5ac20d8dcd4246a277b2eefd1a84441 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 12 Sep 2026 23:45:29 +0300 Subject: [PATCH 1/6] docs(roadmap): answer each gate at its last responsible moment Phase 02b's P02b-0 bundled every decision the phase needs ahead of the code those decisions govern. Phase 02a's records show the cost: ADR-0033, Accepted a month before Packet 9 implemented it, took six amendments while that packet was written. The roadmap now states the convention: a gate closes in the decision pass of the first packet whose code it shapes, together with the gates coupled to it. Phase 02b applies it. P02b-0 becomes a re-verification of the register once Phase 02d exits, and each Blocks cell names the part each packet waits on: G7 and G1 were missing from the dispatcher packet whose criteria assert them. Pre-assigned amendment numbers are gone, and P02b-4 follows P02b-3. G12's dev transport closes in Phase 02d, since moving the hosts later would rewrite what that phase ships. Co-Authored-By: Claude Opus 5 (1M context) --- docs/roadmap/README.md | 34 ++++++++ docs/roadmap/phase-02b-events-auth.md | 96 ++++++++++++++-------- docs/roadmap/phase-02d-walking-skeleton.md | 9 +- 3 files changed, 102 insertions(+), 37 deletions(-) diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 35cc647e..3f57078e 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -180,6 +180,40 @@ mergeable slice with its own pull request; see [Glossary](../glossary.md). The roadmap deliberately carries **no effort estimates, owners, or timeboxes**. It is a dependency and scope plan; sequencing decisions belong here, capacity decisions do not. +## Decision Timing + +A phase document registers the questions its packets cannot be written without, and +answers none of them ahead of the code they govern. An answer given early is written +against code that does not exist yet, and is usually amended when that code arrives. In +Phase 02a, [ADR-0033](../decisions/0033-audit-durability-model.md) was Accepted a month +before Packet 9 implemented it and took six amendments while that packet was written, +the first titled *The write path against the code that shipped after this ADR*. + +Each open question is a **gate**, answered at its last responsible moment: + +- **When.** A gate is Accepted before the first packet whose code would have to change + if it were answered differently — not earlier, and never after. It is the + [one-way-door test](#sequencing-principle) applied to a decision rather than to a + building block: *if this answer waited one more packet, would code written in between + have to change?* +- **Coupled gates close together.** Gates that define one mechanism — one state + machine, one amendment — close in the same pass. A gate whose parts shape different + packets' code is split into parts, and each part blocks the first packet that writes + what it decides. +- **Two layers.** The contract — ownership, invariants, the shape of a state machine — + is the decision record, an ADR or a dated amendment, and closes before its first + writer. The detail — DDL, slugs, metric names and labels, catalogue rows, glossary + headwords — lives in the standard or register that owns it and lands with the first + packet that uses it. +- **Every packet opens with a decision pass.** Each premise its gates cite is + re-verified against `HEAD` — the code, the corpus, the Hub repository, pinned package + versions — its records are drafted and Accepted, and the decisions and their catalogue + rows are the packet's first commit. A contradiction the implementation finds is + amended inside the packet, before it merges. +- **A register pre-assigns nothing that moves.** An amendment takes the next free number + on the day it is written. A phase that waits behind another re-verifies its register + when that phase exits, before its first decision pass. + ## Success Criteria At the end of this roadmap, LearnStack can: diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 47a4182a..ab5299ac 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -206,53 +206,63 @@ Decisions made or referenced in this phase: ### Packets and decision gates Phase 02a needed twelve packets for a narrower scope. This phase declares nine, in -dependency order. `P02b-0` writes no code. +dependency order. `P02b-0` writes no code. Every packet after it opens with the decision +pass [Roadmap § Decision Timing](README.md#decision-timing) describes: its gates are +Accepted, and its catalogue rows registered, before its first line of code. | Packet | Contents | Cannot start until | |---|---|---| -| **P02b-0** | Decisions only: the two reserved ADRs drafted and Accepted, the dated amendments the gates below name, Database Standards § Inbox, the catalogue rows for every rule this phase introduces, and the glossary headwords its mechanisms need — `inbox`, outbox lease, dead letter, audit frame, job activator, integration-event consumer, each written once the gate that fixes its meaning closes | — | -| **P02b-1** | Producer: `IOutbox`, the `OutboxFlushBehavior` body, the domain-event collector and its single dispatch site, and the first event with its enqueue path | G2, G6, G19 Accepted | -| **P02b-2** | Dispatcher: the lease migration and grant extension, claim / publish / mark, backoff, the producer terminal state, the credential and its boot guard, the metrics and the health check | G3, G4, G5, G8, G18 Accepted; P02b-1 | +| **P02b-0** | Phase entry: every premise the register below cites re-verified against `HEAD` — the code, the corpus, the Hub repository — and the register corrected where Phase 02d moved it. It drafts no record: each gate closes in the decision pass of the first packet it shapes, and each glossary headword this phase needs — `inbox`, outbox lease, dead letter, audit frame, job activator, integration-event consumer — is written in the pass whose gate fixes its meaning | [Phase 02d](phase-02d-walking-skeleton.md) exits | +| **P02b-1** | Producer: `IOutbox`, the `OutboxFlushBehavior` body, the domain-event collector and its single dispatch site, and the first event with its enqueue path | G2, G3 (the key's derivation), G5 (the stored identities), G6, G18 (the payload rule), G19 Accepted | +| **P02b-2** | Dispatcher: the lease migration and grant extension, claim / publish / mark, backoff, the producer terminal state, the credential and its boot guard, the metrics and the health check | G1, G3, G4, G5, G7, G8, G17, G18 (`last_error`) Accepted; P02b-1 | | **P02b-3** | Consumer: the inbox DDL, `IInboxGuard`, the per-delivery transaction, the sample flow's consumer, per-subscription attempts, the dead-letter store and its audit row, replay as a dispatcher operation | G1, G6, G7 Accepted; P02b-2 | -| **P02b-4** | Background jobs: the runner, its storage, the tenant-context hook, the enqueue guard, no job definition | G9 Accepted | -| **P02b-5** | API authentication: JwtBearer wiring and its insertion order, claim population, the host/JWT cross-check through the existing recorder, token-keyed rate limits, the 401 rows, one authenticated endpoint | G10, G11, G14, G20 Accepted | -| **P02b-6** | Realm and seed: the shared client scope, the mappers, the user-profile declaration, UUID attributes agreeing with `SeedData`, the warm-realm reconciler, the Keycloak test fixture | G12 Accepted; P02b-5 | +| **P02b-4** | Background jobs: the runner, its storage, the tenant-context hook, the enqueue guard, no job definition | G9 Accepted; P02b-3 | +| **P02b-5** | API authentication: JwtBearer wiring and its insertion order, claim population, the host/JWT cross-check through the existing recorder, token-keyed rate limits, the 401 rows, one authenticated endpoint | G10, G11, G12 (the BFF's client), G14, G20 Accepted | +| **P02b-6** | Realm and seed: the shared client scope, the mappers, the user-profile declaration, UUID attributes agreeing with `SeedData`, the warm-realm reconciler, the Keycloak test fixture | G12, G13 (the access-token lifetime's authority) Accepted; P02b-5 | | **P02b-7** | BFF session: Auth.js, the cookie session, single-flight refresh, CSRF, logout, the studio guard, the SDK's bearer attachment | G13 Accepted; P02b-6 | -| **P02b-8** | `LS0002`, the carrier reconciliations this phase owes, the branch-protection edit that makes the behavioural check gate, and the exit checks | G16, G17, G18 Accepted | +| **P02b-8** | `LS0002`, the carrier reconciliations this phase owes, the branch-protection edit that makes the behavioural check gate, and the exit checks | G16, G18 (the erasure inventory) Accepted | The events packets precede the identity packets because the Hub's `P02c-3` waits on this phase's events half ([Phase 02c](phase-02c-hub-foundation.md)), not on its session half. +`P02b-4` follows `P02b-3` rather than running beside it. ADR-0047's retry contract +adopts G7's class, slug and posture, and a job frame owes the same owner duties as the +delivery frame `P02b-3` ships first, so the record is written against that frame rather +than its plan. + #### The decision register Each row is a question this phase must answer before the packet it blocks starts. The **Leaning** column records the reviewers' consolidated proposal and is **not** a decision; the **Vehicle** column is what this repository's own rules require for the answer to count. A gate whose vehicle is an amendment is Accepted **before** the code it -governs is written, not alongside it. +governs is written, not alongside it. It closes in the decision pass of the first packet +whose code it shapes, together with the gates coupled to it; where its parts shape +different packets, the **Blocks** cell names the part each packet waits on. Every +premise a row cites is re-verified at that pass rather than trusted. | # | Question | Leaning | Vehicle | Blocks | |---|---|---|---|---| -| G1 | What is the subscriber's fate when its handler exhausts retries, and what is the inbox table — one shared or per module, keyed `event_id` or `(tenant_id, consumer, event_id)`, claimed by the handler or by the transport? Three arms travel with it: the class of consumer whose effect cannot share the inbox transaction (an external call, the platform-scope erasure handler the Audit spec already carries) and what "effectively once" means for it; whether an event without the organization marker is delivered tenant-wide regardless of the producing request's organization; and whether the inbox entity joins [ADR-0044 § 7](../decisions/0044-audit-write-path.md)'s named audit-capture exclusion list once a delivery declares intents | One shared tenant-owned `inbox_messages`, transport-claimed; the terminal state in its own store so "the inbox row is not marked processed" stays true | **ADR-0046** + a dated **ADR-0006 Amendment** (or an explicit supersession note in ADR-0046 naming Amendment 1's consumer-side paragraph, which fixes the per-module inbox) + a dated **ADR-0010 Amendment**, because its § Idempotency makes two things mandatory that the shared-table leaning changes — the inbox is "the module's **own** inbox table", and the guard is called by the consumer — + the `Integration_Event_Handlers_Use_InboxGuard` assertion re-written in the same record to name whoever ends up owning the call, since a transport-claimed delivery fails a structural rule that looks for the call inside the handler + Database Standards § Inbox + an ADR-0041 erratum beside ADR-0023's minting list | P02b-3 | +| G1 | What is the subscriber's fate when its handler exhausts retries, and what is the inbox table — one shared or per module, keyed `event_id` or `(tenant_id, consumer, event_id)`, claimed by the handler or by the transport? Three arms travel with it: the class of consumer whose effect cannot share the inbox transaction (an external call, the platform-scope erasure handler the Audit spec already carries) and what "effectively once" means for it; whether an event without the organization marker is delivered tenant-wide regardless of the producing request's organization; and whether the inbox entity joins [ADR-0044 § 7](../decisions/0044-audit-write-path.md)'s named audit-capture exclusion list once a delivery declares intents | One shared tenant-owned `inbox_messages`, transport-claimed; the terminal state in its own store so "the inbox row is not marked processed" stays true | **ADR-0046** + a dated **ADR-0006 Amendment** (or an explicit supersession note in ADR-0046 naming Amendment 1's consumer-side paragraph, which fixes the per-module inbox) + a dated **ADR-0010 Amendment**, because its § Idempotency makes two things mandatory that the shared-table leaning changes — the inbox is "the module's **own** inbox table", and the guard is called by the consumer — + the `Integration_Event_Handlers_Use_InboxGuard` assertion re-written in the same record to name whoever ends up owning the call, since a transport-claimed delivery fails a structural rule that looks for the call inside the handler + Database Standards § Inbox + an ADR-0041 erratum beside ADR-0023's minting list | P02b-2 (the record and its amendments: the dispatcher's retry state and a failed delivery's next attempt are one state machine), P02b-3 (Database Standards § Inbox and the ADR-0023 erratum) | | G2 | Does `IOutbox.EnqueueAsync` write the row immediately on the ambient transaction, or buffer for `OutboxFlushBehavior` at step 7 — and what is the flush point for the two paths that have no step 7? | Immediate write, with the behavior asserting no buffered message survives the handler | Phase-doc statement + [Events and Outbox § Producer pattern](../architecture/15-event-and-outbox.md#producer-pattern); a dated ADR-0032 Amendment if step 7 stops being a flush point | P02b-1 | -| G3 | What replaces the unconditional same-key publish-order promise — a scoped bound, or strict per-key head-of-line blocking — and is the key's derivation checkable structurally, given that [§ Ordering](../architecture/15-event-and-outbox.md#ordering) admits an aggregate id or a deliberately declared tenant-wide key and nothing else? | Scope it: no column in `outbox_messages` carries an unconditional per-key guarantee, so the honest bound is one processor, no failure, enqueue order | Scoping is a phase-doc plus [§ Ordering](../architecture/15-event-and-outbox.md#ordering) edit; blocking is a dated ADR-0006 Amendment plus an index | P02b-2 | +| G3 | What replaces the unconditional same-key publish-order promise — a scoped bound, or strict per-key head-of-line blocking — and is the key's derivation checkable structurally, given that [§ Ordering](../architecture/15-event-and-outbox.md#ordering) admits an aggregate id or a deliberately declared tenant-wide key and nothing else? | Scope it: no column in `outbox_messages` carries an unconditional per-key guarantee, so the honest bound is one processor, no failure, enqueue order | Scoping is a phase-doc plus [§ Ordering](../architecture/15-event-and-outbox.md#ordering) edit; blocking is a dated ADR-0006 Amendment plus an index | P02b-1 (whether the key's derivation is checked structurally: the first event declares a key), P02b-2 (the ordering bound) | | G4 | What counts as an attempt, what is `MaxAttempts` bound to, and what marker keeps a terminal row terminal as the clock advances? | Six attempts (one immediate plus the five documented delays) and an explicit terminal column, because a pushed-out `available_after` stops being terminal and never leaves the pending gauge | Dated ADR-0006 Amendment + the Database Standards § Outbox DDL, index and grant edits riding it | P02b-2 | -| G5 | Does the stored event identity stay the assembly-qualified type name with declared compatibility mappings, and what happens to an unresolvable type, a retired type and an event with no subscriber? And what is the **row's** identity — a surrogate `id` plus an `event_id` column, or `EventId` as the key — given that two documents list an `event_id` the canonical DDL does not have? | Keep the type identity, add a bounded resolver reading through `PayloadJsonOptions` and declared mappings; dead-letter a retired type at once rather than spending its attempt budget; reconcile the row's column list in one direction | Phase-doc + Database Standards § Outbox if kept; a dated ADR-0006 Amendment if the type identity is replaced, and a dated ADR-0032 Amendment if the `event_id` column is withdrawn from its § Sub-decision 12 list | P02b-2 | -| G6 | When the unit-of-work owner is not an `AuditLogBehavior`, who performs the four owner-side audit acts and enters the outermost audit frame — and does a state-mutating delivery declare its own intents, keyed by handler type? | The transport opens the outermost frame beside its transaction; a delivery declares its own intents and an unregistered handler is refused as an unregistered request is | One dated diff: **ADR-0044 Amendment 8**, carried into **ADR-0033 Amendment 8** and **ADR-0040 Amendment 8** | P02b-1, P02b-3 | -| G7 | For a dead-letter transition: what class, what `OperationType`, what slug, on which transaction, in what order against the state change, with what failure posture — and may a second non-MediatR caller reach `IAuditStore.WriteStandaloneAsync`? | class MUST, `OperationType` `security-event`, the slug open under the § The join grammar `{module}.{resource}.{verb}`; the row written before the transition, so a failed write leaves the transition unmade and the next poll retries | Dated ADR-0033 Amendment + `DeclareOffPath` registrations + the Baseline row in [Audit Coverage Standards](../standards/18-audit-coverage.md#baseline-coverage-learnstack-core-modules). If the chosen ordering persists the terminal transition and its MUST row on **separate** transactions, that changes what ADR-0033's Decision guarantees rather than clarifying it, and the vehicle is a superseding record, not an amendment | P02b-3 | +| G5 | Does the stored event identity stay the assembly-qualified type name with declared compatibility mappings, and what happens to an unresolvable type, a retired type and an event with no subscriber? And what is the **row's** identity — a surrogate `id` plus an `event_id` column, or `EventId` as the key — given that two documents list an `event_id` the canonical DDL does not have? | Keep the type identity, add a bounded resolver reading through `PayloadJsonOptions` and declared mappings; dead-letter a retired type at once rather than spending its attempt budget; reconcile the row's column list in one direction | Phase-doc + Database Standards § Outbox if kept; a dated ADR-0006 Amendment if the type identity is replaced, and a dated ADR-0032 Amendment if the `event_id` column is withdrawn from its § Sub-decision 12 list | P02b-1 (the stored type identity and the row's identity, both written at enqueue), P02b-2 (unresolvable, retired and unsubscribed types) | +| G6 | When the unit-of-work owner is not an `AuditLogBehavior`, who performs the four owner-side audit acts and enters the outermost audit frame — and does a state-mutating delivery declare its own intents, keyed by handler type? | The transport opens the outermost frame beside its transaction; a delivery declares its own intents and an unregistered handler is refused as an unregistered request is | One dated diff: an **ADR-0044** amendment, carried into **ADR-0033** and **ADR-0040** | P02b-1, P02b-3 | +| G7 | For a dead-letter transition: what class, what `OperationType`, what slug, on which transaction, in what order against the state change, with what failure posture — and may a second non-MediatR caller reach `IAuditStore.WriteStandaloneAsync`? | class MUST, `OperationType` `security-event`, the slug open under the § The join grammar `{module}.{resource}.{verb}`; the row written before the transition, so a failed write leaves the transition unmade and the next poll retries | Dated ADR-0033 Amendment + `DeclareOffPath` registrations + the Baseline row in [Audit Coverage Standards](../standards/18-audit-coverage.md#baseline-coverage-learnstack-core-modules). If the chosen ordering persists the terminal transition and its MUST row on **separate** transactions, that changes what ADR-0033's Decision guarantees rather than clarifying it, and the vehicle is a superseding record, not an amendment | P02b-2 (the producer dead-letter row its criteria assert), P02b-3 | | G8 | Does the dispatcher's `BYPASSRLS` use carry a per-invocation `security-event` row, or is it exempt — and on what written terms? | Exempt, on three terms: the bypass is grant-bounded and code-path-confined, dispatch attempts are logged rather than audited, and the dead-letter transitions are the audited events | Dated ADR-0006 or ADR-0003 Amendment; an architecture document cannot grant it | P02b-2 | | G9 | Which record decides the background-job runtime — its storage under the closed four-role model, the single tenant-context writer, the job frame's transaction owner, the enqueue-site rule *and who may enqueue whose job type*, the retry contract (whose class, slug and posture adopt **G7**'s), the tenantless platform-job class a purge needs, the ADR-0035 classification, the queue grammar, and the package pins with their licence verdict? | One record answering all of it; a job reaches the database only through `ISender`, which leaves ADR-0040's closed setter set at eight | **ADR-0047** + a dated ADR-0003 Amendment for the storage + a dated **ADR-0036 Amendment** (or an erratum) if the job-path writer is not the `JobActivator` its § Rules names in the closed four-caller set, then Standards 05 and Standards 20 | P02b-4 | | G10 | Does the active tenant travel as a scalar `tenant_id` claim, a `memberships` array, or both — and what value does `UserId` hold? | The scalar claim, whose 02b source is an admin-set user attribute; a second UUID-valued `user_id` attribute for the actor, with `sub` staying Keycloak's subject | Phase-doc: [ADR-0036 § What is out of scope](../decisions/0036-tenant-resolution-trusted-inputs.md#what-is-out-of-scope-and-what-is-not) hands the shape to this phase by name. Reconcile `architecture/13`'s array sketch in the same change | P02b-5 | -| G11 | Which [ADR-0036 matrix](../decisions/0036-tenant-resolution-trusted-inputs.md#the-reconciliation-matrix) rows resolve in this phase, given that row 10 consults a reader that denies everyone — and what row covers a signature-valid token carrying no `tenant_id` claim on a tenant host? | Either mint `organization_id` scoped to the host's organization, which makes row 10 need no membership read, or declare the organization host's authenticated 404 and test it as the expected outcome | Dated **ADR-0036 Amendment 8** plus errata beside § Staging across packets and the 2026-09-02 erratum, both of which assign row 10 to this phase without noting that its `M covers (T, O)` term is denied until Phase 03 — which groups it with rows 7 and 14 rather than with 6 and 9 | P02b-5 | -| G12 | Which Keycloak client does the BFF exchange the code with, where does sign-in happen across two tenant hosts, **over what transport** — the seed hosts are `*.learnstack.local` and the frontend's dev script serves plain HTTP, which no browser will return a `Secure` cookie over — and how do the client scope, the mappers and the seed users reach a Keycloak database that has already consumed the import? | A confidential BFF client with one redirect URI per seed host; local TLS for those hosts with a trust step, or hosts under `localhost`, which browsers exempt; and an idempotent reconciler inside `make seed` — a mapper added only to the import reaches no existing workstation | Phase-doc + the realm JSON + `scripts/seed.sh` + `infra/keycloak/README.md`; a new ADR only if the reconciler becomes ADR-0004's `IIdentityProvider` | P02b-6 | -| G13 | What is the BFF session — its custody of the refresh token, its store, its idle and absolute lifetimes, its refresh serialisation under concurrent requests, its terminal `invalid_grant` behaviour, its CSRF control and its logout — and which document owns the access-token lifetime? | The `HttpOnly` cookie Security Standards already describes, encrypted by the session adapter, read only by server code, with single-flight refresh and one authority for the TTL | Phase-doc + one reconciled [Security Standards § Authentication](../standards/11-security.md#authentication); a **new ADR** only for a server-side session store | P02b-7 | +| G11 | Which [ADR-0036 matrix](../decisions/0036-tenant-resolution-trusted-inputs.md#the-reconciliation-matrix) rows resolve in this phase, given that row 10 consults a reader that denies everyone — and what row covers a signature-valid token carrying no `tenant_id` claim on a tenant host? | Either mint `organization_id` scoped to the host's organization, which makes row 10 need no membership read, or declare the organization host's authenticated 404 and test it as the expected outcome | A dated **ADR-0036** amendment plus errata beside § Staging across packets and the 2026-09-02 erratum, both of which assign row 10 to this phase without noting that its `M covers (T, O)` term is denied until Phase 03 — which groups it with rows 7 and 14 rather than with 6 and 9 | P02b-5 | +| G12 | Which Keycloak client does the BFF exchange the code with, where does sign-in happen across two tenant hosts, **over what transport** — the seed hosts are `*.learnstack.local` and the frontend's dev script serves plain HTTP, which no browser will return a `Secure` cookie over — and how do the client scope, the mappers and the seed users reach a Keycloak database that has already consumed the import? | A confidential BFF client with one redirect URI per seed host; local TLS for those hosts with a trust step, or hosts under `localhost`, which browsers exempt; and an idempotent reconciler inside `make seed` — a mapper added only to the import reaches no existing workstation | Phase-doc + the realm JSON + `scripts/seed.sh` + `infra/keycloak/README.md`; a new ADR only if the reconciler becomes ADR-0004's `IIdentityProvider` | [Phase 02d](phase-02d-walking-skeleton.md#host-based-tenant-resolution-end-to-end) (the dev transport and hostnames, closed with the hosts that phase wires), P02b-5 (which client the BFF redeems the code with: the API pins `azp` against it), P02b-6 | +| G13 | What is the BFF session — its custody of the refresh token, its store, its idle and absolute lifetimes, its refresh serialisation under concurrent requests, its terminal `invalid_grant` behaviour, its CSRF control and its logout — and which document owns the access-token lifetime? | The `HttpOnly` cookie Security Standards already describes, encrypted by the session adapter, read only by server code, with single-flight refresh and one authority for the TTL | Phase-doc + one reconciled [Security Standards § Authentication](../standards/11-security.md#authentication); a **new ADR** only for a server-side session store | P02b-6 (which document owns the access-token lifetime: the realm export and its reconciler write it), P02b-7 | | G14 | What bounds the request types reachable over HTTP while `AuthorizationBehavior` is `return next()`, and what is the shape of the token-keyed rate-limit stage behind a shared BFF connection? | An enumerated read-only routable set held by a catalogue-registered rule, and a limiter stage after `UseAuthentication` keyed on the validated subject — which on its own changes nothing, because the shipped global limiter already partitions every request on the socket peer at the anonymous budget and runs before authentication, so two users behind one BFF share that quota and a request it rejects never reaches a token stage. The record says what the early stage does once a caller is validated, and keeps the pre-classification protection against unknown-host abuse | Phase-doc + catalogue registration; a dated ADR-0032 Amendment for a deny-by-default step instead | P02b-5 | | G15 | What initiates a replay in this phase? | A dispatcher-level operation on the dispatcher's own connection, with the operator surface deferred | Phase-doc; the URL space is its own decision record, per ADR-0036 | — (answered in § The consumer side; recorded so the question travels with the deferral) | -| G16 | What is `LS0002`'s rule name, which project trees does it run over, what does it inspect, and what escalates it to Error? | The name and scope recorded the way Amendment 1 recorded `LS0001`, with the scope reaching the assemblies that actually handle tokens | Dated **ADR-0032 Amendment 4** + a catalogue entry + an `AnalyzerReleases` row | P02b-8 | -| G17 | What are the label sets for the event and job metrics, is the tenant axis permitted, and is it spelled `tenant` or `tenant_id`? | One note in the document that owns metric names, no raw tenant label on a per-event series, and an oldest-eligible-row age metric separate from the pending count | [Observability Standards § Required Metrics](../standards/10-observability.md#required-metrics) edit | P02b-8 (names may land with P02b-2; labels may not) | -| G18 | May an integration-event payload or a job argument carry personal data, what may `last_error` contain, and do the four new durable stores enter the erasure scope? | Identifiers only, a sanitized and length-bounded failure descriptor, and the stores named in the erasure inventory | Dated **ADR-0038 Amendment 2** for the payload rule; [Data Protection § Right to Erasure](../architecture/23-data-protection.md#right-to-erasure-right-to-be-forgotten) and Phase 11 for the rest | P02b-2 (the `last_error` rule and its DDL bound), P02b-8 (the payload rule and the erasure inventory) | +| G16 | What is `LS0002`'s rule name, which project trees does it run over, what does it inspect, and what escalates it to Error? | The name and scope recorded the way Amendment 1 recorded `LS0001`, with the scope reaching the assemblies that actually handle tokens | A dated **ADR-0032** amendment + a catalogue entry + an `AnalyzerReleases` row | P02b-8 | +| G17 | What are the label sets for the event and job metrics, is the tenant axis permitted, and is it spelled `tenant` or `tenant_id`? | One note in the document that owns metric names, no raw tenant label on a per-event series, and an oldest-eligible-row age metric separate from the pending count | [Observability Standards § Required Metrics](../standards/10-observability.md#required-metrics) edit | P02b-2 (the first series ships there, and a label added afterwards touches every emitter already written) | +| G18 | May an integration-event payload or a job argument carry personal data, what may `last_error` contain, and do the four new durable stores enter the erasure scope? | Identifiers only, a sanitized and length-bounded failure descriptor, and the stores named in the erasure inventory | A dated **ADR-0038** amendment for the payload rule; [Data Protection § Right to Erasure](../architecture/23-data-protection.md#right-to-erasure-right-to-be-forgotten) and Phase 11 for the rest | P02b-1 (the payload rule: the first payload ships there), P02b-2 (the `last_error` rule and its DDL bound), P02b-8 (the erasure inventory) | | G19 | Where do the sample flow's publisher and consumer live — a fixture pair in the integration assembly, or a real event a shipped module consumes? | A real event between two of the three modules that hold domain code, because a test-assembly-only consumer leaves every module-scoped sweep with zero subjects | Phase-doc + the Tenancy module spec, which books three `learnstack.tenancy.*` rows to this phase and names Audit as a consumer of one, and the Audit module spec, which says it consumes nothing and has no inbox — a disagreement whichever pair G19 picks has to resolve. If the pick is a provisioning event, the flow needs its own proof: provisioning leaves `ITenantContext` unresolved and announces its tenant on the transaction instead, so an enqueue check that reads the ambient context would refuse the seed's own first event | P02b-1 | -| G20 | What does an anonymous request to a **routed non-public** request type on a live tenant host answer — and does the catalogued `Backend_RequiresJwt_OnAllAuthenticatedRoutes` narrow to a surface outside tenant resolution? A second half rides with it: the shipped assertion middleware says that from this phase the refusal code differs by caller, `tenant_mismatch` for an authenticated one and `not_found` for an anonymous one, written by the middleware itself | The matrix wins on a tenant host — [row 2](../decisions/0036-tenant-resolution-trusted-inputs.md#the-reconciliation-matrix) answers 404, byte-identical to an unknown host's, and the 401 rule narrows to the surface where a 401 discloses nothing | Dated **ADR-0036 Amendment 8** (the same one G11 needs) + a catalogue edit to that rule's Asserts line | P02b-5 | +| G20 | What does an anonymous request to a **routed non-public** request type on a live tenant host answer — and does the catalogued `Backend_RequiresJwt_OnAllAuthenticatedRoutes` narrow to a surface outside tenant resolution? A second half rides with it: the shipped assertion middleware says that from this phase the refusal code differs by caller, `tenant_mismatch` for an authenticated one and `not_found` for an anonymous one, written by the middleware itself | The matrix wins on a tenant host — [row 2](../decisions/0036-tenant-resolution-trusted-inputs.md#the-reconciliation-matrix) answers 404, byte-identical to an unknown host's, and the 401 rule narrows to the surface where a 401 discloses nothing | The dated **ADR-0036** amendment G11 needs + a catalogue edit to that rule's Asserts line | P02b-5 | ### Durable outbox dispatch @@ -449,8 +459,9 @@ below, registered in the same place under **G17**. the dispatcher, and the alerting row that reads a raw count is corrected with it. - Dispatcher liveness is an `outbox` health check plus a heartbeat gauge, following `audit`'s registered-not-mapped precedent — not the job scheduler's surface. -- Label sets wait on **G17**. Names may ship before labels; a series shipped with a - guessed label cannot be renamed once a dashboard and an alert read it. +- Label sets are settled by **G17** before the first series ships, in the decision pass + of `P02b-2`: a label added afterwards touches every emitter, and a series shipped with + a guessed label cannot be renamed once a dashboard and an alert read it. ### Background jobs @@ -809,10 +820,12 @@ omitted. What this phase owes the catalogue beyond implementing those rows: -- **New rows**, registered in `P02b-0` before the code that satisfies them: the ADR-0036 - middleware-ordering rule, the same-transaction outbox write, the two domain-event - rules, the routable-set bound (**G14**), the Keycloak-role-claim rule, a - payload-content rule (**G18**), and `LS0002` under its rule name (**G16**). +- **New rows**, each registered in the decision pass of the first packet whose code + satisfies it: the same-transaction outbox write, the two domain-event rules and a + payload-content rule (**G18**) in `P02b-1`; the ADR-0036 middleware-ordering rule, the + routable-set bound (**G14**) and the Keycloak-role-claim rule — with the realm-role + question it answers — in `P02b-5`, where the API first reads a claim; and `LS0002` + under its rule name (**G16**) in `P02b-8`. - **Widened assertions** where a catalogued rule is satisfied by the defect it exists to catch. `Outbox_Row_Carries_Correlation_Context` asserts only non-nullness of two columns and extends to `partition_key` and to value correctness. @@ -825,13 +838,14 @@ What this phase owes the catalogue beyond implementing those rows: assemblies and cannot observe a runtime key — an uninitialised instance reads a zeroed Guid as non-blank — while the compiler and the envelope's own guard already hold both halves; it becomes a behavioural assertion over persisted rows, or is re-typed and - retired in `P02b-0`. Two rules the catalogue stamps **02a** also widen here, so the - Phase-field sweep above does not reach them: the transport arm of + retired, in `P02b-1`'s decision pass. Two rules the catalogue stamps **02a** also + widen here, so the Phase-field sweep above does not reach them: the transport arm of `Tenant_Context_Guard_Fires_Only_On_An_Unmarked_Transaction`, whose second arm has no subject until the transport announces a tenant, and `Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork`'s connection allow-list, which - the dispatcher's keyed data source and the job storage both touch — an eighth entry - there is a decision, recorded under **G9**. + the dispatcher's keyed data source and the job storage both touch — a new entry there + is a decision, taken in the pass of the packet that adds it: the dispatcher's in + `P02b-2`, the job storage's under **G9**. - **A planted-offender companion for every structural rule this phase ships.** The catalogue's own rule is that a rule shipping before its first subject exists carries a companion that plants the violation it exists to catch; with one sample event and one @@ -846,9 +860,9 @@ What this phase owes the catalogue beyond implementing those rows: projection registers it with its first subject. The provider-SDK rule is the catalogue's and is wider than the paraphrase this document carried — and because its row is stamped 02b, the Phase-field obligation above would otherwise sweep a rule with - no subject, so `P02b-0` moves its owning-phase cell to the phase that lands the first - provider adapter, unless the Keycloak reconciler **G12** settles is that adapter and - gives it its first subject here. + no subject, so `P02b-6`'s decision pass, where **G12** settles the Keycloak + reconciler, either makes that reconciler the first provider adapter and gives the rule + its first subject here, or moves its owning-phase cell to the phase that lands one. Two notes on what "green in CI" can mean. Several of this phase's rows are behavioural rather than structural, and CI runs those in a separate job under a Docker trait filter, @@ -1216,8 +1230,18 @@ shape depends on an open gate say so, and are written when that gate is Accepted - **A gate is skipped because its code looks writable without it.** Every gate in the register blocks a packet, and most of them need an amendment to an Accepted record. An amendment written after the code it governs is a record nobody can disagree with — the - lesson Packet 9 recorded for its own readings. `P02b-0` is the mitigation, and it - ships no code. + lesson Packet 9 recorded for its own readings. The mitigation is the decision pass + each packet opens with: its decisions are its first commit, and its code follows them. +- **A gate is answered before the ground it stands on exists.** Several premises the + register cites are code [Phase 02d](phase-02d-walking-skeleton.md) has not yet + written: the first `/api/v1/*` endpoints, which **G14** and **G20** bound; the local + hosts **G12**'s transport serves; and the socket-peer limiter G14 describes, which + Phase 02d's server-rendered pages reach through one peer. Earlier packets move later + premises the same way: the dispatcher's keyed data source can change the connection + allow-list before **G9** decides the job storage's entry in it. The mitigation is + [Roadmap § Decision Timing](README.md#decision-timing): `P02b-0` re-verifies the + register once Phase 02d exits, and each gate closes in the decision pass of the first + packet it shapes. - **The claim fix is treated as a detail.** The specified lease is a few dozen lines, which makes it easy to defer past a green build: single-instance development never reproduces the failure, and a single-processor run of the concurrency test passes diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 9f0ed99e..75cafe1b 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -171,7 +171,14 @@ written and the transient `ITenantContext` resolves from it on every access, the transaction sets `app.tenant_id` / `app.organization_id` with `SET LOCAL`, and Row Level Security filters every read. -Two hosts are registered in local development, one per seed tenant. +Two hosts are registered in local development, one per seed tenant. How a browser +reaches them is decided here, in the decision pass of the packet that wires them. +[Phase 02b](phase-02b-events-auth.md)'s session cookie is `Secure`, which a browser does +not send back over plain HTTP to the seed hosts' `*.learnstack.local` names, and that +phase's gate **G12** chooses between local TLS for those hosts and hosts under +`localhost`. Moving the hosts afterwards would rewrite the seed, the host mappings and +this phase's tests, so that part of G12 closes with the hosts this phase wires, per +[Roadmap § Decision Timing](README.md#decision-timing). ### Genericity proof From b7f1c1b4bd5260ae62be2ba2ff2974f7d16dbd62 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 12 Sep 2026 23:47:33 +0300 Subject: [PATCH 2/6] docs(skills): open every packet with its decision pass The roadmap's Decision Timing convention closes each gate in the decision pass of the first packet whose code it shapes, but the two entry-point skills still read a pending ADR as a phase-level blocker to surface and wait on. Both now point at the convention: start-task plans the pass, and implement-task runs it in Step 1, so a packet's decisions are its first commit rather than something its code is written around. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/implement-task/SKILL.md | 6 ++++++ .claude/skills/start-task/SKILL.md | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.claude/skills/implement-task/SKILL.md b/.claude/skills/implement-task/SKILL.md index 2c587d57..03b0bc4e 100644 --- a/.claude/skills/implement-task/SKILL.md +++ b/.claude/skills/implement-task/SKILL.md @@ -73,6 +73,12 @@ Output at the end of Step 1: a one-paragraph problem statement in your own words, the phase the task belongs to, the standards that govern the change, and the specific skill(s) you'll invoke for the implementation. +When the task is a phase packet, Step 1 also opens its **decision pass** +([Roadmap § Decision Timing](../../../docs/roadmap/README.md#decision-timing)): the +gates the packet waits on are re-verified against `HEAD`, their records are shown to +the user and Accepted, and the decisions are the packet's first commit — before Step 4 +writes any code. + ### Step 2 — Inspect and understand Read every file the change will touch **before** modifying any of them. Trace diff --git a/.claude/skills/start-task/SKILL.md b/.claude/skills/start-task/SKILL.md index 8c5e8d32..ec2cfb40 100644 --- a/.claude/skills/start-task/SKILL.md +++ b/.claude/skills/start-task/SKILL.md @@ -93,7 +93,11 @@ Open [docs/roadmap/README.md](../../../docs/roadmap/README.md) and confirm: - The current phase is the one that **owns** this work. If the work belongs to a later phase, stop and surface that explicitly — the user may want to defer. - Any **pending ADRs** ([decisions/README.md § Open ADR Drafts](../../../docs/decisions/README.md)) - that block this phase are Accepted. If they aren't, surface that. + and open gates that block this packet are Accepted. If they aren't, the packet opens + with its decision pass + ([Roadmap § Decision Timing](../../../docs/roadmap/README.md#decision-timing)): + re-verify each premise against `HEAD`, draft the records for the user's approval, and + commit them before any code. ### Step 3: Confirm the change is allowed From d9611fb175ff34fc31f1a64927c7f101271468f0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 13 Sep 2026 10:05:24 +0300 Subject: [PATCH 3/6] docs(roadmap): turn Phase 02d into a gated plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pre-development reviews of Phase 02d, verified finding by finding at HEAD, showed the phase doc answering questions no packet had decided yet and naming carriers that contradicted it. It said which parent a lesson references, which publication values exist, how settings are cached and that Lighthouse activates here, while standards, skills, architecture pages and other phases described a different renderer, host flow and theming path. Following Roadmap § Decision Timing and the Phase 02b precedent, the phase becomes packets P02d-0 to P02d-7 with a 45-row decision register. Each criterion, deliverable and risk names the gate it depends on and stays conditional until that gate is Accepted. The carriers are corrected to link the register instead of pre-answering it, and every exclusion now names a phase whose own document carries the capability. No gate is answered here; that is each packet's decision pass. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/add-frontend-route/SKILL.md | 72 +- .claude/skills/add-i18n-key/SKILL.md | 4 +- .claude/skills/add-page-block/SKILL.md | 4 +- .../skills/add-tenant-owned-entity/SKILL.md | 14 +- .claude/skills/implement-task/SKILL.md | 7 +- .claude/skills/local-dev-setup/SKILL.md | 24 +- .claude/skills/seed-tenant/SKILL.md | 15 +- .claude/skills/standards-check/SKILL.md | 10 +- .github/CONTRIBUTING.md | 1 - .github/workflows/ci.yml | 8 +- CLAUDE.md | 10 +- README.md | 2 +- docs/architecture/02-domain-model.md | 3 +- docs/architecture/05-mvp-scope.md | 12 +- docs/architecture/12-localization.md | 20 +- docs/architecture/14-frontend-architecture.md | 61 +- .../28-platform-tenant-organization.md | 1 - docs/architecture/30-api-gateway.md | 2 +- docs/glossary.md | 7 +- docs/modules/customization/README.md | 9 +- docs/modules/tenancy/README.md | 10 +- docs/roadmap/README.md | 25 +- docs/roadmap/phase-01-repository-tooling.md | 4 +- docs/roadmap/phase-02a-kernel-tenancy.md | 5 +- docs/roadmap/phase-02b-events-auth.md | 22 +- docs/roadmap/phase-02d-walking-skeleton.md | 1893 ++++++++++++++--- docs/roadmap/phase-03-identity-admin.md | 38 +- docs/roadmap/phase-04-cms-media-pages.md | 31 +- .../phase-05-education-learning-content.md | 86 +- .../roadmap/phase-06-renderer-admin-studio.md | 11 +- docs/standards/03-frontend-coding.md | 4 +- docs/standards/05-database.md | 52 +- docs/standards/06-testing.md | 6 +- docs/standards/07-frontend-architecture.md | 3 +- docs/standards/08-localization.md | 5 + docs/standards/09-error-handling.md | 7 +- docs/standards/15-performance.md | 10 +- docs/standards/17-code-review.md | 3 +- .../21-architecture-tests-catalogue.md | 42 +- docs/standards/README.md | 6 +- 40 files changed, 2080 insertions(+), 469 deletions(-) diff --git a/.claude/skills/add-frontend-route/SKILL.md b/.claude/skills/add-frontend-route/SKILL.md index e34f2e11..42c70a33 100644 --- a/.claude/skills/add-frontend-route/SKILL.md +++ b/.claude/skills/add-frontend-route/SKILL.md @@ -32,7 +32,8 @@ contract per - Operator portal pages — they live in `operator-portal`, a separate repo. - Calling the API directly from a Client Component without the SDK — forbidden by - ESLint (`no-restricted-imports`). + ESLint (`no-restricted-globals` on `fetch`, + `frontend/packages/config/eslint/index.cjs`). - Routes that bypass tenant resolution — every authenticated route requires a resolved tenant. @@ -52,7 +53,7 @@ contract per | Group | Purpose | Auth | Default render | |-------|---------|------|----------------| -| `(public)` | Tenant public site (marketing, catalog, blog). | Anonymous by default; auth optional. | SSR + ISR-like cache per `(tenantId, organizationId?, locale, slug)`. | +| `(public)` | Tenant public site (marketing, catalog, blog). | Anonymous by default; auth optional. | SSR + ISR-like cache per `(tenantId, organizationId?, locale, slug)` (under an open gate — see Step 8). | | `(studio)` | Tenant admin Studio. | Tenant-admin or org-admin. Required at the edge. | SSR, no cache (always fresh). | | `(portal)` | Learner / instructor portal. | Membership in the resolved tenant. | SSR shell + Client Component for interactivity. | @@ -95,16 +96,15 @@ Rules: ### Step 4: Tenant + organization context (automatic) -The Next.js middleware (`src/middleware.ts`) resolves the host via -`IHostToTenantResolver` and sets: - -- `x-tenant-id` header -- `x-organization-id` header (when the host maps to a specific organization) -- `x-locale` header - -The SDK reads these from the request context automatically; you don't pass them. -Don't read `host` directly inside a page; the resolution is the middleware's -contract. +The API resolves tenant and organization from the host +([ADR-0036 § Effective host and the trusted hop](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md#effective-host-and-the-trusted-hop)). +The frontend never calls `IHostToTenantResolver`, and ADR-0036 makes +`frontend/packages/sdk/src/server.ts` the only frontend place that sets the hop +headers. At HEAD that file is a typed stub, and `src/middleware.ts` is a scaffold +that copies the raw host into `x-tenant-id` and sets `x-locale`; it sets no +`x-organization-id`. Their replacement is recorded as G35 and G36 in +[Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register). +Don't read `host` directly inside a page. ### Step 5: Authentication + permission gating @@ -114,7 +114,7 @@ For `(studio)` and `(portal)` routes: - Permission check happens at the page level via the `auth()` helper: ```tsx -import { auth } from "@learnstack/auth/server"; +import { auth } from "@learnstack/auth/server"; // illustrative: no such package exists yet; Phase 02b's session work owns the real helper import { redirect } from "next/navigation"; export default async function UsersPage() { @@ -164,14 +164,17 @@ See [add-i18n-key](../add-i18n-key/SKILL.md). ### Step 8: Public-site SSR caching -For `(public)` routes that render CMS content: - -```tsx -export const revalidate = 60; // ISR-like; tenant publishes invalidate via webhook -``` - -Cache key includes tenant + org + locale + slug automatically because the SDK -threads them through. +This step is under an open gate. How `(public)` routes render, and which Next.js +caches they may use, is G37 in +[Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register). +The step is rewritten when that gate closes; until then add no `revalidate`, +`generateStaticParams` or `unstable_cache` to a `(public)` route. The cache key is +**not** tenant-bearing automatically. A statically rendered route, which is what +`revalidate` produces when the page reads no request data, is cached by path, and a +public URL carries no tenant +([Frontend Architecture Standards](../../../docs/standards/07-frontend-architecture.md)). +Every cache key carries the tenant, the organization where applicable, and the locale +([Security Standards § Multi-Tenant + Organization Isolation Review Checklist](../../../docs/standards/11-security.md#multi-tenant--organization-isolation-review-checklist)). ### Step 9: Loading + error boundaries @@ -184,21 +187,34 @@ Every route ships its own: ### Step 10: Tests -- Component test (`frontend/apps/web/src/app/(studio)/dashboard/users/page.test.tsx`) with - `axe-core` for accessibility. -- Lighthouse budget check on representative public routes (CI). +- Component tests (`frontend/apps/web/src/app/(studio)/dashboard/users/page.test.tsx`) + with Testing Library, 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 + [Testing Standards § End-to-End Tests](../../../docs/standards/06-testing.md#end-to-end-tests); + 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. +- Lighthouse budget check on representative public routes — CI's `lighthouse budget` + job is a deferred placeholder until Phase 02d activates it; until then judge by + reading. ## Validation - `pnpm build` / `next build` succeeds. -- `pnpm lint` is green; specifically the `no-restricted-imports` rule that bans - raw `fetch('/v1/...')` from any component. +- `pnpm lint` is green; specifically the `no-restricted-globals` rule on `fetch`, + which bans a direct `fetch` call outside the SDK. - The route renders under the resolved tenant/org/locale and rejects mismatched authn. - A `(studio)` route returns 403 when the actor lacks the required permission; the API was already authoritative — confirm. -- Lighthouse budgets (LCP < 2.5s, INP < 200ms, CLS < 0.05) green on - representative routes. +- The public-route budgets in + [Performance Standards](../../../docs/standards/15-performance.md) hold on + representative routes — judged by reading until CI's `lighthouse budget` job is + active. ## Common pitfalls diff --git a/.claude/skills/add-i18n-key/SKILL.md b/.claude/skills/add-i18n-key/SKILL.md index f0d9b28d..7c4b3dea 100644 --- a/.claude/skills/add-i18n-key/SKILL.md +++ b/.claude/skills/add-i18n-key/SKILL.md @@ -142,7 +142,9 @@ return

{t("count", { count: learners.length })}

; ### Step 4: Variable interpolation ICU placeholders: `{name}`, `{count}`, `{date, date, short}`. The frontend i18n -library (next-intl / react-intl — see ADR-pending) handles ICU natively. +library (next-intl / react-intl — ADR-0027, reserved in +[the decisions index](../../../docs/decisions/README.md#open-adr-drafts)) handles ICU +natively. ### Step 5: Don't branch on locale diff --git a/.claude/skills/add-page-block/SKILL.md b/.claude/skills/add-page-block/SKILL.md index 11e9546e..0171d01f 100644 --- a/.claude/skills/add-page-block/SKILL.md +++ b/.claude/skills/add-page-block/SKILL.md @@ -197,7 +197,9 @@ the page. - JSON Schema validation test for the new primitive / tenant schema. - Renderer snapshot test. - Accessibility test (`axe-core` violations fail). -- Lighthouse budget check for representative pages embedding the block. +- Budgets in [Performance Standards](../../../docs/standards/15-performance.md) for + representative pages embedding the block — judged by reading until CI's + `lighthouse budget` job, deferred to Phase 02d, is active. ## Validation diff --git a/.claude/skills/add-tenant-owned-entity/SKILL.md b/.claude/skills/add-tenant-owned-entity/SKILL.md index 71adb4b7..ecc44a3e 100644 --- a/.claude/skills/add-tenant-owned-entity/SKILL.md +++ b/.claude/skills/add-tenant-owned-entity/SKILL.md @@ -361,13 +361,25 @@ what you pasted — a reviewer will: nothing may *write* outside its own. `WITH CHECK` is not sufficient on its own for that guarantee — PostgreSQL has no `WITH CHECK` for `DELETE`, and `USING` is also what selects the rows an `UPDATE` may target — which is why the two `AS RESTRICTIVE` - guards above are part of the template and not an optional extra. + guards above are part of the template and not an optional extra. One write path stays + open — the organization arm of `WITH CHECK` admits `organization_id IS NULL` from any + session, so an organization-scoped session can `INSERT` a tenant-wide row; its status + is in + [05-database.md § Tenant-Owned and Organization-Scoped Tables](../../../docs/standards/05-database.md#tenant-owned-and-organization-scoped-tables). - **The composite `UNIQUE (tenant_id, id)` and the composite foreign keys** — referential integrity is checked on behalf of the table owner and bypasses RLS entirely, so a single-column FK is a cross-tenant reference waiting to happen, invisible to every policy. See [05-database.md § Foreign keys between tenant-owned tables](../../../docs/standards/05-database.md). +Two obligations the policy block does not carry, both in +[05-database.md](../../../docs/standards/05-database.md): the `BEFORE UPDATE` +organization immutability trigger on every org-scoped table (the rule after the +template), and an index supporting every foreign key +([§ Indexes](../../../docs/standards/05-database.md#indexes)). Neither makes a child's +or satellite's mirrored `organization_id` equal its parent's at insert — see +[§ Translation satellite tables](../../../docs/standards/05-database.md#translation-satellite-tables). + Always call `current_setting` with the second argument `true`. Without it an unset context raises inside a pooled connection instead of simply filtering the row out. diff --git a/.claude/skills/implement-task/SKILL.md b/.claude/skills/implement-task/SKILL.md index 03b0bc4e..0377fccf 100644 --- a/.claude/skills/implement-task/SKILL.md +++ b/.claude/skills/implement-task/SKILL.md @@ -162,8 +162,11 @@ match the change: - Backend changes → `dotnet build`, `LearnStack.Tests.Architecture`, `LearnStack.Tests.Unit`, `LearnStack.Tests.Integration` (Testcontainers). -- Frontend changes → `pnpm lint`, `pnpm typecheck`, `pnpm test`, and Lighthouse on - representative routes if the public surface changed. There is no `pnpm test:a11y` yet +- Frontend changes → `pnpm lint`, `pnpm typecheck` and `pnpm test`. No Lighthouse + command exists yet, locally or in CI — the `lighthouse-budget` job is a deferred + placeholder — so judge public-route budgets against + [Performance Standards](../../../docs/standards/15-performance.md) by reading. There + is no `pnpm test:a11y` yet — the axe suite arrives in Phase 06 ([run-tests-locally](../run-tests-locally/SKILL.md)). - Documentation-only → broken-link sweep + `docs/analysis/` residual scan (see Step 7's link audit). diff --git a/.claude/skills/local-dev-setup/SKILL.md b/.claude/skills/local-dev-setup/SKILL.md index da208f07..a3abc930 100644 --- a/.claude/skills/local-dev-setup/SKILL.md +++ b/.claude/skills/local-dev-setup/SKILL.md @@ -216,9 +216,13 @@ What it does not write yet, and which phase owns each: the `users` table arrives with [Phase 03](../../../docs/roadmap/phase-03-identity-admin.md)'s Identity migration, and Packet 7 creates none. -4. Customization data (`TenantContentType`, `TenantPageBlock`, - `TenantLevelTaxonomy`, …) — the Customization module is empty until - [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md), which is what first needs them. +4. Each tenant's **own** content type, level taxonomy and branding token values, and + the customization aggregates that have no schema yet (`TenantPageBlock`, …). The + built-in `card` content type and `plain` taxonomy are already written for both + tenants, since + [Phase 02a Packet 8](../../../docs/roadmap/phase-02a-kernel-tenancy.md). Which phase + adds each of the rest is in + [seed-tenant § Step 4: What a later phase adds](../seed-tenant/SKILL.md#step-4-what-a-later-phase-adds). 5. SeaweedFS buckets and Meilisearch indexes — both adapters are demand-gated to [Phase 11](../../../docs/roadmap/phase-11-production-hardening.md) under [ADR-0035](../../../docs/decisions/0035-demand-gated-infrastructure.md). @@ -245,12 +249,13 @@ open http://localhost:8080/realms/learnstack-hub/account # SeaweedFS filer UI (replaces the MinIO console of the prior stack) open http://localhost:9001 # S3 access: learnstack / learnstack-dev-secret -# Web app -open http://localhost:3000 # one of the demo tenants - -# A second demo tenant (use the Hosts file to alias) -# /etc/hosts: 127.0.0.1 demo-yoga.learnstack.local demo-english.learnstack.local -open http://demo-english.learnstack.local:3000 +# Web app (after `pnpm --filter @learnstack/web dev`): the scaffold page only. +# `localhost` is a platform host (Tenancy:PlatformHosts in +# appsettings.Development.json) and never resolves a tenant. No tenant-rendered +# page exists on any host yet: browsing the two demo tenants, and the host step +# it needs, arrive with Phase 02d (docs/roadmap/phase-02d-walking-skeleton.md +# § Host-based tenant resolution, end to end). +open http://localhost:3000 ``` ### Step 6: Switch deployment modes locally @@ -287,7 +292,6 @@ dotnet run --project backend/src/LearnStack.Api | `relation "tenants" does not exist` | The owning Tenancy migrations have not landed or were not applied; check the active phase plan before adding an ad-hoc target. | | `unable to read app.tenant_id` | The `DbCommandInterceptor` tenant-context guard is unwired, or `TransactionBehavior` did not issue the `SET LOCAL` pair. It is deliberately **not** a connection-checkout interceptor — checkout precedes `BEGIN`. | | Keycloak realm not found | Recreate local data with destructive `make clean`, then `make seed`. The realms are imported at compose boot from `infra/keycloak/realms/`, not by the seeder. | -| Web app shows raw i18n keys | i18n bundle build skipped; `pnpm build:i18n`. | | Hub-backed mode hangs | The `learnstack-hub` repo's stack isn't up; start it or switch to `Development`. | | LiveKit join fails with TURN error | coturn not reachable from the browser; check firewall + container network. | diff --git a/.claude/skills/seed-tenant/SKILL.md b/.claude/skills/seed-tenant/SKILL.md index 1d150fab..c77040a7 100644 --- a/.claude/skills/seed-tenant/SKILL.md +++ b/.claude/skills/seed-tenant/SKILL.md @@ -197,6 +197,7 @@ tenant eventually carries belongs to a phase that has not written its schema yet | Keycloak OIDC wiring and the realm's `tenant_id` claim mapper | [Phase 02b](../../../docs/roadmap/phase-02b-events-auth.md) | | `TenantContentType`, `TenantLevelTaxonomy` | **Shipped** — [Phase 02a Packet 8](../../../docs/roadmap/phase-02a-kernel-tenancy.md). `SeedRunner` writes the built-in pair through `RegisterTenantContentTypeCommand` / `PublishTenantContentTypeCommand` and their taxonomy siblings, so a seeded tenant already has something to render | | `Course`, `Lesson` and their translation satellites | [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md) | +| Rows in `tenant_locales` — each tenant's enabled locales and its one default — written through the Tenancy command raising `tenancy.locale.write` | [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md) | | Each tenant's **own** content type and level taxonomy, and its branding token **values** written as `TenantSetting` rows — the seed that makes the two tenants differ, not only the built-in pair they share | [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md), through the `tenancy.setting.write` command those rows need | | `TenantCustomFieldDef` | [Phase 03](../../../docs/roadmap/phase-03-identity-admin.md) | | `TenantPageBlock` | [Phase 04](../../../docs/roadmap/phase-04-cms-media-pages.md) | @@ -235,10 +236,16 @@ To browse a tenant on a host that matches production-like custom domains: 127.0.0.1 demo-yoga.learnstack.local ``` -Then visit `http://demo-english.learnstack.local:3000`. The middleware resolves -the host through `IHostToTenantResolver`, which reads `platform_host_to_tenant` -and nothing else — never the Hub -([ADR-0034](../../../docs/decisions/0034-hub-contract-surface-invariant.md)). +The API resolves the host: `HostClassificationMiddleware` calls +`IHostToTenantResolver`, which reads `platform_host_to_tenant` and nothing else — never +the Hub ([ADR-0034](../../../docs/decisions/0034-hub-contract-surface-invariant.md)) +— and the renderer states the visitor's host to the API over the trusted hop +([ADR-0036](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md#effective-host-and-the-trusted-hop)). +The Next.js middleware at `frontend/apps/web/src/middleware.ts` is still a scaffold +that copies the raw host into `x-tenant-id`, so the web app renders no tenant page on +either host until [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md). Its +§ Host-based tenant resolution, end to end owns the hostnames and the step a browser +needs to reach them. ### Step 6: Verify diff --git a/.claude/skills/standards-check/SKILL.md b/.claude/skills/standards-check/SKILL.md index c2a6cf92..c88fdbe8 100644 --- a/.claude/skills/standards-check/SKILL.md +++ b/.claude/skills/standards-check/SKILL.md @@ -302,10 +302,12 @@ domain the diff doesn't touch. - [ ] No `--force` on `main`; no `--amend` on a published commit. #### `15-performance.md` -- [ ] Public-route Lighthouse budgets respected (LCP < 2.5s, INP < 200ms, - CLS < 0.05). **Not mechanically checkable yet** — `ci.yml`'s - `lighthouse-budget` job is a placeholder deferred to Phase 02d. Judge by - reading; do not report a pass or a failure as measured. +- [ ] Public-route budgets in + [15-performance.md § Initial Budgets](../../../docs/standards/15-performance.md#initial-budgets) + and [§ Bundle Size](../../../docs/standards/15-performance.md#bundle-size) respected. + **Not mechanically checkable yet** — `ci.yml`'s `lighthouse-budget` job is a + placeholder deferred to Phase 02d. Judge by reading; do not report a pass or a + failure as measured. - [ ] Backend latency budget per module respected. #### `16-accessibility.md` diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 0a2195d7..64efbad6 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -40,7 +40,6 @@ Configure these in **GitHub → Settings → Branches → Branch protection rule - Required status checks (the job names from `.github/workflows/ci.yml`): - `backend (build + unit + arch + contract)` - `frontend (typecheck + lint + build + test)` - - `meta (compose + commit hygiene + link audit)` - `secret scan (leakwatch)` - `meta (compose + commit hygiene + link audit)` — ⚠️ **the live rule still requires the pre-rename name** `meta (commit hygiene + link audit)`, which diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3acfee65..90018f49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,10 +25,10 @@ # § Branch protection. # # Still deferred to later phases. Each is scaffolded behind a repository variable -# (`vars.ENABLE_*`), unset by default, so activation is one variable plus the -# real steps. A constant `if: false` would be simpler but actionlint rejects it -# ([if-cond] constant expression). Activation is never *only* the variable — -# see .github/CONTRIBUTING.md § Branch protection for the three edits: +# (`vars.ENABLE_*`), unset by default. A constant `if: false` would be simpler but +# actionlint rejects it ([if-cond] constant expression). Activation is never *only* +# the variable — .github/CONTRIBUTING.md § Branch protection lists every edit it +# takes, including the rename and the live required-check registration: # - openapi-diff : oasdiff against the prior `main` spec. Activates # in Phase 02d, which ships the first real `/api/v1/*` read endpoints and # retires `/healthz` as the only documented surface. diff --git a/CLAUDE.md b/CLAUDE.md index 131c67a0..9cd6cb68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,9 +56,11 @@ two behavioural properties, and three named isolation proofs as `learnstack_app` 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 catalogue entry, a published count or a planted `Skip` fails the build rather than a -reader. The whole suite runs with **zero skips**, which the runner now refuses to let -change. -**[Phase 02d](docs/roadmap/phase-02d-walking-skeleton.md) is next.** +reader. The whole .NET suite runs with **zero skips**, which the runner now refuses to +let change. +**[Phase 02d](docs/roadmap/phase-02d-walking-skeleton.md) is in progress**: its kickoff +shipped the packet table and the decision register, and every later packet opens with +its decision pass. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` (core + 7 modules × 4 projects + 4 test projects including the @@ -311,7 +313,7 @@ let the entry point pick it. - **The core platform stays domain-generic.** Domain-specific shapes (CEFR levels, English placement-test scoring, kyu/dan ranks, yoga asana catalogs, …) live as **tenant customization data** ([ADR-0018](docs/decisions/0018-tenant-driven-customization-model.md)), never as code in any module. There is no `Verticals/` folder. ADR-0011 is superseded. The boundary of that claim is in [Platform Vision § Genericity boundary](docs/architecture/01-platform-vision.md). - **Irreversible now, additive on demand — the one-way-door test** ([ADR-0035](docs/decisions/0035-demand-gated-infrastructure.md)): *if I add this six months from now, will I have to touch code that is already written?* - **Yes → ship it now.** Tenant + organization isolation, the corrected RLS policies, the `outbox_messages` table and its ownership, strongly-typed identifiers, the localization schema, MUST-class audit durability, module boundaries and their architecture tests. These touch every query, every migration, and every job payload. - - **No → ship the port now, the adapter on a named trigger.** Dapr pub/sub, Kafka, Valkey-backed cache, Vault, APISIX, the Hub entitlement source, signed licence keys, custom-domain TLS automation, `audit_log` partitioning. Each has a port in `LearnStack.SharedKernel` (shipped), a working default implementation (`InProcessEventBus`, `InMemoryCacheService`, `ConfigurationSecretProvider`, `NullEntitlementProvider`), an owning phase, and a written trigger condition. A building block missing any of those four is not demand-gated — it is missing. + - **No → ship the port now, the adapter on a named trigger.** Dapr pub/sub, Kafka, Valkey-backed cache, Vault, APISIX, the Hub entitlement source, signed licence keys, custom-domain TLS automation, `audit_log` partitioning. Each has a port in `LearnStack.SharedKernel` wherever [ADR-0035 § The gated set](docs/decisions/0035-demand-gated-infrastructure.md#the-gated-set) names one, a working default implementation (`InProcessEventBus`, `InMemoryCacheService`, `ConfigurationSecretProvider`, `NullEntitlementProvider`), an owning phase, and a written trigger condition. A building block missing any of those, other than a port that table records as absent, is not demand-gated — it is missing. - **Provider adapters everywhere.** Payments, auth, storage, search, live classroom, notifications, **event bus, cache, secrets, Hub contract, entitlement source, host resolver** — all sit behind interfaces. No SaaS lock-in in `Domain` or `Application`. See [20-infrastructure-stack.md](docs/standards/20-infrastructure-stack.md). - **The Hub contract is governed by two invariants, not by a count** ([ADR-0034](docs/decisions/0034-hub-contract-surface-invariant.md)): (1) the Hub stores **no tenant content** — courses, lessons, learners, enrollments, sessions and media live only in LearnStack, and the Hub holds tenant *metadata* only; (2) **every LearnStack↔Hub crossing goes through a named adapter** — `IEntitlementProvider`, `IUsageReporter`, `IHubTenantSync`, and nothing else may hold a Hub client. Adding an endpoint still requires an ADR, because the surface is a cross-repository contract both repositories have to agree on. - **One binary, five `DeploymentMode` values, two of them wired.** Selection happens at the composition root; module code never branches on the mode ([ADR-0020](docs/decisions/0020-triple-deployment-hybrid-license.md), enforced by `Modules_Do_Not_Reference_DeploymentMode`). `Development` and `SaaS` are wired end to end; `Dedicated`, `SelfHostedOnline` and `SelfHostedAirGapped` are **prepared seams, not supported deployments**, until [Phase 11](docs/roadmap/phase-11-production-hardening.md) builds their adapters and integration suites. diff --git a/README.md b/README.md index da733280..8a30dff2 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ repository right now, and the documentation says so wherever it describes them. | Tenant customization foundation — content types and level taxonomy | [02a](docs/roadmap/phase-02a-kernel-tenancy.md) | **Running** (2 of the 8 aggregates) | | Audit trail, written inside the business transaction | [02a](docs/roadmap/phase-02a-kernel-tenancy.md) | **Running** | | API conventions, entitlement socket, foundation ports | [02a](docs/roadmap/phase-02a-kernel-tenancy.md) | **Running** | -| Two tenants rendering their own sites, side by side | [02d](docs/roadmap/phase-02d-walking-skeleton.md) | **Next** | +| Two tenants rendering their own sites, side by side | [02d](docs/roadmap/phase-02d-walking-skeleton.md) | **In progress** | | Authentication, sessions, events | [02b](docs/roadmap/phase-02b-events-auth.md) | Planned | | Users, roles, permissions, admin foundation | [03](docs/roadmap/phase-03-identity-admin.md) | Planned | | Headless CMS, page builder, media library | [04](docs/roadmap/phase-04-cms-media-pages.md) | Planned | diff --git a/docs/architecture/02-domain-model.md b/docs/architecture/02-domain-model.md index 5c278eb6..41cc429c 100644 --- a/docs/architecture/02-domain-model.md +++ b/docs/architecture/02-domain-model.md @@ -43,7 +43,6 @@ flowchart LR Tenant Organization TenantDomain - TenantBranding TenantFeatureFlag TenantLocale TenantSetting @@ -220,7 +219,7 @@ flowchart LR | `Tenant` | Yes | Tenant-owned, **self-keyed**: no `tenant_id` column, because its `id` *is* the tenant id and its RLS policy keys on `id`. Status: Trial / Active / Suspended / Archived. | | `Organization` | Yes | Sub-unit within a tenant (branch, studio, campus, department, cohort). Two-level hierarchy strict (ADR-0017). Every tenant has at least one default org. | | `TenantDomain` | Yes | Subdomain on `{slug}.learnstack.app` (always available) or custom domain (Hub-managed; see [27-custom-domain-tls.md](27-custom-domain-tls.md)). | -| `TenantBranding` | Inside Tenant | Logo, colors, typography tokens. May be overridden per-organization via `OrganizationBranding`. | +| `TenantBranding` | No — not an entity; the values are `TenantSetting` rows ([Frontend Architecture Standards § Tenant Branding](../standards/07-frontend-architecture.md#tenant-branding)) | Logo, colors, typography tokens. May be overridden per-organization via `OrganizationBranding`. | | `OrganizationBranding` | Inside Organization | Optional partial design-token override (logo / colors / typography) merged on top of `TenantBranding` at render time. When the resolved request carries an organization id and a row exists, the merged token set is injected as CSS variables on the SSR'd HTML root; missing fields fall through to the tenant default. See [Glossary § Branding](../glossary.md). | | `TenantFeatureFlag` | Inside Tenant | Experimental / gradual-rollout flags. Plan-level features are surfaced via the entitlement projection (ADR-0021), not stored here. See [21-feature-flags.md](21-feature-flags.md). | | `TenantLocale` | Inside Tenant | The locales a tenant publishes in ([ADR-0008](../decisions/0008-localization-schema.md)). Composite key `(tenant_id, locale)`, no surrogate id; exactly one row is the default. | diff --git a/docs/architecture/05-mvp-scope.md b/docs/architecture/05-mvp-scope.md index edf062c6..bd72993f 100644 --- a/docs/architecture/05-mvp-scope.md +++ b/docs/architecture/05-mvp-scope.md @@ -25,11 +25,13 @@ Run an end-to-end online English-learning tenant on LearnStack, where: from `TenantContentType`, `TenantLevelTaxonomy`, `TenantScoringRule`, etc. **No English-specific code lives in any module.** - A second tenant — a **yoga studio** — has existed since - [Phase 02a Packet 7](../roadmap/phase-02a-kernel-tenancy.md), with its own - taxonomy, content types and branding loaded from its own customization data. The - substrate-genericity proof is therefore continuous from - [Phase 02d](../roadmap/phase-02d-walking-skeleton.md) onward, not a checkbox at MVP - exit. + [Phase 02a Packet 7](../roadmap/phase-02a-kernel-tenancy.md), holding the same + built-in customization rows as the English school, owned per tenant. + [Phase 02d](../roadmap/phase-02d-walking-skeleton.md) gives each tenant its own + taxonomy, content type and branding as tenant data and renders both sites + ([§ Genericity proof](../roadmap/phase-02d-walking-skeleton.md#genericity-proof)). The + substrate-genericity proof is therefore continuous from Phase 02d onward, not a + checkbox at MVP exit. ## Vertical Slice First diff --git a/docs/architecture/12-localization.md b/docs/architecture/12-localization.md index 3e33ed5d..d4a183fb 100644 --- a/docs/architecture/12-localization.md +++ b/docs/architecture/12-localization.md @@ -21,7 +21,14 @@ Out of scope for the initial implementation: ## Locale Identifiers -- Format: BCP 47 (`en`, `tr`, `en-US`, `tr-TR`). LearnStack stores locale codes as canonical lowercase BCP 47 strings. +- Format: BCP 47 (`en`, `tr`, `en-US`, `tr-TR`). The shipped `tenant_locales.locale` + column and `LocalizedText` keys hold the tag in the case `LocaleTag.Canonicalize` + produces: language lowercase, a four-letter script Title-cased, a two-letter region + uppercased (`tr-TR`, `zh-Hans`). + [Localization Standards § Locale Codes](../standards/08-localization.md#locale-codes) + currently states lowercase. Which spelling content tables and request parameters use + is G6 in + [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). - A tenant declares its **available locales** and one **default locale**. - A user can have a **preferred locale**; if absent, the tenant default is used; if the requested resource doesn't have content in that locale, fallback rules apply (see below). @@ -69,7 +76,7 @@ CREATE TABLE courses ( -- non-translatable columns only: no title, no description, no slug created_at timestamptz NOT NULL, -- ... - CONSTRAINT ux_courses_tenant_id_slug_key UNIQUE (tenant_id, slug_key), + -- slug_key: unique per tenant among live rows; the index is in Database Standards. CONSTRAINT ux_courses_tenant_id_id UNIQUE (tenant_id, id) ); @@ -118,8 +125,8 @@ CREATE TABLE course_translations ( satellite carrying `title` and `slug` carries the content. - `organization_id` mirrors the parent and exists **only** so the satellite can carry the same isolation predicate. It is deliberately absent from the slug constraint — see - [§ Slugs and URLs](#slugs-and-urls). Denormalizing it is safe because - `organization_id` on a tenant-owned row is immutable after insert; see + [§ Slugs and URLs](#slugs-and-urls). What keeps the mirror equal to the parent's is + stated in [Database Standards § Translation satellite tables](../standards/05-database.md). - The foreign key is composite on `tenant_id` for the reason in [Database Standards § Foreign keys between tenant-owned tables](../standards/05-database.md): @@ -234,7 +241,10 @@ apps/web/locales/ studio.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 captured in [Frontend Architecture](14-frontend-architecture.md). +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). 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. diff --git a/docs/architecture/14-frontend-architecture.md b/docs/architecture/14-frontend-architecture.md index 25fce473..5fe95638 100644 --- a/docs/architecture/14-frontend-architecture.md +++ b/docs/architecture/14-frontend-architecture.md @@ -74,6 +74,17 @@ Splitting into separate apps is governed by [ADR 0009 — Frontend Single App Fi ## Tenant + Organization Resolution at the Edge +> **Open in Phase 02d.** Whether the edge calls an API host lookup at all — and if so, +> what it returns and how it states the visitor's host to the API — is G25 in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> It is answered in the decision pass of the packet that ships the public reads. This +> section's "Phase 02d ships it" and the diagram's host-lookup step record the plan the +> section was written against. They are reconciled with the answer in that pass, +> together with +> [Frontend Architecture Standards § Tenant Resolution](../standards/07-frontend-architecture.md#tenant-resolution) +> and +> [Infrastructure Stack Standards § Host → Tenant Resolution](../standards/20-infrastructure-stack.md#host--tenant-resolution). + Next.js middleware resolves the tenant, and optionally the organization, before any route handler runs. Four rules, all of them from [ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md); @@ -209,18 +220,21 @@ Static export is not used; tenants are resolved at request time and the renderer ## Theming -A tenant's branding flows from the API as design tokens. The renderer applies them as -CSS variables on the document root. When the resolved 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. - -```html - -``` +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 +[Frontend Architecture Standards § Tenant Branding](../standards/07-frontend-architecture.md#tenant-branding) +names and the shared Tailwind preset reads; this document keeps no second vocabulary. +Which tokens a tenant may set and the value each accepts are G16 in +[Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +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. + +When the resolved 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. -Tailwind reads these variables via -`theme.extend.colors.brand.primary = 'rgb(var(--brand-primary) / )'`. The first paint is themed; there is no FOUC because tokens are injected into the SSR'd HTML. @@ -357,22 +371,31 @@ The classroom screen is the only place that knows the LiveKit URL; the rest of t ## Performance Budgets -The public renderer has hard budgets: - -- Time to First Byte: < 200 ms at the origin under steady state. -- Largest Contentful Paint: < 2.5 s on a mid-tier mobile device on 4G. -- JavaScript shipped on the public segment: < 150 KB gzipped initial route bundle. +The public renderer's budgets — time to first byte, Largest Contentful Paint, layout +shift, interaction latency and the initial JavaScript payload — are owned by +[Performance Standards § Initial Budgets and § Bundle Size](../standards/15-performance.md). +This document does not restate them. Studio and Portal have higher budgets because they are authenticated apps and benefit from client-side state. -CI runs Lighthouse on representative public pages on every PR; budgets failing the threshold fail the build. +CI's Lighthouse job over representative public pages is scaffolded and not yet active. +Whether it activates in [Phase 02d](../roadmap/phase-02d-walking-skeleton.md), and what +it asserts, is G44 in +[Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). ## Accessibility - WCAG 2.2 AA is the target. -- `axe-core` runs in component tests; violations fail the test. +- Automated `axe-core` checks run through Playwright, per + [Accessibility Standards § Tooling](../standards/16-accessibility.md#tooling); + [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. -- Color contrast is verified for every branded theme — tenant brand tokens that violate contrast cannot be saved. +- 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). + Whether a failing token set is refused or saved with a warning is G16 in + [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). ## Splitting into Multiple Apps Later diff --git a/docs/architecture/28-platform-tenant-organization.md b/docs/architecture/28-platform-tenant-organization.md index 4bb2087f..b21f4804 100644 --- a/docs/architecture/28-platform-tenant-organization.md +++ b/docs/architecture/28-platform-tenant-organization.md @@ -136,7 +136,6 @@ erDiagram Tenant ||--o{ TenantLessonItemType : "defines" Tenant ||--o{ TenantLevelTaxonomy : "defines" Tenant ||--o{ TenantScoringRule : "defines" - Tenant ||--o{ TenantBranding : "has" Tenant ||--o{ CustomDomain : "has 0..N" Tenant ||--|| TenantPlan : "subscribed to (via Hub)" Organization ||--o{ User : "has members" diff --git a/docs/architecture/30-api-gateway.md b/docs/architecture/30-api-gateway.md index 39d756e7..29e562ed 100644 --- a/docs/architecture/30-api-gateway.md +++ b/docs/architecture/30-api-gateway.md @@ -210,7 +210,7 @@ The banding is therefore **specific-beats-general, written down**: | Band | Priority | Contents | |---|---|---| | Infrastructure | 400 | `/healthz`, `/openapi/*` and `/docs*` (environment-gated), `/admin/hangfire*` | -| Public anonymous | 300 | Named public paths: localization, public catalog reads, auth endpoints with their own strict limits | +| Public anonymous | 300 | Named public paths: localization, every anonymous read enumerated in [API Standards § Public surface](../standards/04-api-design.md#public-surface), auth endpoints with their own strict limits. The route table below does not yet carry a route per enumerated read; they arrive with the reviewed route allow-list in [Phase 11](../roadmap/phase-11-production-hardening.md) | | CORS preflight | 200 | `OPTIONS` on the versioned prefix — must beat the authenticated band so preflight never reaches `openid-connect` | | Authenticated catch-all | 100 | Everything else under the versioned prefix | diff --git a/docs/glossary.md b/docs/glossary.md index 186a433d..2d45a740 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -180,7 +180,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | Term | Definition | |------|------------| -| **TenantBranding** | The aggregate inside Tenancy that carries the tenant's design tokens — logo, primary / secondary colour set, typography tokens, header / footer settings. Resolved once per request at the layout level and injected as CSS variables on the SSR'd HTML root. | +| **TenantBranding** | The tenant's design tokens — logo, primary / secondary colour set, typography tokens, header / footer settings. 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)). Resolved once per request at the layout level and injected as CSS variables on the SSR'd HTML root. | | **OrganizationBranding** | An optional override row attached to an `Organization` that supplies a partial design-token set. When the resolved request carries an organization id, the runtime merges `OrganizationBranding` on top of `TenantBranding` before injecting tokens; missing fields fall through to the tenant default. | ## Module-Loading Contracts @@ -347,7 +347,10 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **Phase** | A roadmap-level milestone with its own `phase-NN-topic.md` file under `docs/roadmap/`. Phases are numbered, sometimes letter-suffixed (`02a` / `02b` / `02c`) when sub-streams ship in parallel or in sequence. Each phase doc carries its own `## Phase Exit Decision` section spelling out the gate that closes the phase. | | **Packet** | A dependency-ordered shipping slice **within** a phase, sized to be independently reviewable in one pull request. Packet numbering restarts per phase (`P02a-0`, `P02a-1`, …); the canonical reference shape is `P-` (so the kickoff packet of Phase 02a is `P02a-0`). Commit and PR titles use the prose form (`feat(phase-02a): packet 0 — kickoff`). A packet may ship documentation only (e.g. a kickoff packet that defines the breakdown), decisions only (an ADR move from Draft to Accepted), code only, or any combination — but always one phase's worth of progress, no cross-phase bundling. Per-phase packet history lives in the phase doc's Status block (see [phase-01-repository-tooling.md](roadmap/phase-01-repository-tooling.md) for the canonical shape). | | **Kickoff Packet** | The first packet of a phase when that phase is large enough to need an explicit plan up front. A kickoff packet ships only the per-packet breakdown for its phase plus any glossary / cross-reference updates the breakdown depends on; no code. Phase 01 did not need one (packets fell out cleanly from the existing scaffold targets); Phase 02a does (the foundation surface is wide). | -| **Walking Skeleton** | A **thin vertical slice through every layer** that produces a working, browser-visible artefact as early as the foundation allows — deliberately shallow in features and complete in path. LearnStack's is [Phase 02d](roadmap/phase-02d-walking-skeleton.md): two hosts, two tenants in unrelated domains, `Course` + `Lesson`, three read endpoints, two public pages, one binary, one database, one schema. Its purpose is evidence, not features: it moves the platform's single most testable claim — that the same code paths serve unrelated education domains — from an assertion five phases away to something a non-engineer can check in a browser. Each capability it touches is delivered shallowly there and completely in its owning phase, and each owning phase records what the skeleton already shipped so no work is claimed twice. The exit gate is a browser, not a feature set: a change that does not move a pixel on one of the two pages belongs to its owning phase. | +| **Decision Gate** | An open question a phase document registers because one of its packets cannot be written without the answer. It is Accepted at its last responsible moment, in the decision pass of the first packet whose code it shapes. Not a phase's exit gate (its `## Phase Exit Decision`), and not one of the four payload-validation gates of [ADR-0043](decisions/0043-customization-payload-validation.md). See [Roadmap § Decision Timing](roadmap/README.md#decision-timing). | +| **Decision Pass** | The step every packet of a gated phase opens with: each premise its gates cite is re-verified against `HEAD`, its records are drafted and Accepted, and those records are the packet's first commit. See [Roadmap § Decision Timing](roadmap/README.md#decision-timing). | +| **Decision Register** | The table in a phase document with one row per decision gate: Question, Leaning (a proposal, not a decision), Vehicle and Blocks. [Phase 02b](roadmap/phase-02b-events-auth.md#the-decision-register) carries the first. | +| **Walking Skeleton** | A **thin vertical slice through every layer** that produces a working, browser-visible artefact as early as the foundation allows — deliberately shallow in features and complete in path. LearnStack's is [Phase 02d](roadmap/phase-02d-walking-skeleton.md), which owns its scope and exit: two hosts, two tenants in unrelated domains, one backend binary, one database, one schema. Its purpose is evidence, not features: it moves the platform's single most testable claim — that the same code paths serve unrelated education domains — from an assertion five phases away to something a non-engineer can check in a browser. Each capability it touches is delivered shallowly there and completely in its owning phase, and each owning phase records what the skeleton already shipped so no work is claimed twice. | ## List Queries diff --git a/docs/modules/customization/README.md b/docs/modules/customization/README.md index 6f069f77..aa7fdadb 100644 --- a/docs/modules/customization/README.md +++ b/docs/modules/customization/README.md @@ -38,10 +38,11 @@ plan. - **The content.** A `TenantContentType` says what an article looks like; the articles live in Content, from [Phase 04](../../roadmap/phase-04-cms-media-pages.md). -- **The renderers.** `default-card` and its eight siblings are frontend - components; this module stores their keys and - `Composite_Renderer_Keys_Match_The_Frontend_Registry` holds the two lists - equal. +- **The renderers.** `default-card` and its eight siblings are keys of frontend + composites; this module stores the keys, and + [`Composite_Renderer_Keys_Match_The_Frontend_Registry`](../../standards/21-architecture-tests-catalogue.md#composite_renderer_keys_match_the_frontend_registry) + holds the frontend's registered set inside the nine this module declares — + containment, not equality. - **The evaluator.** Scoring and completion rule bodies are `text` plus a `dialect` discriminator, decided here and given a table in [Phase 05](../../roadmap/phase-05-education-learning-content.md) once ADR-0025 diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index 4adec07e..b31f503f 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -58,9 +58,13 @@ Tenancy owns **who a request belongs to** and nothing about what they do with it `IEntitlementProvider.RefreshAsync`, and never calls the Hub to read it. - **Certificate material.** It moves by secret-store replication and is referenced by path; `tenant_domains` carries verification state and no keys. -- **Branding tokens.** `OrganizationBranding` and the token merge are - [Phase 06](../../roadmap/phase-06-renderer-admin-studio.md); the column arrives with - them rather than as an unused `jsonb` nobody writes. +- **The per-organization branding override.** `OrganizationBranding` and the token merge + are [Phase 06](../../roadmap/phase-06-renderer-admin-studio.md); the column arrives + with them rather than as an unused `jsonb` nobody writes. The tenant's own token + values are not a separate store: they are `TenantSetting` rows + ([Frontend Architecture Standards § Tenant Branding](../../standards/07-frontend-architecture.md#tenant-branding)), + and their key set and value grammar are G16 in + [Phase 02d's decision register](../../roadmap/phase-02d-walking-skeleton.md#the-decision-register). - **Any domain-specific shape.** CEFR levels, asana catalogs, kyu/dan ranks and every other vertical concept are tenant customization data ([ADR-0018](../../decisions/0018-tenant-driven-customization-model.md)), not diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 3f57078e..93e9610e 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -29,9 +29,10 @@ later means touching every query, every migration, every job payload. **No → ship the port now, the adapter on demand.** Dapr, Kafka, APISIX, Vault, the Hub integration, signed licence keys, custom-domain TLS automation, `audit_log` -partitioning. Each has a port in `LearnStack.SharedKernel`, a working default -implementation, an owning phase, and a written trigger condition. None of them blocks a -user-visible artefact. +partitioning. Each has a working default implementation, an owning phase and a written +trigger condition, and a port wherever +[ADR-0035 § The gated set](../decisions/0035-demand-gated-infrastructure.md#the-gated-set) +names one. None of them blocks a user-visible artefact. The second consequence of that principle is **Phase 02d**: a two-tenant vertical slice that puts a working education site in a browser immediately after the kernel is sound, @@ -42,8 +43,8 @@ not deferred to the showcase phase. - [Phase 00: Product Strategy and Architecture Definition](phase-00-product-architecture.md) — **complete** - [Phase 01: Repository, Tooling, and Local Infrastructure](phase-01-repository-tooling.md) — **complete** -- [Phase 02a: Platform Kernel, Multi-Tenancy, Organization, and Foundation Sockets](phase-02a-kernel-tenancy.md) — **complete** (packets 0–3, 3b and 4–10 shipped; [Phase 02d](phase-02d-walking-skeleton.md) is next) -- [Phase 02d: Two-Tenant Walking Skeleton](phase-02d-walking-skeleton.md) +- [Phase 02a: Platform Kernel, Multi-Tenancy, Organization, and Foundation Sockets](phase-02a-kernel-tenancy.md) — **complete** (packets 0–3, 3b and 4–10 shipped) +- [Phase 02d: Two-Tenant Walking Skeleton](phase-02d-walking-skeleton.md) — **in progress** (see its Status block) - [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) @@ -113,9 +114,11 @@ billed or plan-gated, which is the trigger condition | Phase 12 — Hub Marketplace | Pointer; post-MVP, optional | Product-market evidence | | Demand-gated adapters (Dapr, Kafka, APISIX, Vault, licence keys, custom-domain TLS, `audit_log` partitioning) | Land in Phase 11 unless their trigger fires earlier | Per the table in [ADR-0035](../decisions/0035-demand-gated-infrastructure.md) | -A demand-gated item is not "deferred". It has a port, a working default implementation, -an owning phase, and a trigger condition — all four written down. If a trigger fires -early, the item moves to the phase where it fired and ADR-0035's table is amended. +A demand-gated item is not "deferred". It has a working default implementation, +an owning phase and a trigger condition, all written down, and a port wherever +[ADR-0035 § The gated set](../decisions/0035-demand-gated-infrastructure.md#the-gated-set) +names one. If a trigger fires early, the item moves to the phase where it fired and +ADR-0035's table is amended. ## Roadmap Logic @@ -166,9 +169,9 @@ Three exceptions, all deliberate: - [Phase 09b](phase-09b-hub-billing.md) and [Phase 12](phase-12-hub-marketplace.md) are **pointer documents** into the `learnstack-hub` repository, which owns their plan. - They carry Goal, Scope on the LearnStack side, Trigger and Phase Exit Decision only; - Deliverables, Completion Criteria and Risks live in the Hub's own roadmap. Restating - them here would duplicate a plan this repository does not own. + They carry Goal, Scope on the LearnStack side, Trigger and Phase Exit Decision, and no + Deliverables, Completion Criteria or Risks, which live in the Hub's own roadmap. + Restating them here would duplicate a plan this repository does not own. - [Phase 01](phase-01-repository-tooling.md) predates the `## Phase Exit Decision` convention and carries `## Technical Notes` instead. Its annotation block records this; it is not a gap to fill. diff --git a/docs/roadmap/phase-01-repository-tooling.md b/docs/roadmap/phase-01-repository-tooling.md index ba781f3b..10b95df3 100644 --- a/docs/roadmap/phase-01-repository-tooling.md +++ b/docs/roadmap/phase-01-repository-tooling.md @@ -104,10 +104,10 @@ > |---|---|---| > | § Frontend Scaffold | The operator portal is `learnstack-hub-web` | The app is **`operator-portal`** (`frontend/apps/operator-portal` in the Hub repository, asserted by its `Frontend_Has_Only_The_OperatorPortal_App` test). The name was renamed corpus-wide; this line is left as the historical record | > | § Deliverables | "`make seed` populating two demo tenants + one platform admin user" | Landed in [Packet 7](phase-02a-kernel-tenancy.md) — two tenants, an English school and a **yoga studio**, each with two organizations and a host row. **Not** the platform-admin user: Packet 7 creates no `users` table (Phase 03's Identity migration owns it) and `UserId.SystemActor` is a CLR constant with no row behind it | -> | § Completion Criteria | "CI passes on `main`" | True, but the frontend job passes with **zero tests** (`vitest run --passWithNoTests` against no test files). [Packet 3b](phase-02a-kernel-tenancy.md) makes a zero test count a failure; the first real tests arrive with [Phase 02d](phase-02d-walking-skeleton.md) | +> | § Completion Criteria | "CI passes on `main`" | True, but when this phase closed the frontend job passed with **zero tests** (`vitest run --passWithNoTests` against no test files). [Packet 3b](phase-02a-kernel-tenancy.md#delivery-record-packet-3b) replaced the flag with the Vitest harness and one render test, so a zero test count now fails; the first substantive tests arrive with [Phase 02d](phase-02d-walking-skeleton.md) | > | § Local Infrastructure | The 14-service compose stack is the development environment | Per [ADR-0035](../decisions/0035-demand-gated-infrastructure.md), **Dapr, Kafka, APISIX and Vault move behind a non-default compose profile** in [Packet 5](phase-02a-kernel-tenancy.md). Their ports ship with in-process defaults; the adapters land in [Phase 11](phase-11-production-hardening.md) against written triggers. The daily loop runs roughly seven services | > | § CI Baseline | OpenAPI diff activates in Phase 03; Lighthouse in Phase 04 | Both move earlier: [Phase 02d](phase-02d-walking-skeleton.md) ships the first real `/api/v1/*` endpoints **and** the first content-bearing public pages | -> | § CI Baseline | Integration tests activate "in Phase 02a" | More precisely: [Packet 7](phase-02a-kernel-tenancy.md), when the first cross-tenant isolation test lands. The `if: false` placeholder is removed there | +> | § CI Baseline | Integration tests activate "in Phase 02a" | More precisely: [Packet 6](phase-02a-kernel-tenancy.md#delivery-record-packet-6), one packet earlier than planned — [Phase 02a § Packet Sequence](phase-02a-kernel-tenancy.md#packet-sequence) says why. The placeholder step and its gate are gone; making the job a required check is a repository setting, per [CONTRIBUTING](../../.github/CONTRIBUTING.md) | > > ### Known defects this phase shipped with, and where they are fixed > diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index bb8a93f8..b964930d 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -1466,9 +1466,8 @@ Per [API Standards](../standards/04-api-design.md): - Secret handling — never in source. - Tenant-level + organization-level settings model: `tenant_settings`, with org-scoped rows on its nullable `organization_id`, ships in Packet 6. The typed accessor over it - lands with its first reader — [Phase 02d](phase-02d-walking-skeleton.md)'s renderer, - which takes layout, typography and colour from it; nothing in this phase reads a - setting. + lands with its first reader — [Phase 02d](phase-02d-walking-skeleton.md)'s renderer; + nothing in this phase reads a setting. ### Architecture Tests diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index ab5299ac..f5b75bde 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -220,7 +220,7 @@ Accepted, and its catalogue rows registered, before its first line of code. | **P02b-5** | API authentication: JwtBearer wiring and its insertion order, claim population, the host/JWT cross-check through the existing recorder, token-keyed rate limits, the 401 rows, one authenticated endpoint | G10, G11, G12 (the BFF's client), G14, G20 Accepted | | **P02b-6** | Realm and seed: the shared client scope, the mappers, the user-profile declaration, UUID attributes agreeing with `SeedData`, the warm-realm reconciler, the Keycloak test fixture | G12, G13 (the access-token lifetime's authority) Accepted; P02b-5 | | **P02b-7** | BFF session: Auth.js, the cookie session, single-flight refresh, CSRF, logout, the studio guard, the SDK's bearer attachment | G13 Accepted; P02b-6 | -| **P02b-8** | `LS0002`, the carrier reconciliations this phase owes, the branch-protection edit that makes the behavioural check gate, and the exit checks | G16, G18 (the erasure inventory) Accepted | +| **P02b-8** | `LS0002`, the carrier reconciliations this phase owes, the re-verification that the behavioural check is still required under the name it reports, and the exit checks | G16, G18 (the erasure inventory) Accepted | The events packets precede the identity packets because the Hub's `P02c-3` waits on this phase's events half ([Phase 02c](phase-02c-hub-foundation.md)), not on its session half. @@ -254,9 +254,9 @@ premise a row cites is re-verified at that pass rather than trusted. | G9 | Which record decides the background-job runtime — its storage under the closed four-role model, the single tenant-context writer, the job frame's transaction owner, the enqueue-site rule *and who may enqueue whose job type*, the retry contract (whose class, slug and posture adopt **G7**'s), the tenantless platform-job class a purge needs, the ADR-0035 classification, the queue grammar, and the package pins with their licence verdict? | One record answering all of it; a job reaches the database only through `ISender`, which leaves ADR-0040's closed setter set at eight | **ADR-0047** + a dated ADR-0003 Amendment for the storage + a dated **ADR-0036 Amendment** (or an erratum) if the job-path writer is not the `JobActivator` its § Rules names in the closed four-caller set, then Standards 05 and Standards 20 | P02b-4 | | G10 | Does the active tenant travel as a scalar `tenant_id` claim, a `memberships` array, or both — and what value does `UserId` hold? | The scalar claim, whose 02b source is an admin-set user attribute; a second UUID-valued `user_id` attribute for the actor, with `sub` staying Keycloak's subject | Phase-doc: [ADR-0036 § What is out of scope](../decisions/0036-tenant-resolution-trusted-inputs.md#what-is-out-of-scope-and-what-is-not) hands the shape to this phase by name. Reconcile `architecture/13`'s array sketch in the same change | P02b-5 | | G11 | Which [ADR-0036 matrix](../decisions/0036-tenant-resolution-trusted-inputs.md#the-reconciliation-matrix) rows resolve in this phase, given that row 10 consults a reader that denies everyone — and what row covers a signature-valid token carrying no `tenant_id` claim on a tenant host? | Either mint `organization_id` scoped to the host's organization, which makes row 10 need no membership read, or declare the organization host's authenticated 404 and test it as the expected outcome | A dated **ADR-0036** amendment plus errata beside § Staging across packets and the 2026-09-02 erratum, both of which assign row 10 to this phase without noting that its `M covers (T, O)` term is denied until Phase 03 — which groups it with rows 7 and 14 rather than with 6 and 9 | P02b-5 | -| G12 | Which Keycloak client does the BFF exchange the code with, where does sign-in happen across two tenant hosts, **over what transport** — the seed hosts are `*.learnstack.local` and the frontend's dev script serves plain HTTP, which no browser will return a `Secure` cookie over — and how do the client scope, the mappers and the seed users reach a Keycloak database that has already consumed the import? | A confidential BFF client with one redirect URI per seed host; local TLS for those hosts with a trust step, or hosts under `localhost`, which browsers exempt; and an idempotent reconciler inside `make seed` — a mapper added only to the import reaches no existing workstation | Phase-doc + the realm JSON + `scripts/seed.sh` + `infra/keycloak/README.md`; a new ADR only if the reconciler becomes ADR-0004's `IIdentityProvider` | [Phase 02d](phase-02d-walking-skeleton.md#host-based-tenant-resolution-end-to-end) (the dev transport and hostnames, closed with the hosts that phase wires), P02b-5 (which client the BFF redeems the code with: the API pins `azp` against it), P02b-6 | +| G12 | Which Keycloak client does the BFF exchange the code with, where does sign-in happen across two tenant hosts, **over what transport** — the seed hosts are `*.learnstack.local` and the frontend's dev script serves plain HTTP, which no browser will return a `Secure` cookie over — and how do the client scope, the mappers and the seed users reach a Keycloak database that has already consumed the import? | A confidential BFF client with one redirect URI per seed host; local TLS for those hosts with a trust step, or hosts under `localhost`, which browsers exempt; and an idempotent reconciler inside `make seed` — a mapper added only to the import reaches no existing workstation | Phase-doc + the realm JSON + `scripts/seed.sh` + `infra/keycloak/README.md`; a new ADR only if the reconciler becomes ADR-0004's `IIdentityProvider` | [Phase 02d](phase-02d-walking-skeleton.md#the-decision-register) G32 (the dev transport and hostnames, closed in the decision pass of the 02d packet that row blocks), P02b-5 (which client the BFF redeems the code with: the API pins `azp` against it), P02b-6 | | G13 | What is the BFF session — its custody of the refresh token, its store, its idle and absolute lifetimes, its refresh serialisation under concurrent requests, its terminal `invalid_grant` behaviour, its CSRF control and its logout — and which document owns the access-token lifetime? | The `HttpOnly` cookie Security Standards already describes, encrypted by the session adapter, read only by server code, with single-flight refresh and one authority for the TTL | Phase-doc + one reconciled [Security Standards § Authentication](../standards/11-security.md#authentication); a **new ADR** only for a server-side session store | P02b-6 (which document owns the access-token lifetime: the realm export and its reconciler write it), P02b-7 | -| G14 | What bounds the request types reachable over HTTP while `AuthorizationBehavior` is `return next()`, and what is the shape of the token-keyed rate-limit stage behind a shared BFF connection? | An enumerated read-only routable set held by a catalogue-registered rule, and a limiter stage after `UseAuthentication` keyed on the validated subject — which on its own changes nothing, because the shipped global limiter already partitions every request on the socket peer at the anonymous budget and runs before authentication, so two users behind one BFF share that quota and a request it rejects never reaches a token stage. The record says what the early stage does once a caller is validated, and keeps the pre-classification protection against unknown-host abuse | Phase-doc + catalogue registration; a dated ADR-0032 Amendment for a deny-by-default step instead | P02b-5 | +| G14 | What bounds the request types reachable over HTTP while `AuthorizationBehavior` is `return next()`, and what is the shape of the token-keyed rate-limit stage behind a shared BFF connection? | An enumerated read-only routable set held by a catalogue-registered rule, and a limiter stage after `UseAuthentication` keyed on the validated subject — which on its own changes nothing, because the shipped global limiter already partitions every request on the socket peer at the anonymous budget and runs before authentication, so two users behind one BFF share that quota and a request it rejects never reaches a token stage. The record says what the early stage does once a caller is validated, and keeps the pre-classification protection against unknown-host abuse | Phase-doc + catalogue registration; a dated ADR-0032 Amendment for a deny-by-default step instead | P02b-5 (the token-keyed stage and what the early stage does for a validated caller); how the pre-classification stage treats anonymous requests over the trusted hop is [Phase 02d](phase-02d-walking-skeleton.md#the-decision-register)'s G34, closed with the hop that phase wires | | G15 | What initiates a replay in this phase? | A dispatcher-level operation on the dispatcher's own connection, with the operator surface deferred | Phase-doc; the URL space is its own decision record, per ADR-0036 | — (answered in § The consumer side; recorded so the question travels with the deferral) | | G16 | What is `LS0002`'s rule name, which project trees does it run over, what does it inspect, and what escalates it to Error? | The name and scope recorded the way Amendment 1 recorded `LS0001`, with the scope reaching the assemblies that actually handle tokens | A dated **ADR-0032** amendment + a catalogue entry + an `AnalyzerReleases` row | P02b-8 | | G17 | What are the label sets for the event and job metrics, is the tenant axis permitted, and is it spelled `tenant` or `tenant_id`? | One note in the document that owns metric names, no raw tenant label on a per-event series, and an oldest-eligible-row age metric separate from the pending count | [Observability Standards § Required Metrics](../standards/10-observability.md#required-metrics) edit | P02b-2 (the first series ships there, and a label added afterwards touches every emitter already written) | @@ -866,12 +866,16 @@ What this phase owes the catalogue beyond implementing those rows: Two notes on what "green in CI" can mean. Several of this phase's rows are behavioural rather than structural, and CI runs those in a separate job under a Docker trait filter, -so "the architecture suite is green" does not reach them. That job is **not** a required -check today — CONTRIBUTING names adding it to branch protection as the one remaining -edit, a repository setting no phase owns — and every behavioural criterion below sits in -it, so `P02b-8` takes that edit and records its date. And the executed-test guard counts -executed tests, so a per-test skip leaves its count non-zero — the criterion says *not -skipped*, per assembly. +so "the architecture suite is green" does not reach them. That job was **not** a +required check when this was written — CONTRIBUTING names adding it to branch protection +as the remaining edit — and every behavioural criterion below sits in it. +[Phase 02d](phase-02d-walking-skeleton.md) makes that edit no later than its first +Education migration, because +[Git Workflow Standards § Checks](../standards/14-git-workflow.md#checks) already makes +tenant-isolation tests a merge condition. `P02b-8` re-verifies that the check is still +required under the name the job reports, and records the date. And the executed-test +guard counts executed tests, so a per-test skip leaves its count non-zero — the +criterion says *not skipped*, per assembly. ## Deliverables diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 75cafe1b..95fd4287 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -1,9 +1,28 @@ # Phase 02d: Two-Tenant Walking Skeleton +> **Status (2026-09-13).** 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 +> row, linking its delivery record. +> +> | Packet | Title | State | +> |---|---|---| +> | P02d-0 | Kickoff | ✅ this plan | +> | P02d-1 | Education schema and database-level isolation | not started | +> | P02d-2 | Writers and seed | not started | +> | P02d-3 | Read internals | not started | +> | P02d-4 | Public read API and contract checks | not started | +> | P02d-5 | Server-rendering path | not started | +> | P02d-6 | Public renderer | not started | +> | P02d-7 | Demo, full-stack CI and exit | not started | + ## Goal Put a working education site in a browser — twice, on two hosts, for two tenants in -unrelated domains, served by one binary and one database. +unrelated domains, served by one backend binary +([ADR-0018](../decisions/0018-tenant-driven-customization-model.md)), one `apps/web` +application and one database. This is the first phase whose output someone who does not read C# can evaluate. It exists because the alternative — reaching a visible artefact only after Phases 03 @@ -11,321 +30,1703 @@ through 06 — puts the project's single most testable claim (the same code path unrelated education domains) five phases away from any evidence, and puts the first piece of user-facing value even further. -Phase 02d is a **thin vertical slice through later phases**, not a replacement for -them. Every capability it touches is delivered shallowly here and completely in its -owning phase. Phases 04 through 07 keep their full scope; each records what 02d already -shipped so the two never claim the same work twice. +Phase 02d is a **thin vertical slice through later phases**, not a replacement for them. +Every capability it touches is delivered shallowly here and completely in its owning +phase. Phases 04 through 07 keep their full scope; each records what 02d already shipped +so the two never claim the same work twice. Depends on [Phase 02a](phase-02a-kernel-tenancy.md) — specifically the corrected Row -Level Security template, tenant + organization resolution, the two seed tenants, and -the customization aggregates with the validated write path that fills them. **The +Level Security template, tenant + organization resolution, the two seed tenants, and the +customization aggregates with the validated write path that fills them. **The customization read path is this phase's own work**: the projection keyed on -`customization_generations.generation` lands here with the renderer that is its first -consumer, which is why 02a stops at the data being resolvable and isolated -([the module spec](../modules/customization/README.md) is the single record of it). Runs **before** -[Phase 02b](phase-02b-events-auth.md): the skeleton is deliberately anonymous, so it -needs no identity provider. +`customization_generations.generation`, and its generation-keyed cache, land here with +the renderer that is their first consumer +([§ Customization read path](#customization-read-path)), which is why 02a stops at the +data being resolvable and isolated +([the module spec](../modules/customization/README.md) is the single record of it). Runs +**before** [Phase 02b](phase-02b-events-auth.md): the skeleton is deliberately +anonymous, so it needs no identity provider. + +### What this phase inherits + +Most of what this phase meets is already shipped or already decided. Each item links its +owner; where a choice is still open, it names the register row that answers it. + +- **The anonymous surface.** A context resolved from the host alone reaches only request + types marked `[PublicSurface]`, and a refusal answers the unknown-host `404` + ([API Standards § Public surface](../standards/04-api-design.md#public-surface)). That + section's table is empty; its first rows are this phase's, and + `PublicSurface_Marker_Set_Is_Enumerated` passes over an empty set until then. Every + request type reaching the audit step is classified, `Off` included + ([ADR-0044](../decisions/0044-audit-write-path.md)). **G28**. +- **The trusted hop.** The hop predicate, its header names, the startup refusal of a + half-configured hop and `EffectiveHostAccessor` shipped in + [Phase 02a Packet 4](phase-02a-kernel-tenancy.md#delivery-record-packet-4). + [ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md) names this phase + twice — § Consequences refers to "Phase 02d's browser test", and § Implementation + Notes lists the anonymous two-host render in its binding runtime matrix — and that + matrix's "direct socket bypassing the hop" row is exercised today only over a + constructed `HttpContext`, never over HTTP. + [Testing Standards § End-to-End Tests](../standards/06-testing.md#end-to-end-tests) + gates this phase on a human opening the sites. **G33**. +- **The anonymous rate limiter.** One global limiter runs before host classification and + partitions every request on its socket peer at the anonymous budget + ([API Standards § Request and Response Limits](../standards/04-api-design.md#request-and-response-limits) + holds the value; the catalogue's `Anonymous_Requests_Are_Rate_Limited_Per_Peer` holds + the rule). This phase's pages fetch from the server, so every call they make reaches + the API from the renderer's peer. **G34**. +- **The first minted cursor.** + [API Standards § Pagination](../standards/04-api-design.md#pagination) leaves the + payload to whoever mints it, and + [Phase 02a](phase-02a-kernel-tenancy.md#completion-criteria) handed this phase the + `400` for a cursor its minter cannot read. **G10**. +- **Tenant settings.** `tenant_settings` holds tenant-wide and organization-scoped rows, + unique per `(tenant_id, organization_id, key)` with nulls not distinct; the + organization-over-tenant fallback is documented on `TenantSetting` and implemented by + no reader; the domain checks only that a value is well-formed JSON; and Phase 02a left + the typed accessor to its first reader, this renderer. `tenancy.setting.write` is + MUST-class and `(planned)` in [the Tenancy audit matrix](../modules/tenancy/audit.md). + The Packet 7 isolation fixture writes raw `tz` and `theme` rows for both seed tenants. + [Phase 03](phase-03-identity-admin.md) records that no command writing + `tenant_settings` lands before the `[PiiSensitive]` decision on `TenantSetting.Value`. + **G16**, **G17**, **G23**. +- **Tenant locales.** `tenant_locales` shipped in + [Packet 6](phase-02a-kernel-tenancy.md#delivery-record-packet-6); neither seed tenant + holds a row, and no command writes one. **G11**, **G13**. +- **The customization write path.** Both tenants hold the built-in `card` content type + and `plain` taxonomy, owned per tenant, and every Customization command bumps the + tenant's generation + ([Packet 8](phase-02a-kernel-tenancy.md#delivery-record-packet-8)). + `Customization.Application.Contracts` exposes no read; the module spec's § Primary + read flow says "Not implemented". **G12**, **G22**. +- **The seed.** [Packet 7](phase-02a-kernel-tenancy.md#delivery-record-packet-7) seeds + two tenants through the request path, maps the English school's host tenant-wide and + the yoga studio's host to its default organization, and writes both host rows on names + under `*.learnstack.local` that a browser reaches only after a hosts-file edit. Every + seeder step after provisioning announces the tenant's default organization. **G7**, + **G14**, **G32**. +- **The frontend scaffolds.** `frontend/apps/web/src/middleware.ts` answers `503` + whenever `NODE_ENV` is `production` (so under `next start`), writes the raw host into + `x-tenant-id`, copies every inbound header, matches `/api/healthz` too, and carries + TODOs that assign the work to Phase 02a. + `frontend/packages/sdk/src/{server,client}.ts` take a `tenantId` option and return + `{}`, which stops compiling once the generated `paths` is non-empty, and the package + root re-exports the server entry. `frontend/apps/web/.env.local.example` points the + API origin at APISIX, which only `make dev-gated` starts. The root layout fixes + `lang="en"` and a platform ``. The Vitest harness, Packet 3b's placeholder page + test and Packet 10's `src/test/lint-rules.test.ts` exist; + `lib/customization/composites.ts` registers four composite keys and no component. + **G31**, **G35**, **G36**, **G38**, **G43**. +- **CI and the development loop.** The `openapi diff` and `lighthouse budget` jobs are + placeholders behind unset `vars.ENABLE_*` variables and report as skipped; activation + takes the edits + [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main) + lists. The SDK pipeline is wired with empty output, and + [Frontend Architecture Standards § SDK](../standards/07-frontend-architecture.md#sdk) + gives its drift gate to this phase. The contract suite asserts only a `200` + ([Testing Standards § API Contract Tests](../standards/06-testing.md#api-contract-tests)). + The `backend integration (Testcontainers)` job runs on every pull request and is not a + required check; CONTRIBUTING records that edit and a renamed `meta` check as + outstanding. `make dev` starts containers only, `dotnet run` reads no `.env`, and + `make seed` waits on every default-profile service and both Keycloak realms + ([Infrastructure Standards § Healthchecks and the readiness gate](../standards/12-infrastructure.md#healthchecks-and-the-readiness-gate)). + No job starts the API or `next start` as a process a browser can reach. **G31**, + **G44**, **G45**. +- **Standards that meet their first subject here.** A new module owes its spec and + audit-coverage matrix + ([Documentation Standards § Per-Module Specifications](../standards/13-documentation.md#per-module-specifications), + [Audit Coverage Standards](../standards/18-audit-coverage.md)). + [Accessibility Standards](../standards/16-accessibility.md) binds the first public + surfaces, and the [standards index](../standards/README.md#honest-status-today) row + for Localization Standards places the i18n runtime in Phase 04. **G39**, **G43**. +- **What Phase 02b expects of this phase.** The development-transport part of its gate + **G12** + ([Phase 02b § The decision register](phase-02b-events-auth.md#the-decision-register)), + now **G32**; the shared-peer premise of its **G14**, now **G34**; and its criterion + that both sites still render anonymously with Keycloak stopped. + +What is genuinely absent: every Education type, table, command, endpoint and module +spec; every renderer component; the server SDK transport; any trusted-hop configuration; +any Lighthouse tooling; and a `make demo` target. + +### Explicitly not in this phase + +Named so that no reader has to guess, and so no later phase can assume it was done here: + +| Capability | Owning phase | +|---|---| +| Authentication, sessions, login | [Phase 02b](phase-02b-events-auth.md) | +| Token-keyed authenticated and write rate-limit budgets, and what the pre-authentication limiter does once a caller is validated | [Phase 02b](phase-02b-events-auth.md) (G14) | +| Keycloak realm reconciliation (a seed step that writes to Keycloak) | [Phase 02b](phase-02b-events-auth.md) (G12) | +| The `learnstack.tenancy.settings` eager invalidation event | [Phase 02b](phase-02b-events-auth.md), per [the Tenancy spec](../modules/tenancy/README.md) | +| Isolation tests through authenticated or id-addressed routes | [Phase 02b](phase-02b-events-auth.md) | +| Identity domain, roles, permissions, invitations; permission keys for public reads; the rule that every endpoint carries `[Authorize]` or `[AllowAnonymous]` | [Phase 03](phase-03-identity-admin.md) | +| A tenant-scope read across all organizations on a tenant host (the `app.scope` carrier) | [Phase 03](phase-03-identity-admin.md) | +| CMS editing, page builder, media library and tenant media origins | [Phase 04](phase-04-cms-media-pages.md) | +| Per-locale publish readiness workflow, `tenant_route_slugs` registry | [Phase 04](phase-04-cms-media-pages.md) | +| Studio authoring of translated field values (titles, bodies, slugs) and the view of untranslated gaps — where it lives is an open question | [Phase 04](phase-04-cms-media-pages.md#admin-studio-cms-screens); its screen row goes in [Phase 06 § Admin Studio — screen ownership](phase-06-renderer-admin-studio.md#admin-studio--screen-ownership) | +| `Accept-Language` negotiation for API-returned messages; the per-tenant locale fallback chain; the `/{locale}/{slug}` page routing shape | [Phase 04](phase-04-cms-media-pages.md) | +| Authoring and translating a content type's field order and labels; the closed set of built-in primitive field types (number, boolean, date/time, select) | [Phase 04](phase-04-cms-media-pages.md) | +| The schema-version migration path for stored instances; deleting a content-type revision after a zero-instance count | [Phase 04](phase-04-cms-media-pages.md) | +| Customization deprecate, revise and rename commands, and per-band taxonomy editing | [Phase 04](phase-04-cms-media-pages.md) and [Phase 05](phase-05-education-learning-content.md), consolidated in [Phase 06](phase-06-renderer-admin-studio.md) | +| Tenant-authored page composition (`TenantPageBlock` rows and their cache family) | [Phase 04](phase-04-cms-media-pages.md); the block resolver is [Phase 06](phase-06-renderer-admin-studio.md)'s | +| Deciding whether a customization change owes an integration event | [Phase 04](phase-04-cms-media-pages.md), per [the Customization spec § Integration-event catalogue](../modules/customization/README.md#integration-event-catalogue) | +| The `Redirect` model the CMS auto-creates when a published slug changes | [Phase 04](phase-04-cms-media-pages.md) | +| Search — the `ITenantSearch` port and its PostgreSQL default | [Phase 04](phase-04-cms-media-pages.md) | +| Search — the Meilisearch adapter behind that port | [Phase 09](phase-09-billing-integrations-analytics.md) | +| Course versioning, programs, lesson items, completion rules; publish validation and the course publish flow; catalog visibility separate from publication | [Phase 05](phase-05-education-learning-content.md) | +| The migration that brings this phase's courses and lessons under course versions and modules | [Phase 05](phase-05-education-learning-content.md) | +| The authenticated course and lesson authoring surface, with the commands that edit a course or lesson or reorder lessons after the seed | [Phase 05](phase-05-education-learning-content.md) | +| The `Level` aggregate and the catalog API's levels resource; the `TenantLevelTaxonomy` editor | [Phase 05](phase-05-education-learning-content.md) | +| The `TenantLessonItemType` read, the batched reference walk and the measured customization cost model; the `embed-html` sanitisation contract | [Phase 05](phase-05-education-learning-content.md) | +| Admin Studio, navigation menus, SEO metadata beyond what G40 settles for translated public pages, full block registry | [Phase 06](phase-06-renderer-admin-studio.md) | +| The full `UnknownVersionBlock` / `UnknownBlock` placeholders and per-block error boundary; tenant-authored error pages; redirect handling | [Phase 06](phase-06-renderer-admin-studio.md) | +| Branding configuration surface — tenant-admin editor, theme preview, logo, header and footer settings; the per-organization branding override (`OrganizationBranding`) and the token merge | [Phase 06](phase-06-renderer-admin-studio.md) | +| Preview of draft courses and lessons through the production renderer | [Phase 06](phase-06-renderer-admin-studio.md) | +| Browser end-to-end flows (Playwright) and axe checks through Playwright | [Phase 06](phase-06-renderer-admin-studio.md), per [Testing Standards § End-to-End Tests](../standards/06-testing.md#end-to-end-tests); any narrow smoke this phase adopts is G38 | +| Enrollment, learner portal, progress tracking; [Course Access](../glossary.md) — whether content published anonymously here stays on the public path once enrollment exists | [Phase 07](phase-07-enrollment-learner-portal.md) | +| Live classroom | [Phase 08c](phase-08c-classroom.md) | +| Billing | [Phase 09](phase-09-billing-integrations-analytics.md) | +| Hub, entitlement gating | [Phase 02c](phase-02c-hub-foundation.md) | +| APISIX as the edge gateway for a non-development deployment; gateway or CDN caching; edge rate limiting | [Phase 11](phase-11-production-hardening.md#security), on [ADR-0035](../decisions/0035-demand-gated-infrastructure.md)'s trigger | +| Plan-differentiated API rate limits (`LimitKeys.ApiRatePerMinute`) | [Phase 11](phase-11-production-hardening.md#security) for the key's gate; [Phase 02c](phase-02c-hub-foundation.md) for its enforcement path | +| The L2 cache tier behind `ICacheService` (default `InMemoryCacheService`) | [Phase 11](phase-11-production-hardening.md), on [ADR-0035](../decisions/0035-demand-gated-infrastructure.md)'s trigger | +| Content-Security-Policy and the other secure headers | [Phase 11 § Security](phase-11-production-hardening.md#security), per the [standards index](../standards/README.md#honest-status-today) row for Security Standards | +| `GET /readyz`; the production Dockerfile and image pipeline; the performance baseline and load tests against [Performance Standards](../standards/15-performance.md) | [Phase 11](phase-11-production-hardening.md) | +| Custom-domain TLS automation | [Phase 11](phase-11-production-hardening.md), on [ADR-0035](../decisions/0035-demand-gated-infrastructure.md)'s trigger | + +Decisions made or referenced in this phase: + +- [ADR-0003 Tenant Isolation Defense in Depth](../decisions/0003-tenant-isolation-defense-in-depth.md) + (Amendment 3's template and Amendment 5's organization write guards, which every + Education table takes; G7 asks whether the insert arm matches them) +- [ADR-0008 Localization Schema](../decisions/0008-localization-schema.md) (satellite + translation tables; slugs on the translation row, unique within `(tenant_id, locale)`) +- [ADR-0010 Cross-Module Communication](../decisions/0010-cross-module-communication.md) + (Education reads Customization and Tenancy only through their application contracts) +- [ADR-0013 Page Block Schema Versioning](../decisions/0013-page-block-schema-versioning.md) + (the pin rule + [Tenant Customization Model § 4](../architecture/32-tenant-customization-model.md) + extends to content types, and the placeholder for an unknown version) +- [ADR-0017 Tenant / Organization Hierarchy](../decisions/0017-tenant-organization-hierarchy.md) + (organization scope; `OrganizationBranding` as Phase 06's override) +- [ADR-0018 Tenant-Driven Customization Model](../decisions/0018-tenant-driven-customization-model.md) + (genericity, the renderer architecture, and its 2026-09-04 key and locale amendment) +- [ADR-0023 Strongly-Typed Id Source Generator](../decisions/0023-strongly-typed-id-source-generator.md) + (the new aggregates' identifiers) +- [ADR-0024 API Versioning Policy](../decisions/0024-api-versioning-policy.md) (what the + breaking-change check freezes) +- [ADR-0032 Exception Handling, Logging, and Observability Architecture](../decisions/0032-exception-handling-logging-and-observability.md) + (the pipeline, `Result<T>`, Problem Details, the controller shape) +- [ADR-0033 Audit Durability Model](../decisions/0033-audit-durability-model.md) and + [ADR-0044 Audit Write Path](../decisions/0044-audit-write-path.md) (classification of + the anonymous reads and of the MUST-class writes) +- [ADR-0034 Hub Contract Surface Invariant](../decisions/0034-hub-contract-surface-invariant.md) + (host resolution never calls the Hub) +- [ADR-0035 Demand-Gated Infrastructure](../decisions/0035-demand-gated-infrastructure.md) + (this scope fires no trigger) +- [ADR-0036 Tenant Resolution and Trusted Inputs](../decisions/0036-tenant-resolution-trusted-inputs.md) + (host resolution, the trusted hop and the pre-classification limiter; G33 and G34 may + owe it an amendment) +- [ADR-0039 The Optimistic Concurrency Token](../decisions/0039-optimistic-concurrency-token.md) + (`row_version` and the one ETag derivation) +- [ADR-0040 Ambient Unit of Work](../decisions/0040-ambient-unit-of-work.md) (the closed + setter set G15, G22 and G23 test against, and the read-only mode G28 may add) +- [ADR-0041 Correcting False Statements in Accepted ADRs](../decisions/0041-correcting-false-statements-in-accepted-adrs.md) + (how an erratum a decision pass finds is written) +- [ADR-0042 Tenant Provisioning Cross-Aggregate Transaction](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md) + (each locale and setting row by its own command; one aggregate root per command) +- [ADR-0043 Customization Payload Validation](../decisions/0043-customization-payload-validation.md) + (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 ## Scope +### Packets and decision gates + +Phase 02a needed twelve packets for a narrower scope. This phase declares eight, in +dependency order. `P02d-0` writes no code. Every packet after it opens with the decision +pass [Roadmap § Decision Timing](README.md#decision-timing) describes: its gates are +Accepted, and its catalogue rows registered, before its first line of code. + +| Packet | Contents | Cannot start until | +|---|---|---| +| **P02d-0** | Phase entry: 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. It answers no gate. The required-check edits CONTRIBUTING records as outstanding are due no later than `P02d-1` | Phase 02a exits (met) | +| **P02d-1** | Education schema and database-level isolation: the Education migration chain (`courses`, `lessons`, both translation satellites) with its policies, grants, foreign-key indexes and immutability triggers; the insert-time organization control; aggregates and EF configuration; the structural guards; the schema-level Education isolation suite as `learnstack_app`; the Education module spec skeleton and its pinned-list entry | G1, G2, G3 (values), G4, G5 (column), G6 (a), G7, G8, G9, G10 (order), G26 (slug grammar) Accepted | +| **P02d-2** | Writers and seed: the Education write commands and the Tenancy locale and setting commands, with their catalogue sources, matrices and composition-root registration; the Customization contract the lesson writer calls; the seeder's acts, contexts, ordering and re-run behaviour; each tenant's own content type, taxonomy, locales, branding, courses and lessons; `SeederTests` and the Packet 7 suite recomputed | G3 (transitions), G5 (validation), G7 (child derivation), G11, G12 (contract), G13, G14, G15, G16 (a–e), G17, G18, G19, G20 (literal source), G21 (subresources), G23 (bound) Accepted; P02d-1 | +| **P02d-3** | Read internals, no HTTP: the generation-keyed customization projection and its cache families; the typed settings accessor | G12 (cache key), G22, G23 (accessor), G24 Accepted; P02d-2 | +| **P02d-4** | Public read API and contract checks: the `[PublicSurface]` reads, their table rows and classification; response contracts, read eligibility, the cursor and the locale matrix; the cache directive; the committed OpenAPI snapshot with the breaking-change check, the regenerated SDK types and the drift gate, activated and required; the request-level Education isolation suite with positive controls | G5 (unresolved band), G6 (b), G10 (codec), G12 (response), G16 (f, g), G24 (response), G25, G26, G27, G28, G29, G30, G31 Accepted; P02d-3 | +| **P02d-5** | Server-rendering path: the development hostnames and transport; the development trusted-hop configuration; the server SDK transport; the middleware replacement and entry behaviour; the anonymous limiter over the hop; the rendering mode; the hop runtime evidence; the frontend skip refusal and the hop predicates' tests | G6 (c), G20 (exemptions), G21 (cookies), G30 (headers), G32, G33, G34, G35, G36, G37, G38 (a, d), G44 (whether) Accepted; P02d-4 | +| **P02d-6** | Public renderer: the `(public)` route tree, pages and page states; the UI message layer; lesson field rendering and its fallbacks; theming injection; the accessibility minimum; the renderer's tests | G5 (unresolved band), G12 (page state), G16 (g), G20 (subjects), G38 (b, c), G39, G40, G41, G42, G43 Accepted; P02d-5 | +| **P02d-7** | Demo, full-stack CI and exit: `make demo` and its stop behaviour; the full-stack Lighthouse job; the Keycloak-stopped check; the standards-index transitions; the outbound carrier re-verification; the delivery record naming what `P02b-0` re-verifies; the exit checks | G20 (Implemented), G33 (CI), G38 (c's job), G44, G45 Accepted; P02d-6 | + +`P02d-2` precedes `P02d-3` because the projection reads the definitions the seed +publishes, and because the lesson writer — not the renderer — is the first caller of the +Customization contract. `P02d-4` regenerates the SDK, so the SDK surface that must +compile against non-empty `paths` is decided there. The rendering mode and the entry +behaviour close in `P02d-5`, not `P02d-6`, because the middleware replacement and the +SDK transport are written there and change under a different answer. G32 closes in +`P02d-5` unless an earlier packet writes a seed-host literal outside `SeedData`. + +#### The decision register + +Each row is a question this phase must answer before the packet it blocks starts. The +**Leaning** column records the reviewers' consolidated proposal and is **not** a +decision; where the reviews disagreed, it states each position. The **Vehicle** column +is what this repository's own rules require for the answer to count. A gate whose +vehicle is an amendment is Accepted **before** the code it governs is written, not +alongside it. It closes in the decision pass of the first packet whose code it shapes, +together with any coupled gate that blocks the same packet; where its parts shape +different packets, the **Blocks** cell names the part each packet waits on. Every +premise a row cites is re-verified at that pass rather than trusted. + +| # | Question | Leaning | Vehicle | Blocks | +|---|---|---|---|---| +| G1 | How does a row whose vehicle is a phase-doc statement, a standard or a catalogue row show that it is Accepted, so that the exit's "no row open" can be checked — and is the answer this phase's or roadmap-wide? | The row stays and gains a closed date and a link to the statement, and each packet's Status row and delivery record list the rows it closed. Roadmap-wide if Phase 02b's phase-doc rows should close the same way | A sentence in [Roadmap § Decision Timing](README.md#decision-timing) if roadmap-wide, or in this register's framing paragraph if local. No ADR | P02d-1 (the first pass to close such a row; it shapes no code) | +| 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`) | +| 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 (transitions, the publishing command, seeded states) | +| 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) | +| 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) | +| 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) | +| 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) | +| 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) | +| 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_<satellite>_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) | +| 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) | +| 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) | +| 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) | +| 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 § Locale Identifiers](../architecture/12-localization.md#locale-identifiers) 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) | +| 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) | +| 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) | +| 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 false under `NullEntitlementProvider` 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) | +| 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) | +| 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) | +| 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) | +| 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) | +| 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) | +| 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 | +| 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) | +| G24 | Display fallback. Which document owns the chain — [Localization § Fallback Rules](../architecture/12-localization.md#fallback-rules) or [Localization Standards § Locale Model](../standards/08-localization.md#locale-model), which state different chains, while the shipped `LocalizedText.Resolve` narrows one subtag at a time and ends at the first authored value? What is the terminal state of a nullable Pattern A field and of a Pattern B label? Does a response say which locale a fallback value resolved in, so the page can mark its language (WCAG 3.1.2)? | Localization architecture owns the chain and Localization Standards links it, both recording the shipped narrowing and the first-authored terminal for labels; a nullable Pattern A field renders absent; each fallback-capable field reports its resolved locale | Detail: Localization Standards § Locale Model linking its owner, reconciled with `LocalizedText` in the same diff; the Customization contract's signature; the response schema under G26. No ADR | P02d-3 (the first caller that passes a fallback chain), P02d-4 (response fields) | +| G25 | Site data and the page set. How does the renderer get the per-host data none of the Education reads returns — enabled and default locales, branding tokens, taxonomy display values, content-type field lists: fields embedded in the course reads (which cannot supply a default locale before a locale is known), a separate `[PublicSurface]` read resolved from the effective host, or the edge host lookup [Frontend Architecture Standards § Tenant Resolution](../standards/07-frontend-architecture.md#tenant-resolution) and [Infrastructure Stack Standards § Host → Tenant Resolution](../standards/20-infrastructure-stack.md#host--tenant-resolution) prescribe today, which must then state the effective host over the hop? Does the frontend ever hold a tenant or organization id? And which `(public)` pages ship — catalog, course with ordered lesson links and lesson, or two pages with bounded lesson links in the catalog response? | One `[PublicSurface]` site-data read with no host parameter, returning a closed projection and no ids, and three pages, which gives the course-detail read a consumer; one review keeps two pages with an explicit catalog outline. The first two options change what two Active standards prescribe | Contract: a phase-doc statement in § Read API and § Public renderer; for the first two options, edits to the two standards named, with an ADR if the pass judges the change non-trivial (no ADR carries the edge-lookup rule). Detail: the API Standards § Public surface rows; the Frontend Architecture sketch, sequence diagram and cache rows; the Localization architecture's edge locale sentence; the glossary; Phase 06 § What Phase 02d already shipped; Phase 05's inherited row if the course-detail read changes | P02d-4 (the endpoint set and DTOs the OpenAPI baseline freezes; a two-page answer changes the catalog response) | +| G26 | The v1 public read contract. The path shape beside Phase 05's authoring `/courses/{id}` — a shared slot, a distinct public prefix, or `/courses/by-slug/{slug}`; each response as an allow-list and what it never carries; the embedded lesson list's fields, order and bound, and whether an empty list is valid; per-locale alternates; how enums and envelopes stay additive; and which Problem Details responses each operation documents, given that no non-idempotent operation documents any today and a baseline of `200`s cannot see a status change | Fields limited to what the pages render; object envelopes, extensible enums, a deny-list contract test (`tenantId`, `organizationId`, `createdBy`, `updatedBy`, `deletedAt`, `rowVersion`, `slugKey`); the embedded list carries title, slug and order under a cap; `alternates` for enabled, translated locales; one shared transformer declaring each operation's statuses as `application/problem+json`. No review settled the path | Contract: a phase-doc statement recorded before the breaking-change check stores its baseline. Detail: the OpenAPI snapshot; [API Standards § URL Structure](../standards/04-api-design.md#url-structure) for a prefix class, § Pagination for an embedded list, § OpenAPI; the gateway's public-band row. [ADR-0024](../decisions/0024-api-versioning-policy.md) settles that later additions are non-breaking | P02d-1 (whether the slug grammar must refuse GUID shapes, with G9), P02d-4 (route templates, records, snapshot) | +| G27 | The cache posture of public reads. What directive do anonymous responses carry — the `200`s, the Problem Details `400`s and `404`s, the tenancy edge's unmapped-host `404` — what freshness do a newly published or unpublished course and a not-found have, and do anonymous reads emit an `ETag` and honour `If-None-Match`? [API Standards § Optimistic Concurrency](../standards/04-api-design.md#optimistic-concurrency) says mutable resources expose an `ETag`, and [ADR-0039](../decisions/0039-optimistic-concurrency-token.md) fixes one derivation, which a composite read cannot use without publishing `row_version` | An explicit `Cache-Control: no-store`, asserted by a test, and no `ETag` on anonymous reads — a response without explicit freshness may be cached heuristically by a shared cache. One review proposed no directive, stated | Contract: a phase-doc statement. Detail: API Standards — the directive, and a § Optimistic Concurrency sentence on anonymous read contracts, owed under either answer. A dated ADR-0039 amendment if a body-hash validator ships; [Performance Standards § Caching](../standards/15-performance.md#caching) if the answer caches | P02d-4 (the header-setting code and the headers the snapshot documents) | +| G28 | Public-surface controls. (a) What audit class do `[PublicSurface]` requests register, and does a rule make `Off` the only permitted one? (b) `GET` only, or `GET` and `HEAD`, and what does the catalogue's permitted-methods leg compare a row against? (c) What mechanically stops a marked request from writing — a `READ ONLY` unit of work, a structural scan, or both? (d) What control beyond review keeps a controller dispatching only through `ISender` — a controller taking a module `DbContext` fails loudly, SQL on `IUnitOfWork.Connection` reads zero rows, and code that announces the tenant itself reads real rows? | (a) `Off` for every marked type — a SHOULD or MAY class would make every anonymous `GET` a best-effort write a caller controls — with a sibling rule and companion; (b) one review `GET` only, one the standard's `GET` / `HEAD`; (c) a `READ ONLY` transaction for marked requests, which three shipped setters already open before announcing, and which refuses any in-transaction MUST write, so it is checked against (a); (d) a type-reference rule over controller bodies with a planted offender | Detail: the API Standards § Public surface rows; Standards 21 rows and companions, including the two legs of `PublicSurface_Marker_Set_Is_Enumerated` not yet implemented; an [Error Handling Standards § Controller Mapping](../standards/09-error-handling.md#controller-mapping--resultt--iactionresult) sentence for (d). Contract: a dated ADR-0040 amendment if the unit of work gains a read-only mode | P02d-4 (the first marked query's registration, method attributes and handler; if P02d-3 writes on the read path, (c) closes there) | +| G29 | Which rows do the anonymous reads serve, and what does every hidden row answer? The rule covers course state, lesson state (G3), the lesson's membership in the course its URL names — lesson slugs are unique per tenant, so a lesson resolves without its course segment unless the read checks — and soft deletion. Does every hidden cause (draft, deleted, wrong course, untranslated, other tenant, sibling organization, nonexistent) answer one `not_found` body with no per-cause detail, compared with `instance` and `correlationId` masked? | One eligibility rule used by every read; a lesson resolves only under its eligible parent, only when it belongs to it, only in the requested locale; lists show only eligible entries; deleted rows excluded now; one masked-equal body | Contract: a phase-doc statement whose single record is the Education spec. Detail: the failure constant. Settled and linked: a cross-tenant row is a `404` ([Security Standards § Error Messages](../standards/11-security.md#error-messages)), and an organization-scoped row is served only on its own organization's host ([Localization § Slugs and URLs](../architecture/12-localization.md#slugs-and-urls)) | P02d-4 (handlers, the failure constant, documented `404`s); the fixture rows close with G14 in P02d-2 if they live in the seed | +| G30 | The locale error matrix and transport. On each read, what answers an empty, repeated, malformed, over-length, non-canonical, not-enabled or enabled-but-untranslated locale? Does a not-enabled locale answer the Active `unsupported_locale` `400` or the not-found body? Is `X-Locale`, which [Frontend Architecture Standards § Locale Resolution](../standards/07-frontend-architecture.md#locale-resolution) still names as the API carrier, withdrawn, so that locale reaches the API only as the query parameter? | Missing or malformed → `400` `validation_failed` naming `locale`; untranslated → an empty catalog page; the query parameter only. The reviews split on not-enabled — not-found, amending the Error Handling row, or the Active `400`; a uniform answer after the enabled check hides pre-launch rows under either | Detail: a phase-doc statement; the [Error Handling Standards](../standards/09-error-handling.md) table only if not-found; Standards 07 § Locale Resolution; the Frontend Architecture SDK sketch; the API Standards § Pagination example gains `locale` | P02d-4 (validators, OpenAPI parameters and responses), P02d-5 (the server SDK's header set) | +| G31 | Contract checks and the SDK surface. The committed OpenAPI snapshot's path, and how the contract suite proves it equals the served document; how the base copy is read; how the first run behaves; which `oasdiff` version and fail level, and which ADR-0024 rows that level detects (a tightened validator or a changed status may be invisible to any diff); what is uploaded on failure. The drift gate's source — the committed snapshot through `LEARNSTACK_OPENAPI`, or a running API — and its job. Whether an activated deferred job keeps its `if: vars.ENABLE_*` condition, given that GitHub treats a skipped required job as passing, or loses it as the integration job's did. And what the SDK surface becomes when regeneration makes `paths` non-empty: a hand-written transport over `paths` or a typed client library, the fate of `createClientSdk`, and whether the package root keeps re-exporting the server entry | A committed snapshot the contract suite asserts equal, diffed against the base ref's copy with `oasdiff` pinned; drift generated from the snapshot in the required `frontend` job; the condition removed on activation; a thin hand-written transport, and no root re-export of the server entry | Detail: [Testing Standards § API Contract Tests](../standards/06-testing.md#api-contract-tests), the API Standards § OpenAPI links, `ci.yml`, [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main) (its activation procedure follows the answer), [Frontend Architecture Standards § SDK](../standards/07-frontend-architecture.md#sdk); a package pin with its licence verdict if a client library is chosen | P02d-4 (the first operation, its snapshot and assertion, the drift gate, and the regenerated types the factories must compile against) | +| G32 | **The development-transport part of [Phase 02b](phase-02b-events-auth.md#the-decision-register)'s G12.** Which development hostnames and transport serve the two seed tenants — keep `*.learnstack.local`, with a hosts-file step and local TLS with a trust step, or move the seed hosts under `*.localhost`? What does a reviewer do between a clean checkout and both sites, and which carriers move, including host rows already on warm databases, where the host is the primary key and the seeder removes no mapping? | `*.localhost` over HTTP, provided the pass verifies in each browser the team uses and in CI's Chrome that both hosts resolve with no hosts entry and that a `Secure` cookie set on them is stored and returned; otherwise `*.learnstack.local` with local TLS and a named trust step. Tenants are never told apart by port: the effective host strips it | Detail: a dated phase-doc statement in [§ Host-based tenant resolution, end to end](#host-based-tenant-resolution-end-to-end), with `SeedData`, `scripts/seed.sh`, the README Quickstart, the `seed-tenant` and `local-dev-setup` skills and `apps/web`'s dev script and Next configuration in the same packet; Infrastructure Standards only if a TLS proxy publishes a port. No ADR | P02d-5 (the development transport, and the hop configuration a TLS proxy would change), or the first earlier packet that writes a seed-host literal outside `SeedData` | +| G33 | The server-rendering topology and its evidence. Where do Next.js and the API run relative to each other — the workstation loopback, containers, gated APISIX; which networks are trusted; how does one hop secret reach both processes; what is the server-only API origin — and the same for the CI job that renders the pages? What evidence discharges ADR-0036's "Phase 02d's browser test" and its matrix rows "a direct socket bypassing the hop" and "the Phase 02d anonymous two-host browser render", given that Testing Standards gate this phase on a human? And ADR-0036 § Consequences says the root refuses to start outside Development when the secret list is empty or short, while the shipped rule, in every mode, refuses a half-configured hop, a network entry that is not CIDR and a blank secret or one under 32 characters (characters, not bytes), and admits both lists empty: is that recorded or restored, and is non-development hop configuration this phase's or Phase 11's? | Both processes on the loopback, networks `127.0.0.1/32` and `::1/128`, one generated secret from a single source and never under `NEXT_PUBLIC_`, with networks and secrets arriving together (committing networks without secrets breaks every Development-environment fixture); the API called directly; a per-run secret in CI. Evidence: request-level hop tests as `learnstack_app` — no `X-Tenant-Id`, a non-hop peer, a wrong secret, a repeated header — beside the human walkthrough, with one review adding an automated browser smoke. Record the shipped startup rule; non-development hop configuration is Phase 11's | Contract: one dated [ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md) amendment if the evidence departs from the ADR's words, recording the startup rule, and carrying G34 if G34 changes the key or budget. Detail: `.env.example`, `apps/web/.env.local.example`, `appsettings.Development.json` or the demo recipe; a Standards 21 row for the hop runtime test; a Testing Standards § End-to-End Tests sentence only if ownership moves; a Phase 11 scope row | P02d-5 (the hop configuration and the fixture shape — in-process test hosts have no socket peer), P02d-7 (the CI part) | +| G34 | How does the pre-classification anonymous limiter treat a request arriving over the authenticated trusted hop? Every server-rendered call reaches the API from the renderer's peer, so every visitor of both tenants shares one partition, and one client sending random `Host` values through the renderer can starve both sites. The partition key, the budget and how the renderer derives any visitor identity it states — while unknown-host floods stay bounded before database work, and a direct peer is still limited per peer | The reviews differ: a visitor address stated over the hop, in a dedicated single-valued header or through `X-Forwarded-For` with the peer captured first, as ADR-0036 anticipates; a separate hop budget with limiting in the renderer; or an explicitly sized shared quota. A per-host ceiling as the only backstop multiplies under a random-`Host` flood | Contract: a dated ADR-0036 amendment if the hop changes the key or the budget, stating the new input's trust rule and whether it may be logged or audited; otherwise a phase-doc statement. Detail: API Standards § Request and Response Limits, [Security Standards § Rate Limiting](../standards/11-security.md#rate-limiting), the catalogue's per-peer rule. `P02b-0` re-verifies Phase 02b's G14 against the answer | P02d-5 | +| 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 | +| 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) | +| 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) | +| 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) | +| 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) | +| 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, a not-found page is this phase's own choice, and Phase 06 says this phase has none; 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 | +| 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 | +| 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 `<style>` element built from validated values; or a per-tenant stylesheet route — without constraining [Security Standards § HTTP Headers](../standards/11-security.md#http-headers)' nonce-based target before Phase 11 documents it per surface? | A `<style>` element built only from registry-validated values, emitting only the `--ls-*` vocabulary, never a `style` attribute; a nonce would force dynamic rendering (G37) | Detail: Frontend Architecture Standards § Tenant Branding for the mechanism; Frontend Architecture § Theming | P02d-6 (the layout's token injection; closes with G37's answer and G16's grammar) | +| G43 | Which accessibility checks on this phase's pages fail a build — route tests asserting `lang`, one `<main>`, the heading outline, a skip link and a descriptive title; `jsx-a11y` at error severity, where most of the shipped config's rules warn; or jsdom axe, which Testing Standards puts through Playwright in Phase 06? Is catalog → lesson a critical flow that needs [Accessibility Standards § Testing](../standards/16-accessibility.md#testing)' screen-reader smoke test? | Route tests in the `frontend` job for the checkable semantics; keyboard, focus and 320 CSS px reflow in the manual record; failing `jsx-a11y` with a planted companion. No review addresses the screen-reader question | Detail: a phase-doc statement; [Accessibility Standards § Tooling](../standards/16-accessibility.md#tooling) if lint severity or component axe becomes a rule; the lint configuration. Whether failing lint enforces the standard couples with G44; its index row changes only in the enforcing pull request | P02d-6 (page components and their tests) | +| G44 | The Lighthouse job. Does it activate in this phase, and on what full-stack harness — a migrated stack, a seed written as `learnstack_app`, the API as a process over the hop with a per-run secret, `next start` on a production build, both seed hosts reachable from CI's browser, a readiness and tenant-marker check before the audit — or does activation move to the phase that brings a browser harness? What does it assert: the URL set and which [Performance Standards](../standards/15-performance.md) row each page answers to (its LCP row names a landing page this phase does not ship); which rows, under which throttling preset; the 200 KB budget or the 250 KB forbidden line; hard or warning, run count and aggregation; categories, including the accessibility audit [Accessibility Standards § Tooling](../standards/16-accessibility.md#tooling) requires; the runtime ceiling, tool install and report destination; and what the standards index rows for Performance and Accessibility say afterwards? | Activate here, reusing the `make demo` entrypoint; catalog, course and lesson on both hosts plus the bilingual tenant's second locale; hard assertions on deterministic audits (script transfer size, CLS, the accessibility category or contrast over both palettes) and median-of-N timings as warnings, with TBT rather than a lab INP; tooling from the lockfile; reports to workflow artifacts only; Accessibility promoted if asserted hard, Performance kept Adopted as a pre-baseline check unless a hard budget makes it Active for what ships. The budget authority is settled: [Frontend Architecture Standards § Performance](../standards/07-frontend-architecture.md#performance) names Performance Standards | Contract: a phase-doc statement plus a committed Lighthouse configuration. Detail: `ci.yml`, CONTRIBUTING's activation edits, the `run-tests-locally` skill; Performance Standards only if a number or lab profile changes; the standards index rows and status headers in the enforcing pull request. Moving activation edits `ci.yml`, CONTRIBUTING and the skills, and leaves Phase 01's and 02a's records as written | P02d-5 (whether it activates, as an input to G32: the hosts must work in CI's browser), P02d-7 (the harness and assertions) | +| G45 | What does `make demo` start and guarantee on a clean checkout? The process model — a foreground supervisor over detached compose, or detached processes with a stop target; the web app's mode; how environment reaches host processes, given `dotnet run` reads no `.env` and nothing creates `apps/web/.env.local`; readiness waits before Phase 11's `/readyz`; stale-`.env` detection; re-run and stop behaviour; whether its seed step keeps `make seed`'s wait on every default-profile service and both Keycloak realms; and what it prints, including the bilingual tenant's second-locale URLs | The reviews differ on attached versus detached processes; both reject a destructive reset, wait on both processes, keep re-runs idempotent and have CI reuse the entrypoint | Detail: the `Makefile` targets with help lines and a dated phase-doc statement; README § Quickstart, `scripts/seed.sh`'s closing output and the `local-dev-setup` and `seed-tenant` skills in the same packet; Infrastructure Standards § Healthchecks if the seed's gate narrows. No ADR | P02d-7 (with G44, whose job invokes the same entrypoint; if G44's harness moves earlier, this row moves with it) | + +Gates that define one mechanism are answered against each other. Where their parts shape +different packets' code, the mechanism is split as +[Roadmap § Decision Timing](README.md#decision-timing) splits a gate: parts that block +the same packet close together in its pass, and a part that blocks a later packet closes +with or after the earlier one, never contradicting it: + +- G2 and G3 — the aggregate boundary decides where the state lives. +- G4 and G12 — the pin and the contract that resolves it. +- G7 — one ADR-0003 amendment — with G14's seed context in `P02d-2`. +- G8 and G9 — the Education chain's detail and the guards that hold it. +- G11, G14 and G15 — the writers pass. +- G16, G17 and G21 — the closed key set the PII answer rests on, and what a token may + point at. +- G18 and G19 — what the seed may publish. +- G22, G23 and G24 — the read internals. +- G25 through G31 — one public contract and one OpenAPI baseline. +- G32 through G37 — one hop; G33 and G34 share any ADR-0036 amendment. +- G39 and G40 — routing and strings. +- G44 and G45 — one stack entrypoint. + ### Education content — minimum viable -From [Phase 05](phase-05-education-learning-content.md), the two aggregates without -which there is nothing to render: - -- `Course` — tenant-owned, optionally organization-scoped, with a published / draft state - and an ordered lesson list. Its title, summary and per-locale slug live in - `course_translations`, not on the parent — see § Localization schema below. No - versioning, no programs, no cohorts. -- `Lesson` — ordered within a course, with a body rendered from a single built-in content - primitive; its title and per-locale slug likewise live in `lesson_translations`. Which - fields that primitive renders is driven by the tenant's own `TenantContentType`, so the - two tenants' lesson pages differ in **shape**, not only in copy — no `ContentEntry` - aggregate and no authoring surface, which are - [Phase 04](phase-04-cms-media-pages.md); the lesson body carries its field values - inline and validates against the declared schema on write. No - lesson items, no lesson item types, no completion semantics. Its foreign key to - `Course` is **composite on `tenant_id`** - (`FOREIGN KEY (tenant_id, course_id) REFERENCES courses (tenant_id, id)`): PostgreSQL - evaluates referential integrity with Row Level Security bypassed, so a single-column - key would let one tenant's lesson reference another tenant's course, invisibly. The - same rule applies to each translation satellite's key back to its parent. See - [Database Standards § Foreign keys between tenant-owned tables](../standards/05-database.md). - -Both carry `[TenantOwned]`, an EF global query filter, and the Row Level Security -policy set from the canonical template in -[Database Standards](../standards/05-database.md) — the permissive isolation policy plus, -where the table is org-scoped, the two `AS RESTRICTIVE` write guards. The isolation -machinery is exercised by **Education content** tables here, not only by the tenancy, -customization and audit tables Phase 02a applied it to — seventeen of them across four -migration chains. This is the first time the template meets a table a learner's page reads, -so it is the first chance to find out it is wrong under that load: treat a surprising query -result as a template bug, not a data bug. +From [Phase 05](phase-05-education-learning-content.md), the two entities without which +there is nothing to render. Which aggregate `Lesson` belongs to, and what its parent is, +is **G2**: the canonical model puts `Lesson` inside `CourseVersion` under a `Module` +([Domain Model § Learning Content](../architecture/02-domain-model.md#learning-content)), +and this phase ships neither. + +- `Course` — tenant-owned, optionally organization-scoped, with a publication state + (**G3**); its lessons are ordered per course. Its title, summary and per-locale slug + live in `course_translations`, not on the parent — see § Localization schema below. + Whether `Course` carries a reference to a band of the tenant's level taxonomy, and so + whether the catalog can show a level per course, is **G5**. No versioning, no + programs, no cohorts. +- `Lesson` — ordered within a course; its title and per-locale slug likewise live in + `lesson_translations`. Its body is drawn the way + [ADR-0018 § Renderer architecture](../decisions/0018-tenant-driven-customization-model.md) + draws every content type: a `TenantContentType` the tenant authored names a composite + in its `renderer_key`, and each declared field maps to a primitive + ([Tenant Customization Model § 2 and § 8.1](../architecture/32-tenant-customization-model.md)). + The two tenants' lesson pages therefore differ in **shape**, not only in copy. Which + composite draws a lesson and which primitives this phase implements are **G18**. There + is no `ContentEntry` aggregate and no authoring surface, which are + [Phase 04](phase-04-cms-media-pages.md)'s. The lesson body carries its field values + inline and is bound to the content-type revision it was validated against (**G4**); on + write it is validated through `IJsonSchemaValidator` + ([ADR-0043](../decisions/0043-customization-payload-validation.md)) against that + revision, and which revisions a writer may bind is **G12**. That check constrains + structure, not URL schemes, so which URLs a field may hold is **G19**. The read path + does no schema evaluation + ([Tenant Customization Model § 8.1](../architecture/32-tenant-customization-model.md)). + No lesson items, no lesson item types, no completion semantics. Its foreign key to its + parent, whichever parent **G2** records, is **composite on `tenant_id`** — for example + `FOREIGN KEY (tenant_id, course_id) REFERENCES courses (tenant_id, id)` if the parent + is `Course`: PostgreSQL evaluates referential integrity with Row Level Security + bypassed, so a single-column key would let one tenant's lesson reference another + tenant's parent row, invisibly. The same rule applies to each translation satellite's + key back to its parent. See + [Database Standards § Foreign keys between tenant-owned tables](../standards/05-database.md#foreign-keys-between-tenant-owned-tables). + +Course, Lesson and their translations are written only through Education commands on the +request path, as the customization rows and settings are +([§ Writers and seed](#writers-and-seed)). The read path trusts what the database holds, +so a row that bypasses the validating command is never checked again. Each command +writes one aggregate root +([ADR-0042](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md)). None +has an HTTP route: the authoring surface is Phase 05's, and until +[Phase 03](phase-03-identity-admin.md) reachability stands in for authorization, as +[the Tenancy permission matrix](../modules/tenancy/permissions.md) records. Publish +validation, the course publish flow and versioning are Phase 05's. + +Every table this phase creates carries `organization_id`, so each is **tenant-owned and +organization-scoped** (§ Table classes in +[Database Standards](../standards/05-database.md)): `[TenantOwned]` **and** +`[OrganizationScoped]`, a nullable `OrganizationId`, the organization-aware EF query +filter, and the full policy set from the canonical template — the permissive isolation +policy and the two `AS RESTRICTIVE` write guards — as +`Every_TenantOwned_Entity_HasFilterAndRlsPolicy` and +`Every_OrgScoped_Entity_HasOrgIdAndFilter` require. How the two satellites are mapped, +and so how those marker-enumerated sweeps reach them, is **G2**. The isolation machinery +is exercised by **Education content** tables here, not only by the tables Phase 02a +applied it to (the [standards index](../standards/README.md#honest-status-today) row for +Database Standards carries the count, and `P02d-1` updates it). This is the first time +the template meets a table a learner's page reads, so it is the first chance to find out +it is wrong under that load: treat a surprising query result as a template bug, not a +data bug — once the row's `organization_id` and the host's class have been checked +against +[Localization § Slugs and URLs](../architecture/12-localization.md#slugs-and-urls). A +tenant host that does not show an organization-scoped course is that rule working, which +is the misreading +[ADR-0036 § Consequences](../decisions/0036-tenant-resolution-trusted-inputs.md#consequences) +names. ### Localization schema — the one-way door this phase walks through [ADR-0008](../decisions/0008-localization-schema.md) is Accepted, it names `Course` and `Lesson` explicitly among the side-translation-table entities, and its Consequences say -plainly: *"Slugs are stored on the translation row, not on the parent."* Phase 02d creates -the first real tenant-owned content tables in the whole roadmap, so it is the phase that -either honours that decision or spends [Phase 04](phase-04-cms-media-pages.md) undoing it -with the global migration ADR-0008's Context exists to avoid — and no later phase budgets -for that move. There is no walking-skeleton exemption. +plainly: *"Slugs are stored on the translation row, not on the parent."* Phase 02d +creates the first real tenant-owned content tables in the whole roadmap, so it is the +phase that either honours that decision or spends +[Phase 04](phase-04-cms-media-pages.md) undoing it with the global migration ADR-0008's +Context exists to avoid — and no later phase budgets for that move. There is no +walking-skeleton exemption. -The parents hold non-translatable columns only: +The four tables take their shape from the canonical artefacts, not from a list kept +here: the parent and its policy set in [Database Standards](../standards/05-database.md) +and its § Foreign keys between tenant-owned tables, the satellite in +[§ Translation satellite tables](../standards/05-database.md#translation-satellite-tables), +and the split between translatable and non-translatable columns in +[Localization Standards § Pattern A](../standards/08-localization.md#pattern-a--side-translation-table-default-for-content-shaped-entities). +What those documents already require of this phase's tables: -- `courses` — `id`, `tenant_id`, `organization_id` (nullable), `slug_key` (a stable - authoring handle that **nothing routes on**), published / draft state, audit columns, - `row_version`, plus `UNIQUE (tenant_id, id)` so `lessons` can carry its composite - foreign key. -- `lessons` — `id`, `tenant_id`, `organization_id`, `course_id`, `sort`, state, audit - columns. - -The translatable columns live in `course_translations` and `lesson_translations`, each -keyed `PRIMARY KEY (<entity>_id, locale)` and each holding `title`, `summary` / `body`, -and the routable `slug`. Both satellites carry a real `tenant_id` column, a mirrored -`organization_id`, their own `ENABLE` + `FORCE ROW LEVEL SECURITY` with the full canonical -policy set, and a composite foreign key on `(tenant_id, <entity>_id)`. Row Level Security -is per table and is never inherited from a parent through a check constraint; a satellite -holding `title` and `slug` holds the content, so an unprotected satellite is a content -leak. +- The parents hold non-translatable columns only. `courses.slug_key` is a stable + authoring handle that **nothing routes on**, unique per tenant among live rows. The + satellites are keyed `PRIMARY KEY (<entity>_id, locale)`, each with a real `tenant_id` + and a mirrored `organization_id`. `course_translations` holds the title, the summary + and the routable `slug`; `lesson_translations` holds the title, the routable `slug`, + and whatever **G4** places there. +- `courses` **and** `lessons` each carry `UNIQUE (tenant_id, id)`, because a composite + foreign key references each of them. +- Every foreign key is composite on `tenant_id`, sets `ON DELETE` explicitly and has a + supporting index + ([Database Standards § Indexes](../standards/05-database.md#indexes)); a translation + satellite cascades from its parent. +- A closed-set state column is `text NOT NULL` with a `CHECK`. An entity deriving + `AuditableEntity<TId>` maps its audit, soft-delete and `row_version` columns, and a + unique index on a table carrying `deleted_at` is filtered `WHERE deleted_at IS NULL` + ([§ Soft Delete](../standards/05-database.md#soft-delete)). +- Each of the four tables has `ENABLE` + `FORCE ROW LEVEL SECURITY`, the full policy set + and the `organization_id` immutability trigger. Row Level Security is per table and is + never inherited from a parent through a check constraint; a satellite holding `title` + and `slug` holds the content, so an unprotected satellite is a content leak. +- Each table's grants are written by the migration that creates it + ([§ GRANT matrix](../standards/05-database.md#grant-matrix)). + +Every stored lesson body is also bound to the content-type key and `schema_version` it +was validated against, as +[Tenant Customization Model § 4](../architecture/32-tenant-customization-model.md) and +[the Customization spec's state diagrams](../modules/customization/README.md#state-diagrams) +require; where that binding and the body live is **G4**. + +What the corpus leaves open for these tables is registered and closes in `P02d-1`'s +decision pass: the aggregate and satellite mapping and the `sort` invariant (**G2**), +the publication values (**G3**), the body (**G4**), a level reference (**G5**), the +locale column's spelling (**G6**), the content slug's shape, foreign keys into Tenancy +and the exact grants (**G9**), and the catalog's order (**G10**). + +**The organization mirror is not enforced at insert.** The immutability trigger keeps a +correct mirror correct; it does not make a mirror correct at insert. Neither does the +composite foreign key, which carries no organization, nor `WITH CHECK`, which admits +`organization_id IS NULL` from any session. So nothing in the template forces a +satellite's — or a lesson's — `organization_id` to equal its parent's, and the same arm +lets an organization-scoped session insert a tenant-wide row it can then never update. +Both are **G7**. The shared immutability function reads `OLD.id`, which a satellite does +not have; that is **G8**. The slug constraint is `UNIQUE (tenant_id, locale, slug)` — flat across organizations, with `organization_id` deliberately excluded. Every column in that key is `NOT NULL`, so PostgreSQL's nulls-are-distinct rule cannot apply and the constraint rejects every -duplicate it is meant to reject. The full reasoning, including why adding `organization_id` -to the key is the tempting move that breaks it, is in -[Localization Standards § Pattern A](../standards/08-localization.md) and -[Localization § Slugs and URLs](../architecture/12-localization.md). +duplicate it is meant to reject. The full reasoning, including why adding +`organization_id` to the key is the tempting move that breaks it, is in +[Localization Standards § Pattern A](../standards/08-localization.md#pattern-a--side-translation-table-default-for-content-shaped-entities) +and [Localization § Slugs and URLs](../architecture/12-localization.md#slugs-and-urls). +The key is the same on `lesson_translations`: +[ADR-0008 § Decision](../decisions/0008-localization-schema.md) makes every slug unique +within `(tenant_id, locale)`, so two lessons in two different courses of one tenant +cannot hold one slug in one locale, and the course segment of the lesson route does not +scope the lesson slug. Under this table-wide key a translation row holds its slug from +the moment it is inserted, published or not; which command reports the collision is +**G11**. + +`tenant_locales` already exists — +[Phase 02a Packet 6](phase-02a-kernel-tenancy.md#delivery-record-packet-6) ships it and +already states it is required before any tenant-owned content table ships. The table +ships; the configuration does not. Neither seed tenant holds a row, and no command +writes one — `Tenant.AddLocale` and `SetDefaultLocale` have no caller outside tests. +[ADR-0042](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md) +requires locale rows to be written by their own command in their own transaction: the +one raising `tenancy.locale.write`, `(planned)` in +[the Tenancy audit matrix](../modules/tenancy/audit.md). This phase ships it (**G11**). +Case variants of one tag are one locale +([ADR-0018](../decisions/0018-tenant-driven-customization-model.md)'s 2026-09-04 +amendment), and how the shipped table spells a locale is in +[Localization § Locale Identifiers](../architecture/12-localization.md#locale-identifiers). +The satellites' spelling and parameter canonicalization are **G6**; whether a +translation may be written for, or a read resolve under, a locale absent from or +disabled in `tenant_locales` is **G13**. + +What this phase does **not** build: per-locale publish readiness as a workflow and the +`tenant_route_slugs` cross-table registry, which are +[Phase 04](phase-04-cms-media-pages.md#localization-and-slug-uniqueness)'s; any Studio +surface for entering translated values, which this phase seeds and which Phase 04 +registers as an open question in +[§ Admin Studio CMS Screens](phase-04-cms-media-pages.md#admin-studio-cms-screens); or +locale negotiation from `Accept-Language` for API-returned messages +([Error Handling Standards § Validation Errors](../standards/09-error-handling.md#validation-errors)), +which is also Phase 04's. What it builds is the shape, so that adding a locale later is +data — a `tenant_locales` row written through the Tenancy locale command, plus +translation rows — and not a migration. + +The public routes this phase adds fall under +[Localization Standards § URL Strategy](../standards/08-localization.md#url-strategy), +including its locale-less redirect. The +[standards index](../standards/README.md#honest-status-today) row for that standard +places the i18n runtime in Phase 04, so how much of it this phase builds is **G36**. + +### Writers and seed -`tenant_locales` already exists — [Phase 02a Packet 6](phase-02a-kernel-tenancy.md) ships -it and already states it is required before any tenant-owned content table ships. This is -the phase that makes good on that. +The seed stays on the request path, as Phase 02a's is: every row it writes goes through +a command, and every command it sends crosses the pipeline, is classified in its +module's audit catalogue and registered in both composition roots. -What this phase does **not** build: a translation editor, per-locale publish readiness as a -workflow, the `tenant_route_slugs` cross-table registry, or locale negotiation from -`Accept-Language`. Those are [Phase 04](phase-04-cms-media-pages.md). What it builds is the -shape, so that adding a locale later is an `INSERT` and not a migration. +- **Education commands** write courses, lessons and their translations, and publish + courses. Course published / unpublished is baseline MUST-class in + [Audit Coverage Standards](../standards/18-audit-coverage.md). The command set and + re-run behaviour are **G11**; the Customization contract the lesson writer calls, and + which revisions it may bind, are **G12**. +- **Tenancy commands** raise `tenancy.locale.write` (SHOULD) and `tenancy.setting.write` + (MUST), both `(planned)` today. The setting command lands only once **G17** is + answered, because no command writing `tenant_settings` precedes the `[PiiSensitive]` + decision ([Phase 03](phase-03-identity-admin.md)); its keys and value checks follow + **G16**, and it performs the contrast check + [Accessibility Standards § Color and Contrast](../standards/16-accessibility.md#color-and-contrast) + requires before a theme token is saved, with the outcome G16 records. +- **The Education module spec**, `docs/modules/education/` (`README.md`, `audit.md`, + `permissions.md`), lands with the first Education aggregate in `P02d-1`: + `Every_Module_With_An_Aggregate_Or_A_Request_Has_A_Matrix` fails without it, and + `education` joins the list `Every_Module_Has_An_AuditCoverage_Matrix` pins in the same + change. `permissions.md` is a forward declaration on + [the Tenancy precedent](../modules/tenancy/permissions.md). +- **Composition.** Each new request type is registered in its module's + `IAuditCatalogSource`, and the Education Application assembly joins both roots' + MediatR handler scans. No structural test holds the scan: a missing assembly fails as + "no handler for request" at the call site. +- **The seeder** gains an act and an ownership check per new write — a conflict on an + act with no check stops the run, so a missing check fails only the second run — and + every seeded row is written at the scope **G14** names. Scope comes from the seeder's + announced context, never from a command field + ([Security Standards § Forbidden](../standards/11-security.md#forbidden)), and a + tenant-wide row can be updated only from a context that announces no organization. The + seeder announces its context today by calling `IUnitOfWork.SetTenantContextAsync` + itself, which ADR-0040's closed setter set does not list: **G15**. -### Read API — three endpoints +Each tenant's **own** customization data — the table in § Genericity proof — is authored +through the Customization module's commands, as Phase 02a's seed is, and presented as +**G18** decides; its branding tokens are tenant settings written through +`tenancy.setting.write`. The surface a tenant admin writes them through is +[Phase 06](phase-06-renderer-admin-studio.md)'s. Phase 02a seeds both tenants with the +same built-in `card` content type and `plain` taxonomy, owned per tenant: that proves +the rows are isolated, not that they differ, and making them differ is this phase's. + +### Customization read path + +The design is decided and this section links it rather than restating it. +[Tenant Customization Model § 8.2](../architecture/32-tenant-customization-model.md#82-cache-strategy) +owns the cache families, their keys and the generation rule; +[ADR-0043](../decisions/0043-customization-payload-validation.md) § 6 removes the +compiled-validator cache and § 7 places the counter; and +[the Customization spec](../modules/customization/README.md)'s § Primary read flow is +the record this phase rewrites from "Not implemented". + +Obligations already imposed: + +- Other modules read Customization only through `Customization.Application.Contracts`, + which names SharedKernel types only + ([ADR-0010](../decisions/0010-cross-module-communication.md)). Education's lesson + write needs that contract before any anonymous read does. +- Every MediatR request type added is classified + ([ADR-0044](../decisions/0044-audit-write-path.md)). +- Keys are composed through `CacheKey`, tenant first, and each family is registered in + [Infrastructure Stack Standards](../standards/20-infrastructure-stack.md)' cache cheat + sheet and the adapter's `cache.name` mapping together. +- The generation counter is domain state, never a cache entry, and correctness never + lives in the cache. +- `InMemoryCacheService` is L1 only; the L2 tier arrives on ADR-0035's trigger in Phase + 11. +- A loader that announces `app.tenant_id` on its own transaction is a new out-of-band + setter, owing a dated ADR-0040 amendment and a + [Security Standards § The out-of-band setters](../standards/11-security.md#the-out-of-band-setters) + row. +- The read path does not validate + ([Tenant Customization Model § 8.1](../architecture/32-tenant-customization-model.md)). + +Open: the contract (**G12**), how the projection loads and stays correct (**G22**), and +the display fallback it applies (**G24**). + +**The typed settings accessor** over `tenant_settings`, which Phase 02a left to its +first reader, lands here too. Under Row Level Security a `tenant_settings` read returns +tenant-wide rows plus the caller's organization's rows, so its result depends on +`app.organization_id`, and the policy's tenant-scope read has no carrier until +[Phase 03](phase-03-identity-admin.md) +([Security Standards § Tenant Context](../standards/11-security.md#tenant-context)); +resolution follows the organization-over-tenant fallback. The declared eager +invalidation, `learnstack.tenancy.settings`, is booked to Phase 02b in the Tenancy spec, +and this phase's settings writes come from the seed, which runs as its own process, so +nothing it writes reaches a cache inside the API process. The accessor's name, keys, +loader and staleness bound, if any, are **G23**. + +### Read API From [Phase 05](phase-05-education-learning-content.md), through the API conventions -established in [Phase 02a Packet 4](phase-02a-kernel-tenancy.md): +established in +[Phase 02a Packet 4](phase-02a-kernel-tenancy.md#delivery-record-packet-4), the +Education reads — a working proposal whose paths, shapes and count are **G25** and +**G26**: -- `GET /api/v1/courses?locale={locale}` — the tenant's published courses, for the catalog - page, cursor-paginated per - [API Standards § Pagination](../standards/04-api-design.md#pagination). +- `GET /api/v1/courses?locale={locale}` — the published courses the host's resolved + scope can see, for the catalog page, cursor-paginated per + [API Standards § Pagination](../standards/04-api-design.md#pagination). Which rows a + tenant host and an organization host each serve is decided in + [Localization § Slugs and URLs](../architecture/12-localization.md#slugs-and-urls). - `GET /api/v1/courses/{slug}?locale={locale}` — course detail with its lesson list. - `GET /api/v1/courses/{slug}/lessons/{lessonSlug}?locale={locale}` — lesson detail. -`locale` is **required**; a request without it is a 400, not a silent default. With -per-locale slugs a slug does not identify a course on its own — the same string can be one -course's Turkish slug and another's English slug inside one tenant — so locale is an +The pages also need per-host site data none of these returns: the tenant's enabled and +default locales, branding tokens, taxonomy display values and the lesson content type's +field list. How that data reaches the renderer, and which pages consume which read, is +**G25**, answered before the OpenAPI baseline is stored. Whatever ships, every public +read is anonymous and is a `[PublicSurface]` request type with its row in +[API Standards § Public surface](../standards/04-api-design.md#public-surface), which +owns the set. + +`locale` is **required**; a request without it is a `400`, not a silent default. With +per-locale slugs a slug does not identify a course on its own — the same string can be +one course's Turkish slug and another's English slug inside one tenant — so locale is an identifying input, not a preference, and a defaulted locale would let one URL resolve to different courses as a tenant's locale set changes. It lives in the query string rather -than the path because [Localization Standards](../standards/08-localization.md) puts locale -in the path of **public URLs**, and the public URL is the renderer's -`/{locale}/courses/{slug}` route, which supplies this parameter. Any cache in front of -these endpoints keys on the query string. - -Slug resolution is **exact** on `(tenant_id, locale, slug)`. The fallback chain applies to -display fields after the entity is resolved and **never** to the slug lookup: a course with -no `en` translation has no `en` URL, and requesting one is a 404. For the same reason a -lesson with no translation in the requested locale is omitted from the course's lesson list -rather than rendered as a link that cannot resolve. - -**The catalog list is the first query in the platform that mints a cursor.** So it -decides the cursor's payload — API Standards leave the shape to whoever mints it — and it -is where a cursor its minter cannot read becomes a **400** `validation_failed` naming -`cursor`, not a 500. Phase 02a's completion criteria carried that clause with nothing to -mint a cursor; it lives here, with the query that does. The two detail reads are -addressed by slug and paginate nothing. - -All three are anonymous, resolve tenant and organization from the host, return RFC 7807 -Problem Details on failure, and flow through the MediatR pipeline returning -`Result<T>`. Nothing bypasses the pipeline — the tenant-context and audit machinery has -its first real caller here. - -### Public renderer — two pages +than the path because [Localization Standards](../standards/08-localization.md) puts +locale in the path of **public URLs**, and the renderer route serving that URL supplies +this parameter; the route set is **G25**'s. No header changes which resource a URL +identifies. This section answers only a missing locale and a slug untranslated in the +requested locale; every other locale input is **G30**, and until it closes the Active +`unsupported_locale` row in +[Error Handling Standards](../standards/09-error-handling.md) binds. +[Frontend Architecture Standards § Locale Resolution](../standards/07-frontend-architecture.md#locale-resolution) +still names `X-Locale` as the API carrier, which G30 reconciles; `Accept-Language` in +the [Frontend Architecture](../architecture/14-frontend-architecture.md) SDK sketch is +the message-language input, not the identifying locale. + +Because locale identifies the response, any cache in front of these reads, or in front +of the pages that render them, keys on the full path and query **and** on the tenant the +request resolves to, and the organization where applicable +([Security Standards § Multi-Tenant + Organization Isolation Review Checklist](../standards/11-security.md#multi-tenant--organization-isolation-review-checklist)). +On the anonymous server-side path both tenants send the same request line, and the host +reaches the API only as a forwarded header +([ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md)), so a key built from +the URL alone serves one tenant's response to the other. Whether these reads are +cacheable at all is **G27**; how the renderer renders and which Next.js caches it may +use is **G37**. + +Slug resolution is **exact** on `(tenant_id, locale, slug)` +([Localization Standards § Pattern A](../standards/08-localization.md#pattern-a--side-translation-table-default-for-content-shaped-entities)). +The display fallback chain, computed once per request +([ADR-0008](../decisions/0008-localization-schema.md)), applies to display fields after +the entity is resolved and **never** to the slug lookup: a course with no `en` +translation has no `en` URL, and requesting one is a `404`. For the same reason a lesson +with no translation in the requested locale is omitted from the course's lesson list +rather than rendered as a link that cannot resolve. Localization Standards § Locale +Model and Localization § Fallback Rules state different chains, and the shipped +`LocalizedText.Resolve` narrows one subtag at a time and ends at the first authored +value; which chain is the record is **G24**, and a per-tenant fallback configuration is +Phase 04's. + +**Publication is not a Row Level Security term.** The canonical policy filters on tenant +and organization only, so the database does not keep an unpublished course or lesson off +these anonymous reads; the application does. **G29** decides which rows each read serves +— by course state, by lesson state where **G3** gives lessons one, by the lesson's +membership in the course its URL names, and by soft deletion — and what a caller +receives for a row it may not see. Settled, and linked rather than restated: another +tenant's row is a `404` +([Security Standards § Error Messages](../standards/11-security.md#error-messages)); an +organization-scoped row is served only on its own organization's host; and the public +renderer renders published content only +([Frontend Architecture Standards § Public Site Renderer](../standards/07-frontend-architecture.md#public-site-renderer)), +with draft preview in Phase 06. + +**The catalog list is the first query in the platform that mints a cursor.** Its +decision pass answers **G10**: the default order and anything that order needs in the +schema, the cursor's payload and version, what it binds, whether it carries an integrity +tag, its direction, which list parameters the endpoint binds, and where it is decoded. +API Standards leave the shape to whoever mints it, and the answer's detail lands there. +This is also where a cursor its minter cannot read becomes a **400** `validation_failed` +naming `cursor`, not a `500`. Phase 02a's completion criteria carried that clause with +nothing to mint a cursor; it lives here, with the query that does. Whatever a cursor +carries, tenant and organization come from the resolved context, never from the cursor +([API Standards § Tenant Context](../standards/04-api-design.md#tenant-context)). The +detail reads are addressed by slug; whether the course detail's embedded lesson list is +bounded, and which fields it carries, is **G26**. + +**The public surface.** A host-only context reaches only request types marked +`[PublicSurface]`, and a refusal answers the unknown-host `404`. Every anonymous request +type this phase ships carries the marker, and its row in the API Standards table lands +in the same commit, because the rule reads the table in both directions. Every one is +registered in the audit catalogue, `Off` included; none may be MUST-class +`read-sensitive`; no write command this phase ships carries the marker; and each +anonymous endpoint carries `[AllowAnonymous]` with the one-line reason +[Permissions Standards § HTTP endpoints](../standards/19-permissions.md#http-endpoints) +requires. Their audit class, permitted methods, write ban and the control that keeps a +controller on the pipeline are **G28**. + +The reads resolve tenant and organization from the host and return Problem Details in +the one shape of +[API Standards § Error Responses](../standards/04-api-design.md#error-responses) — a +host that resolves no tenant and a host-only request to an unmarked type answer the same +`not_found` — and every response is a purpose-built contract, never an EF entity +([API Standards § Forbidden](../standards/04-api-design.md#forbidden)). Each endpoint's +action dispatches its request through `ISender` and maps the result with +`ToActionResult()` +([ADR-0032 § Sub-decision 6](../decisions/0032-exception-handling-logging-and-observability.md)); +§ Risks says what enforces that today. The pipeline is not new here: every Tenancy and +Customization command the seeder sends already crosses `TenantContextBehavior` and +`TransactionBehavior` ([Packet 7](phase-02a-kernel-tenancy.md#delivery-record-packet-7)) +and writes its MUST rows through `AuditLogBehavior` +([Packet 9](phase-02a-kernel-tenancy.md#delivery-record-packet-9)), and Packet 7's +request-level suite already drives a marked probe query through the host-only ceiling. +What is new: the first business endpoints, the first production request types a +host-only context reaches, and the first production queries the audit catalogue +classifies. What the first contract freezes under +[ADR-0024](../decisions/0024-api-versioning-policy.md) is **G26**, **G27** and **G31**. + +### Public renderer From [Phase 06](phase-06-renderer-admin-studio.md), in `frontend/apps/web` under the -`(public)` route group: +`(public)` route group, the renderer serves at least a catalog page and a lesson page, +and a visitor reaches a lesson from the catalog through rendered links. Whether that +path passes a course page, which consumes the course-detail read, or the catalog carries +bounded lesson links is **G25**, answered in the same pass as the site data because one +option changes the catalog response. -- Course catalog page — lists the tenant's published courses through the catalog list. +- Course catalog page — lists the published courses the host's resolved scope can see, + through the catalog list. - Lesson page — renders a lesson body. -Both are Server Components fetching through the typed SDK. Both read the tenant's -branding tokens, level taxonomy and lesson-body `TenantContentType` from customization -data — the lesson page renders the field list the tenant declared, not a fixed one. -Layout, typography and colour come from `TenantSetting`, not from a hard-coded theme, read -through the typed tenant and organization settings accessor this phase adds: Phase 02a -shipped `tenant_settings` and left the accessor to its first reader, which is this -renderer. +Both are Server Components fetching through the typed SDK. The level taxonomy and the +lesson content types come from the Customization module, and the lesson page renders the +fields the content-type revision each lesson is bound to declares, not a fixed set. A +tenant may hold more than one content type — Phase 02a's built-in `card` sits beside the +tenant's own, in the state **G14** records — so the renderer never infers a lesson's +type by convention or by tenant. The stored schema carries no display order — JSON +Schema gives `properties` none, and `tenant_content_types.json_schema` is `jsonb`, which +does not keep key order — and nothing gives a field a label in each of the tenant's +locales: **G18**. How a field the implemented subset does not draw renders, how an +`x-taxonomy` value displays and which band a course shows are **G41** and **G5**. + +The visual identity comes from branding tokens that are tenant settings, per +[Frontend Architecture Standards § Tenant Branding](../standards/07-frontend-architecture.md#tenant-branding), +not from a hard-coded theme. They are read through the typed tenant and organization +settings accessor this phase adds in Tenancy (§ Customization read path) and reach the +page only through a public read. The accessor is internal: +[the Tenancy permission matrix](../modules/tenancy/permissions.md) grants no anonymous +role a `TenantSetting` read, so no `[PublicSurface]` request returns settings by key, +and an anonymous response carries only the tokens **G16** names, for the tenant and +organization resolution produced. Which tokens exist, what each accepts, whether any +layout option is among them, and whether `tenancy.white_label_branding` governs any of +it are **G16**; how the tokens reach the document is **G42**; the per-organization +override and the token merge remain Phase 06's. + +The lesson page renders tenant-authored values to anonymous visitors, under rules +already in force: `dangerouslySetInnerHTML` only in the sanitised-HTML primitive +([Frontend Coding Standards § Forbidden](../standards/03-frontend-coding.md#forbidden), +enforced by lint); outbound URLs validated against an allow-list before rendering +([Frontend Architecture Standards § Security](../standards/07-frontend-architecture.md#security)); +markdown, if rendered, through an allowlist-sanitising library +([Security Standards § XSS & Output Encoding](../standards/11-security.md#xss--output-encoding)); +theme variables in the `--ls-*` vocabulary. No Content-Security-Policy exists before +[Phase 11](phase-11-production-hardening.md#security), so how the renderer handles +output is the only control on this surface. The URL and markup policy is **G19**. + +These are the platform's first public pages, and +[Accessibility Standards](../standards/16-accessibility.md) bind them from their first +render: the target is WCAG 2.2 AA, never a later improvement. Testing Standards assigns +the automated axe run to Phase 06; nothing else in the standard is deferred. Document +language and direction follow the route's locale per +[Localization Standards § SEO](../standards/08-localization.md#seo) and +[§ Right-to-Left](../standards/08-localization.md#right-to-left), so the scaffold root +layout's fixed `lang="en"` and platform `<title>` do not survive this phase. What +evidence fails a build is **G43**; the language of fallback-resolved fields is **G24**. + +Open here, each a register row: the UI strings and the i18n library (**G39**); route +files, page states, catalog pagination and chrome (**G40**); the rendering mode +(**G37**); whether the path sets cookies or loads cross-origin subresources (**G21**); +and the test set (**G38**). ### Host-based tenant resolution, end to end -The full path from [Phase 02a Packet 7](phase-02a-kernel-tenancy.md), exercised for -real: an inbound request's `Host` header resolves through `platform_host_to_tenant` to -a `(tenant_id, organization_id?)` pair, the singleton `ITenantContextAccessor` is -written and the transient `ITenantContext` resolves from it on every access, the -transaction sets `app.tenant_id` / `app.organization_id` with `SET LOCAL`, and Row -Level Security filters every read. - -Two hosts are registered in local development, one per seed tenant. How a browser -reaches them is decided here, in the decision pass of the packet that wires them. -[Phase 02b](phase-02b-events-auth.md)'s session cookie is `Secure`, which a browser does -not send back over plain HTTP to the seed hosts' `*.learnstack.local` names, and that -phase's gate **G12** chooses between local TLS for those hosts and hosts under -`localhost`. Moving the hosts afterwards would rewrite the seed, the host mappings and -this phase's tests, so that part of G12 closes with the hosts this phase wires, per -[Roadmap § Decision Timing](README.md#decision-timing). +The full path from +[Phase 02a Packet 7](phase-02a-kernel-tenancy.md#delivery-record-packet-7), exercised +for real — on the path a Server Component takes: + +1. The browser's `Host` reaches Next.js. Both pages are Server Components, so the API + call is made by the Next.js server, and the `Host` the API sees is not the visitor's. +2. The server SDK states the visitor's host to the API over the trusted hop + ([ADR-0036 § Effective host and the trusted hop](../decisions/0036-tenant-resolution-trusted-inputs.md#effective-host-and-the-trusted-hop)), + whose § Consequences names `frontend/packages/sdk/src/server.ts` as the only frontend + place that sets the hop headers. +3. The API resolves that effective host through `platform_host_to_tenant` to a + `(tenant_id, organization_id?)` pair, the singleton `ITenantContextAccessor` is + written and the transient `ITenantContext` resolves from it on every access, the + transaction sets `app.tenant_id` / `app.organization_id` with `SET LOCAL`, and Row + Level Security filters every read. +4. Whatever the edge resolves is for rendering only, and any `X-Tenant-Id` is an + assertion + ([Frontend Architecture Standards § Tenant Resolution](../standards/07-frontend-architecture.md#tenant-resolution)). +5. In this phase's default topology the loopback bind, not the secret, is the boundary + (ADR-0036 § Consequences). + +None of that path is wired from the renderer's side yet — § What this phase inherits +lists the scaffolds this phase replaces. The topology and its evidence are **G33**, the +limiter in front of it **G34**, the SDK transport **G31** and **G35**, the middleware +and entry behaviour **G36**, and the rendering mode **G37**. + +Two hosts are registered in local development, one per seed tenant: +[Phase 02a Packet 7](phase-02a-kernel-tenancy.md#delivery-record-packet-7) wrote one +`platform_host_to_tenant` row per seed tenant on the `*.learnstack.local` names +`SeedData` carries, and a browser reaches them today only after a hosts-file edit. How a +browser and `make demo` reach them — the development hostnames and transport — is the +part of [Phase 02b](phase-02b-events-auth.md)'s gate **G12** that closes here, because +the session cookie that phase sets is `Secure`; it is **G32**, Accepted in the decision +pass of the packet its Blocks cell names, per +[Roadmap § Decision Timing](README.md#decision-timing). Moving the hosts later rewrites +the seed literals, the URLs `scripts/seed.sh` prints, the README Quickstart, the +`seed-tenant` and `local-dev-setup` skills and the demo URLs, and leaves stale host rows +on warm workstation databases. Neither answer changes how the API classifies or resolves +a host +([ADR-0036 § Effective host and the trusted hop](../decisions/0036-tenant-resolution-trusted-inputs.md#effective-host-and-the-trusted-hop)). ### Genericity proof Both seed tenants — the English school and the yoga studio from -[Phase 02a Packet 7](phase-02a-kernel-tenancy.md) — render their own catalog and lesson -pages from their own customization data: +[Phase 02a Packet 7](phase-02a-kernel-tenancy.md#delivery-record-packet-7) — render +their own catalog and lesson pages from their own customization data: | Tenant | `TenantLevelTaxonomy` | `TenantContentType` | Branding | |---|---|---|---| | English school | CEFR levels (A1 … C2), key `cefr` | `grammar-topic` | Its own tokens | | Yoga studio | Difficulty levels (Foundation … Advanced) | `asana-pose` | Its own tokens | +Level keys follow +[ADR-0018's key rule](../decisions/0018-tenant-driven-customization-model.md#2026-09-04--customization-keys-and-item-keys-are-lowercase): +`A1` … `C2` and `Foundation` … `Advanced` are display names, not keys. **G14** records +the yoga taxonomy's key, which tenant carries two locales, and the state of the built-in +`card` / `plain` beside each tenant's own. + The two sites differ in taxonomy, content shape, copy and visual identity. The binary, the schema and the query paths are identical. If a code path has to branch on which tenant it is serving, [ADR-0018](../decisions/0018-tenant-driven-customization-model.md) is not being honoured and the branch is the bug. -### Explicitly not in this phase +The proof has two halves, each with a Completion Criterion. First, the data differs in +shape: the taxonomies in key and band set, and the lesson-body content types in property +set. Second, no production code branches on which tenant it serves, which **G20**'s +mechanism checks. Seeing the difference in a browser is the reviewer's confirmation, not +the evidence. Page composition is not tenant data in this phase: it becomes data with +[Phase 04](phase-04-cms-media-pages.md)'s `TenantPageBlock` rows, and this phase's pages +are hard-coded route segments. -Named so that no reader has to guess, and so no later phase can assume it was done -here: +### Local development, demo and CI -| Capability | Owning phase | -|---|---| -| Authentication, sessions, login | [Phase 02b](phase-02b-events-auth.md) | -| Identity domain, roles, permissions, invitations | [Phase 03](phase-03-identity-admin.md) | -| CMS editing, page builder, media library | [Phase 04](phase-04-cms-media-pages.md) | -| Translation editor, per-locale publish readiness workflow, `tenant_route_slugs` registry | [Phase 04](phase-04-cms-media-pages.md) | -| Course versioning, programs, lesson items, completion rules | [Phase 05](phase-05-education-learning-content.md) | -| Admin Studio, navigation, SEO, full block registry | [Phase 06](phase-06-renderer-admin-studio.md) | -| Enrollment, learner portal, progress tracking | [Phase 07](phase-07-enrollment-learner-portal.md) | -| Search — the `ITenantSearch` port and its PostgreSQL default | [Phase 04](phase-04-cms-media-pages.md) | -| Search — the Meilisearch adapter behind that port | [Phase 09](phase-09-billing-integrations-analytics.md) | -| Live classroom | [Phase 08c](phase-08c-classroom.md) | -| Billing | [Phase 09](phase-09-billing-integrations-analytics.md) | -| Hub, entitlement gating | [Phase 02c](phase-02c-hub-foundation.md) | +- **`make demo`** is the single command a reviewer runs on a clean checkout. It brings + the development stack up, seeds through the request path as `make seed` does, starts + the API and the web app, and prints where both tenant sites answer. **G45** fixes the + rest; the host step a browser needs is **G32**'s. Both sites render anonymously once + seeded — the baseline Phase 02b's criterion re-checks with Keycloak stopped. +- **The contract checks.** The OpenAPI breaking-change check is a committed snapshot per + live major, diffed per + [API Standards § OpenAPI](../standards/04-api-design.md#openapi) and + [Testing Standards § API Contract Tests](../standards/06-testing.md#api-contract-tests); + the SDK drift gate is + [Frontend Architecture Standards § SDK](../standards/07-frontend-architecture.md#sdk)'s; + both activate with the first public operation (**G31**). +- **The Lighthouse budget** measures the two tenants' public pages against + [Performance Standards](../standards/15-performance.md), which + [Frontend Architecture Standards § Performance](../standards/07-frontend-architecture.md#performance) + names as the budget owner, and runs the accessibility audit + [Accessibility Standards § Tooling](../standards/16-accessibility.md#tooling) + requires. Whether it activates here, on what harness, and what it asserts are **G44**. + Lighthouse is neither an end-to-end suite nor WCAG conformance. +- **Activation and required checks.** Each deferred job activates through the edits + [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main) + lists, with its skip condition per **G31**. The two required-check edits CONTRIBUTING + records as outstanding — requiring `backend integration (Testcontainers)` and the + `meta` check under the name it reports — are this phase's no later than its first + Education migration, because + [Git Workflow Standards § Checks](../standards/14-git-workflow.md#checks) makes + tenant-isolation tests a merge condition and the Education isolation suites run in + that job. +- **Frontend tests.** The case set and the frontend skip refusal are **G38**. + +### Architecture tests + +[Architecture Tests Catalogue](../standards/21-architecture-tests-catalogue.md) is the +canonical reference, and no copy of it lives here. Every catalogue row whose Phase names +02d is Implemented and green by exit. Two rules this phase's tables are the first new +subjects of are stated in the corpus and enforced by nothing mechanical today: Database +Standards' rule that every foreign key between tenant-owned tables is composite on +`tenant_id` (`Every_Foreign_Key_Has_A_Supporting_Index` checks index support, not +shape), and [ADR-0008](../decisions/0008-localization-schema.md)'s Consequence that "the +migration linter rejects ad-hoc per-locale columns", whose four rules Localization +Standards leaves to a reviewer. **G8** decides whether either becomes a catalogue row. +`PublicSurface_Marker_Set_Is_Enumerated` gets its first subjects here while two of its +catalogued legs are not implemented (**G28**), and the tenant-branching check is +**G20**'s. ## Deliverables -- `Course` and `Lesson` aggregates in `LearnStack.Modules.Education`, with migrations, - EF configurations, query filters and RLS policies. -- `course_translations` and `lesson_translations` satellites, each with its own - `tenant_id`, mirrored `organization_id`, `ENABLE` + `FORCE ROW LEVEL SECURITY` and full - policy set, composite foreign key on `(tenant_id, <entity>_id)`, and - `UNIQUE (tenant_id, locale, slug)`. -- Three anonymous read endpoints with OpenAPI documentation and generated SDK clients, - taking `locale` as a required parameter — the cursor-paginated catalog list and the two - detail reads. -- The typed tenant and organization settings accessor over `tenant_settings`. -- Two public route segments in `frontend/apps/web`, tenant-branded, rendering from - customization data. -- Two hosts wired to two tenants in `platform_host_to_tenant`, resolvable in local - development. -- Seed data extending [Phase 02a Packet 7](phase-02a-kernel-tenancy.md)'s two tenants - with a course and a handful of lessons each. One of the two tenants has **two** enabled - locales with genuinely different slugs per locale, so the schema is exercised rather - than merely declared; the other has one. -- Each tenant's **own** customization data — the table in § Genericity proof — authored - through the Customization module's commands, as Phase 02a's seed is; and its own - branding tokens in `tenant_settings`, written through a Tenancy `tenancy.setting.write` - command — MUST-class and `(planned)` in [the Tenancy audit matrix](../modules/tenancy/audit.md) - — so the seed stays on the request path. The surface a tenant admin writes them through - is [Phase 06](phase-06-renderer-admin-studio.md)'s. Phase 02a seeds both tenants with - the same built-in `card` content type and `plain` taxonomy, owned per tenant: that - proves the rows are isolated, not that they differ, and making them differ is this - phase's. -- A demo script (`make demo` or equivalent) that boots the stack, seeds, and prints the - two URLs. -- Frontend tests covering host-to-tenant resolution and `(public)` route rendering — - the first *substantive* tests in `apps/web`. Packet 3b removed the - `--passWithNoTests` placeholder and left one render test plus the harness - (`vitest.config.ts`, jsdom, Testing Library) for this phase to build on. There is no - authenticated route to test against yet; that split arrives with - [Phase 02b](phase-02b-events-auth.md)'s session. -- **Two CI jobs activate here.** [Phase 01](phase-01-repository-tooling.md) scaffolded - three deferred jobs against the phase each expected to unblock it; two of them - unblock now, earlier than that phase predicted. (Phase 01 gated them with - `if: false`; they are now gated on unset `vars.ENABLE_*` repository variables, - because actionlint rejects a constant condition.) - - **OpenAPI breaking-change check** — Phase 01 expected Phase 03, because that was - where the first real `/api/v1/*` endpoint was going to replace `/healthz`. The read - endpoints above are that first endpoint. - - **Lighthouse budget** — Phase 01 expected Phase 04, because that was where the - first content-bearing public page was going to ship. The catalog and lesson pages - above are that first page, and they are the right ones to hold a budget against: - they are what a visitor actually loads. - - The third placeholder, the integration-test job, activated earlier still — in - [Phase 02a Packet 6](phase-02a-kernel-tenancy.md), with the first Docker-bound test: the - four-role provisioning suite. +**Schema and isolation** + +- `Course` and `Lesson` in `LearnStack.Modules.Education`, in the aggregate shape **G2** + records, with migrations, EF configurations, query filters and Row Level Security + policies, including each lesson body's content-type binding and its storage (**G4**). +- `course_translations` and `lesson_translations`, each with its own `tenant_id`, + mirrored `organization_id`, `ENABLE` + `FORCE ROW LEVEL SECURITY` and full policy set, + a composite foreign key on `(tenant_id, <entity>_id)` with its supporting index, and + `UNIQUE (tenant_id, locale, slug)` with the slug shape **G9** settles. +- The organization immutability trigger on all four tables, with a function that works + on a table without `id` (**G8**); an index supporting every Education foreign key; the + insert-time organization control and its test, and the organization write-guard + behavioural test extended to all four tables, asserting `UPDATE`, `DELETE` and + `INSERT` with the outcome G7 records (**G7**). +- The four tables' rows in + [Database Standards § GRANT matrix](../standards/05-database.md#grant-matrix), written + by the migration that creates each table, with the exact-grant test and + `SchemaFixture.KnownTables` extended (**G9**). +- The structural guards **G8** settles, each Implemented with a companion that plants + its violation. +- Education isolation tests connected as `learnstack_app`, at schema level and at + request level, over the four tables, across tenants and across organizations, per + [Testing Standards § Tenant Isolation Tests](../standards/06-testing.md#tenant-isolation-tests) + and the + [Security Standards isolation checklist](../standards/11-security.md#multi-tenant--organization-isolation-review-checklist). + The packet that ships the first request-level Education case also replaces that + Testing Standards section's authenticated, id-addressed example with the shape these + tests take, or links the shipped class. The Phase 02a request-level suite + (`TenantIsolationHttpTests`) and `SeederTests` are updated to the grown seed without + loosening any assertion. +- The Education module spec under `docs/modules/education/` (`README.md`, `audit.md`, + `permissions.md`), with its state diagram and the eligibility rule **G29** records, + and `education` added to `Every_Module_Has_An_AuditCoverage_Matrix`'s pinned list in + the same change. + +**Writers and seed** + +- The Education write commands (**G11**) and the Tenancy commands raising + `tenancy.locale.write` and `tenancy.setting.write`; each removes its matrix row's + `(planned)` marker and registers its catalogue entry in the same change + ([Audit Coverage Standards](../standards/18-audit-coverage.md)). +- The `Customization.Application.Contracts` surface — an interface or a query, as + **G12** records — through which Education obtains the content-type revision a lesson + body is validated against and bound to. +- An Education `IAuditCatalogSource` registered in the API and seeder composition roots, + and the Education Application assembly in both roots' handler scans, with a + request-level test dispatching each Education request through the API root and + `SeederTests` through the seeder root. +- The seeder's acts, ownership checks, ordering and contexts (**G14**, **G15**), and + each tenant's own content type, taxonomy, locales, branding, courses and lessons. + Every seeded row is written at the scope G14 names, and the rows the isolation + criteria read exist. One of the two tenants has **two** enabled locales with genuinely + different slugs per locale, so the schema is exercised rather than merely declared; + the other has one. +- The `[PiiSensitive]` record on `TenantSetting.Value` (**G17**) and the branding token + contract (**G16**), with its detail in Frontend Architecture Standards § Tenant + Branding. + +**Read internals** + +- The customization read path per Tenant Customization Model § 8.2: the Customization + contract, classified wherever it adds request types, and the generation-keyed + projection with its cache families (**G22**). Each family is registered in the + Infrastructure Stack Standards cheat sheet, the adapter's `cache.name` mapping and the + Observability Standards metrics family list. The module spec's § Primary read flow is + rewritten with its diagram and budget. +- The typed tenant and organization settings accessor over `tenant_settings`, internal + to the backend, with its interface name and glossary headword, key composition, loader + and staleness bound, if any (**G23**), recorded in the cheat sheet when G23 caches + settings, and in the Tenancy spec's event row and budget. + +**Public API and contract checks** + +- The anonymous public reads — the Education reads in § Read API and whatever **G25** + adds — each with its `[PublicSurface]` marker, its row in API Standards § Public + surface, its audit-catalogue registration in the class **G28** records, + `[AllowAnonymous]` with its reason, OpenAPI documentation, and `locale` a required + parameter on the Education reads. Each response is a purpose-built contract whose + fields, embedded list, locale metadata, validator stance and documented Problem + Details responses are **G26** and **G27**'s. +- The two unimplemented legs of `PublicSurface_Marker_Set_Is_Enumerated`, each with a + companion that proves it can fail, and the catalogue entry's Status updated (**G28**). +- The `@learnstack/sdk` types regenerated from that document + (`src/generated/schema.d.ts`) and the SDK surface that compiles against them + (**G31**). +- The CI gates the corpus assigns here: the OpenAPI breaking-change check over a + committed snapshot and the SDK drift gate (**G31**), each activated through + CONTRIBUTING's edits and shown able to fail, and the Lighthouse budget likewise if + **G44** activates it in this phase; and the two outstanding required-check edits, made + no later than the first Education migration merges. + +**Server-rendering path** + +- The two seed host rows Phase 02a Packet 7 wrote, reachable from a browser and from + `make demo` over the hostnames and transport **G32** records, with every committed + carrier of the seed host names moved in the same packet if G32 moves them. +- The server SDK transport in `frontend/packages/sdk/src/server.ts`, stating the + visitor's host over the trusted hop (**G35**). +- `frontend/apps/web/src/middleware.ts` no longer answering the scaffold's `503`, + writing the raw host as a tenant id or carrying TODOs that assign the work to Phase + 02a; what it carries, removes and answers follows **G36**. +- A development trusted-hop configuration the API and the Next.js server agree on, with + every variable it adds listed in `.env.example` + ([Infrastructure Standards](../standards/12-infrastructure.md)); placement per + **G33**. +- The hop runtime evidence **G33** chooses, and the dated ADR-0036 amendment if G33 or + **G34** requires one. +- The anonymous limiter's treatment of trusted-hop traffic as **G34** decides, including + a decision to leave it unchanged; any rule change ships with its carriers — API + Standards § Request and Response Limits, Security Standards § Rate Limiting and the + catalogue entry `Anonymous_Requests_Are_Rate_Limited_Per_Peer` — and a companion case. + +**Renderer** + +- The `(public)` routes **G25** and **G40** fix in `frontend/apps/web`, tenant-branded, + rendering from customization data, with link navigation from catalog to lesson. +- The lesson-body renderer: the composite **G18** chooses, registered in `composites.ts` + under the containment rule; primitive components for the implemented subset, in the + home **G41** chooses; the render-time outbound URL check. +- Theming token injection in the `(public)` layout per **G42**, emitting only the + `--ls-*` vocabulary. +- The UI message layer and catalogue at the location **G39** records. +- Frontend tests for the code this phase adds, with the case set **G38** decides. The + harness exists (`vitest.config.ts`, jsdom, Testing Library), with Packet 3b's + placeholder page test and Packet 10's `src/test/lint-rules.test.ts`. These tests do + not prove host-to-tenant resolution — the API resolves the host with authority, and + the isolation suites prove it as `learnstack_app`. There is no authenticated route to + test against yet; that split arrives with [Phase 02b](phase-02b-events-auth.md)'s + session. +- The required `frontend` job refusing a Vitest run that reports a skipped or todo case + in every workspace package that carries tests, proven with a planted skip, with + `No_Architecture_Test_Is_Skippable` updated in the same pull request (**G38**). +- The accessibility review + [Accessibility Standards](../standards/16-accessibility.md#process) requires of every + new screen, recorded in the renderer pull request's description: a manual keyboard + walkthrough of each public page on both hosts, a contrast check of each seeded token + set, and the accessibility confirmation. Which of it is also asserted by a test or + lint rule is **G43**'s. +- The tenant-branching check **G20** selects, Registered in the first pass that uses it + and Implemented with its companion before exit. + +**Demo, CI and exit** + +- `make demo` and its stop behaviour per **G45**, with README § Quickstart, the closing + output of `scripts/seed.sh` and the `local-dev-setup` and `seed-tenant` skills updated + in the same packet, and `run-tests-locally` if G44's job changes how Lighthouse runs. +- The full-stack Lighthouse job and its committed configuration, if **G44** activates it + here. +- The [standards index](../standards/README.md#honest-status-today) rows this phase + makes stale — the notes on Frontend Coding, Frontend Architecture and Accessibility + Standards, the table count on Database Standards — each updated in the pull request + that lands the code, plus any Performance or Accessibility status change G44 settles, + made with its enforcer. The prose counts the Education chain makes stale — + `SchemaFixture.KnownTables`' summary and the `add-integration-test` skill's note — are + updated in `P02d-1`. +- The delivery record at exit names the Education model shipped, the preservation + obligations G2 recorded, the seed fixtures Phase 05's migration must carry, and the + premises this phase moved that `P02b-0` re-verifies: the first `/api/v1` endpoints, + G32's hosts, G34's limiter answer, G14's seed context and the + `learnstack.tenancy.settings` event G23 leaves to Phase 02b. ## Completion Criteria -- Opening host A shows the English school's catalog with CEFR levels and its branding; - opening host B shows the yoga studio's catalog with its own difficulty taxonomy and - branding. One binary, one database, one schema. -- Clicking through to a lesson on either host renders that tenant's lesson body. +Each criterion below is observable. Either a named test in a named CI job can pass or +fail on it, or it is marked **manual** because +[Testing Standards § End-to-End Tests](../standards/06-testing.md#end-to-end-tests) +leaves the browser check to a human; the packet's delivery record then records the +commit, the command run, each URL opened and what it showed. Criteria whose shape +depends on an open gate name that gate, and are written in full when it is Accepted. + +**Two sites in a browser** + +- Opening host A shows the English school's catalog, and host B the yoga studio's. Each + shows its own branding with `NullEntitlementProvider` registered, which **G16** (g)'s + answer must keep true, and level bands drawn from its own tenant's + `TenantLevelTaxonomy`, each band named from the item's `display_name` in the requested + locale. Where a band is observed, and what renders for a band the resolved revision + does not declare, are **G5**'s. **Manual.** +- On either host, a visitor starting at the catalog reaches a lesson through rendered + links alone, without typing a URL, and the lesson renders that tenant's lesson body; + every link the catalog and any intermediate page render resolves on the same host and + locale (**G25**). **Manual**, plus a render test in the `frontend` job asserting the + links match routes that exist. +- Both hosts are answered by the same API build and the same `apps/web` build against + one database and one schema; no build or deployment exists per tenant. **Manual.** - The two lesson pages render **different field sets**, driven by each tenant's - `TenantContentType` — not the same template with different strings. A reviewer can see - the difference without reading the seed data. -- Requesting tenant B's course slug on tenant A's host returns 404 — not tenant B's - course, and not a 500. -- The two-locale tenant serves the same course at two different slugs under two locale - prefixes, and adding a third locale is an `INSERT` into `tenant_locales` plus - translation rows — no migration. -- Two courses in one tenant cannot both hold one `(locale, slug)` pair; the second insert - is rejected by the database, and the rejection also fires when one of the two is - tenant-wide and the other organization-scoped. An integration test attempts both, - connected as `learnstack_app`. -- Requesting a slug in a locale the course has no translation for returns 404, and a - lesson with no translation in the requested locale does not appear in the course's - lesson list. -- A cursor the catalog list did not mint — malformed, truncated or tampered — returns - 400 naming `cursor`, not 500. -- The isolation integration tests from - [Phase 02a Packet 7](phase-02a-kernel-tenancy.md) still pass, now with real - `Course` and `Lesson` rows rather than fixtures, and still run as `learnstack_app`. -- No code path branches on tenant identity. A reviewer can grep for the two tenant - slugs and find them only in seed data and tests. -- `make demo` on a clean checkout produces both working sites. + `TenantContentType` — not the same template with different strings — each field in the + order, and under a label in the route locale, that **G18** defines, asserted against + the content type as read back from `tenant_content_types`; on the two-locale tenant + every rendered field label is in the route locale under both locale prefixes, and no + property identifier appears as visible label text (`frontend` job, plus the manual + record). The two seeded lesson-body schemas differ in property count or in at least + one property's JSON type, not only in key names, and the two taxonomies differ in key + and item keys (`SeederTests`, `backend integration`). The same lesson rendering code, + fed each tenant's content type, renders different field sets with no tenant input, and + a companion that swaps the schemas swaps the rendered fields (`frontend` job; **G38**, + **G41**). +- Neither seeded lesson page, served by the running stack, renders a fallback or + placeholder block (the full-stack job, **G44**; otherwise **manual**). +- `make demo` on a clean checkout — no `.env`, no `frontend/apps/web/.env.local`, no + compose volumes, and no host configuration beyond **G32**'s host step — leaves both + tenant sites answering at the addresses it prints, each showing its own tenant's + content. Its re-run and stop behaviour are asserted in the shape **G45** fixes, and + the delivery record names the browser and operating system the check ran on. + **Manual**, or the full-stack job if **G44** builds one. +- If **G32** moves the seed hosts, re-running the documented seed or demo path against a + database seeded with the old host names leaves both new hosts resolving to their + tenants, and the step G32 records says what happens to the old + `platform_host_to_tenant` rows (a `SeederTests` case against a pre-seeded old host + row, `backend integration`; otherwise **manual**, in the blocking packet's delivery + record). +- After seeding, with the compose `keycloak` service stopped, the catalog and a lesson + page on both hosts still render anonymously — the baseline Phase 02b re-checks. A step + in the full-stack job if **G44** builds one; otherwise **manual**. + +**Schema and isolation** — Education schema-level and request-level suites in +`LearnStack.Tests.Integration`, connected as `learnstack_app`, in `backend integration` + +- Two courses in one tenant cannot both hold one `(locale, slug)` pair; the second + insert is rejected by the database, and the rejection also fires when one of the two + is tenant-wide and the other organization-scoped. Two lessons in two different courses + of one tenant cannot hold one `(locale, slug)` pair. Two courses in one tenant can + hold one slug in two different locales, and each resolves to its own course. +- Read directly, each of `courses`, `lessons`, `course_translations` and + `lesson_translations` returns no foreign tenant's rows, tenant-wide ones included, and + no sibling organization's rows; each has a foreign-`tenant_id` write case, which + `Every_WithCheck_Policy_Has_A_Foreign_Write_Case` already demands once the chain is + applied. With the EF query filter removed, a read of each satellite under tenant A + returns no tenant B row. +- Under tenant A, inserting a lesson whose parent key (the parent **G2** records) names + tenant B's row, or a translation whose parent names tenant B's row, is refused by the + foreign key. +- A child row whose organization differs from its parent's — including a + null-organization child planted under another organization's course and an + organization child under a tenant-wide parent — is refused by the mechanism **G7** + records, while matching inserts in the same test succeed. An organization-scoped + session's `INSERT` of a tenant-wide row into `courses` and `tenant_settings` has the + outcome G7 records, and + `An_Organization_Scoped_Session_Cannot_Write_A_Tenant_Wide_Row`, which today asserts + only `UPDATE` on `tenant_settings`, asserts `INSERT`, `UPDATE` and `DELETE` of a + tenant-wide row on both tables. On each of `courses`, `lessons`, `course_translations` + and `lesson_translations`, an organization-scoped session's `UPDATE` or `DELETE` of a + tenant-wide row affects no row or is refused, and its `INSERT` has the outcome G7 + records. +- Changing `organization_id` on a row of each of the four tables raises the immutability + error from a session the restrictive guard admits (**G8**). +- `Every_Foreign_Key_Has_A_Supporting_Index` passes with the four tables in its swept + set, and `Unique_Indexes_On_Soft_Deletable_Tables_Exclude_Deleted_Rows` with each of + them that carries `deleted_at` under the mapping **G2** records; the grant-matrix test + lists exactly the Education privileges § GRANT matrix records (**G9**). Each guard + **G8** registers is Implemented, and its companion fails against a planted violation. + Every migration chain, Education included, reverses to an empty schema. +- Before the first Education migration merges, **G2**'s row is closed, the Education + spec's data model names `Course`'s and `Lesson`'s aggregate roles, and the lessons + foreign key's delete rule matches that role. **Manual**, recorded in `P02d-1`'s + delivery record. +- A publication-state value outside the `CHECK` set **G3** records is rejected by the + database as `learnstack_app`. +- `docs/modules/education/audit.md` exists, and + `Every_Module_Has_An_AuditCoverage_Matrix` names `education` (`backend` job). + +**Writers and seed** — `backend integration`, as `learnstack_app`, unless named +otherwise + +- After the seed, each seed tenant has one committed MUST-class audit row per published + course and per `tenant_settings` write, whose entity id matches the row (**G3**, + **G11**, **G17**). The `tenancy.setting.write` row's before and after for `Value` + match the recorded `[PiiSensitive]` answer. +- No Tenancy or Education matrix row whose command ships still carries `(planned)`, and + `Every_Shipped_Request_Is_Registered` and `CompositionRootCatalogueTests` pass for + both roots. +- A second seed run exits 0 and changes no Education, `tenant_locales` or + `tenant_settings` row; each seeded row carries the `organization_id` **G14** names; + each seed tenant has exactly one enabled default locale, and the bilingual tenant two + enabled locales. +- The Phase 02a request-level suite (`TenantIsolationHttpTests`) still names the exact + rows it expects: its customization cases assert `BeEquivalentTo` an enumerated + per-tenant set that includes the built-in `card` / `plain` rows. `SeederTests` asserts + exact counts and generations, recomputed from the seed **G14** records, with each + value derived in the writers delivery record. The suite's raw `tz` and `theme` + `tenant_settings` inserts still succeed beside the seeded settings (**G14**). +- With migration history unchanged, a request-level test adds a third locale — a + `tenant_locales` row through the Tenancy locale command plus translation rows through + the Education commands — and the host answers `200` at the new slug while the existing + locales' responses are unchanged (**G11**, **G13**, **G22**). +- An Education translation write for a locale absent from, or disabled in, the tenant's + `tenant_locales` behaves as **G13** records, and a refused write commits no + translation row. After the seed, no `course_translations` or `lesson_translations` row + stores a `locale` spelling other than the one **G6** (a) records. +- A lesson write whose body fails its bound revision returns `validation_failed` with + details keyed by JSON pointer and persists no lesson or translation row; any audit row + the catalogue owes records outcome `failed`. A write binding an absent revision, or + one that exists only in the other tenant, is refused indistinguishably, as is a + revision **G12** declares ineligible. The English tenant's seeded lessons are bound to + `grammar-topic` and the yoga tenant's to `asana-pose`, none to the built-in `card`. +- A duplicate slug written through the command **G11** names returns + `Result.Fail(business_rule_violation)` and does not throw. A slug outside the shape + **G9** settles is refused by every layer G9 names. +- A publication-state transition **G3** forbids answers + `Result.Fail(business_rule_violation)` from the command, does not throw, and writes + nothing (`backend integration`, plus a command or aggregate unit case in `backend`). +- A branding value outside **G16**'s grammar — `red;}body{background:url(//x)}`, a + string containing `</style>` — never reaches a rendered document, and a refused write + commits neither a `tenant_settings` row nor an audit row. A key outside the contract, + and a failing contrast pair, behave as G16 records — never a silent save. +- A branding key written as an organization-scoped `tenant_settings` row behaves as + **G16** (e) records, checked request-level through a host mapped to the organization: + refused at write, committing neither a `tenant_settings` row nor an audit row; stored + while the page served on that host is unchanged; or, if G16 (e) applies it, shown on + that host's page and on no other host's. +- If **G19** includes a write-time rule, a lesson write holding a disallowed URL is + refused with details naming the JSON pointer, and nothing is written. +- Whatever **G18** refuses at save — which may include a presentation entry naming a + property absent from `properties`, a malformed label map or a field shape it does not + admit — returns `validation_failed` with details naming the JSON pointer and writes no + `tenant_content_types` row (`backend`). +- If **G5** stores a band reference, a course or lesson write naming a taxonomy or band + key the tenant does not declare, including one only the other tenant declares, returns + `validation_failed` naming the field and writes no row. +- Under **G15**'s answer, `SeedRunner` either no longer announces its own tenant context + or appears in ADR-0040's setter table, and a planted caller fails any scan G15 adds + (`backend`). + +**Read internals** — `LearnStack.Tests.Integration` and `LearnStack.Tests.Unit` + +- With the API running, publishing a successor content type or taxonomy for one tenant + through its command changes what the next read returns for that tenant, within the + bound **G22** states and without a restart; the other tenant's read is unchanged. + After seeding, a breaking successor of the English tenant's lesson content type leaves + every stored binding unchanged, and the lesson read still presents the original + revision's fields (**G12**). +- A second warm read for the same tenant and generation issues no definition-set query; + a publish that rolls back after its generation bump leaves reads on the prior + definitions, including after a later committed bump reaches the same number; warm + reads alternated between the two tenants return only that tenant's definitions, + including for the built-in `card` both hold (**G22**). +- None of the public reads, and no definition or settings read, invokes + `IJsonSchemaValidator`. +- If **G23** caches settings, then after that cache is warmed in the yoga studio's + default organization, a read in the sibling organization's context and a tenant-wide + read each return exactly what an uncached read in that context returns. After a + settings write, the accessor's value changes within the bound G23 states — + immediately, under a counter or with no cache — recorded where G23 places it. +- Every cache family this phase adds appears in the cheat sheet, the Observability + Standards family list and the `cache.name` mapping, and none of its keys reports + `other` (`InMemoryCacheServiceTests`). +- For a request in an enabled `tr-TR`, a label authored only under `tr` resolves as the + chain **G24** records, and a value missing in both the requested and the default + locale renders the terminal state G24 records. +- The out-of-band setter guard stays green: either the setter table is unchanged, or the + ADR-0040 amendment and Security Standards row land with the loader. + +**Public read API** — request-level tests through the real middleware chain, as +`learnstack_app`, in `backend integration`, unless named otherwise + +- In one test, a course slug that exists only in tenant B returns `200` with that course + on tenant B's host and `404` on tenant A's host, and a `(locale, slug)` held by one + course in each tenant returns each host's own course, compared by id. A status alone + proves nothing here: a route miss and an unknown host also answer `404` with the same + `not_found` code, and the same route's `200` is what rules them out. +- In the same test, a course returns `200` at its slug in a locale it is translated into + and `404` for that slug under an enabled locale it has no translation for; a lesson + with no translation in the requested locale is absent from the course's lesson list + while a lesson that has one is present; a course with no translation in locale L is + absent from the catalog in L and still listed in its other locales. +- On the organization host, a sibling organization's course is absent from the catalog + and its slug returns `404`, while a tenant-wide course and its own organization's + course are present; on the tenant host, an organization-scoped course is absent and + `404`, while a tenant-wide course is present. Each translation satellite, read on the + request's own connection with no query filter, returns only the resolved scope's rows. +- A draft course is absent from the catalog, and its detail slug and every lesson + requested under it answer per **G29**; a lesson of published course X resolves under X + and answers per G29 under published course Y's slug, and Y's lesson list never + contains it; where **G3** gives lessons a state, a draft lesson in a published course + is absent from the lesson list and no response carries its title or slug; a + soft-deleted course or lesson answers per G29. Where G29 requires one body, the + comparison is against a slug that exists nowhere, with `instance` and `correlationId` + masked. +- Each of the reads without `locale` returns `400` `validation_failed` naming `locale`, + never a `500` or a defaulted locale; sending a different `X-Locale` or + `Accept-Language` with the same `locale` parameter returns the same resource; a + malformed, over-length, empty, repeated, non-canonical or not-enabled locale — + including a disabled locale holding translations — returns the answer **G30** records, + never a `500`, and exposes no translated field. +- A cursor value the catalog list cannot read — malformed or truncated — returns `400` + `validation_failed` naming `cursor`, never `500`. Walking `nextCursor` with a `limit` + smaller than the host's visible course count returns each visible course exactly once, + in **G10**'s order. No cursor value, whatever it carries, returns a row outside the + requesting host's visible set; which further classes also answer `400` — an edited + payload, or one minted on another host or organization, under another locale, sort or + filter — is recorded by G10, and each recorded class is a named test case. The catalog + operation in the committed OpenAPI document publishes only the list parameters **G10** + records; if it publishes `sort`, an unpermitted field answers `400` + `lockey_sort_field_not_allowed`. Where G10 records that a rejected cursor is refused + before `TransactionBehavior`, a test asserts that no transaction opens for it. +- A course's embedded lesson list returns the same order on every read, including for + any sort values **G2**'s invariant permits to tie. +- A lesson whose bound `(key, schema_version)` revision cannot be resolved (a fixture + removes it) answers as **G12** records — never a `500`, and never another revision's + fields. +- A stored band that the resolved taxonomy revision does not declare yields the state + **G5** records, never a `500` and never another tenant's band name. +- On each seed host, each shipped anonymous read returns `200` under a host-only + context, and the same request on an unknown host returns the unknown-host `not_found` + body. The API Standards § Public surface table names exactly the anonymous request + types this phase ships, and `PublicSurface_Marker_Set_Is_Enumerated` passes over that + non-empty set (`backend`). Every `[PublicSurface]` type is registered with the class + **G28** records, and `PublicSurface_Requests_Are_Never_ReadSensitive` runs over the + production set. The permitted-methods and tenant-owned-write legs are Implemented, + each companion failing on its planted probe, and if **G28** (c) adds a runtime + control, a marked probe whose handler attempts a tenant-owned write is refused by it + with nothing committed. The control G28 chooses for controllers is shown to fail on a + planted offender, or § Risks says review is the only control. Each anonymous endpoint + carries `[AllowAnonymous]` with a one-line reason (**manual**, listed in the delivery + record). +- If **G28** records `Off`, serving the anonymous reads on both hosts adds no + `audit_log` rows. +- Every anonymous response — a catalog `200`, a detail `404`, a bad-cursor `400` and an + unmapped-host `404` — carries the directive **G27** decides, and the validator stance + G27 records holds. The identical request sent over the trusted hop with host A, then + B, then A returns each tenant's own courses every time, with every cache the API + registers enabled. +- The committed OpenAPI document lists exactly the public operations the register + decides, each with `locale` required on the Education reads, and a planted extra + operation fails the contract test; no schema reachable from a `[PublicSurface]` + operation carries a property on **G26**'s deny-list, with a failing companion; each + operation documents the Problem Details statuses G26 names, and a host test observes + each documented `4xx` (`LearnStack.Tests.Contract`, `backend`). The course detail's + embedded lessons carry only the fields G26 names, in the order it records, and no + lesson body; if G26 sets a bound, a seeded course above it returns the bound and its + truncation indicator. If G26 adopts alternates, the bilingual tenant's course and + lesson detail reads return the other enabled, translated locale's slug and never a + disabled or untranslated one, checked against a seeded translation in a disabled + locale; each fallback-capable field **G24** makes report its resolved locale does so. +- Every anonymous operation in the committed OpenAPI document is referenced from + `apps/web` — a `(public)` route or the middleware — so no public read ships without a + consumer; a companion fails on a planted unreferenced operation (`frontend`; **G25**). +- The contract suite fails when the served `/openapi/v1.json` differs from the committed + snapshot, shown with a companion; the breaking-change job fails on a planted change + the pinned tool reports at the chosen level, and the delivery record lists which + ADR-0024 rows that level detects and which it cannot see (**G31**). The SDK drift gate + fails on a planted stale `schema.d.ts` and passes on the phase's final commit, and the + regenerated `paths` is non-empty with `apps/web` typechecking against it; no + `(public)` page or component declares a hand-written response type for a public read, + and removing from the document a field a page renders fails typecheck, shown with a + companion (`frontend`). +- Each activated check has no `(deferred …)` suffix in `ci.yml`, appears in + CONTRIBUTING's required list and in the live required contexts, and its skip condition + is handled per **G31**; `backend integration (Testcontainers)` and + `meta (compose + commit hygiene + link audit)` are live required contexts before the + first Education migration merges, and CONTRIBUTING's "Two required-check edits are + outstanding" note and its warnings on those two entries are removed in the same + change. **Manual**: the dated + `gh api repos/HodeTech/LearnStack/branches/main/protection/required_status_checks` + output, and for each pull request from `P02d-1` on, a comparison of those contexts + against the pull request's check rollup, in the delivery record. +- `Handlers_Return_Result` stays green with Education's handlers counted, and an + Education handler changed to return a raw DTO fails the build (`backend`). + +**Server-rendering path** + +- On both seed hosts the catalog renders through the Next.js server while no + server-to-API call carries `X-Tenant-Id`, each host showing only its own tenant's + markers; an `X-LearnStack-Host` naming host B leaves the effective host at the + request's own host when sent from a peer outside the trusted networks, with a wrong or + missing secret, or repeated; with the Next.js server's secret removed or mismatched, + neither host renders tenant content. In the form **G33** and **G38** (c) choose: + request-level hop tests in `backend integration` and the manual walkthrough, plus any + automated smoke G38 adopts. +- Neither the built client assets nor rendered HTML contain the hop secret value, and no + `NEXT_PUBLIC_` variable carries it; only `frontend/packages/sdk/src/server.ts` sets + the hop headers on a request to the API, and importing the server entry from a Client + Component fails the build or lint; the server SDK's API origin comes from server + configuration, never from the inbound `Host` or any request header (`frontend` job, + each with a failing companion; **G35**). +- Client-supplied `x-tenant-id`, `x-organization-id`, `x-locale`, `x-learnstack-host` + and `x-learnstack-hop-secret` never reach an SDK call's outbound headers unchanged; + under `next build && next start`, no `(public)` route and not `/api/healthz` answers + the scaffold's `503`; `/` and a locale-less path, a disabled or malformed locale + segment, and a platform or unknown host answer as **G36** records — on a seed tenant + whose default locale is not `en`, never `en` (`frontend` job). Once `P02d-5` merges, + no comment under `frontend/apps/web/src` assigns unbuilt host wiring to Phase 02a or + cites a `resolve-host` endpoint + (`git grep -nE "resolve-host|(wired|lands|plug in) in Phase 02a|Phase 02a (wires|resolves|resolution)" frontend/apps/web/src` + is empty, recorded in that packet's delivery record). +- With the development hop configuration committed, the API starts under the committed + Development configuration with no `.env` present, and every Development-environment + fixture stays green (`backend`, `backend integration`); every hop variable is listed + in `.env.example`. +- A non-hop peer is still limited per socket peer, getting `429` with `Retry-After` over + budget, and rotating `X-Forwarded-For` or any header **G34** introduces buys it + nothing (`RateLimitingHttpTests`). Through the whole middleware chain and one + trusted-hop peer, anonymous traffic is partitioned and budgeted as G34 decides, and a + single source sending novel `Host` values through the hop is refused before more + resolver lookups than G34's budget, with the unknown-host cache within its cap. +- If **G35** places it here, a server-to-API call carries a `traceparent` whose trace id + matches the API's Problem Details `correlationId`. +- On a production build, one `(public)` path requested on host A, then B, then A returns + each tenant's own markers every time; the build reports every tenant-varying + `(public)` route in the rendering mode **G37** decides, and the frontend source holds + no cache option G37 forbids, shown to fail on a planted one; after a customization + write bumps a tenant's generation, a page on that host reflects it within G37's + freshness and the other host's page is unchanged. + +**Renderer** — `frontend` job, unless named otherwise + +- Each rendered public page's `<html lang>` equals the locale in its path, on both + locales of the bilingual tenant, and `dir` follows that locale. +- Each tenant-host page has one `<main>`, no skipped heading level, a skip link and a + descriptive `<title>` rather than a fixed platform string, and zoom is not disabled — + route tests or the manual record, as **G43** picks; whatever `jsx-a11y` findings G43 + makes failing, a planted violation in a public route proves `pnpm -r lint` fails on + it. +- Catalog to lesson works keyboard-only on both hosts with focus always visible, each + page reflows at 320 CSS px, and the text and UI-component colours of both seeded token + sets meet + [Accessibility Standards § Color and Contrast](../standards/16-accessibility.md#color-and-contrast). + **Manual**, in the renderer pull request's accessibility record. +- On the two-locale tenant, every visible platform-authored string resolves through the + message layer **G39** records, in both locales; Localization Standards § Strings in + Code, Localization § UI String Catalogue and the `add-i18n-key` and + `add-frontend-route` skills name one catalogue path, and it exists. At exit, either + ADR-0027 is Accepted with its decisions-index row targeting 02d, or the standards + index still records the i18n runtime carve-out and Phase 04 owns ADR-0027. +- Tenant B's course URL requested on host A returns an HTTP `404` page in the browser, + not a `200` with error text and not a `500`; a tampered `?cursor=`, an API `429` and + an unavailable API each yield the state **G40** records; an empty catalog renders a + state distinct from the error state. +- A page whose course or lesson carries a band the resolved revision does not declare + renders the state **G5** records, without throwing and without another tenant's name. +- Field values containing `<script>`, an `<img onerror>` fragment and a `javascript:` + URL render inert, and a `data:` or `http:` URL is rendered only if **G19** admits it. + A lesson whose content type names a composite the frontend does not register renders + the fallback block and logs a warning without throwing. A lesson whose bound + content-type revision cannot be resolved renders **G12**'s page state without throwing + and shows no other revision's fields. A field outside the implemented subset renders + **G41**'s fallback carrying neither the raw value nor HTML built from it. +- A branding token value containing `;`, `}`, `url(` or `</style>` never reaches + rendered HTML, and the theme is emitted only as `--ls-*` custom properties (**G42**, + **G16**). Public page responses conform to **G21**'s answer on `Set-Cookie` and + cross-origin subresources, checked mechanically. +- A non-branding setting present for a tenant (the fixture's `tz` row) appears in no + `[PublicSurface]` response body and in no rendered HTML, on either host + (`backend integration`, and the smoke run if **G38** (c) adopts one). +- A change that adds `it.skip`, `describe.skip` or `it.todo` to any Vitest file under + `frontend/` fails the required `frontend` check, and each predicate in **G38**'s set + has a test that fails when the predicate is inverted. The page-level two-host claim is + asserted by a CI job that fails on a `503` or an error page, or recorded at exit as a + dated manual check naming the commit, as G38 decides. No committed text of this phase + calls a Vitest, Lighthouse or HTTP check end-to-end or proof of host-to-tenant + resolution. + +**Genericity and governance** + +- No production code branches on which tenant it serves. The mechanism is **G20**'s: + registered in [the catalogue](../standards/21-architecture-tests-catalogue.md), + Implemented and green in a required check before exit, asserting that it read + non-empty backend and frontend subjects, with a companion that plants a violation in + each and shows it failing. + [`Core_Modules_HaveNo_DomainSpecific_Names`](../standards/21-architecture-tests-catalogue.md#core_modules_haveno_domainspecific_names) + does not stand in for it: that rule reads names and strips literals. +- No Education type, member, column, key or DTO name carries a forbidden domain term, + and Education references Customization only through its `Application.Contracts` + assembly (`backend`). +- If **G44** activates it in this phase, the Lighthouse job runs real steps under a name + without "(deferred to Phase 02d)", audits the URL set G44 decides on both hosts, runs + the accessibility audit Accessibility Standards § Tooling requires, cannot pass on an + error page, a `503` or the other tenant's page, and a planted regression on a + hard-asserted budget turns it red; its tool install and report destination are the + ones G44 records. If G44 moves activation, the job's name and CONTRIBUTING's entry + name the owning phase G44 records. The Lighthouse run over both hosts, if G44 builds + one, and a scripted click-through of both sites record zero API `429`s with the + limiter still registered ahead of classification. +- Every register row is closed in the form **G1** chose; every Deliverable is observed + by at least one criterion; no catalogue row whose Phase names 02d is Registered or + Awaiting backfill; backend runs report zero skips (`scripts/assert-tests-ran.py`); and + the standards headers and index rows agree after the phase's transitions + (`Standard_Status_Headers_Match_The_Index`, `backend`). +- Every row in § Explicitly not in this phase names a phase whose own document carries + that capability in its scope, its deliverables or a registered open question; every + committed carrier that names a seed host names the hosts **G32** records; and the + carriers that describe this phase's output — Phase 06 § What Phase 02d already + shipped, the MVP scope's second-tenant bullet and genericity paragraph, and the + platform vision's two-tenant bullet — match what shipped. **Manual**, in the exit + packet's checklist. +- The documentation Deliverables are in the tree at exit: the Education spec's + `README.md` and `permissions.md` beside `audit.md`, with its state diagram and the + eligibility rule **G29** records; the Customization spec's § Primary read flow with + its diagram and budget; Testing Standards § Tenant Isolation Tests' authenticated, + id-addressed example replaced or the shipped class linked; the + `SchemaFixture.KnownTables` summary and the `add-integration-test` skill's note + matching the applied chains; and the exit delivery record naming the Education model, + the preservation obligations G2 recorded and the seed fixtures Phase 05's migration + must carry. **Manual**, in the exit packet's checklist. ## Risks - **The slice grows.** Every capability listed under "explicitly not in this phase" has - a plausible argument for inclusion. The exit gate is a browser, not a feature set; if - a change does not move a pixel on one of the two pages, it belongs to its owning - phase. + a plausible argument for inclusion. The exit gate is a browser, not a feature set: a + change that neither moves a pixel on one of the two sites nor is required by this + phase's Deliverables, Completion Criteria or register belongs to its owning phase. +- **A gate is skipped, or answered before its ground exists.** + [Phase 02b § Risks](phase-02b-events-auth.md#risks) carries both and names the + premises this phase moves for it. The mitigation is each packet's decision pass, and + the delivery record listing those premises for `P02b-0`. - **The second tenant becomes decorative.** A yoga studio whose data is a renamed copy - of the English school's proves nothing. Its taxonomy, content type and page - composition must differ in shape, not only in strings. + of the English school's proves nothing. Its taxonomy and its lesson-body content type + must differ in shape, not only in strings, and the Completion Criteria assert both. + Page composition is not tenant data in this phase. - **Tenant-specific branching creeps into the renderer.** The most likely place is the - block or content-type resolution path, where a missing generic primitive is easiest to - paper over with a conditional. Any such branch is a defect in the customization model - and should be fixed there. -- **Shortcuts around the pipeline.** Three read endpoints are simple enough to write as - direct queries. Doing so skips the tenant-context behavior and the RLS session - variables — the exact machinery this phase exists to exercise. - `Handlers_Return_Result` catches the shape; reviewers catch the intent. + content-type and taxonomy resolution path, where a missing generic primitive is + easiest to paper over with a conditional. Any such branch is a defect in the + customization model and should be fixed there. The domain-term scan strips literals + and cannot see such a branch; the controls are **G20**'s mechanism and the criterion + that the two tenants' content types render as different field sets through the same + code. A branch that compares against a value read at runtime carries no literal: only + the behavioural criterion and review reach it. +- **The renderer walks `properties` in storage order.** It is stable and green, and it + shows fields in `jsonb`'s key order under their property names, so the genericity + proof reads as a data dump. **G18** closes before the seed writes its content types. +- **Shortcuts around the pipeline.** The public reads are simple enough to write without + a handler. A controller that takes a module `DbContext` fails loudly: the context is + refused outside the ambient transaction + ([ADR-0040](../decisions/0040-ambient-unit-of-work.md), + [Database Standards § Connection Management](../standards/05-database.md#connection-management)). + Two shortcuts fail quietly instead. SQL issued on `IUnitOfWork.Connection` without an + announcement reads zero rows, which looks like an empty catalog. Code that opens the + unit of work and announces the host's tenant itself reads that tenant's real rows, + unpublished ones included, and skips the authority ceiling, audit classification and + the query's own filters. `Handlers_Return_Result` sees neither, because it inspects a + handler's response type and neither has a handler. The mechanical control is + **G28**'s; until it closes, review is the only control. +- **A missing marker looks like a resolver bug.** A host-only request to a type without + `[PublicSurface]` answers the unknown-host `404` by design, so a public read shipped + without its marker reads as a resolution or Row Level Security defect. The tempting + repair, widening the authority ceiling, is wrong: the fix is the marker and its API + Standards row (**G28**). +- **The seed takes the short path.** A `DbContext` or SQL write for courses, lessons, + locales or settings passes every rendering criterion while skipping write-time + validation, the MUST publish row and the tenant context. The catalogue join catches a + new command, not a write that bypasses commands, so the control is `SeederTests` + asserting the MUST rows the seed must produce. +- **The seed writes content at the wrong scope.** Every seeder step after provisioning + announces the tenant's default organization. If a command takes its scope from that + context, it writes the English school's content organization-scoped, where the + tenant-wide English host cannot see it, and a tenant-wide row re-seeded under that + context is filtered out of the update by the `AS RESTRICTIVE` guard. An empty English + catalog is then a seed defect, not a template bug. +- **The seed grows into the Packet 7 fixture.** The isolation fixture inserts raw `tz` + and `theme` settings rows after the seed. A seeded tenant-wide `tz`, or an + organization-scoped `theme`, collides with them under the unique + `(tenant_id, organization_id, key)` index, which treats nulls as equal. The per-tenant + content types break the suite's exact `BeEquivalentTo` sets. In both cases the easy + repair loosens an exact assertion instead of recomputing it. +- **Seed data outside `SeedData` fails the genericity guard.** + `Core_Modules_HaveNo_DomainSpecific_Names` exempts the `SeedData` type, its nested + types and the file `SeedData.cs`, and nothing else. A seed type, member or file named + for either domain anywhere else under `backend/src` fails the build. - **The translation tables get written as an afterthought.** The satellite is a tenant-owned table in its own right: its own `tenant_id`, its own policy, its own - `FORCE`. The failure mode is quiet — the parent's policy looks like it covers the child, - and the child is where the title and the slug actually live. An isolation test that - reads only the parent will not find it. + `FORCE`. The failure mode is quiet — the parent's policy looks like it covers the + child, and the child is where the title and the slug actually live. An isolation test + that reads only the parent will not find it; the satellite-only isolation criterion + does. +- **Common lesson slugs collide across courses.** ADR-0008 makes lesson slugs unique per + tenant and locale, so two courses cannot each hold an `introduction` lesson in one + locale. Changing that is a dated amendment to ADR-0008, Accepted before `P02d-1`; + afterwards, rows written under the flat key constrain the move. +- **A structural rule over one chain passes because nothing violates it.** Any guard + **G8** registers has exactly one new subject here, and without a planted offender it + cannot tell clean from blind. An Education dependency on the Tenancy chain — a foreign + key into Tenancy, or triggers calling `fn_organization_id_immutable`, which only the + Tenancy chain declares — would make Database Standards § Migrations' record of the one + cross-chain key incomplete. +- **Publication filtered in one read and forgotten in another.** Row Level Security has + no state term, and the seed publishes everything a reviewer clicks. A handler that + filters the catalog but resolves a detail slug, or a lesson slug without its course + segment, passes every happy-path check and serves unpublished content anonymously. The + visibility criteria exist to catch it. +- **Unpublish and delete have no owning phase.** No roadmap document assigns course or + lesson unpublishing or deletion to a phase, so a later writer arrives without the + read-eligibility (**G29**) and cache (**G27**) rules they need. **G3** names the + owner. +- **The read path ships uncached, or on a scope-blind key.** Every functional criterion + passes either way; only the cache criteria and **G22** and **G23** catch it. The + generation bump is an upsert increment, so a cache filled inside a transaction that + bumped and rolled back can later be served as a committed generation; under + `READ COMMITTED`, rows read before the generation can be cached under the newer key. + Under a TTL answer to **G23**, a seed re-run against a running API shows stale + settings for the stated bound, which a demo reviewer reads as a defect. +- **Cross-host reuse of a cached page or response.** Every isolation layer reports + success because the cache answers before the API is reached, and `next dev` does not + cache the way `next build && next start` does, so the defect shows only under a + production build. The controls are **G27**, **G37** and the cross-host criteria. +- **A response or page cache inherits Performance Standards' invalidation rule.** + [Performance Standards § Caching](../standards/15-performance.md#caching) names the + course catalog list and the published page render as read-through candidates + invalidated by integration events from the producing module, and this phase ships no + Education publish event; the generation-keyed cache covers customization definitions, + not course content. A **G27** or **G37** answer that caches Education responses or + rendered pages reconciles that rule in the same pass, and the pull toward a late cache + to meet **G44**'s budget is the same risk. +- **One anonymous budget behind the renderer.** Server-rendered reads share the renderer + peer's limiter partition. A few visitors, a Lighthouse run or a click-through of both + hosts can draw `429`s, and one client sending random `Host` values through the + renderer can starve both sites while a direct caller keeps its own budget. The + tempting repair — raising, bypassing or header-keying the limiter in the + pre-classification stage, or relaxing it only where the checks run — would make the + exit evidence measure a configuration no deployment runs. A catalog that fetches one + detail read per course multiplies that draw (**G25**). The mitigation is **G34**. With + fewer seeded courses than the catalog's `limit`, catalog pagination is never exercised + in the UI, so courses past the first cursor page can be unreachable without any check + noticing (**G40**). +- **The hop secret drifts between processes.** The API refuses to start only on a + misconfigured hop, so if the API's copy of the secret and the Next.js server's copy + diverge, nothing errors: the hop is silently ignored and every anonymous render + answers `404`, which reads as a seed or template defect. The mitigation is **G33**'s + answer to how one hop secret reaches both processes, plus the mismatched-secret + criterion under § Completion Criteria's server-rendering group. +- **The first public contract freezes early.** Once the breaking-change check stores its + baseline, the site-data shape, an internal identifier, an unbounded embedded list, a + validator header or a list parameter (`sort`, `q`) the endpoint does not implement + becomes a v1 contract Phase 05 and Phase 06 inherit: under + [ADR-0024](../decisions/0024-api-versioning-policy.md), removing a field, renaming a + path segment or changing an outcome's status needs `/api/v2`. G25 through G31 close + before that baseline is stored. +- **An activated check gates nothing.** Required checks match by name, and a job skipped + by its `if:` condition satisfies a required check. + [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main) + records the required-check edits still outstanding, and admin bypass stays possible. A + first run over a base with no `/api/v1` operations cannot fail, and neither can a + Lighthouse run over placeholder pages. The mitigation is the live required-check list + recorded at exit, a per-pull-request rollup comparison, and a planted failure per + check. +- **Phase 05 migrates this phase's shape rather than extending it.** The canonical model + puts lessons inside a course version and under a module + ([Domain Model § Learning Content](../architecture/02-domain-model.md#learning-content)). + Rich content with version history carries `version` on its side table + ([Localization Standards § Choosing between patterns](../standards/08-localization.md#choosing-between-patterns)), + which a flat `(tenant_id, locale, slug)` key would reject, and changing a published + slug is breaking. The mitigation is **G2**: it records the shape this phase ships and + what that shape obliges Phase 05 to preserve, and Phase 05 designs the migration in + its own decision pass. This phase's published state could also later be read as + learner authorization; Phase 07 owns course access. +- **The proof pages fail the audience they are shown to.** Two seeded palettes become + the screenshots everyone evaluates, and a Lighthouse score is not WCAG conformance. + The contrast rule's outcome is also unsettled: + [Accessibility Standards § Color and Contrast](../standards/16-accessibility.md#color-and-contrast) + describes a Studio warning, which means nothing for a command with no interface or for + the seed. Until **G16** closes, the writer and Phase 06's editor can implement + different rules. +- **The anonymous path quietly acquires cookies or third-party requests.** The Frontend + Architecture Standards flowchart sets cookies, and Frontend Architecture serves logo + and custom-font assets from CDN URLs; followed literally, either reaches a visitor + before anyone has reviewed it (**G21**). +- **The demo passes only on a warm workstation.** A running stack, a `.env` copied once + and never refreshed, host entries, a warm Keycloak volume, or processes already + holding the API or web port can let a "clean checkout" pass on the author's machine + alone. The mitigation is **G45**'s contract, and, if **G44** activates its job here on + the same entrypoint, that job's run on a fresh runner. +- **A hidden identity-provider dependency.** The seed waits on both Keycloak realms and + the web example environment already carries OIDC variables, so a render path that + touches Keycloak would surface only in Phase 02b. The mitigation is the + Keycloak-stopped criterion. ## Phase Exit Decision -Phase 02b begins when a reviewer, on a clean checkout, can open two hosts in a browser -and see two structurally and visually different education sites served by one binary, -with the cross-tenant isolation suite green under `learnstack_app` and no -tenant-conditional code anywhere in the request path. +[Phase 02b](phase-02b-events-auth.md) begins when a reviewer, on a clean checkout, can +open two hosts in a browser and see two visually different education sites, whose +taxonomies and lesson field sets differ in shape as the Completion Criteria assert, +served by one backend binary, one `apps/web` application and one database — and: + +- every Completion Criterion holds with its named evidence, the manual ones recorded in + the delivery record; +- every register row is closed through its vehicle, in the form **G1** chose, and + reflected in its carriers — including **G32**, the development-transport part of Phase + 02b's G12, recorded where that row points; +- the Phase 02a request-level isolation suite and the Education schema-level and + request-level isolation tests are green under `learnstack_app`, across tenants and + across organizations, with zero skipped cases, in a required check; +- the OpenAPI breaking-change check and the SDK drift gate run in required checks, and + the Lighthouse job as **G44** settles, each shown able to fail as its criterion + states, with the date and the live required-check list recorded, and no job name still + reads "(deferred to Phase 02d)"; +- `make demo` on a clean checkout prints both addresses and both answer, and both sites + still render with Keycloak stopped; +- the tenant-branching check **G20** selects is Implemented and green in a required + check; +- both sites meet the accessibility criteria, with the accessibility review recorded in + the renderer pull request; +- no catalogue row whose Phase names 02d is Registered or Awaiting backfill, and the + standards index rows this phase changes agree with their documents; +- the delivery record names the Phase 02b premises this phase moved, for `P02b-0`. diff --git a/docs/roadmap/phase-03-identity-admin.md b/docs/roadmap/phase-03-identity-admin.md index f6e7b1bc..7258169d 100644 --- a/docs/roadmap/phase-03-identity-admin.md +++ b/docs/roadmap/phase-03-identity-admin.md @@ -93,14 +93,19 @@ data, and three concrete defects follow from it: runtime; column-level classification cannot see inside it. Retention, export and redaction are therefore undefined for exactly the data most likely to be personal. -This phase resolves all three — and settles one marking the audit capture is waiting on. -`[PiiSensitive]`, the capture's redaction marker -([ADR-0044 § 8 and Amendment 4 § 1](../decisions/0044-audit-write-path.md)), is on no -property as of Phase 02a Packet 9, because no shipped aggregate holds personal data. -`TenantSetting.Value` — a tenant-authored document on a MUST-class operation, whose whole -value the marker would replace — is the first candidate. This phase decides whether it -carries the marker, and no command writing `tenant_settings` lands before that decision, -whichever phase ships it. +This phase resolves all three. One marking the audit capture is waiting on is sequenced +by the first writer rather than by this phase. `[PiiSensitive]`, the capture's redaction +marker ([ADR-0044 § 8 and Amendment 4 § 1](../decisions/0044-audit-write-path.md)), is +on no property as of Phase 02a Packet 9, because no shipped aggregate holds personal +data. `TenantSetting.Value` — a tenant-authored document on a MUST-class operation, +whose whole value the marker would replace — is the first candidate. Whether it carries +the marker is decided before the first command writing `tenant_settings` lands, +whichever phase ships it. [Phase 02d](phase-02d-walking-skeleton.md) plans that command, +so the question is G17 in +[Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register), +answered in the decision pass of the packet that ships it, per +[Roadmap § Decision Timing](README.md#decision-timing). If Phase 02d stops shipping the +command, the decision returns to this phase. **Attribute ownership.** Each attribute has exactly one owner, and the owner determines the table it lives in and who may write it. @@ -236,6 +241,14 @@ refresh token storage, or brute-force protection — those are Keycloak responsi - Resource-scoped policies (for example, an instructor edits only their own courses). - Admin and Studio route guards. - API authorization policies. +- Every endpoint carries `[Authorize(Policy = …)]` or `[AllowAnonymous]` with its + one-line reason, as + [Permissions Standards § HTTP endpoints](../standards/19-permissions.md#http-endpoints) + requires, and an architecture test registered in the catalogue fails the build for an + endpoint that carries neither — the anonymous reads + [Phase 02d](phase-02d-walking-skeleton.md) ships included. +- Permission keys for those public reads, granted to the `Portal Public` role of + [Permissions Standards § Built-in Roles](../standards/19-permissions.md#built-in-roles). - **Lights up the [Phase 02a Packet 3](phase-02a-kernel-tenancy.md) `AuthorizationBehavior` shell** — resolves each command's `[Authorize(Policy)]`, calls `IAuthorizationService.AuthorizeAsync` against the tenant + @@ -293,9 +306,12 @@ strictly higher value than it. `GET /api/v1/localization/en`. When the catch-all wins the tie, `openid-connect` with `bearer_only: true` returns 401 to a request that has no token and is not supposed to need one. -- That request is the first call the public renderer makes. Anonymous rendering from - [Phase 02d](phase-02d-walking-skeleton.md) and the public CMS reads from - [Phase 04](phase-04-cms-media-pages.md) sit behind exactly this route. +- The rule is not specific to localization. Every anonymous read the public renderer + makes — the `[PublicSurface]` request types enumerated in + [API Standards § Public surface](../standards/04-api-design.md#public-surface), whose + first rows come from [Phase 02d](phase-02d-walking-skeleton.md) — and the public CMS + reads from [Phase 04](phase-04-cms-media-pages.md) are matched by the authenticated + catch-all, so each needs a public-band route that outranks it. **Route 100's `client_secret_ref` is deleted.** The line currently reads `client_secret_ref: vault://learnstack/hub/internal-api-hmac-key`, binding the diff --git a/docs/roadmap/phase-04-cms-media-pages.md b/docs/roadmap/phase-04-cms-media-pages.md index 0ac34b95..69e89ff1 100644 --- a/docs/roadmap/phase-04-cms-media-pages.md +++ b/docs/roadmap/phase-04-cms-media-pages.md @@ -7,12 +7,12 @@ merely a course-management system. This phase enables landing pages, blog conten catalog pages, campaign pages, and tenant-defined page blocks. [Phase 02d](phase-02d-walking-skeleton.md) already renders two tenants' catalog and -lesson pages from customization data. It does so with hard-coded route segments and a -single built-in content primitive. This phase replaces that with an authored, -versioned, localized content system that a tenant admin drives from Admin Studio — and -it is the phase where four long-standing modelling conflicts in the corpus get an -answer, because every one of them becomes load-bearing the moment content is authored -rather than seeded. +lesson pages from customization data. It does so with hard-coded route segments, and +draws lesson bodies through their content type's composite over the subset of primitives +that phase implements. This phase replaces that with an authored, versioned, localized +content system that a tenant admin drives from Admin Studio — and it is the phase where +four long-standing modelling conflicts in the corpus get an answer, because every one of +them becomes load-bearing the moment content is authored rather than seeded. Decisions consumed in this phase: @@ -87,6 +87,8 @@ Also in scope: - `ContentEntry` CRUD per type, with draft and published states. - Schema-version migration path: lazy on entry save, plus bulk migration as a tenant-admin operation with a dry run. +- Whether a customization change owes an integration event, per + [the Customization spec § Integration-event catalogue](../modules/customization/README.md#integration-event-catalogue). ### Customization Key Shape and Immutable Schema Versions @@ -177,8 +179,10 @@ one thing a per-table constraint cannot do. never resolved by picking a winner at render time. Also in scope: locale fallback chain per tenant, the `/{locale}/{slug}` routing shape, -and per-locale publish readiness. The frontend i18n library is chosen in ADR-0027 (see -the Phase Exit Decision). +per-locale publish readiness, and locale negotiation from `Accept-Language` for +API-returned messages +([Error Handling Standards § Validation Errors](../standards/09-error-handling.md#validation-errors)). +The frontend i18n library is chosen in ADR-0027 (see the Phase Exit Decision). ### Page Blocks — Two-Tier Registry @@ -317,6 +321,17 @@ not write it. - Navigation editor. - Publish and preview controls, including per-locale readiness. +**Open question, answered in this phase's decision pass before its first Studio +screen:** where a tenant admin enters the per-locale values of translatable fields — +titles, bodies, slugs, SEO metadata — for this phase's entities and for the courses and +lessons [Phase 02d](phase-02d-walking-skeleton.md) seeds. The candidates are each +entity's own editor (this phase's screens here, +[Phase 05](phase-05-education-learning-content.md)'s for courses and lessons) or a +separate translation screen. The answer is recorded as a row in +[Phase 06 § Admin Studio — screen ownership](phase-06-renderer-admin-studio.md#admin-studio--screen-ownership), +together with where untranslated gaps are shown, per +[Localization § Risks](../architecture/12-localization.md#risks). + The visual drag-and-drop schema builder is [Phase 06](phase-06-renderer-admin-studio.md); this phase ships the picker-and-reorder Studio MVP the page-builder architecture describes. diff --git a/docs/roadmap/phase-05-education-learning-content.md b/docs/roadmap/phase-05-education-learning-content.md index ed98e951..85e221e3 100644 --- a/docs/roadmap/phase-05-education-learning-content.md +++ b/docs/roadmap/phase-05-education-learning-content.md @@ -11,9 +11,12 @@ their per-tenant shape. Every model here is **domain-agnostic**. Domain-specific This phase **deepens** what [Phase 02d](phase-02d-walking-skeleton.md) already shipped rather than creating it. `Course` and `Lesson` exist in thin form from the walking -skeleton — slug, title, published state, an ordered lesson list, and a body rendered -from a single primitive. Phase 05 adds the structure a real catalog needs: programs, -versioning, modules, lesson items, and tenant-defined item types. +skeleton — a published state, a per-locale slug, title and summary held in translation +satellites, an ordered lesson list, and a body drawn through its content type's +composite over the primitive subset that phase implements (G18 in +[Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register)). +Phase 05 adds the structure a real catalog needs: programs, versioning, modules, lesson +items, and tenant-defined item types. This phase also carries the decision the customization model has been running without. **[ADR-0025](../decisions/README.md#open-adr-drafts) — the scoring and completion DSL @@ -41,15 +44,19 @@ Decisions consumed: ### What Phase 02d already shipped -Phase 05 does not re-create these; its migrations are additive, and the one destructive -change (lesson bodies become lesson items) goes through the two-step deprecation window -in [Database Standards](../standards/05-database.md). +Phase 05 does not re-create these. Its destructive changes are enumerated in this +phase's own decision pass. They include lesson bodies becoming lesson items, and +whatever it takes to bring Phase 02d's lessons under course versions and modules, given +the model recorded as G2 in +[Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register). +Each follows [Database Standards § Migrations](../standards/05-database.md#migrations). | Already exists | Phase 05 adds | |---|---| -| `Course` — slug, title, summary, published / draft | Program membership, versioning, categories, tags, SEO, catalog visibility | -| `Lesson` — ordered within a course, single-primitive body | Module membership, lesson items, required / optional, duration, prerequisites | -| Three anonymous read endpoints — the catalog list and two detail reads | The authenticated authoring surface and the versioned read path | +| `Course` — the publication state G3 in [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register) records, with its per-locale slug, title and summary in `course_translations` ([Phase 02d § Localization schema](phase-02d-walking-skeleton.md#localization-schema--the-one-way-door-this-phase-walks-through)) | Program membership, versioning, categories, tags, SEO, catalog visibility — and how that visibility and `CourseVersion` state compose with 02d's publication state, decided in this phase's decision pass against the values and meaning G3 in [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register) records | +| `Lesson` — ordered within a course, its body drawn through its content type's composite over Phase 02d's primitive subset (G18) | Module membership, lesson items, required / optional, duration, prerequisites | +| The anonymous public reads Phase 02d ships — their paths, shapes and count are G25 and G26 in [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register) | The authenticated authoring surface and the versioned read path | +| The customization definition read path — content types and taxonomies through the generation-keyed cache ([32-tenant-customization-model.md § 8.2](../architecture/32-tenant-customization-model.md#82-cache-strategy)) | The `TenantLessonItemType` read, the batched reference walk and the measured cost model | | `[TenantOwned]` markers, EF query filters, RLS policies | The same layers on every new table, with no exception | ### ADR-0025 — the scoring and completion DSL engine @@ -189,12 +196,16 @@ boundary, and both are load-bearing here: The customization runtime is the platform's central read path — every rendered page, lesson and catalog entry passes through schema lookup, taxonomy lookup, reference -resolution and, on the learner path, rule evaluation. Its cost model is written here -because this is the phase where the read path first carries real payloads, and because a -cost model retrofitted after the caches exist is a rewrite. The conceptual model lives -in +resolution and, on the learner path, rule evaluation. The definition read path and its +generation-keyed cache are not this phase's: they land with their first consumer in +[Phase 02d](phase-02d-walking-skeleton.md), per [Phase 02a](phase-02a-kernel-tenancy.md) +and [the Customization module spec](../modules/customization/README.md). Its cost model +is written here because this is the phase whose lesson renders put lesson items, +reference resolution and rule evaluation on that path — the load the batched walk, the +limit set and the rule cache exist for — and because a cost model retrofitted after +those caches exist is a rewrite. The conceptual model lives in [32-tenant-customization-model.md](../architecture/32-tenant-customization-model.md); -this phase implements and measures it. +this phase extends what Phase 02d shipped and measures the whole. **Validation timing — write time, not read time.** @@ -202,32 +213,35 @@ this phase implements and measures it. when an instance is saved (does this payload match its schema version?). Both are authoring-path operations with a human waiting on one request. - The read path does **not** re-validate. Entries pin `schema_version` at creation - ([ADR-0013](../decisions/0013-page-block-schema-versioning.md)) and schema versions are - immutable, so a stored payload cannot drift out of conformance with the version it - declares. An entry whose declared version no longer resolves renders the - `UnknownVersionBlock` placeholder — it does not trigger validation on a hot path. + ([ADR-0013](../decisions/0013-page-block-schema-versioning.md)), and the schema is a + write-time contract the read path trusts — + [32-tenant-customization-model.md § 8.1](../architecture/32-tenant-customization-model.md#81-validation-timing--write-time-not-read-time) + owns that rule and names what breaks it. An entry whose declared version no longer + resolves renders the `UnknownVersionBlock` placeholder — it does not trigger + validation on a hot path. - Validating on read would put a schema compile and a full document walk in front of every catalog page, for a defect the write path already prevents. **Cache strategy.** -- Three read-through caches: `TenantContentType` by `(tenant, key, version)`, - `TenantLessonItemType` by `(tenant, key, version)`, `TenantLevelTaxonomy` by - `(tenant, key)`. -- Because schema versions are immutable, a versioned entry **never needs invalidation**. - Only the *active version pointer* for a key changes, and only on publish. Cache the - immutable body aggressively; keep the pointer on a short TTL behind a per-tenant - generation key that publish bumps. -- Invalidation uses that generation key, not a prefix scan. +- Definition sets are cached on + [32-tenant-customization-model.md § 8.2](../architecture/32-tenant-customization-model.md#82-cache-strategy)'s + families, which [Phase 02d](phase-02d-walking-skeleton.md) ships for content types and + taxonomies. The `TenantLessonItemType` read this phase adds follows + [Infrastructure Stack § `ICacheService`](../standards/20-infrastructure-stack.md#icacheservice-state), + and its family is added to that document's cheat sheet and the adapter's `cache.name` + mapping together. +- Invalidation uses the generation key, not a prefix scan. `ICacheService.RemoveByPrefixAsync` was removed in [Phase 02a Packet 5](phase-02a-kernel-tenancy.md) precisely because prefix eviction cannot be honoured across instances; this phase must not reintroduce the assumption. -- Compiled artefacts — the compiled JSON Schema validator and the compiled rule — live - in a per-process L1 cache only and are never serialised to L2. The L1 cache is a - bounded LRU with a global entry cap, so a tenant with a thousand schema versions - cannot evict every other tenant's working set. -- Cold start after a deploy pays compilation once per `(tenant, key, version)` actually - requested. That cost is measured, not assumed. +- There is no compiled JSON Schema validator cache + ([ADR-0043 § 6](../decisions/0043-customization-payload-validation.md#6-there-is-no-compiled-validator-cache)). + The compiled **rule** lives in a per-process L1 cache only and is never serialised to + L2; that cache is a bounded LRU with a global entry cap, so a tenant with a thousand + rules cannot evict every other tenant's working set. +- Cold start after a deploy pays § 8.2's miss cost per definition set and one + compilation per rule actually requested. That cost is measured, not assumed. **Reference resolution and the N+1 path.** @@ -329,8 +343,12 @@ cross-phase screen ownership table lives in **one** place — - Public catalog rendering data, extending the read endpoints [Phase 02d](phase-02d-walking-skeleton.md) shipped. - The customization runtime cost model implemented and **measured**: validation on the - write path, the two-tier cache with generation-key invalidation, the batched - reference walk, the enforced limit set, and the `embed-html` sanitisation contract. + write path, the generation-keyed definition cache + [Phase 02d](phase-02d-walking-skeleton.md) shipped, extended to this phase's + aggregates (its L2 tier arrives on + [ADR-0035](../decisions/0035-demand-gated-infrastructure.md)'s trigger in + [Phase 11](phase-11-production-hardening.md)), the batched reference walk, the + enforced limit set, and the `embed-html` sanitisation contract. ## Completion Criteria diff --git a/docs/roadmap/phase-06-renderer-admin-studio.md b/docs/roadmap/phase-06-renderer-admin-studio.md index 7de69ca2..268a1044 100644 --- a/docs/roadmap/phase-06-renderer-admin-studio.md +++ b/docs/roadmap/phase-06-renderer-admin-studio.md @@ -8,8 +8,9 @@ learner-facing and instructor-facing screen after this phase is built into. [Phase 02d](phase-02d-walking-skeleton.md) already put a site in a browser — a catalog page and a lesson page, on two hosts, for two tenants. That skeleton proved the request -path. It is not a website: it has no navigation, no SEO, no error pages, one block, and -no editing surface. Phase 06 **deepens** it into something a tenant can publish. +path. It is not a website: it has no navigation, no SEO, no error pages, no block +registry beyond the primitive subset its lesson composites draw, and no editing surface. +Phase 06 **deepens** it into something a tenant can publish. After this phase, LearnStack publishes a simple but real education website for a tenant, and a non-developer tenant admin can maintain it. @@ -21,10 +22,10 @@ and a non-developer tenant admin can maintain it. | Already exists | Phase 06 adds | |---|---| | Host-based tenant + organization resolution, end to end | Per-organization branding override on the resolved context | -| Catalog page and lesson page, Server Components over the typed SDK | Navigation, SEO metadata, 404 and redirect handling, full page composition | -| One built-in content primitive | The complete two-tier block registry with safe-render placeholders | +| The anonymous `(public)` pages listed in [Phase 02d § Public renderer](phase-02d-walking-skeleton.md#public-renderer), Server Components over the typed SDK | Navigation, SEO metadata, 404 and redirect handling, full page composition | +| Lesson bodies drawn through their content type's composite, over the primitive subset [Phase 02d](phase-02d-walking-skeleton.md) implements | The complete two-tier block registry with safe-render placeholders | | Branding tokens read from `TenantSetting` | The branding configuration surface that writes them | -| First frontend tests, replacing the `--passWithNoTests` placeholder | The browser-level end-to-end suite | +| The `apps/web` Vitest suite [Phase 02d](phase-02d-walking-skeleton.md) extends | The browser-level end-to-end suite | ### Public site renderer diff --git a/docs/standards/03-frontend-coding.md b/docs/standards/03-frontend-coding.md index e59c56ab..31011957 100644 --- a/docs/standards/03-frontend-coding.md +++ b/docs/standards/03-frontend-coding.md @@ -92,7 +92,9 @@ export function CourseCard({ course, onEnroll }: CourseCardProps) { - Server Components call the typed SDK directly. - Client Components route through server actions or RSC props; never call the API with bearer tokens directly. -- Cache keys include `tenantId` and `locale`. +- Cache keys carry the tenant, the organization where applicable, and the locale; the + rule lives in + [Security Standards § Multi-Tenant + Organization Isolation Review Checklist](11-security.md#multi-tenant--organization-isolation-review-checklist). - API errors mapped to typed `AppError` before reaching UI code (see [09-error-handling.md](09-error-handling.md)). ## Forms diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index 16dee915..2b0e7276 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -110,12 +110,18 @@ CREATE TABLE courses ( deleted_at timestamptz NULL, deleted_by uuid NULL, row_version bigint NOT NULL DEFAULT 0, - CONSTRAINT ux_courses_tenant_id_slug_key UNIQUE (tenant_id, slug_key), -- Composite unique on (tenant_id, id) exists solely so child tables can -- carry a composite FK. See § Foreign keys between tenant-owned tables. + -- It contains the primary key, so § Soft Delete's partial rule exempts it. CONSTRAINT ux_courses_tenant_id_id UNIQUE (tenant_id, id) ); +-- slug_key is unique per tenant among live rows. A table with deleted_at takes a +-- partial unique INDEX: PostgreSQL has no partial UNIQUE table constraint, and a +-- table-wide one lets a deleted row hold its key forever (§ Soft Delete). +CREATE UNIQUE INDEX ux_courses_tenant_id_slug_key + ON courses (tenant_id, slug_key) WHERE deleted_at IS NULL; + -- Enable *and* force: without FORCE, the table owner bypasses its own policies. ALTER TABLE courses ENABLE ROW LEVEL SECURITY; ALTER TABLE courses FORCE ROW LEVEL SECURITY; @@ -179,9 +185,10 @@ CREATE POLICY courses_org_delete_guard ON courses OR organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid ); --- No standalone index on tenant_id: ux_courses_tenant_id_slug_key and --- ux_courses_tenant_id_id are both b-trees with tenant_id leading, and either serves --- a tenant-only lookup. One composite index carries the organization dimension. +-- No standalone index on tenant_id: ux_courses_tenant_id_id is a b-tree with +-- tenant_id leading and serves a tenant-only lookup (the partial slug_key index +-- serves only queries that exclude deleted rows). One composite index carries the +-- organization dimension. -- Deliberately NOT partial: the policy's `organization_id IS NULL` branch matches -- every tenant-wide row, and a b-tree indexes NULLs, so the non-partial form serves -- both branches of the predicate. @@ -214,6 +221,7 @@ CREATE TABLE lessons ( CONSTRAINT fk_lessons_course FOREIGN KEY (tenant_id, course_id) REFERENCES courses (tenant_id, id) ); +-- Plus an index led by (tenant_id, course_id) — § Indexes: index every foreign key. ``` The parent therefore carries `UNIQUE (tenant_id, id)` purely to be referenceable this @@ -241,12 +249,12 @@ makes the absence loud. The standing exception is a **child inside an aggregate boundary**, which cascades from its own root: it is not an independent row, and outliving its root would leave it referring to nothing. That is deletion *within* an aggregate, not -deletion *of* one, and it is why the two fences differ. Two tables are in the -class today — a **translation satellite** (`ON DELETE CASCADE` in the -`course_translations` fence above), and `tenant_level_taxonomy_items`, whose -composite key names the taxonomy revision it belongs to and which is meaningless -without it. A cascade from anything that is *not* a root's own child is still a -decision, not a convenience, and it needs a record. +deletion *of* one, and it is why the two fences differ. Two shapes are in the +class — a **translation satellite** (`ON DELETE CASCADE` in the `course_translations` +fence in § Translation satellite tables below; no shipped chain creates one yet), and +`tenant_level_taxonomy_items`, whose composite key names the taxonomy revision it +belongs to and which is meaningless without it. A cascade from anything that is *not* a +root's own child is still a decision, not a convenience, and it needs a record. **The circular reference, and why it is still composite.** `tenants.default_organization_id` points at `organizations`, which points back at @@ -297,7 +305,12 @@ Rules: for `DELETE`, and `USING` is also what selects the rows an `UPDATE` may target — so the two `AS RESTRICTIVE` `FOR UPDATE` / `FOR DELETE` guards in the template above are what actually close the `USING`-only write paths. They are part of the template for - every organization-scoped table, not an optional hardening step. + every organization-scoped table, not an optional hardening step. One write path is + not closed: the organization arm of `WITH CHECK` admits `organization_id IS NULL` + from any session, so an organization-scoped session can `INSERT` a tenant-wide row + that the guards then stop it updating or deleting. Whether that arm is tightened is + G7 in + [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). - The session variable names `app.tenant_id`, `app.organization_id`, `app.scope` and `app.resolving_host` are canonical and the set is closed; do not invent alternatives (`app.current_tenant_id`, `learnstack.tenant_id`, …). `app.tenant_id` and @@ -378,6 +391,11 @@ CREATE TABLE course_translations ( FOREIGN KEY (tenant_id, course_id) REFERENCES courses (tenant_id, id) ON DELETE CASCADE ); + +-- Index every foreign key (§ Indexes): neither the primary key, led by course_id, +-- nor the slug key, led by (tenant_id, locale), serves (tenant_id, course_id). +CREATE INDEX ix_course_translations_tenant_id_course_id + ON course_translations (tenant_id, course_id); ``` It then declares `ENABLE` + `FORCE ROW LEVEL SECURITY` and the same @@ -386,7 +404,13 @@ policy of its own is unprotected — a check constraint on the parent does not p Row Level Security, and a table holding `title` and `slug` holds the content. `organization_id` on a satellite exists **only** to carry the isolation predicate. -Denormalizing it is safe because of the immutability rule above. It is deliberately +The immutability rule above keeps a correct mirror correct; it does not make a mirror +correct at insert. Neither does the composite foreign key, which carries no +organization, nor `WITH CHECK`, which admits `organization_id IS NULL` from any session +— so nothing in this template forces a satellite's `organization_id` to equal its +parent's. The insert-time control is G7 in +[Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register), +answered before the first Education migration. `organization_id` is deliberately **absent** from the slug unique key — see § Constraints and [Localization Standards § Pattern A](08-localization.md). @@ -1113,7 +1137,9 @@ changes `xmin` while leaving `row_version` intact. - `NOT NULL` aggressively; defaults only when business sense dictates. - `CHECK` constraints for invariants the database can enforce (`status IN (...)`, `length(slug) BETWEEN 1 AND 120`). -- `UNIQUE` constraints for tenant-scoped natural keys (`UNIQUE (tenant_id, slug_key)`). +- Uniqueness for tenant-scoped natural keys (`(tenant_id, slug_key)`): a `UNIQUE` + constraint, or — on a table with a `deleted_at` column — a partial unique index, per + § Soft Delete. - **A nullable column in a `UNIQUE` constraint does not constrain the rows where it is null.** PostgreSQL follows the SQL standard and treats nulls as distinct for uniqueness purposes, so `UNIQUE (tenant_id, organization_id, key)` permits unlimited duplicates diff --git a/docs/standards/06-testing.md b/docs/standards/06-testing.md index ff89b1e5..5035fc2a 100644 --- a/docs/standards/06-testing.md +++ b/docs/standards/06-testing.md @@ -214,7 +214,11 @@ If a test gets slow, fix the test before the suite. - A flaky test is a bug. Triage immediately. - Quarantined tests re-enabled within one sprint or deleted. -- `Skip = "..."` requires a linked issue and a date. +- A `Skip` fails CI: `scripts/assert-tests-ran.py` refuses any backend run whose results + report a case that did not run + ([`No_Architecture_Test_Is_Skippable`](21-architecture-tests-catalogue.md#no_architecture_test_is_skippable)). + The frontend Vitest run is not yet checked; closing that is G38 in + [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). ## Live Classroom Testing diff --git a/docs/standards/07-frontend-architecture.md b/docs/standards/07-frontend-architecture.md index ebc4c4ee..49d48a52 100644 --- a/docs/standards/07-frontend-architecture.md +++ b/docs/standards/07-frontend-architecture.md @@ -99,7 +99,8 @@ Rules: ## Locale Resolution - Public-site URL: `/{locale}/...`. -- Default locale from tenant settings. +- Default locale from the tenant's default `tenant_locales` row, which the Tenancy + module owns ([ADR-0008](../decisions/0008-localization-schema.md)). - Locale propagated as `X-Locale` to downstream API calls. - Client-side locale switching triggers `router.push` to the new locale path. diff --git a/docs/standards/08-localization.md b/docs/standards/08-localization.md index 44a86994..65da7b5c 100644 --- a/docs/standards/08-localization.md +++ b/docs/standards/08-localization.md @@ -101,6 +101,11 @@ CREATE TABLE course_translations ( ); ``` +This sketch shows where the translatable columns and the slug key live. The table's +complete DDL — its foreign-key index, policy set and triggers — is +[Database Standards § Translation satellite tables](05-database.md#translation-satellite-tables), +the canonical artefact. + Slug lookup is **exact** on `(tenant_id, locale, slug)`. The fallback chain resolves display fields after the entity is found; it never resolves a slug. An entity with no translation in the requested locale has no URL in that locale, and a link to it is diff --git a/docs/standards/09-error-handling.md b/docs/standards/09-error-handling.md index 86e9ffb3..6cdb9313 100644 --- a/docs/standards/09-error-handling.md +++ b/docs/standards/09-error-handling.md @@ -317,7 +317,12 @@ Rules: - FluentValidation produces field-level errors. - Always include all failures, not just the first one. - Field names match the request shape (`camelCase`). -- Messages are localizable; the API returns the locale-appropriate message based on the request's `Accept-Language` or tenant default. +- Messages are localizable because they travel as keys: `messageKey` and each `errors` + entry are `LocalizedMessage` payloads (`key` + optional `params`) the frontend + resolves against its i18n catalogue, as [§ API Surface](#api-surface) states. The API + returns no message text today; locale negotiation from `Accept-Language` for any + message text the API composes later is + [Phase 04](../roadmap/phase-04-cms-media-pages.md)'s. - **`ValidationBehavior` returns `Result.Fail(validation_failed, errors)` — it does NOT throw `FluentValidation.ValidationException`.** Per [ADR-0032 § Sub-decision 3](../decisions/0032-exception-handling-logging-and-observability.md), diff --git a/docs/standards/15-performance.md b/docs/standards/15-performance.md index 21f23eff..ba16af81 100644 --- a/docs/standards/15-performance.md +++ b/docs/standards/15-performance.md @@ -38,15 +38,17 @@ Budgets are reviewed quarterly against measured production metrics. - Read-through cache for stable, public, read-heavy data (published page render, course catalog list). - Cache invalidation triggered by integration events from the producing module. -- Cache keys include `tenant_id` and `locale` where relevant. +- Cache keys carry the tenant, the organization where applicable, and the locale + ([Security Standards § Multi-Tenant + Organization Isolation Review Checklist](11-security.md#multi-tenant--organization-isolation-review-checklist)). - TTL chosen per content type; default 5 minutes for catalog, 1 minute for course detail. - Cache hit ratio per cache name surfaced as a metric. ### Pagination -- All list endpoints paginated. Default `limit = 20`, max `limit = 100`. -- Cursor pagination by default; offset only for bounded admin lists. -- API rejects requests without explicit pagination on resource collections. +- All list endpoints are paginated as + [API Standards § Pagination](04-api-design.md#pagination) specifies; that section owns + the default and maximum `limit`, the answer to an absent `limit`, and when offset + pagination is allowed. ### Background Work diff --git a/docs/standards/17-code-review.md b/docs/standards/17-code-review.md index 2785d464..03eb64f0 100644 --- a/docs/standards/17-code-review.md +++ b/docs/standards/17-code-review.md @@ -61,7 +61,8 @@ Before requesting reviews, run the relevant checklist on your own diff: - [ ] No hardcoded user-facing strings in JSX (grep for new strings outside `t('...')`). - [ ] Strict TypeScript: no `any`, no `// @ts-ignore` (use `@ts-expect-error` with a comment if absolutely necessary). - [ ] Server Components are the default; `"use client"` only where interactivity needs it. -- [ ] Cache keys include `tenantId` and `locale` where relevant. +- [ ] Cache keys carry the tenant, the organization where applicable, and the locale + ([Security Standards § Multi-Tenant + Organization Isolation Review Checklist](11-security.md#multi-tenant--organization-isolation-review-checklist)). - [ ] New forms use React Hook Form + Zod and render errors at the field level. - [ ] New routes have `error.tsx` and `loading.tsx` where appropriate. - [ ] Accessibility: every interactive element keyboard-reachable; labels associated; color contrast checked; axe-core tests pass. diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 76038866..9a98c75b 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -412,11 +412,17 @@ otherwise). #### `Handlers_Return_Result` -- **Asserts:** every `IRequestHandler<TRequest, TResponse>` implementation - in a `*.Application` assembly has `TResponse : IResultBase`. A handler - that returns a raw DTO would satisfy none of the - `where TResponse : IResultBase`-constrained pipeline behaviors and so - would silently bypass validation / audit / tenant-context + RLS. +- **Asserts:** every `IRequestHandler<TRequest, TResponse>` implementation in any + `LearnStack.*` project under `backend/src` (derived from the tree, not listed) has + `TResponse : IResultBase`. Every pipeline behavior is constrained + `where TResponse : IResultBase`, so a handler returning a raw DTO runs with none of + them: no validation, no audit classification, no authority ceiling and no transaction. + A module `DbContext` it resolves is refused outside the ambient transaction, so its EF + access fails loudly. SQL it issues on `IUnitOfWork.Connection` carries no tenant + announcement and reads zero rows. +- **What it does not catch:** code that never dispatches a request: a controller that + queries, or that opens the unit of work and announces a tenant itself. No rule in this + catalogue constrains what a controller depends on. - **Source:** ADR-0032 § Sub-decision 2; [02-backend-coding.md § MediatR Use Cases](02-backend-coding.md). - **Type:** xUnit + reflection over `IRequestHandler<,>` implementations. @@ -568,8 +574,8 @@ otherwise). (and its 2026-08-08 genericity-boundary amendment); [00-principles.md § 1](00-principles.md). - **Type:** xUnit — reflection over the production assemblies, the EF models, the - audit catalogue and the key registries, plus a file scan of `frontend/apps/web`. - **Kind:** structural. + audit catalogue and the key registries, plus a file scan of every app and package + under `frontend/`. **Kind:** structural. - **Status:** **Implemented** — `DomainGenericityTests.cs`, Packet 10. It is the mechanical guarantee behind the platform's entire premise — "the core stays generic" — and it had no implementation while its far weaker sibling `No_Source_Folder_Named_Verticals` stayed green @@ -666,8 +672,11 @@ otherwise). #### `No_Architecture_Test_Is_Skippable` - **Asserts:** no test in the architecture assembly carries a `Skip` on its `[Fact]` or - `[Theory]`, and — at the runner, where the rule itself cannot see — no suite reports a - case that did not run. + `[Theory]`, and — at the runner, where the rule itself cannot see — no backend suite's + `.trx` reports a case that did not run. The `frontend` job's Vitest run is not read: a + skipped or todo case there, including one in `lint-rules.test.ts`, exits 0 today, and + closing that gap is G38 in + [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). - **Why it matters:** "architecture tests are non-skippable" is a policy the corpus states in three places and nothing enforced. Adding `Skip = "…"` is one edit, the suite goes green, and it reports the same number of passing files as before — which is precisely @@ -1406,7 +1415,9 @@ catalogue as the carrier of their status — so all three are Packet 10's. of them — a unique prefix already yields at most one candidate row, which is why `tenants`' primary key supports the composite `fk_tenants_default_organization`. Every foreign key in this schema is - `ON DELETE RESTRICT`, so every parent delete pays the child scan. + `ON DELETE RESTRICT`, or `ON DELETE CASCADE` for a child inside an aggregate boundary + ([Database Standards § Foreign keys between tenant-owned tables](05-database.md#foreign-keys-between-tenant-owned-tables)), + so every parent delete pays the child scan. - **Source:** [05-database.md § Indexes](05-database.md). - **Type:** **integration** test (Testcontainers + PostgreSQL), reading `pg_constraint` / `pg_index`. **Kind:** structural. @@ -3668,8 +3679,15 @@ structural test proves — and what it does not. - **Source:** ADR-0036 § The reconciliation matrix; [Standards 04 § Public surface](04-api-design.md). - **Type:** xUnit + reflection. **Kind:** structural. -- **Status:** **Implemented** (`RequestSurfaceTests`, Packet 7 step 6). -- **Phase:** 02a (Packet 7). +- **Status:** **Implemented** for the enumeration legs (`RequestSurfaceTests`, Packet 7 + step 6): every marked request type is a row of the table, every row names a marked + type, and the attribute keeps the shape `TenantContextBehavior` reads it with. The + table parser reads only the **Request type** cell, and nothing in the suite checks a + row's permitted methods or that a marked request writes nothing. The permitted-methods + leg and the tenant-owned-write leg are Registered. +- **Phase:** 02a (Packet 7) for the enumeration legs. The permitted-methods and + tenant-owned-write legs arrive with the first `[PublicSurface]` request type, in + [Phase 02d](../roadmap/phase-02d-walking-skeleton.md). - **Note:** the two directions are not equally vacuous, and the existing note above covers only one of them. **Marked set → table** is vacuous while no type carries the marker. **Table → marked set** is live from the day it ships: the table may not name a type that diff --git a/docs/standards/README.md b/docs/standards/README.md index bcf5c288..347719c2 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -86,7 +86,7 @@ included. | 03 | [Frontend Coding](03-frontend-coding.md) | **Active** | ESLint and TypeScript strict mode are configured, the Vitest harness runs, and Packet 10 added the two rules this document's § Forbidden names — the direct-`fetch` ban and `dangerouslySetInnerHTML` outside the sanitised-HTML primitive — with a test that lints fixtures through the app's own configuration, because a configured rule and a missing one look identical from a green `pnpm lint`. `apps/web` is otherwise still a scaffold; its first real components land in [Phase 02d](../roadmap/phase-02d-walking-skeleton.md). | | 04 | [API Design](04-api-design.md) | **Active** | Packet 4 shipped the versioned route convention and its startup guards, one Problem Details shape on every error including the framework-minted ones, cursor pagination, the sort grammar, idempotency keys, ETag concurrency, correlation ids, the request-body limit and the tenancy edge — each with tests in the required `backend` check. No *business* endpoint exists yet; the conventions they will land into do. | | 05 | [Database](05-database.md) | **Active** | Packet 6 applied it — two migration chains, ten tables — and Packets 8 and 9 took it to four chains and seventeen tables: the four-role model, and the canonical RLS template this document owns — `ENABLE` **and** `FORCE`, one `AND`-ed policy per table, an explicit `WITH CHECK` — asserted against a real PostgreSQL as `learnstack_app`. Its § Concurrency, § Table classes, § Indexes and § GRANT matrix each have a test that fails without them. Partitioning and the retention job are still ahead. | -| 06 | [Testing](06-testing.md) | **Active** | Unit, architecture, contract **and** integration suites all run in the required `backend` job — Packet 4 removed the filter that used to exclude the integration assembly, which by then held the only tests that could catch an unversioned route. The Docker-bound `backend-integration` job activated in Packet 6 with the four-role provisioning suite; the split is by `[Trait("Requires","Docker")]` and the two jobs' filters are exact complements. | +| 06 | [Testing](06-testing.md) | **Active** | Unit, architecture and contract suites, and the Docker-free integration tests, run in the required `backend` job — Packet 4 removed the filter that used to exclude the integration assembly, which by then held the only tests that could catch an unversioned route. The Docker-bound `backend-integration` job activated in Packet 6 with the four-role provisioning suite; the split is by `[Trait("Requires","Docker")]` and the two jobs' filters are exact complements. The `backend-integration` job is not yet a required check ([CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main)). | | 07 | [Frontend Architecture](07-frontend-architecture.md) | **Active** | The one-app rule is mechanical — `Frontend_Has_Only_The_Web_App` fails a second application in this repository — and the route groups it prescribes exist as layouts. The rest, the server/client split and the tenant context an SDK call carries, is exercised first in [Phase 02d](../roadmap/phase-02d-walking-skeleton.md): Active for what ships, and the phase that adds components is the one that tests them. | | 08 | [Localization](08-localization.md) | **Active** | Packet 6 shipped `tenant_locales` and the slug schema, and "exactly one default locale per tenant" is enforced twice: a partial unique index `UNIQUE (tenant_id) WHERE is_default` and an aggregate guard that carries the message. `LocalizedText` and `LocalizedMessage` ship with their own cases, and every error the API returns is keyed rather than written. The i18n **runtime** — routing, negotiation, formatting — lands in [Phase 04](../roadmap/phase-04-cms-media-pages.md). | | 09 | [Error Handling](09-error-handling.md) | **Active** | L1 `IExceptionHandler`, the exception hierarchy, `ProblemDetailsFactory` and `HttpStatusMap` shipped in Packet 3. | @@ -94,14 +94,14 @@ included. | 11 | [Security](11-security.md) | **Active** | No authentication yet, and the isolation half of this document is live and mechanical. Row Level Security with the four roles and the isolation suite that runs as `learnstack_app`; the tenancy edge, the trusted-hop predicate and the anonymous rate limiter from Packet 4; and, since Packet 10, § The out-of-band setters is mechanical — every announcer of a session variable is one the table names and each reader opens its transaction read-only, with `App_Role_Cannot_Enumerate_Tenants`, `App_Role_Cannot_Enumerate_Host_Map` and `Tenant_A_Cannot_Repoint_Tenant_B_Host` proving the role cannot read or repoint what the policies bar. Authentication and authorisation land in [Phase 02b](../roadmap/phase-02b-events-auth.md) and [Phase 03](../roadmap/phase-03-identity-admin.md). Three sections are **not** enforced by anything today and are the document's own carve-out: § Transport, § HTTP Headers and § CORS — nothing sets HSTS, `nosniff`, a CSP or an origin policy, at the edge or in either app. [Phase 11 § Secure headers](../roadmap/phase-11-production-hardening.md) owns them, at APISIX and in the ASP.NET layer beside it. | | 12 | [Infrastructure](12-infrastructure.md) | **Active** | Compose stack, `Makefile`, CI workflow, pre-commit hooks and secret scanning all live since Phase 01. | | 13 | [Documentation](13-documentation.md) | **Active** | Governs this corpus; the CI link audit walks changed Markdown. | -| 14 | [Git Workflow](14-git-workflow.md) | **Active** | Conventional Commits, hooks and required checks are live. Two branch-protection settings — `Require approvals` and `Do not allow bypassing` — are **deferred by maintainer decision (2026-08-10)** while the repository has one active contributor; the trigger and what activating them involves are recorded in [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md). Everything else in Standards 14 is enforced today. | +| 14 | [Git Workflow](14-git-workflow.md) | **Active** | Conventional Commits, hooks and required checks are live. Two branch-protection settings — `Require approvals` and `Do not allow bypassing` — are **deferred by maintainer decision (2026-08-10)** while the repository has one active contributor; the trigger and what activating them involves are recorded in [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md). The required-check list itself has two outstanding edits, recorded in [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main); [Phase 02d](../roadmap/phase-02d-walking-skeleton.md) makes both no later than its first Education migration. | | 15 | [Performance](15-performance.md) | **Adopted** | No budget is measured and no load test exists. Enforcement lands in [Phase 11](../roadmap/phase-11-production-hardening.md). | | 16 | [Accessibility](16-accessibility.md) | **Adopted** | No user interface to audit. First surfaces render in [Phase 02d](../roadmap/phase-02d-walking-skeleton.md); automated axe checks in [Phase 06](../roadmap/phase-06-renderer-admin-studio.md). | | 17 | [Code Review](17-code-review.md) | **Active** | Applied to every pull request merged so far; the zero-tolerance blocker list is in live use. | | 18 | [Audit Coverage](18-audit-coverage.md) | **Active** | Packet 9 lit the write path under [ADR-0033](../decisions/0033-audit-durability-model.md) and [ADR-0044](../decisions/0044-audit-write-path.md): classification at step 3, MUST rows on the business transaction before `COMMIT`, `audit_log` / `audit_config` with their append-only layers, and the catalogue ↔ matrix join enforced in both directions and per request type by `AuditCoverageTests`. Retention and partitioning are [Phase 11](../roadmap/phase-11-production-hardening.md)'s. | | 19 | [Permissions](19-permissions.md) | **Adopted** | No permission key, policy or role exists. Lands in [Phase 03](../roadmap/phase-03-identity-admin.md). | | 20 | [Infrastructure Stack](20-infrastructure-stack.md) | **Active** | `ISecretProvider` shipped in Packet 3, the foundation ports and their defaults in Packet 5, and the entitlement socket in Packet 9; `DeploymentMode` branching happens once, at the composition root. Packet 10 made the bans this document states mechanical: no module reaches a cache client, a Hub namespace, `audit_log` or `platform_entitlement_cache`, and every entitlement key a call site names is a registry member. The adapters themselves — Dapr, Kafka, Valkey, Vault, APISIX — arrive on [ADR-0035](../decisions/0035-demand-gated-infrastructure.md)'s triggers, which is the model rather than a gap in it. | -| 21 | [Architecture Tests Catalogue](21-architecture-tests-catalogue.md) | **Active** | The catalogue's own § Implemented today carries the counts, and `The_Catalogue_Counts_Its_Own_Rules` recomputes them, so this row does not keep a third copy. Rules run in the architecture assembly and beside it in the unit, integration and frontend suites — each where it can actually fail, against an applied schema, a real host or a real ESLint configuration. `Every_Implemented_Rule_Names_A_Test_That_Exists` holds each Implemented entry to a method of that name, and `No_Architecture_Test_Is_Skippable` to the policy that none of them can be turned off. | +| 21 | [Architecture Tests Catalogue](21-architecture-tests-catalogue.md) | **Active** | The catalogue's own § Implemented today carries the counts, and `The_Catalogue_Counts_Its_Own_Rules` recomputes them, so this row does not keep a third copy. Rules run in the architecture assembly and beside it in the unit, integration and frontend suites — each where it can actually fail, against an applied schema, a real host or a real ESLint configuration. `Every_Implemented_Rule_Names_A_Test_That_Exists` holds each Implemented entry to a method of that name, and `No_Architecture_Test_Is_Skippable` to the policy that none of them can be turned off — in the backend suites today; the frontend Vitest run is outside that check until it is closed, which is G38 in [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). | Nineteen `Active`, three `Adopted`. Packet 10 moved five: the frontend rules, the one-app rule, the locale invariants, the out-of-band setters and the port bans are all mechanical From 4880fac60b50bb6133b98652ff06a4d9db9316ec Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 14 Sep 2026 10:17:39 +0300 Subject: [PATCH 4/6] docs: point every carrier of an open Phase 02d gate at the register A review of d9611fb found Active standards, the glossary, skills, architecture pages and other phase documents still stating, as settled, answers that Phase 02d's decision register holds open, so a reader of the carrier alone would implement a Leaning before its decision pass. A corpus-wide sweep found more of the same. Each such carrier now names its gate beside the text instead of being rewritten, because the register's questions quote that text as their premises and the pass that closes each gate edits it with the answer. The sweep also found statements false against shipped code today, and those are corrected outright: NullEntitlementProvider grants every feature, so G16 (g)'s premise was inverted; the out-of-band setter set is eight, not seven; the rate-limiter comment described forwarded headers backwards; a jsonb column keeps no authored key order; and several docs described compose services, cache-key prefixing, Keycloak seed data and module-spec paths that do not exist. The Lighthouse packet cell, the rename exclusion, Phase 06's error-page claim and G13's anchor are fixed in the plan itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .claude/skills/add-ef-migration/SKILL.md | 10 +++-- .claude/skills/add-frontend-route/SKILL.md | 29 ++++++++++++-- .claude/skills/add-i18n-key/SKILL.md | 9 +++++ .claude/skills/add-mediatr-handler/SKILL.md | 12 ++++++ .claude/skills/add-page-block/SKILL.md | 24 ++++++++++- .claude/skills/add-permission/SKILL.md | 6 +++ .../skills/add-tenant-content-type/SKILL.md | 12 ++++++ .../skills/add-tenant-owned-entity/SKILL.md | 14 +++++-- .claude/skills/code-review/SKILL.md | 22 ++++++---- .claude/skills/implement-task/SKILL.md | 4 +- .claude/skills/local-dev-setup/SKILL.md | 23 +++++++---- .claude/skills/run-tests-locally/SKILL.md | 29 +++++++++----- .claude/skills/seed-tenant/SKILL.md | 14 +++++-- .claude/skills/standards-check/SKILL.md | 18 +++++++-- .env.example | 8 ++++ .github/CONTRIBUTING.md | 7 ++++ .github/workflows/ci.yml | 7 ++++ README.md | 6 +++ .../LearnStack.Api/Pagination/ListRequest.cs | 13 +++--- .../Tenancy/RateLimitingExtensions.cs | 21 ++++++++-- .../MultiTenancy/PlatformAdminScope.cs | 2 +- .../Audit/IAuditCatalog.cs | 12 ++++-- .../Persistence/IUnitOfWork.cs | 6 +-- .../Tenancy/IPlatformAdminScope.cs | 6 +-- .../Tenancy/IProvisionsTenant.cs | 2 +- .../Validation/IJsonSchemaValidator.cs | 6 ++- .../TenantContentType.cs | 12 +++++- .../Tenant/ProvisionTenantCommandHandler.cs | 6 +-- .../PlatformAdminScopeConventionTests.cs | 2 +- .../Database/TenancySchemaTests.cs | 2 +- .../RateLimitingHttpTests.cs | 7 ++++ docs/architecture/02-domain-model.md | 18 +++++++++ .../architecture/04-technical-architecture.md | 29 +++++++++++--- docs/architecture/05-mvp-scope.md | 9 +++++ docs/architecture/09-tenant-isolation.md | 6 +-- docs/architecture/12-localization.md | 20 ++++++++++ docs/architecture/13-identity-and-auth.md | 30 ++++++++++---- docs/architecture/14-frontend-architecture.md | 35 ++++++++++++---- docs/architecture/25-deployment-models.md | 19 +++++++-- .../28-platform-tenant-organization.md | 2 +- .../32-tenant-customization-model.md | 40 +++++++++++++++++++ docs/glossary.md | 10 ++--- docs/modules/customization/README.md | 2 +- docs/modules/tenancy/README.md | 25 +++++++----- docs/roadmap/README.md | 8 +++- docs/roadmap/phase-02b-events-auth.md | 15 ++++--- docs/roadmap/phase-02d-walking-skeleton.md | 17 ++++---- docs/roadmap/phase-04-cms-media-pages.md | 18 ++++++++- .../phase-05-education-learning-content.md | 11 +++-- .../roadmap/phase-06-renderer-admin-studio.md | 11 +++-- .../phase-07-enrollment-learner-portal.md | 8 ++++ docs/standards/01-architecture-standards.md | 8 ++++ docs/standards/02-backend-coding.md | 15 +++++-- docs/standards/03-frontend-coding.md | 32 ++++++++++++++- docs/standards/04-api-design.md | 38 +++++++++++++++++- docs/standards/05-database.md | 17 ++++++-- docs/standards/06-testing.md | 22 ++++++++-- docs/standards/07-frontend-architecture.md | 31 ++++++++++++++ docs/standards/08-localization.md | 14 +++++++ docs/standards/09-error-handling.md | 2 +- docs/standards/10-observability.md | 7 ++++ docs/standards/11-security.md | 21 +++++++++- docs/standards/12-infrastructure.md | 14 ++++++- docs/standards/13-documentation.md | 7 +++- docs/standards/15-performance.md | 14 +++++++ docs/standards/16-accessibility.md | 12 ++++++ docs/standards/17-code-review.md | 8 ++++ docs/standards/19-permissions.md | 3 +- docs/standards/20-infrastructure-stack.md | 14 ++++++- .../21-architecture-tests-catalogue.md | 15 ++++++- docs/standards/README.md | 6 +-- frontend/apps/web/.env.local.example | 7 ++++ frontend/apps/web/README.md | 8 +++- frontend/apps/web/src/app/globals.css | 7 ++-- scripts/seed.sh | 4 ++ 75 files changed, 836 insertions(+), 174 deletions(-) diff --git a/.claude/skills/add-ef-migration/SKILL.md b/.claude/skills/add-ef-migration/SKILL.md index de5fa650..bfb99eea 100644 --- a/.claude/skills/add-ef-migration/SKILL.md +++ b/.claude/skills/add-ef-migration/SKILL.md @@ -172,10 +172,12 @@ migrationBuilder.Sql(""" context filters the row out instead of raising on a pooled connection. Write the `app.scope` term into the policy even though **nothing sets it**: the flag derives from the actor's role and roles arrive in -[Phase 02b](../../../docs/roadmap/phase-02b-events-auth.md), which is the earliest -phase that can own the carrier, so the cross-organization read hatch is unreachable at -runtime until then — the correct default, and the reason the two `AS RESTRICTIVE` -guards need a test that sets the variable by hand. +[Phase 03](../../../docs/roadmap/phase-03-identity-admin.md), which is the earliest +phase that can own the carrier +([Security Standards § Tenant Context](../../../docs/standards/11-security.md#tenant-context)), +so the cross-organization read hatch is unreachable at runtime until then — the correct +default, and the reason the two `AS RESTRICTIVE` guards need a test that sets the +variable by hand. **Roles.** Migrations run as `learnstack_migration` (the table owner); the application connects as `learnstack_app` (`NOBYPASSRLS`, not the owner). Grant the diff --git a/.claude/skills/add-frontend-route/SKILL.md b/.claude/skills/add-frontend-route/SKILL.md index 42c70a33..b4d9de2f 100644 --- a/.claude/skills/add-frontend-route/SKILL.md +++ b/.claude/skills/add-frontend-route/SKILL.md @@ -85,6 +85,14 @@ export default async function UsersPage({ searchParams }: { searchParams: { q?: } ``` +> **Open in Phase 02d.** The call shape above is illustrative. At HEAD +> `@learnstack/sdk/server` exports `createServerSdk(options)`, a typed stub that takes +> a `tenantId` and a `locale` and returns `{}`; no `sdk` object and no module namespace +> exist. What the SDK surface becomes once regeneration makes `paths` non-empty is G31, +> and the server transport's options are G35, in +> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register); +> the passes that close them edit this step. + Rules: - Default to Server Component. `"use client"` only when the screen needs hooks, @@ -162,6 +170,12 @@ export default function CoursesPage() { Translation keys live under `frontend/apps/web/src/i18n/<locale>/courses.json`. 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. + ### Step 8: Public-site SSR caching This step is under an open gate. How `(public)` routes render, and which Next.js @@ -199,8 +213,10 @@ Every route ships its own: recorded in the PR description. The phase that ships a route names its test set in its decision register. - Lighthouse budget check on representative public routes — CI's `lighthouse budget` - job is a deferred placeholder until Phase 02d activates it; until then judge by - reading. + job is a deferred placeholder, so judge by reading. Whether Phase 02d activates it + is G44 in its + [decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register); + the pass that closes it rewrites this bullet. ## Validation @@ -219,9 +235,16 @@ Every route ships its own: ## Common pitfalls - **Mounting under the wrong route group.** `(public)` SSR + ISR is wrong for a - Studio screen — caching across users is a leak. + Studio screen — caching across users is a leak. How a `(public)` route renders, and + which Next.js caches it may use, is G37 in + [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register), + held open in [Step 8](#step-8-public-site-ssr-caching); the pass that closes it + edits this pitfall. - **Hand-rolled `fetch`.** The ESLint rule rejects it; use the SDK. - **Reading `host` inside a page.** The middleware is the only legal resolver. + Whether the middleware resolves anything, and what it carries, is G25 and G36 in + [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register); + the pass that closes them edits this pitfall. - **Client Component by default.** Default to Server. Don't sprinkle `"use client"` to avoid thinking about boundaries; that's how INP regresses. - **Trusting frontend permission check.** Hidden buttons are not security; the diff --git a/.claude/skills/add-i18n-key/SKILL.md b/.claude/skills/add-i18n-key/SKILL.md index 7c4b3dea..19289723 100644 --- a/.claude/skills/add-i18n-key/SKILL.md +++ b/.claude/skills/add-i18n-key/SKILL.md @@ -19,6 +19,15 @@ 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. + ## When to use - A new screen / component renders user-facing English (or any language) text. diff --git a/.claude/skills/add-mediatr-handler/SKILL.md b/.claude/skills/add-mediatr-handler/SKILL.md index d4e9ceb1..9df1b234 100644 --- a/.claude/skills/add-mediatr-handler/SKILL.md +++ b/.claude/skills/add-mediatr-handler/SKILL.md @@ -257,6 +257,18 @@ See [add-audit-coverage](../add-audit-coverage/SKILL.md). ### Step 5: Permission policy +> **Not wired yet.** No `IPermissionRegistry`, `registry.Tenant(...)` or `Roles` +> catalogue exists in `backend/src`, `LearnStack.Api` registers no authorization +> middleware, and `AuthorizationBehavior` passes every request through; the catalogue +> lands with the Identity module in +> [Phase 03](../../../docs/roadmap/phase-03-identity-admin.md). Until then this step's +> registration, Step 7's `[Authorize(Policy = …)]` and the `403` check under +> § Validation have nothing to bind to. A module's `permissions.md` is a forward +> declaration, a handler with no HTTP route stands on reachability, as +> [the Tenancy permission matrix](../../../docs/modules/tenancy/permissions.md) +> records, and an anonymous endpoint carries `[AllowAnonymous]` with its reason +> ([Permissions Standards § HTTP endpoints](../../../docs/standards/19-permissions.md#http-endpoints)). + The endpoint that invokes this handler is guarded by `[Authorize(Policy = "enrollment.enrollment.write")]`. Register the permission in the module: diff --git a/.claude/skills/add-page-block/SKILL.md b/.claude/skills/add-page-block/SKILL.md index 0171d01f..9281e80b 100644 --- a/.claude/skills/add-page-block/SKILL.md +++ b/.claude/skills/add-page-block/SKILL.md @@ -60,6 +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. + ### Step 2: Primitive block (path A) #### A.1 — Author the JSON Schema @@ -196,7 +208,14 @@ the page. - JSON Schema validation test for the new primitive / tenant schema. - Renderer snapshot test. -- Accessibility test (`axe-core` violations fail). +- 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 until CI's `lighthouse budget` job, deferred to Phase 02d, is active. @@ -210,7 +229,8 @@ the page. editor. - For a tenant block: a page can be authored that uses the new block; the renderer dispatches correctly. -- Accessibility (`axe-core`) and contrast checks pass. +- Accessibility (`axe-core`) and contrast checks pass — Step 7 says what runs `axe-core` + and names the Phase 02d gate open on it. ## Common pitfalls diff --git a/.claude/skills/add-permission/SKILL.md b/.claude/skills/add-permission/SKILL.md index 63cc959b..61e2ad26 100644 --- a/.claude/skills/add-permission/SKILL.md +++ b/.claude/skills/add-permission/SKILL.md @@ -21,6 +21,12 @@ Add a permission to the module's naming convention, register it on the right endpoint, and update the per-module permission matrix. +> **Not wired yet.** `IPermissionRegistry`, `IModule.RegisterPermissions` and the +> `Roles` catalogue land with the Identity module in +> [Phase 03](../../../docs/roadmap/phase-03-identity-admin.md); none exists in +> `backend/src`. Until then only Step 1 and Step 5 apply, as a forward declaration on +> [the Tenancy precedent](../../../docs/modules/tenancy/permissions.md). + ## When to use - A new resource needs an `[Authorize]` policy. diff --git a/.claude/skills/add-tenant-content-type/SKILL.md b/.claude/skills/add-tenant-content-type/SKILL.md index 678de0ba..5855a9eb 100644 --- a/.claude/skills/add-tenant-content-type/SKILL.md +++ b/.claude/skills/add-tenant-content-type/SKILL.md @@ -120,6 +120,18 @@ Rules of thumb: - The `x-taxonomy` you name has to exist **before** the content type that references it — any revision of it, published or not. Register the taxonomy first. +> **Open in Phase 02d.** Nothing above gives a field a display order or a label in each +> of the tenant's locales: `json_schema` is `jsonb`, which keeps no key order, and the +> profile collects only the three extensions listed. How a content type carries order, +> labels and a composite's field roles, which composite draws a lesson, which primitives +> the phase implements and how a type with no primitive row maps are G18. Which URL +> schemes a field may hold is G19: validation constrains structure, not schemes, and +> `format: "uri"` admits `javascript:`. +> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register) +> holds both, and the pass that closes each edits this note with its answer. G18's row +> records why it closes before the seed publishes a content type: a later answer needs +> successor revisions. + ### Step 2: Register it through the command Registration goes through the module's command, never through a `DbContext` and diff --git a/.claude/skills/add-tenant-owned-entity/SKILL.md b/.claude/skills/add-tenant-owned-entity/SKILL.md index ecc44a3e..af62a2b0 100644 --- a/.claude/skills/add-tenant-owned-entity/SKILL.md +++ b/.claude/skills/add-tenant-owned-entity/SKILL.md @@ -120,7 +120,7 @@ In `<Module>.Domain/<Name>/<Name>.cs`: public sealed class <Name> : AuditableEntity<<Name>Id> { public TenantId TenantId { get; private set; } - public OrganizationId? OrganizationId { get; private set; } // nullable when row may be tenant-wide + public OrganizationId? OrganizationId { get; private set; } // always nullable: null = tenant-wide // ... domain fields ... @@ -136,8 +136,13 @@ public sealed class <Name> : AuditableEntity<<Name>Id> Rules: - `TenantId` is always set at construction; nullable is not allowed. -- `OrganizationId` is nullable only for entities that may be tenant-wide; if the - entity is **always** org-scoped, make the property non-nullable. +- `OrganizationId` is nullable on every `[OrganizationScoped]` entity — null means + tenant-wide — and `Every_OrgScoped_Entity_HasOrgIdAndFilter` fails a mapped + `organization_id` that is not nullable (Step 4). + > **Open in Phase 02d.** Whether a child or satellite row carries its parent's + > organization, and what forces it to at insert, is G7 in + > [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register). + > The pass that closes it edits this rule with its answer. - Use `AuditableEntity<<Name>Id>` for mutable aggregates. **Never** `Entity<TId>` unless the aggregate is append-only (e.g. `AuditEntry`). - Domain events for state changes; don't write to other aggregates from this one. @@ -500,7 +505,8 @@ See [add-integration-test](../add-integration-test/SKILL.md). `app.tenant_id` but breaks isolation silently — RLS returns zero rows, no test fails unless you test for non-empty. - **Org-scoped without nullable column.** Forces every row to be org-bound, breaking - tenant-wide rows. The default is `nullable + tenant-wide allowed`. + tenant-wide rows, and fails `Every_OrgScoped_Entity_HasOrgIdAndFilter`. Nullable is + the rule, not a default. - **`Entity<TId>` instead of `AuditableEntity<<Name>Id>`.** You lose `created_at` / `updated_at` automation. Only the audit aggregate uses `Entity<TId>`. - **Writing a query filter at all.** Since Packet 7 step 3 the module's diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index c4aa9b8b..9b5e39d8 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -81,9 +81,9 @@ Walk every change against the project's | **Tenant + organization isolation** | New tenant-owned entity has `[TenantOwned]` + EF filter + RLS policy + canonical `app.tenant_id` session var. `[OrganizationScoped]` covered the same way. Any `IgnoreQueryFilters` outside platform-admin paths is a zero-tolerance blocker per [17-code-review.md § Zero Tolerance](../../../docs/standards/17-code-review.md). | | **4-step auth order** | Every write use case checks in order: (1) Authn → (2) Tenant membership → (3) Role / permission → (4) Resource scope. Failure at each step returns the right Problem Details code (`unauthorized` / `tenant_mismatch` / `forbidden` / `resource_scope_violation`). Source: [11-security.md § Authorization](../../../docs/standards/11-security.md), [19-permissions.md § Enforcement Points](../../../docs/standards/19-permissions.md). | | **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 JWT only** | Never from request body / query / header that isn't authenticated. Check command and DTO surfaces. | +| **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. | +| **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. | | **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. | @@ -128,7 +128,7 @@ Each finding → severity (Blocker / Major / Minor) + concrete fix. | **Bundle size (FE)** | Large dependency imported into a public-route Client Component. Lighthouse JS budget violated. | | **Re-render storm (FE)** | Context value computed inline in the provider — every consumer re-renders on every render. | | **Suspense boundary missing** | Server Component awaits slow data on the critical path; missing `<Suspense>` for streaming. | -| **Per-tenant SSR cardinality** | Cache key includes tenant + org + locale + slug; reviewer can confirm memory budget. | +| **Per-tenant SSR cardinality** | Cache key includes tenant + org + locale + slug; reviewer can confirm memory budget. Whether a `(public)` route may hold rendered output or fetched data in a Next.js cache at all is G37 in [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register); until it closes, [add-frontend-route § Step 8](../add-frontend-route/SKILL.md#step-8-public-site-ssr-caching) bars `revalidate`, `generateStaticParams` and `unstable_cache` there, and the pass that closes it edits this row. | ### Step 6 — Refactor / Clean Code lens @@ -163,7 +163,13 @@ This is the lens that generic reviewers miss. Walk: - `docs/modules/<m>/audit.md` / `permissions.md` updated. - For frontend changes: route group is correct, SDK is the only API path, middleware-resolved `x-tenant-id` / `x-organization-id` honoured, no - hand-rolled `fetch('/v1/...')`. + hand-rolled `fetch('/v1/...')`. The API treats an `X-Tenant-Id` or `X-Organization-Id` + it receives as an assertion, never a source + ([ADR-0036](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md#one-header-names-a-host-and-it-is-still-not-a-source)). + Whether the frontend holds a tenant or organization id at all, and what the middleware + carries inward, is G25 and G36 in + [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register); + the pass that closes them edits this item. - For customization changes: data-only, no domain term in core code. If the change is doc-only, the equivalent checks: no `docs/analysis/` refs, @@ -179,7 +185,7 @@ change. | Outbox round-trip test for new integration event | Always | | Permission denied test for every new permission key | Always | | Boundary tests for every DSL band threshold | Scoring / completion rules | -| Lighthouse / axe-core for public-route changes | Frontend — Lighthouse **from Phase 02d**, axe-core **from Phase 06**; neither is wired today, so do not raise a Blocker for a missing run | +| Lighthouse / axe-core for public-route changes | Frontend — Lighthouse **from Phase 02d**, axe-core **from Phase 06**; neither is wired today, so do not raise a Blocker for a missing run. Whether Lighthouse activates in Phase 02d is G44 in [that phase's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register), and the pass that closes it edits this row | A change without tests is incomplete; flag as Blocker unless the user explicitly deferred the test. @@ -228,9 +234,9 @@ separate agent to run the review. Template: ````markdown You are the code-review agent for LearnStack, a multi-tenant PaaS for building -education products. The repository is pre-implementation; current corpus is -documentation under `docs/`. Read `CLAUDE.md` first for hard rules and the -documentation layout. +education products. Code ships under `backend/` and `frontend/` beside the corpus +under `docs/`, and `CLAUDE.md` § What state this is in names the live phase. Read +`CLAUDE.md` first for hard rules and the documentation layout. ## Scope of this review diff --git a/.claude/skills/implement-task/SKILL.md b/.claude/skills/implement-task/SKILL.md index 0377fccf..1ec2f2e9 100644 --- a/.claude/skills/implement-task/SKILL.md +++ b/.claude/skills/implement-task/SKILL.md @@ -253,8 +253,8 @@ prompt for that agent so it doesn't have to re-learn the context. Use the [code-review § Generating a review-agent prompt](../code-review/SKILL.md) template. The prompt must: -- Set the project context (LearnStack PaaS, pre-implementation phase, the - hard-rules summary). +- Set the project context (LearnStack PaaS, the live phase from `CLAUDE.md` § What + state this is in, the hard-rules summary). - Point at the specific commit / branch / file list under review. - Tell the review agent to walk security + bug / potential bug + optimisation + refactor + standards conformance. diff --git a/.claude/skills/local-dev-setup/SKILL.md b/.claude/skills/local-dev-setup/SKILL.md index a3abc930..cf68b75e 100644 --- a/.claude/skills/local-dev-setup/SKILL.md +++ b/.claude/skills/local-dev-setup/SKILL.md @@ -260,9 +260,13 @@ open http://localhost:3000 ### Step 6: Switch deployment modes locally -Edit `.env` to flip `DEPLOYMENT_MODE`. This changes the composition paths that -already exist, such as error tracking and telemetry. It does not make the -demand-gated Dapr adapters exist early: +Set `Deployment__Mode` in the shell that runs `dotnet run`, or `Deployment:Mode` in the +user-secrets store Step 3 uses, to flip the mode. Editing `.env` does nothing: it has no +mode key, and `dotnet run` reads no `.env` (Step 3). The committed value is +`Development`, under `Deployment:Mode` in `appsettings.Development.json`, and the +composition root refuses to start without the key rather than defaulting it. The mode +changes the composition paths that already exist, such as error tracking and telemetry. +It does not make the demand-gated Dapr adapters exist early: | Value | What happens | |-------|--------------| @@ -277,7 +281,7 @@ For **every** value today, the three demand-gated ports still resolve to `ConfigurationSecretProvider`. `DaprEventBus`, `DaprCacheService`, and `DaprSecretProvider` land in Phase 11 only after their ADR-0035 triggers fire. -After changing `.env`, stop and rerun the API process: +After changing the mode, stop and rerun the API process: ```bash # In the terminal running `dotnet run`, press Ctrl+C, then: @@ -307,12 +311,17 @@ state. ## Validation +> **Open in Phase 02d.** Which hostnames serve the two demo tenants, and the step a +> browser needs to reach them, is G32; what `make demo` starts and guarantees is G45. +> Both are in +> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register), +> and the pass that closes each adds its check to this list with its answer. + - `make dev` exits 0; after starting the API separately, `/healthz` responds 200. - After `make dev-gated` and with that API running, APISIX forwards `/healthz`. -- The web app loads against a demo tenant's host (either default subdomain or - Hosts-aliased custom domain). +- The web app serves the scaffold page on `http://localhost:3000` (Step 5); no tenant + page renders on either demo host yet. - Keycloak login works for both realms. -- A test learner can complete a lesson against the seeded English tenant's data. - `dotnet test backend/tests/LearnStack.Tests.Integration` passes against the same containers (the Testcontainers fixture is independent; this is just a consistency check). diff --git a/.claude/skills/run-tests-locally/SKILL.md b/.claude/skills/run-tests-locally/SKILL.md index 8adba995..33da65fc 100644 --- a/.claude/skills/run-tests-locally/SKILL.md +++ b/.claude/skills/run-tests-locally/SKILL.md @@ -71,9 +71,10 @@ backend/tests/ LearnStack.Tests.Contract/ # OpenAPI / SDK contract assertions. frontend/apps/web/ # Vitest. The axe-core and Playwright suites - # arrive in Phase 06; CI's `lighthouse budget` - # job stops being deferred earlier, in Phase 02d, - # with the first content-bearing pages. + # arrive in Phase 06. Whether CI's deferred + # `lighthouse budget` job activates in Phase 02d + # is G44 in that phase's decision register; the + # pass that closes it edits this comment. ``` ### Step 3: Run unit tests @@ -161,9 +162,12 @@ pnpm lint # next lint — what `pnpm -r lint` runs in CI > **`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); CI's -> deferred `lighthouse budget` job activates earlier, in Phase 02d, with the first -> content-bearing pages. Until then there is no accessibility or end-to-end gate to run. +> [Testing Standards § End-to-End Tests](../../../docs/standards/06-testing.md). Whether +> CI's deferred `lighthouse budget` job activates earlier, in Phase 02d, is G44, and +> which accessibility checks fail a build on that phase's pages is G43; +> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register) +> holds both, and the pass that closes each edits this note. Today there is no +> accessibility or end-to-end gate to run. ### Step 7: Single-test focus @@ -247,8 +251,10 @@ dotnet test --blame-hang --blame-hang-timeout 5min - For integration suites, Docker is running and nothing else holds 5432. - 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 accessibility gate joins this list in Phase 06, with the suite that - enforces it. + The axe suite joins this list in Phase 06. Whether route tests or `jsx-a11y` + findings fail a build earlier, on Phase 02d's pages, is G43 in + [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register), + and the pass that closes it edits this line. ## Common pitfalls @@ -265,7 +271,10 @@ dotnet test --blame-hang --blame-hang-timeout 5min against stale binaries. - **Assuming an accessibility gate exists.** [16-accessibility.md](../../../docs/standards/16-accessibility.md) makes WCAG - 2.2 AA binding, and Phase 06 is what makes a suite enforce it. Reading the - standard is the gate until then. + 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. - **CI-only failures.** Usually a race or timing assumption. Use `--blame-hang` + `--blame-crash` locally. diff --git a/.claude/skills/seed-tenant/SKILL.md b/.claude/skills/seed-tenant/SKILL.md index c77040a7..08425278 100644 --- a/.claude/skills/seed-tenant/SKILL.md +++ b/.claude/skills/seed-tenant/SKILL.md @@ -243,9 +243,12 @@ the Hub ([ADR-0034](../../../docs/decisions/0034-hub-contract-surface-invariant. ([ADR-0036](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md#effective-host-and-the-trusted-hop)). The Next.js middleware at `frontend/apps/web/src/middleware.ts` is still a scaffold that copies the raw host into `x-tenant-id`, so the web app renders no tenant page on -either host until [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md). Its -§ Host-based tenant resolution, end to end owns the hostnames and the step a browser -needs to reach them. +either host until [Phase 02d](../../../docs/roadmap/phase-02d-walking-skeleton.md). + +> **Open in Phase 02d.** Whether the seed hosts stay under `*.learnstack.local` with the +> alias above, and what step a browser needs to reach them, is G32 in +> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register); +> the pass that closes it edits this step with its answer. ### Step 6: Verify @@ -302,6 +305,11 @@ To add a third domain showcase (e.g. music school): 2. Register the host in `/etc/hosts` and, from Phase 02d, expect it to render. 3. Run `make seed`. +> **Open in Phase 02d.** Whether a new host needs that hosts-file entry, and which +> development domain it sits under, is G32 in +> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register); +> the pass that closes it edits step 2 with its answer. + Its customization data — content types, level taxonomy, blocks, rules, templates — is added as each owning phase from § What a later phase adds lands the aggregate that holds it. diff --git a/.claude/skills/standards-check/SKILL.md b/.claude/skills/standards-check/SKILL.md index c88fdbe8..2b8b6f7c 100644 --- a/.claude/skills/standards-check/SKILL.md +++ b/.claude/skills/standards-check/SKILL.md @@ -247,7 +247,11 @@ domain the diff doesn't touch. - [ ] Route group correct (`(public)` / `(studio)` / `(portal)`). - [ ] Server Components default; `"use client"` only when needed. - [ ] Tenant + organization + locale resolution via middleware; not - re-implemented in the page. + re-implemented in the page. The API resolves tenant and organization from the host + ([ADR-0036](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md#effective-host-and-the-trusted-hop)); + what the middleware resolves or carries for `(public)` routes is G25 and G36 in + [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register), + and the pass that closes them edits this item. - [ ] Folder structure under `frontend/apps/web/`. #### `08-localization.md` @@ -275,9 +279,17 @@ domain the diff doesn't touch. #### `11-security.md` - [ ] 4-step auth order on every write (Authn → Tenant membership → Role/permission → Resource scope). -- [ ] Tenant id from JWT only, never from request body. +- [ ] Tenant id from the resolved context, never from request body — the host lookup of + the effective host, or validated claims plus live membership on a platform host, per + [ADR-0036](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md); + `X-Tenant-Id` and `X-Organization-Id` are assertions, never sources. - [ ] Secrets via `ISecretProvider`; no plaintext in source / `appsettings`. -- [ ] Secure headers set (HSTS, CSP, COOP, CORP). +- [ ] Secure headers set (HSTS, CSP, COOP, CORP). **Not enforced yet:** nothing sets + them today, and the + [standards index](../../../docs/standards/README.md#honest-status-today) row for + Security Standards carves § Transport and § HTTP Headers out to + [Phase 11](../../../docs/roadmap/phase-11-production-hardening.md#security), so a + missing header is not a failure until that phase sets them. - [ ] File-upload validation (MIME sniff, size, EXIF strip, scoped key). - [ ] Two-realm separation enforced. diff --git a/.env.example b/.env.example index e78cf793..f6120376 100644 --- a/.env.example +++ b/.env.example @@ -101,9 +101,17 @@ SEAWEEDFS_SECRET_KEY=learnstack-dev-secret MEILI_MASTER_KEY=learnstack-dev-master-key # ─── Backend host (LearnStack.Api, runs on workstation via `dotnet run`) ─ +# `dotnet run` reads no `.env`, so these two lines are documentation. The API's launch +# profile, backend/src/LearnStack.Api/Properties/launchSettings.json, sets the same two. ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS=http://localhost:5080 # ─── Frontend host (Next.js apps/web; see frontend/apps/web/.env.local.example # for the apps/web-only overrides — Next reads .env.local from the app dir, # not the repo root, so the FE has its own copy mirroring these values) ─── +# No frontend variable is listed here yet: NEXT_PUBLIC_API_BASE_URL and the OIDC and +# LiveKit values exist only in frontend/apps/web/.env.local.example, and no code reads +# them. Open in Phase 02d: the server-only API origin and the trusted-hop +# configuration are G33, and how environment reaches both host processes is G45, in +# docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register. The pass that +# closes each gate edits this section with its answer. diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 64efbad6..a1efa8fb 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -76,6 +76,13 @@ Configure these in **GitHub → Settings → Branches → Branch protection rule GitHub matches required checks **by name**, so the rename is the dangerous half: a renamed check that nobody re-required is a check that no longer blocks anything, and the PR still shows green. + + > **Open in Phase 02d.** Whether the Lighthouse job activates in Phase 02d, and on + > what harness, is G44; whether an activated job keeps its `vars.ENABLE_*` + > condition — GitHub treats a skipped required job as passing — is G31. + > [Phase 02d's decision register](../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register) + > holds both. The pass that closes each gate edits the activation steps and the + > Lighthouse entry above with its answer. - **Require conversation resolution before merging**: on. - **Require signed commits**: optional (off until the team rolls out signing keys). - **Require linear history**: on (we use squash-merge or rebase-merge, never bubble). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90018f49..fa96a5a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,13 @@ # - lighthouse-budget : LHCI against the built Next.js app. Activates # in Phase 02d, which ships the first content-bearing public pages (the # two tenant catalog / lesson pages a visitor actually loads). +# +# Open in Phase 02d: whether lighthouse-budget activates there, on what harness, +# over which pages and with which assertions is G44, and whether an activated job +# keeps its `if: vars.ENABLE_*` condition is G31, both in +# docs/roadmap/phase-02d-walking-skeleton.md § The decision register. The pass that +# closes each gate edits this block, the deferred jobs and +# .github/CONTRIBUTING.md § Branch protection with its answer. name: ci diff --git a/README.md b/README.md index 8a30dff2..4f04dcc0 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,12 @@ written through `ProvisionTenantCommand` and the same handlers a request uses, s browser — which is how the genericity claim is tested continuously rather than asserted once at the end. +> **Open in Phase 02d.** Whether the seed hosts stay under `*.learnstack.local` with the +> hosts-file step above, and what a reviewer does between a clean checkout and both +> sites, is G32; what `make demo` starts and prints is G45. Both are in +> [Phase 02d's decision register](docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register), +> and the pass that closes each gate edits this section with its answer. + --- ## Where it is today diff --git a/backend/src/LearnStack.Api/Pagination/ListRequest.cs b/backend/src/LearnStack.Api/Pagination/ListRequest.cs index 3f23556d..c651c400 100644 --- a/backend/src/LearnStack.Api/Pagination/ListRequest.cs +++ b/backend/src/LearnStack.Api/Pagination/ListRequest.cs @@ -48,12 +48,13 @@ public record ListRequest : CursorPaginationRequest, IValidatableObject /// Free-text search. /// </summary> /// <remarks> - /// No length cap here. Standards 04 § Request and Response Limits states a - /// 2 KB URL bound, but nothing in this application enforces it — the real - /// ceiling today is Kestrel's request-line and header limits, and the - /// gateway's once it fronts the app. Capping <c>q</c> at some other number - /// would add a third bound that agrees with neither, so the honest move is - /// to inherit whatever actually rejects an over-long URL and to say so. + /// No length cap here. Standards 04 § Request and Response Limits states the + /// URL bound as Kestrel's request-line limit (<c>MaxRequestLineSize</c>, which + /// this application leaves at its 8 KiB default), and Kestrel refuses an + /// over-long URL before any middleware runs; the gateway's limit joins it once + /// the gateway fronts the app. Capping <c>q</c> at some other number would add + /// a third bound that agrees with neither, so the honest move is to inherit + /// whatever actually rejects an over-long URL and to say so. /// </remarks> [FromQuery(Name = QParameterName)] public string? Q { get; init; } diff --git a/backend/src/LearnStack.Api/Tenancy/RateLimitingExtensions.cs b/backend/src/LearnStack.Api/Tenancy/RateLimitingExtensions.cs index 9c7269f6..4de052f1 100644 --- a/backend/src/LearnStack.Api/Tenancy/RateLimitingExtensions.cs +++ b/backend/src/LearnStack.Api/Tenancy/RateLimitingExtensions.cs @@ -37,6 +37,15 @@ namespace LearnStack.Api.Tenancy; /// 02b adds the token-keyed budgets Standards 04 also fixes, and adding a /// partition key that is constant-null today would be a partition in name only. /// </para> +/// <para> +/// <b>Open in Phase 02d.</b> A server-rendered page reaches the API from the +/// renderer's peer, so under this key every visitor of such a page would share the +/// renderer's partition. How a request arriving over the trusted hop is keyed and +/// budgeted is G34 in +/// <see href="../../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register">Phase +/// 02d's decision register</see>; the pass that closes it edits this class and these +/// remarks with its answer. +/// </para> /// </remarks> public static class RateLimitingExtensions { @@ -99,10 +108,14 @@ public static IServiceCollection AddLearnStackRateLimiting(this IServiceCollecti /// <summary> /// Reads the socket peer, with the same caveat as the trusted hop: /// <see cref="IHttpConnectionFeature"/> is the storage - /// <c>UseForwardedHeaders</c> mutates, so if that middleware ever runs ahead - /// of this one, every request behind a proxy shares one partition — which - /// turns the limiter into a global cap. <c>Forwarded_Headers_Are_Not_Wired</c> - /// is the tripwire for both. + /// <c>UseForwardedHeaders</c> mutates, so if that middleware ever ran ahead of + /// this one, the key would become whatever address a forwarded header states — + /// a partition the caller mints for itself unless only a trusted proxy may + /// state it. The remarks on <c>RefuseAmbientForwardedHeaders</c> record the + /// measurement: zero rejections under a rotating <c>X-Forwarded-For</c>. In + /// the shipped state, with no such middleware, every request arriving through + /// one proxy shares that proxy's partition. <c>Forwarded_Headers_Are_Not_Wired</c> + /// is the tripwire for this key and the hop alike. /// </summary> private static string PartitionKeyFor(HttpContext context) => context.Features.Get<IHttpConnectionFeature>()?.RemoteIpAddress?.ToString() diff --git a/backend/src/LearnStack.Infrastructure/MultiTenancy/PlatformAdminScope.cs b/backend/src/LearnStack.Infrastructure/MultiTenancy/PlatformAdminScope.cs index 221fdb3d..a9ffb51a 100644 --- a/backend/src/LearnStack.Infrastructure/MultiTenancy/PlatformAdminScope.cs +++ b/backend/src/LearnStack.Infrastructure/MultiTenancy/PlatformAdminScope.cs @@ -31,7 +31,7 @@ namespace LearnStack.Infrastructure.MultiTenancy; /// <para> /// <b>No <c>set_config('app.tenant_id', …)</c>, and no <c>SET TRANSACTION READ ONLY</c>.</b> /// The first because there is no policy to announce to — the role bypasses them — which -/// is also why this is not an eighth out-of-band setter. The second because nothing calls +/// is also why this is not a ninth out-of-band setter. The second because nothing calls /// this path read-only: both named consumers, GDPR redaction and the retention purge, /// write. /// </para> diff --git a/backend/src/LearnStack.SharedKernel/Audit/IAuditCatalog.cs b/backend/src/LearnStack.SharedKernel/Audit/IAuditCatalog.cs index 332c08be..a1ff6079 100644 --- a/backend/src/LearnStack.SharedKernel/Audit/IAuditCatalog.cs +++ b/backend/src/LearnStack.SharedKernel/Audit/IAuditCatalog.cs @@ -189,9 +189,15 @@ IAuditCatalogBuilder MayAudit<TRequest>( /// Declares that this request is known and audits nothing. /// </summary> /// <remarks> - /// The eight test-only request types register here, in their fixture. Registered and - /// silent is not the same as unregistered — the first is a decision, the second is - /// an omission, and only the second is refused. + /// No production request type registers here yet. Test-only types do: the + /// integration suite's, in <c>TestAuditCatalogSource</c>, and probe types in the + /// unit and architecture suites. Registered and silent is not the same as + /// unregistered — the first is a decision, the second is an omission, and only the + /// second is refused. Which class a <c>[PublicSurface]</c> request registers, and + /// whether a rule makes <c>Off</c> the only one permitted, is G28 (a) in + /// <see href="../../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register"> + /// Phase 02d's decision register</see>; the pass that closes it edits this remark + /// with its answer. /// </remarks> IAuditCatalogBuilder Off<TRequest>() where TRequest : notnull; diff --git a/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs b/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs index e484ef24..15ce3769 100644 --- a/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs +++ b/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs @@ -141,9 +141,9 @@ public interface IUnitOfWork : IAsyncDisposable /// <para> /// <b>It does not widen the setter set.</b> /// <see href="../../../../docs/decisions/0040-ambient-unit-of-work.md">ADR-0040 - /// Amendment 3</see> closes that set at seven, and <c>TransactionBehavior</c> remains - /// the only caller of this as it is of its sibling — this is the same setter - /// announcing a different value for one request shape, not an eighth. + /// Amendment 7</see> closes that set at eight, and <c>TransactionBehavior</c> + /// remains the only caller of this as it is of its sibling — this is the same + /// setter announcing a different value for one request shape, not a ninth. /// </para> /// <para> /// <b>Every way of misusing it throws rather than degrading.</b> No open transaction, diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/IPlatformAdminScope.cs b/backend/src/LearnStack.SharedKernel/Tenancy/IPlatformAdminScope.cs index 562605b9..b38fcc46 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/IPlatformAdminScope.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/IPlatformAdminScope.cs @@ -24,10 +24,10 @@ namespace LearnStack.SharedKernel.Tenancy; /// announce to, because the role bypasses them — so it is outside both closed sets: /// the four writers of <c>ITenantContextAccessor.Current</c> /// (<see href="../../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md">ADR-0036 -/// § Rules</see>, which names it as explicitly not one) and the seven out-of-band +/// § Rules</see>, which names it as explicitly not one) and the eight out-of-band /// setters (<see href="../../../../docs/decisions/0040-ambient-unit-of-work.md">ADR-0040 -/// Amendment 3</see>, whose closing property is that every one of them connects as -/// <c>learnstack_app</c>). +/// Amendments 3 and 7</see>, whose closing property is that every one of them connects +/// as <c>learnstack_app</c>). /// </para> /// <para> /// <b>Entry is audited, and an entry that cannot be recorded does not happen.</b> Every diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/IProvisionsTenant.cs b/backend/src/LearnStack.SharedKernel/Tenancy/IProvisionsTenant.cs index a78090c1..a1dfd692 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/IProvisionsTenant.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/IProvisionsTenant.cs @@ -24,7 +24,7 @@ namespace LearnStack.SharedKernel.Tenancy; /// and any statement issued in it is silently fail-closed, and would hand every handler /// in the solution the ability to retarget the ambient tenant. The setter set /// <see href="../../../../docs/decisions/0040-ambient-unit-of-work.md">ADR-0040 -/// Amendment 3</see> closes at seven stays closed. +/// Amendment 7</see> closes at eight stays closed. /// </para> /// <para> /// <b>It grants nothing on its own.</b> The behavior honours it only when the context is diff --git a/backend/src/LearnStack.SharedKernel/Validation/IJsonSchemaValidator.cs b/backend/src/LearnStack.SharedKernel/Validation/IJsonSchemaValidator.cs index 39b99395..ea1a0a8a 100644 --- a/backend/src/LearnStack.SharedKernel/Validation/IJsonSchemaValidator.cs +++ b/backend/src/LearnStack.SharedKernel/Validation/IJsonSchemaValidator.cs @@ -24,8 +24,10 @@ namespace LearnStack.SharedKernel.Validation; /// LearnStack boundary, and an in-process evaluator crosses nothing. It is here /// because four modules need it: Customization in /// <see href="../../../../docs/roadmap/phase-02a-kernel-tenancy.md">Packet 8</see>, -/// Identity for <c>tenant_custom_field_defs</c> in Phase 03, Content for the -/// validating bulk importer in Phase 04, and Education in Phase 05. A port in +/// Education for lesson bodies in +/// <see href="../../../../docs/roadmap/phase-02d-walking-skeleton.md">Phase 02d</see>, +/// Identity for <c>tenant_custom_field_defs</c> in Phase 03, and Content for the +/// validating bulk importer in Phase 04. A port in /// <c>Customization.Application.Contracts</c> would make three unrelated modules /// depend on Customization to reach a library wrapper. /// </para> diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Domain/TenantContentType.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Domain/TenantContentType.cs index 34e35d4e..7e8e22e5 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Domain/TenantContentType.cs +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Domain/TenantContentType.cs @@ -15,7 +15,7 @@ namespace LearnStack.Modules.Customization.Domain; /// The first half of what /// <see href="../../../../../docs/decisions/0018-tenant-driven-customization-model.md">ADR-0018</see> /// means by "the difference lives in their database rows". A language school's -/// <c>vocabulary-card</c> and a yoga studio's <c>asana-pose</c> are two rows in +/// <c>grammar-topic</c> and a yoga studio's <c>asana-pose</c> are two rows in /// this table, not two types in an assembly, and /// <see href="../../../../../docs/roadmap/phase-02d-walking-skeleton.md">Phase 02d</see> /// renders both from the same code path. @@ -57,7 +57,15 @@ private TenantContentType() RendererKey = null!; } - /// <summary>The draft 2020-12 document, as authored, already gated.</summary> + /// <summary>The draft 2020-12 document, already gated.</summary> + /// <remarks> + /// As authored only on the instance a caller built: the column is <c>jsonb</c>, so + /// an instance materialized from the row carries the same members and values but + /// not the author's key order or whitespace. How a content type's field order and + /// labels are carried is G18 in + /// <see href="../../../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register">Phase 02d's decision register</see>, + /// whose pass edits this remark with its answer. + /// </remarks> public string JsonSchema { get; private set; } /// <summary>A key from the closed set in <see cref="CompositeRendererKey"/>.</summary> diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandHandler.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandHandler.cs index 342d40f4..8980f7c9 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandHandler.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandHandler.cs @@ -27,9 +27,9 @@ namespace LearnStack.Modules.Tenancy.Application.Tenant; /// <para> /// <b>It never announces anything.</b> <c>TransactionBehavior</c> has already announced /// the tenant this command names, by reading <c>IProvisionsTenant</c> off the request at -/// step 6. A handler that announced would be an eighth setter of <c>app.tenant_id</c> -/// against a set two ADRs close at seven, and would hand every handler in the solution -/// the ability to move the ambient tenant. +/// step 6. A handler that announced would be a ninth setter of <c>app.tenant_id</c> +/// against the set ADR-0040 closes at eight (Amendment 7), and would hand every handler +/// in the solution the ability to move the ambient tenant. /// </para> /// <para> /// <b>A name already taken is an answer, not a crash.</b> Every uniqueness the schema diff --git a/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs index bef08a33..0538cbb9 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs @@ -162,7 +162,7 @@ public void The_Platform_Scope_Writes_No_Tenant_Context_And_Sets_No_Session_Vari code.Should().NotContain(SourceText.WithoutWhitespace("set_config("), "a BYPASSRLS connection has no policy to announce a tenant to, and announcing " - + "one would make this an eighth setter in a set two ADRs close at seven"); + + "one would make this a ninth setter in the set ADR-0040 closes at eight"); code.Should().NotContain(SourceText.WithoutWhitespace("SetTenantContextAsync")); code.Should().NotContain(SourceText.WithoutWhitespace("IUnitOfWork"), "enlisting would put the bypass on the request's own connection"); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs index 4a7d14a6..46debe68 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs @@ -728,7 +728,7 @@ public async Task OrganizationIdIsImmutableAfterInsert() // and this case exists because no policy does. // // Run as a TENANT-scope session — no app.organization_id. Since ADR-0003 - // Amendment 4 the restrictive UPDATE guard refuses an organization-scoped session + // Amendment 5 the restrictive UPDATE guard refuses an organization-scoped session // the tenant-wide row outright, so attempting the move from one filters to zero // rows and the trigger never fires: the case would pass while testing nothing. A // tenant-scope session is the one that can still reach the row, which makes it diff --git a/backend/tests/LearnStack.Tests.Integration/RateLimitingHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/RateLimitingHttpTests.cs index 2b0f5693..a40fb1ad 100644 --- a/backend/tests/LearnStack.Tests.Integration/RateLimitingHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/RateLimitingHttpTests.cs @@ -87,6 +87,13 @@ public async Task A_Forwarded_For_Header_Does_Not_Buy_A_Fresh_Budget() // against eleven without it. `RefuseAmbientForwardedHeaders` refuses to // start in that configuration; this asserts the other half — that the // header buys nothing while it is off. + // + // Open in Phase 02d: this caller is outside the trusted hop, and the case + // stays a non-hop case. How a request arriving over the hop is keyed and + // budgeted is G34 in Phase 02d's decision register at + // ../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register + // — the pass that closes it edits "it must stay that way" above with its + // answer. using var client = fixture.CreateClient(); var path = new Uri("/healthz", UriKind.Relative); var rejected = 0; diff --git a/docs/architecture/02-domain-model.md b/docs/architecture/02-domain-model.md index 41cc429c..b0339b9b 100644 --- a/docs/architecture/02-domain-model.md +++ b/docs/architecture/02-domain-model.md @@ -284,6 +284,13 @@ per ADR-0018, not on `Membership` extension tables. > **Course vs. CourseVersion.** `Course` carries identity, catalog metadata, SEO, public visibility. `CourseVersion` carries the structure (modules, lessons, items) and is what enrollments and progress bind to. Editing a course never breaks a learner currently progressing through a published version. +> **Open in Phase 02d.** Whether [Phase 02d](../roadmap/phase-02d-walking-skeleton.md) +> ships a minimal `CourseVersion` is G2, and which publication state `Course` carries +> is G3, in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The split above is Phase 05's target model; see the note under +> [§ Learning Content](#learning-content). + ## Learning Content | Entity | Aggregate root? | Notes | @@ -294,6 +301,17 @@ per ADR-0018, not on `Membership` extension tables. | `LearningPath` | Yes | Optional cross-course traversal. | | `CompletionRule` | Inside CourseVersion | Determines when a lesson / module / course is complete. | +> **Open in Phase 02d.** [Phase 02d](../roadmap/phase-02d-walking-skeleton.md) ships +> `Course` and `Lesson` ahead of Phase 05. Which aggregate `Lesson` belongs to and what +> its parent is — an entity inside `Course`, its own root referencing `Course`, or a +> minimal `CourseVersion` and default `Module` — is G2, and which publication state +> `Course` and `Lesson` carry is G3, in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> Placing `Lesson` inside `Course` makes every lesson edit a structural change to +> `Course`, which [§ Education Catalog](#education-catalog) says a published course +> never undergoes. Where an answer departs from these tables, the pass that closes its +> gate records an interim note here; the tables stay Phase 05's target model. + ## Assessment | Entity | Aggregate root? | Notes | diff --git a/docs/architecture/04-technical-architecture.md b/docs/architecture/04-technical-architecture.md index 351fac04..065a262b 100644 --- a/docs/architecture/04-technical-architecture.md +++ b/docs/architecture/04-technical-architecture.md @@ -199,27 +199,44 @@ Full details: [Events & Outbox](15-event-and-outbox.md). - Tenant resolution at the edge / middleware layer; tenant context propagated via header into RSC and route handlers. - Typed API client generated from OpenAPI. +> **Open in Phase 02d.** For what Phase 02d builds, whether the edge resolves a tenant +> at all and whether the frontend ever holds a tenant or organization id are G25, what +> the middleware carries inward and under which header is G36, and whether the SDK is +> a hand-written transport over the generated `paths` or a typed client library is +> G31 — all in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The passes that close them edit these bullets with their answers. + Detailed conventions: [Frontend Architecture](14-frontend-architecture.md) and [Frontend Architecture Standards](../standards/07-frontend-architecture.md). ## Local Infrastructure -`docker-compose` brings up: +`make dev` brings up the default profile of +[`infra/compose/dev.yml`](../../infra/compose/dev.yml), which +[its README](../../infra/compose/README.md) describes service by service: ``` postgres -valkey seaweedfs # single dev binary: master + volume + filer + S3 gateway +mailpit meilisearch keycloak -livekit-server -livekit-egress +livekit coturn -mailhog -otel-collector ``` +`make dev-gated` adds the `gated` profile — Valkey, Kafka, kafka-ui, Vault, APISIX and +the two Dapr containers ([ADR-0035](../decisions/0035-demand-gated-infrastructure.md)). + Application projects run **outside** containers during active development for fast iteration. CI runs identical container versions. +> **Open in Phase 02d.** Where the API and the web app run relative to each other, on a +> workstation and in the CI job that renders the pages, is G33; what `make demo` starts +> and waits on over this stack is G45. Both are in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> They are answered in the decision passes of the packets they block, and those passes +> edit this section with their answers. + ## Observability Three signals, one correlation id: diff --git a/docs/architecture/05-mvp-scope.md b/docs/architecture/05-mvp-scope.md index bd72993f..498fc960 100644 --- a/docs/architecture/05-mvp-scope.md +++ b/docs/architecture/05-mvp-scope.md @@ -235,6 +235,15 @@ since [Phase 02d](../roadmap/phase-02d-walking-skeleton.md). Phase 10 is therefo customization aggregate against one real tenant, proving the customization surface is complete. Genericity is already proven, and re-proven on every CI run. +> **Open in Phase 02d.** No CI job renders either site today. Whether one re-proves +> after Phase 02d exits that each host renders its own tenant — an HTTP smoke against +> `next start`, a narrow browser smoke, or a dated manual record instead — is G38 (c); +> whether the Lighthouse job runs over both hosts is G44; and what mechanically backs +> "no production code branches on which tenant it serves" is G20, all in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The passes that close those gates edit this paragraph with their answers, and +> Phase 02d's exit re-verifies it against what shipped. + ## Deferred | Capability | Reason for deferral | Owning phase | diff --git a/docs/architecture/09-tenant-isolation.md b/docs/architecture/09-tenant-isolation.md index 9cce300b..52cd0202 100644 --- a/docs/architecture/09-tenant-isolation.md +++ b/docs/architecture/09-tenant-isolation.md @@ -37,7 +37,7 @@ two scopes (tenant + organization): | EF Core | Global query filter `e.TenantId == currentTenantId` | Global query filter `e.OrganizationId == null OR e.OrganizationId == currentOrgId` | | PostgreSQL | The tenant term of the single policy: `tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid` | The organization term `AND`-ed into that **same** policy, plus the restrictive `UPDATE` / `DELETE` write guards. Canonical SQL in [Database Standards](../standards/05-database.md) | | Identity | Single-realm `learnstack` with `tenant_id` JWT claim (default per [ADR-0004](../decisions/0004-authentication-strategy.md); realm-per-tenant is an opt-in for enterprise isolation only) | `organization_id` JWT claim populated from active org membership | -| Cache | Cache key auto-prefixed `{tenant_id}:{key}` | `{tenant_id}:{org_id}:{key}` when org context set | +| Cache | The caller composes `{tenant_id}:{module}:{logical-name}` with `CacheKey.ForTenant`; every `ICacheService` implementation validates the key and prefixes nothing | `{tenant_id}:{org_id}:{module}:{logical-name}` with `CacheKey.ForOrganization`, for a value scoped to one organization. How the settings accessor keys a read whose rows depend on the session's organization is G23 in [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register); the decision pass that closes it edits this row if its answer changes it | | Files (SeaweedFS) | Object key prefix `tenants/{tenant_id}/...` | `tenants/{tenant_id}/organizations/{org_id}/...` for org-scoped assets | | Search | `tenant_id` as a mandatory filter composed **inside** `ITenantSearch` — callers pass criteria, never filter strings. Until Meilisearch's demand gate fires ([ADR-0035](../decisions/0035-demand-gated-infrastructure.md)), search runs on PostgreSQL full-text over tenant-owned tables and inherits Row Level Security; the engine-enforced per-request tenant token arrives with the Meilisearch adapter in [Phase 09](../roadmap/phase-09-billing-integrations-analytics.md) | `organization_id = X OR organization_id IS NULL` clause when org context | | Jobs (Hangfire) | `JobParams.TenantId` mandatory | `JobParams.OrganizationId` nullable | @@ -257,8 +257,8 @@ tenants/{tenant_id}/brand/... ← tenant- ### Cache (Dapr State Store / Valkey) ``` -{tenant_id}:{org_id}:{module}:{logical-name} ← org context set -{tenant_id}:{module}:{logical-name} ← tenant-wide or no org context +{tenant_id}:{org_id}:{module}:{logical-name} ← a value scoped to one organization +{tenant_id}:{module}:{logical-name} ← a tenant-wide value platform:hub:host-map:{normalized-host} ← the host map platform:tenancy:killswitch ← the killswitch overlay ``` diff --git a/docs/architecture/12-localization.md b/docs/architecture/12-localization.md index d4a183fb..1750f2e2 100644 --- a/docs/architecture/12-localization.md +++ b/docs/architecture/12-localization.md @@ -48,6 +48,10 @@ CREATE TABLE tenant_locales ( A tenant with no `tenant_locales` row falls back to the platform default (`en`). +> **Open in Phase 02d.** Nothing implements this fallback yet; what a tenant with no +> `tenant_locales` row serves is G13 in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). + The shipped table is the Tenancy module's migration, which adds the audit-free composite primary key shown above plus `ENABLE`/`FORCE ROW LEVEL SECURITY` and the tenant-wide policy; this fence is the column sketch, not the DDL. @@ -176,6 +180,12 @@ Pattern B is cheaper for short fields where joining a translation table is overk ## Fallback Rules +> **Open in Phase 02d.** This chain and the one in +> [Localization Standards § Locale Model](../standards/08-localization.md#locale-model) +> differ. Which is canonical, and the terminal state of a missing field or label, are +> G24 in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). + When the requested locale is unavailable: 1. Try the requested locale (e.g. `tr-TR`). @@ -195,6 +205,10 @@ Slugs are **per locale**. Two patterns: The Next.js renderer reads tenant locale config at the edge and produces locale-aware routes. +> **Open in Phase 02d.** Whether the edge reads it, and how the renderer gets a tenant's +> locales, are G25 and G36 in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). + Slug uniqueness is `UNIQUE (tenant_id, locale, slug)`, declared on the translation table, and **flat across organizations**. The same entity can have completely different slugs per locale; two different entities in one tenant cannot share one slug in one locale. @@ -246,6 +260,12 @@ frontend uses a lightweight i18n library (e.g. `next-intl` or `react-intl`); the 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. ## Notification Template Localisation diff --git a/docs/architecture/13-identity-and-auth.md b/docs/architecture/13-identity-and-auth.md index 658ba55f..71284c26 100644 --- a/docs/architecture/13-identity-and-auth.md +++ b/docs/architecture/13-identity-and-auth.md @@ -178,14 +178,28 @@ Mirrored events (e.g. `user.created`) are propagated via Keycloak webhooks into ## Local Development -Local Keycloak runs in Docker Compose (`infra/compose/keycloak.yml`). Seed data sets up: - -- Realm `learnstack`. -- Two tenants (Tenant A and Tenant B) with admin users. -- A user with memberships in both tenants for cross-tenant testing. -- A platform admin user. - -The seed is idempotent and runs on `make seed`. +Local Keycloak runs in Docker Compose, in the default profile of +[`infra/compose/dev.yml`](../../infra/compose/dev.yml), and imports the `learnstack` and +`learnstack-hub` realms from `infra/keycloak/realms/` at first start. The imports carry +the demo users [`infra/keycloak/README.md`](../../infra/keycloak/README.md) lists: a +tenant admin and a tenant learner in `learnstack`, whose `tenant_id` attribute is the +slug `tenant-a` and names neither seed tenant, and an operator in `learnstack-hub`. No +platform-admin user belongs in `learnstack`, because operators are the `learnstack-hub` +population +([ADR-0004 Amendment 1](../decisions/0004-authentication-strategy.md#amendment-1--learnstack-hub-realm-for-operators-2026-05-18)). + +`make seed` checks that both realms answer and writes nothing to Keycloak. +`LearnStack.Tools.Seeder` provisions the two seed tenants, `demo-english` and +`demo-yoga`, through the request path +([Phase 02a Packet 7](../roadmap/phase-02a-kernel-tenancy.md#delivery-record-packet-7)), +and a re-run is idempotent. + +> **Open in Phase 02b.** How seed users, mappers and the client scope reach a Keycloak +> database that has already consumed the import is G12 in +> [Phase 02b's decision register](../roadmap/phase-02b-events-auth.md#the-decision-register); +> whether one token can name two tenants is its G10. A user who belongs to both tenants +> also needs `Membership`, which [Phase 03](../roadmap/phase-03-identity-admin.md) +> delivers. The pass that closes each gate edits this section with its answer. ## Risks diff --git a/docs/architecture/14-frontend-architecture.md b/docs/architecture/14-frontend-architecture.md index 5fe95638..57b2b03e 100644 --- a/docs/architecture/14-frontend-architecture.md +++ b/docs/architecture/14-frontend-architecture.md @@ -61,6 +61,12 @@ frontend/ config/ # eslint, tsconfig, tailwind shared bits ``` +> **Open in Phase 02d.** Where composite and primitive components live (G41), where the +> UI string catalogue lives (G39) and what `middleware.ts` resolves (G25, G36) are open +> in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The tree records the plan written before them. + The operator portal (`operator-portal`) is a **separate Next.js application in the separate `learnstack-hub` repository**; nothing about it lives under this `frontend/` tree. @@ -74,13 +80,14 @@ Splitting into separate apps is governed by [ADR 0009 — Frontend Single App Fi ## Tenant + Organization Resolution at the Edge -> **Open in Phase 02d.** Whether the edge calls an API host lookup at all — and if so, -> what it returns and how it states the visitor's host to the API — is G25 in +> **Open in Phase 02d.** Whether the edge calls an API host lookup at all, and what it +> returns, is G25; what the middleware carries inward and answers is G36; the server +> SDK's transport, including the headers the sketch below sends, is G35; and how the +> locale reaches the API is G30 — all in > [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). -> It is answered in the decision pass of the packet that ships the public reads. This -> section's "Phase 02d ships it" and the diagram's host-lookup step record the plan the -> section was written against. They are reconciled with the answer in that pass, -> together with +> This section's "Phase 02d ships it", the diagram's host-lookup step and the SDK sketch +> record the plan the section was written against. Each pass reconciles them with its +> answer, together with > [Frontend Architecture Standards § Tenant Resolution](../standards/07-frontend-architecture.md#tenant-resolution) > and > [Infrastructure Stack Standards § Host → Tenant Resolution](../standards/20-infrastructure-stack.md#host--tenant-resolution). @@ -208,6 +215,11 @@ In text, for a reader whose renderer does not draw it: ## Rendering Strategies +> **Open in Phase 02d.** How tenant-varying `(public)` routes render, and which caches +> may hold tenant data, is G37 in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The `(public)` row below records the plan written before it; that pass rewrites it. + Per segment: | Segment | Strategy | Notes | @@ -239,7 +251,9 @@ The first paint is themed; there is no FOUC because tokens are injected into the HTML. Logo and font assets are URLs (served from CDN). Custom fonts are validated and -rate-limited at upload to prevent unbounded font payloads. +rate-limited at upload to prevent unbounded font payloads. Whether branding may name a +logo or font asset at all (G16) and whether a public page may load one from another +origin (G21) are open in the same register. A `ThemeProvider` is **not** introduced unless dynamic theme switching is needed; the CSS-variable approach handles the static-per-request case (one render = one theme = one @@ -417,6 +431,13 @@ mechanical. ## Risks +> **Open in Phase 02d.** Two bullets below state answers Phase 02d has not given. +> Whether the `(public)` routes it ships are cached at all, and on what key, is G37; +> whether a brand-token set that fails the contrast check is refused or saved with a +> warning is G16 (d). Both are in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The pass that closes each gate edits its bullet with the answer. + - **Per-tenant SSR cost** — caching is per `(tenantId, organizationId?, locale, slug)`. Cardinality is bounded; budget memory headroom. - **Cookie domain scoping** — tenants on custom domains complicate auth cookies. Use diff --git a/docs/architecture/25-deployment-models.md b/docs/architecture/25-deployment-models.md index d7f215a5..a9e314dc 100644 --- a/docs/architecture/25-deployment-models.md +++ b/docs/architecture/25-deployment-models.md @@ -105,9 +105,22 @@ Internet connection, saturate the pool, and degrade every other tenant on the instance while every isolation test stays green. - What exists today: APISIX `limit-req` keyed on `remote_addr`, which throttles a noisy - client but not a noisy tenant — one tenant behind many IPs is unaffected, and many - tenants behind one NAT are punished together. + What exists today: the API's own anonymous limiter, which runs before host + classification and partitions every request on its socket peer + ([API Standards § Request and Response Limits](../standards/04-api-design.md#request-and-response-limits)). + APISIX's `limit-req`, keyed on `remote_addr`, fronts the API only once the gateway + lands, which [ADR-0035](../decisions/0035-demand-gated-infrastructure.md) gates to + [Phase 11](../roadmap/phase-11-production-hardening.md). Each keys on the client, so + each throttles a noisy client but not a noisy tenant — one tenant behind many IPs is + unaffected, and many tenants behind one NAT are punished together. A server-side + renderer is such a NAT: every visitor it renders for, of every tenant, reaches the API + from one peer. + + > **Open in Phase 02d.** How the anonymous limiter treats a request arriving over the + > trusted hop is G34 in + > [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). + > It is answered in the decision pass of the packet that ships the server-rendering + > path, and that pass edits this paragraph with its answer. What is required, and where it lives: **resource fairness is [Phase 11](../roadmap/phase-11-production-hardening.md)** — `statement_timeout` per diff --git a/docs/architecture/28-platform-tenant-organization.md b/docs/architecture/28-platform-tenant-organization.md index b21f4804..031474a1 100644 --- a/docs/architecture/28-platform-tenant-organization.md +++ b/docs/architecture/28-platform-tenant-organization.md @@ -198,7 +198,7 @@ domain-specific shape comes from the tenant's customization data (ADR-0018). | **Audit** | Audit trail of every command + sensitive read | Retention policy (within plan limits) | | **Search** | Meilisearch tenant-scoped indexes per locale | Search-indexed content types (registered as data) | | **Domain** | Tenant subdomain (`{slug}.learnstack.app`) always | Custom domain (paid feature) via Hub admin | -| **Branding** | Tenant brand tokens applied as CSS variables | Logo, colours, typography, custom CSS overrides (plan-gated) | +| **Branding** | Tenant brand tokens applied as CSS variables | Logo, colours, typography, custom CSS overrides (plan-gated). Open in Phase 02d: the branding keys Phase 02d admits are G16 (b), and whether `tenancy.white_label_branding` governs applying theme tokens or only removing LearnStack attribution is G16 (g), in [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register); the decision pass that closes G16 edits this row with its answer. | | **Localization** | Multi-locale per tenant; en + tr baseline | Adding/removing enabled locales | | **Integrations** | Webhook engine, OAuth client manager | Webhook subscriptions, OAuth client definitions | diff --git a/docs/architecture/32-tenant-customization-model.md b/docs/architecture/32-tenant-customization-model.md index 6f18b37f..fc975d4f 100644 --- a/docs/architecture/32-tenant-customization-model.md +++ b/docs/architecture/32-tenant-customization-model.md @@ -45,6 +45,17 @@ organization-scoped where it makes sense. ## 2. Generic primitive renderers +> **Open in Phase 02d.** The closed set below is ADR-0018's and is not in question, and +> no component for any of its keys exists yet. Which members Phase 02d implements, +> whether `markdown` renders, and how a field with no row in the mapping table +> (`integer`, `number`, `boolean`, an `enum`) maps are G18; how the page draws what that +> subset does not, and where the primitive and composite components live, are G41. The +> folder named in the comment below does not exist, and the shipped key registry sits +> in `frontend/apps/web/src/lib/customization/`; neither answers G41. Both gates are +> open in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register), +> and the pass that closes each edits this section with its answer. + The frontend ships a **fixed, closed set** of primitive renderers: ```typescript @@ -201,6 +212,13 @@ output_format: { type: "string", enum: ["a1","a2","b1","b2","c1","c2"] } ### Example B — Yoga studio platform +> **Open in Phase 02d.** Phase 02d seeds a yoga tenant whose content type is also keyed +> `asana-pose`, and this example is not that seed. Which fields the seed declares — this +> one has an `integer`, an `enum` and a video field — and which composite draws them are +> G18; the yoga taxonomy's key is G14. Both are open in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register), +> and the pass that closes each edits this example with its answer. + `tenant_content_types`: ```json @@ -544,6 +562,20 @@ Two rules make this safe: Cache misses cost one indexed query per tenant per definition set. A cold pod serving its first request for a tenant performs at most three such queries, not one per entry. +> **Open in Phase 02d.** This section owns the families, their keys and the generation +> rule, and Phase 02d builds the first loader against them. What a key carries for a +> lesson's bound revision is G12. The rest is G22: whether the loader runs in the +> request's transaction; how a request learns the generation, and in what order it reads +> it and the rows; what an absent row means, since a tenant that has never had a +> customization has none; what keeps an entry filled inside a transaction that bumped +> and rolled back unreachable; what the TTLs bound; how the adapter's `cache.name` +> mapping matches a generation-embedded name; and how many statements a public read +> issues, which the count above states without a generation read. Both are in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register), +> and the pass that closes each edits this section with its answer. The +> `TenantPageBlock` family is [Phase 04](../roadmap/phase-04-cms-media-pages.md)'s, with +> its aggregate. + ### 8.3 The N+1 problem, and the limits that bound it The guided-sequence example in [§ 3](#3-worked-example-three-tenants-same-modules) is a @@ -711,6 +743,14 @@ first; [Phase 06](../roadmap/phase-06-renderer-admin-studio.md) replaces them wi visual schema editor and preview pane. The screen tree above is the target; each row arrives with the aggregate it edits, per [§ 12](#12-phasing). +> **Open in Phase 02d.** The branding keys Phase 02d's writer admits and its seed +> writes, which the Branding screen later edits, are G16 (b). Whether +> `tenancy.white_label_branding` governs applying theme tokens or only removing +> LearnStack attribution is G16 (g). Both are in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The Branding row's "(plan-gated)" records the plan this tree was written against; the +> decision pass that closes G16 edits the row with its answer. + ## 11. Hard architectural invariants Architecture tests enforce: diff --git a/docs/glossary.md b/docs/glossary.md index 2d45a740..4aaefbbf 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -174,13 +174,13 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **Custom Domain** | A tenant-chosen hostname (`learn.acme.com`) that resolves to a tenant via `platform_host_to_tenant`. Hub owns the issuance + TLS lifecycle; LearnStack mirrors the host → tenant mapping. See [ADR-0022](decisions/0022-custom-domain-tls.md) and [27-custom-domain-tls.md](architecture/27-custom-domain-tls.md). | | **Domain Verification** | The DNS-01 / HTTP-01 challenge that proves the tenant controls the host before TLS issuance. Hub-side flow. | | **Reserved Host** | A hostname the platform refuses to assign to a tenant (e.g. `api.*`, `admin.*`, `hub.*`, any platform domain). | -| **`IHostToTenantResolver`** | The interface every host-lookup goes through. Backed by `platform_host_to_tenant`. The frontend edge calls a thin API endpoint that delegates to it. | +| **`IHostToTenantResolver`** | The interface every host lookup goes through, backed by `platform_host_to_tenant` and nothing else. The backend uses it directly for inbound request resolution; whether the frontend edge also calls an endpoint backed by it is G25 in [Phase 02d's decision register](roadmap/phase-02d-walking-skeleton.md#the-decision-register). | ## Branding | Term | Definition | |------|------------| -| **TenantBranding** | The tenant's design tokens — logo, primary / secondary colour set, typography tokens, header / footer settings. 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)). Resolved once per request at the layout level and injected as CSS variables on the SSR'd HTML root. | +| **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)). Which keys exist and the value each accepts are G16, and how validated values reach the server-rendered document is G42, in [Phase 02d's decision register](roadmap/phase-02d-walking-skeleton.md#the-decision-register). | | **OrganizationBranding** | An optional override row attached to an `Organization` that supplies a partial design-token set. When the resolved request carries an organization id, the runtime merges `OrganizationBranding` on top of `TenantBranding` before injecting tokens; missing fields fall through to the tenant default. | ## Module-Loading Contracts @@ -347,9 +347,9 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **Phase** | A roadmap-level milestone with its own `phase-NN-topic.md` file under `docs/roadmap/`. Phases are numbered, sometimes letter-suffixed (`02a` / `02b` / `02c`) when sub-streams ship in parallel or in sequence. Each phase doc carries its own `## Phase Exit Decision` section spelling out the gate that closes the phase. | | **Packet** | A dependency-ordered shipping slice **within** a phase, sized to be independently reviewable in one pull request. Packet numbering restarts per phase (`P02a-0`, `P02a-1`, …); the canonical reference shape is `P<PhaseId>-<PacketIndex>` (so the kickoff packet of Phase 02a is `P02a-0`). Commit and PR titles use the prose form (`feat(phase-02a): packet 0 — kickoff`). A packet may ship documentation only (e.g. a kickoff packet that defines the breakdown), decisions only (an ADR move from Draft to Accepted), code only, or any combination — but always one phase's worth of progress, no cross-phase bundling. Per-phase packet history lives in the phase doc's Status block (see [phase-01-repository-tooling.md](roadmap/phase-01-repository-tooling.md) for the canonical shape). | | **Kickoff Packet** | The first packet of a phase when that phase is large enough to need an explicit plan up front. A kickoff packet ships only the per-packet breakdown for its phase plus any glossary / cross-reference updates the breakdown depends on; no code. Phase 01 did not need one (packets fell out cleanly from the existing scaffold targets); Phase 02a does (the foundation surface is wide). | -| **Decision Gate** | An open question a phase document registers because one of its packets cannot be written without the answer. It is Accepted at its last responsible moment, in the decision pass of the first packet whose code it shapes. Not a phase's exit gate (its `## Phase Exit Decision`), and not one of the four payload-validation gates of [ADR-0043](decisions/0043-customization-payload-validation.md). See [Roadmap § Decision Timing](roadmap/README.md#decision-timing). | -| **Decision Pass** | The step every packet of a gated phase opens with: each premise its gates cite is re-verified against `HEAD`, its records are drafted and Accepted, and those records are the packet's first commit. See [Roadmap § Decision Timing](roadmap/README.md#decision-timing). | -| **Decision Register** | The table in a phase document with one row per decision gate: Question, Leaning (a proposal, not a decision), Vehicle and Blocks. [Phase 02b](roadmap/phase-02b-events-auth.md#the-decision-register) carries the first. | +| **Decision Gate** | An open question a phase document registers because one of its packets cannot be written without the answer; [Roadmap § Decision Timing](roadmap/README.md#decision-timing) owns when and how it closes. Not a phase's exit gate (its `## Phase Exit Decision`), and not one of the four payload-validation gates of [ADR-0043](decisions/0043-customization-payload-validation.md). | +| **Decision Pass** | The step a packet of a gated phase opens with, in which the decision gates it waits on are closed; [Roadmap § Decision Timing](roadmap/README.md#decision-timing) defines it. | +| **Decision Register** | The table in a phase document with one row per decision gate; [Roadmap § Decision Timing](roadmap/README.md#decision-timing) owns its mechanics, and [Phase 02b](roadmap/phase-02b-events-auth.md#the-decision-register) carries the first. | | **Walking Skeleton** | A **thin vertical slice through every layer** that produces a working, browser-visible artefact as early as the foundation allows — deliberately shallow in features and complete in path. LearnStack's is [Phase 02d](roadmap/phase-02d-walking-skeleton.md), which owns its scope and exit: two hosts, two tenants in unrelated domains, one backend binary, one database, one schema. Its purpose is evidence, not features: it moves the platform's single most testable claim — that the same code paths serve unrelated education domains — from an assertion five phases away to something a non-engineer can check in a browser. Each capability it touches is delivered shallowly there and completely in its owning phase, and each owning phase records what the skeleton already shipped so no work is claimed twice. | ## List Queries diff --git a/docs/modules/customization/README.md b/docs/modules/customization/README.md index aa7fdadb..5f7a3ebe 100644 --- a/docs/modules/customization/README.md +++ b/docs/modules/customization/README.md @@ -238,7 +238,7 @@ In [audit.md](audit.md), the file | Path | Budget | Why this number | |---|---|---| | Resolve a tenant's live definitions (cache hit) | **< 1 ms** | On every render of every page | -| Resolve a tenant's live definitions (cache miss) | **< 20 ms** p95 | Two indexed reads on partial unique indexes | +| Resolve a tenant's live definitions (cache miss) | **< 20 ms** p95 | Two indexed reads on partial unique indexes. How a request learns the generation, in what order the loader reads it and the rows, and how many statements a read issues are G22 in [Phase 02d's decision register](../../roadmap/phase-02d-walking-skeleton.md#the-decision-register); the pass that closes it edits this row and the cache-hit row with its answer | | Admit a tenant-authored schema (four gates) | **< 50 ms** p95 | Interactive, on save, and rare | | Validate one instance at the § 8.4 caps | **742 ms, 1.6 GB** | Measured worst case, not a budget — see below | | Publish a successor (2 reads, 2 updates, 1 upsert) | **< 100 ms** p95 | Interactive but rare | diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index b31f503f..33831c0f 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -72,11 +72,11 @@ Tenancy owns **who a request belongs to** and nothing about what they do with it ## Entity-relationship diagram -Aggregate roots in the shipped code are `Tenant` and `Organization` — the two -that implement `IAggregateRoot<TId>`; the promotion below adds `TenantDomain` and -`TenantSetting`, which carry the shape of a root but which no command writes yet. `PlatformHostMapping` and `PlatformEntitlement` are -projections rather than aggregates: nothing in this module mutates them through -a root. +Aggregate roots in the shipped code are `Tenant`, `Organization`, `TenantDomain` and +`TenantSetting` — the four that implement `IAggregateRoot<TId>`, the last two by the +promotion below. No command writes `TenantDomain` or `TenantSetting` yet. +`PlatformHostMapping` and `PlatformEntitlement` are projections rather than +aggregates: nothing in this module mutates them through a root. **The other four resolve two ways, and Packet 7 settles them as promotion.** `TenantDomain`, `TenantSetting`, `TenantLocale` and `TenantFeatureFlag` each have @@ -93,10 +93,15 @@ So the first pair becomes aggregate roots in their own right and the second becomes navigations inside `Tenant` — four roots in Tenancy, with a write to `TenantLocale` or `TenantFeatureFlag` bumping `Tenant.row_version` and the two promoted roots carrying their own. -[Packet 7](../../roadmap/phase-02a-kernel-tenancy.md) writes the first command -that touches any of them, which is the evidence the boundary had none of and -where the promotion lands; provisioning writing `Tenant` and its default -`Organization` in one transaction is sanctioned by enumeration in +[Packet 7](../../roadmap/phase-02a-kernel-tenancy.md) lands the promotion, and none of +its three commands touches `TenantDomain`, `TenantSetting`, `TenantLocale` or +`TenantFeatureFlag`. The first commands that do — the locale and setting commands +raising `tenancy.locale.write` and `tenancy.setting.write` — are Phase 02d's, and +their shape is G11 in +[Phase 02d's decision register](../../roadmap/phase-02d-walking-skeleton.md#the-decision-register); +the pass that closes it edits this section with its answer. Provisioning writing +`Tenant` and its default `Organization` in one transaction is sanctioned by +enumeration in [ADR-0042](../../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md). ```mermaid @@ -357,7 +362,7 @@ In [audit.md](audit.md), the file | Host → tenant resolution (cache miss) | **< 15 ms** p95 | One indexed single-row read in its own short transaction | | Entitlement projection read (L1 hit) | **< 1 ms** | Read on every feature check | | Tenant provisioning (3 statements) | **< 100 ms** p95 | Interactive but rare | -| Settings read for a request | **< 5 ms** p95 | Cached; a miss is one indexed read | +| Settings read for a request | **< 5 ms** p95 | Cached; a miss is one indexed read. Whether settings are cached before Phase 02b's `learnstack.tenancy.settings` event exists, and how a cached read keys tenant-wide and organization rows, is G23 in [Phase 02d's decision register](../../roadmap/phase-02d-walking-skeleton.md#the-decision-register); its pass edits this row | The two resolution numbers are the load-bearing ones: they sit in front of every request and are the only Tenancy work an anonymous visitor pays for. diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 93e9610e..dd49f2b5 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -136,8 +136,12 @@ retrofit**, and **thin where it is not**: ([ADR-0033](../decisions/0033-audit-durability-model.md)). Audit correctness cannot be added later; audit *scale* can, and is. - Module boundaries stay clear; architecture tests enforce them from Phase 02a, - including `Core_Modules_HaveNo_DomainSpecific_Names` — the mechanical guarantee behind - the platform's entire premise. + including `Core_Modules_HaveNo_DomainSpecific_Names`, which keeps every name the + platform ships free of the forbidden domain terms. That rule reads names and strips + literals, so it cannot show that no production code branches on which tenant it + serves. What mechanically backs that claim is open as G20 in + [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register), + and the pass that closes G20 edits this bullet with its answer. - CMS and education catalog capabilities work together against tenant-defined content types, blocks, lesson items, taxonomies, and scoring / completion rules ([ADR-0018](../decisions/0018-tenant-driven-customization-model.md)). diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index f5b75bde..54447d79 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -677,10 +677,14 @@ settles it; what the phase ships either way: also needs a transport the session cookie can survive: `Secure` is a secure-channel attribute, the seed hosts are `*.learnstack.local`, and the frontend's dev script serves plain HTTP, so the cookie this phase sets would be accepted and never sent - back. The gate settles local TLS with its trust step and the matching callback and - post-logout URLs, or moves the dev hosts under `localhost`; what it may not do is drop - `Secure`. The login transaction's `state`, `nonce` and `code_verifier` live in a - host-only transaction cookie, and return URLs are validated. + back. Which transport and hostnames serve the seed tenants — local TLS with its trust + step, or the dev hosts moved under `localhost` — is **G32** in + [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register), + which closes before this phase starts; the pass that closes it edits this text with + its answer, and `P02b-0` re-verifies it. This gate settles the callback and + post-logout URLs that answer needs, and neither gate may drop `Secure`. The login + transaction's `state`, `nonce` and `code_verifier` live in a host-only transaction + cookie, and return URLs are validated. - **Logout**, which [Identity and Auth § Logout](../architecture/13-identity-and-auth.md#logout) describes and Phase 03 expects: the session cleared, the end-session endpoint called, and @@ -1190,7 +1194,8 @@ shape depends on an open gate say so, and are written when that gate is Accepted `SameSite=Lax`, `Path=/` with no `Domain`; and no response body or script-readable cookie carries the refresh token. Asserting the header is not enough: on each real seed host a browser-level case shows the cookie **stored and sent back** on the next - request, which is what fails if the dev transport G12 settles is not in place. + request, which is what fails if the dev transport Phase 02d's G32 settles is not in + place. - Two concurrent requests with a near-expiry token produce exactly one token request at the issuer and one logical session version — counted as versions, not as `Set-Cookie` headers, because a session adapter may split one cookie across several and a correct diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 95fd4287..983a76f9 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -168,7 +168,7 @@ Named so that no reader has to guess, and so no later phase can assume it was do | `Accept-Language` negotiation for API-returned messages; the per-tenant locale fallback chain; the `/{locale}/{slug}` page routing shape | [Phase 04](phase-04-cms-media-pages.md) | | Authoring and translating a content type's field order and labels; the closed set of built-in primitive field types (number, boolean, date/time, select) | [Phase 04](phase-04-cms-media-pages.md) | | The schema-version migration path for stored instances; deleting a content-type revision after a zero-instance count | [Phase 04](phase-04-cms-media-pages.md) | -| Customization deprecate, revise and rename commands, and per-band taxonomy editing | [Phase 04](phase-04-cms-media-pages.md) and [Phase 05](phase-05-education-learning-content.md), consolidated in [Phase 06](phase-06-renderer-admin-studio.md) | +| Customization deprecate and revise commands, and per-band taxonomy editing | [Phase 04](phase-04-cms-media-pages.md) and [Phase 05](phase-05-education-learning-content.md), consolidated in [Phase 06](phase-06-renderer-admin-studio.md) | | Tenant-authored page composition (`TenantPageBlock` rows and their cache family) | [Phase 04](phase-04-cms-media-pages.md); the block resolver is [Phase 06](phase-06-renderer-admin-studio.md)'s | | Deciding whether a customization change owes an integration event | [Phase 04](phase-04-cms-media-pages.md), per [the Customization spec § Integration-event catalogue](../modules/customization/README.md#integration-event-catalogue) | | The `Redirect` model the CMS auto-creates when a published slug changes | [Phase 04](phase-04-cms-media-pages.md) | @@ -262,7 +262,7 @@ Accepted, and its catalogue rows registered, before its first line of code. | **P02d-4** | Public read API and contract checks: the `[PublicSurface]` reads, their table rows and classification; response contracts, read eligibility, the cursor and the locale matrix; the cache directive; the committed OpenAPI snapshot with the breaking-change check, the regenerated SDK types and the drift gate, activated and required; the request-level Education isolation suite with positive controls | G5 (unresolved band), G6 (b), G10 (codec), G12 (response), G16 (f, g), G24 (response), G25, G26, G27, G28, G29, G30, G31 Accepted; P02d-3 | | **P02d-5** | Server-rendering path: the development hostnames and transport; the development trusted-hop configuration; the server SDK transport; the middleware replacement and entry behaviour; the anonymous limiter over the hop; the rendering mode; the hop runtime evidence; the frontend skip refusal and the hop predicates' tests | G6 (c), G20 (exemptions), G21 (cookies), G30 (headers), G32, G33, G34, G35, G36, G37, G38 (a, d), G44 (whether) Accepted; P02d-4 | | **P02d-6** | Public renderer: the `(public)` route tree, pages and page states; the UI message layer; lesson field rendering and its fallbacks; theming injection; the accessibility minimum; the renderer's tests | G5 (unresolved band), G12 (page state), G16 (g), G20 (subjects), G38 (b, c), G39, G40, G41, G42, G43 Accepted; P02d-5 | -| **P02d-7** | Demo, full-stack CI and exit: `make demo` and its stop behaviour; the full-stack Lighthouse job; the Keycloak-stopped check; the standards-index transitions; the outbound carrier re-verification; the delivery record naming what `P02b-0` re-verifies; the exit checks | G20 (Implemented), G33 (CI), G38 (c's job), G44, G45 Accepted; P02d-6 | +| **P02d-7** | Demo, full-stack CI and exit: `make demo` and its stop behaviour; the full-stack Lighthouse job if G44 activates it here; the Keycloak-stopped check; the standards-index transitions; the outbound carrier re-verification; the delivery record naming what `P02b-0` re-verifies; the exit checks | G20 (Implemented), G33 (CI), G38 (c's job), G44, G45 Accepted; P02d-6 | `P02d-2` precedes `P02d-3` because the projection reads the definitions the seed publishes, and because the lesson writer — not the renderer — is the first caller of the @@ -298,10 +298,10 @@ premise a row cites is re-verified at that pass rather than trusted. | 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) | | 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) | | 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) | -| 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 § Locale Identifiers](../architecture/12-localization.md#locale-identifiers) 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) | +| 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) | | 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) | | 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) | -| 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 false under `NullEntitlementProvider` 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) | +| 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) | | 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) | | 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) | | 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) | @@ -325,7 +325,7 @@ premise a row cites is re-verified at that pass rather than trusted. | 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) | | 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) | | 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) | -| 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, a not-found page is this phase's own choice, and Phase 06 says this phase has none; 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 | +| 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 | | 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 | | 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 `<style>` element built from validated values; or a per-tenant stylesheet route — without constraining [Security Standards § HTTP Headers](../standards/11-security.md#http-headers)' nonce-based target before Phase 11 documents it per surface? | A `<style>` element built only from registry-validated values, emitting only the `--ls-*` vocabulary, never a `style` attribute; a nonce would force dynamic rendering (G37) | Detail: Frontend Architecture Standards § Tenant Branding for the mechanism; Frontend Architecture § Theming | P02d-6 (the layout's token injection; closes with G37's answer and G16's grammar) | | G43 | Which accessibility checks on this phase's pages fail a build — route tests asserting `lang`, one `<main>`, the heading outline, a skip link and a descriptive title; `jsx-a11y` at error severity, where most of the shipped config's rules warn; or jsdom axe, which Testing Standards puts through Playwright in Phase 06? Is catalog → lesson a critical flow that needs [Accessibility Standards § Testing](../standards/16-accessibility.md#testing)' screen-reader smoke test? | Route tests in the `frontend` job for the checkable semantics; keyboard, focus and 320 CSS px reflow in the manual record; failing `jsx-a11y` with a planted companion. No review addresses the screen-reader question | Detail: a phase-doc statement; [Accessibility Standards § Tooling](../standards/16-accessibility.md#tooling) if lint severity or component axe becomes a rule; the lint configuration. Whether failing lint enforces the standard couples with G44; its index row changes only in the enforcing pull request | P02d-6 (page components and their tests) | @@ -942,10 +942,9 @@ catalogued legs are not implemented (**G28**), and the tenant-branching check is - `Course` and `Lesson` in `LearnStack.Modules.Education`, in the aggregate shape **G2** records, with migrations, EF configurations, query filters and Row Level Security policies, including each lesson body's content-type binding and its storage (**G4**). -- `course_translations` and `lesson_translations`, each with its own `tenant_id`, - mirrored `organization_id`, `ENABLE` + `FORCE ROW LEVEL SECURITY` and full policy set, - a composite foreign key on `(tenant_id, <entity>_id)` with its supporting index, and - `UNIQUE (tenant_id, locale, slug)` with the slug shape **G9** settles. +- `course_translations` and `lesson_translations`, conforming to + [Database Standards § Translation satellite tables](../standards/05-database.md#translation-satellite-tables), + with the slug shape **G9** settles. - The organization immutability trigger on all four tables, with a function that works on a table without `id` (**G8**); an index supporting every Education foreign key; the insert-time organization control and its test, and the organization write-guard diff --git a/docs/roadmap/phase-04-cms-media-pages.md b/docs/roadmap/phase-04-cms-media-pages.md index 69e89ff1..2150d969 100644 --- a/docs/roadmap/phase-04-cms-media-pages.md +++ b/docs/roadmap/phase-04-cms-media-pages.md @@ -66,7 +66,12 @@ The Content module keeps the half that is genuinely its own: through Mechanism #1 — an application contract in `Customization.Application.Contracts` that resolves a `(tenant_id, key, schema_version)` tuple to its JSON Schema and reports whether the revision is still - publishable. + publishable. [Phase 02d](phase-02d-walking-skeleton.md)'s lesson writer calls this + contract first, so whether it resolves an exact revision or binds the Active one for + a key, whether it is an interface or a query, and which revisions a writer may bind + are G12 in + [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register). + P02d-2's decision pass closes that part of G12 and edits this bullet with its answer. - Referential integrity is therefore enforced in the application, and the failure mode is explicit: deleting a schema revision requires a zero-instance count across the tenant, per [ADR-0013](../decisions/0013-page-block-schema-versioning.md). @@ -178,6 +183,12 @@ one thing a per-table constraint cannot do. organization would leak across the boundary Row Level Security exists to hold. It is never resolved by picking a winner at render time. +> **Open in Phase 02d.** For `Course` and `Lesson`, whose translation rows hold their +> slug from the moment they are inserted under the key Phase 02d ships, which command +> reports a collision, and whether it is still the publish command, is G11 in +> [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register). +> The pass that closes it edits this section with its answer. + Also in scope: locale fallback chain per tenant, the `/{locale}/{slug}` routing shape, per-locale publish readiness, and locale negotiation from `Accept-Language` for API-returned messages @@ -380,7 +391,10 @@ describes. second publish returns a business-rule failure naming the first. The same holds for a page and a redirect competing for one root path, and for an organization-scoped entity competing with a tenant-wide one. An integration test attempts all three and the - database rejects each, connected as `learnstack_app`. + database rejects each, connected as `learnstack_app`. For courses, which command + reports the collision, and so whether the second one fails at publish, is G11 in + [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register); + the pass that closes it edits this criterion. - When the conflicting row belongs to another organization, the failure names the slug and the locale but not the row — asserted by a test, because the constraint is enforced with Row Level Security bypassed and the handler has to make that choice deliberately. diff --git a/docs/roadmap/phase-05-education-learning-content.md b/docs/roadmap/phase-05-education-learning-content.md index 85e221e3..576a5cf8 100644 --- a/docs/roadmap/phase-05-education-learning-content.md +++ b/docs/roadmap/phase-05-education-learning-content.md @@ -95,9 +95,14 @@ scoring and does not re-decide it. - Course version. - Category. - **Level** — `Level` rows are looked up by `(tenant_id, taxonomy_key, key)` against the - tenant's `TenantLevelTaxonomy` (CEFR for the English school, `Difficulty` for the yoga - studio, `Track` for a coding bootcamp). The taxonomy is data, not code; the `Level` - aggregate holds whatever items the active taxonomy declares. + tenant's `TenantLevelTaxonomy` (CEFR levels for the English school, difficulty levels + for the yoga studio, tracks for a coding bootcamp). A taxonomy key and an item key are + lowercase under + [ADR-0018's key rule](../decisions/0018-tenant-driven-customization-model.md#2026-09-04--customization-keys-and-item-keys-are-lowercase), + and a band's name is its `display_name`. Which key the yoga taxonomy uses is G14 in + [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register), + and the decision pass that closes it writes the key here. The taxonomy is data, not + code; the `Level` aggregate holds whatever items the active taxonomy declares. - Tag. - Instructor profile reference, with tenant-defined custom fields via `TenantCustomFieldDef` (which lands in [Phase 03](phase-03-identity-admin.md)) for diff --git a/docs/roadmap/phase-06-renderer-admin-studio.md b/docs/roadmap/phase-06-renderer-admin-studio.md index 268a1044..1ef06f06 100644 --- a/docs/roadmap/phase-06-renderer-admin-studio.md +++ b/docs/roadmap/phase-06-renderer-admin-studio.md @@ -8,8 +8,9 @@ learner-facing and instructor-facing screen after this phase is built into. [Phase 02d](phase-02d-walking-skeleton.md) already put a site in a browser — a catalog page and a lesson page, on two hosts, for two tenants. That skeleton proved the request -path. It is not a website: it has no navigation, no SEO, no error pages, no block -registry beyond the primitive subset its lesson composites draw, and no editing surface. +path. It is not a website: it has no navigation, no full SEO treatment, no +tenant-authored error pages, no block registry beyond the primitive subset its lesson +composites draw, and no editing surface. Phase 06 **deepens** it into something a tenant can publish. After this phase, LearnStack publishes a simple but real education website for a tenant, @@ -22,7 +23,7 @@ and a non-developer tenant admin can maintain it. | Already exists | Phase 06 adds | |---|---| | Host-based tenant + organization resolution, end to end | Per-organization branding override on the resolved context | -| The anonymous `(public)` pages listed in [Phase 02d § Public renderer](phase-02d-walking-skeleton.md#public-renderer), Server Components over the typed SDK | Navigation, SEO metadata, 404 and redirect handling, full page composition | +| The anonymous `(public)` pages listed in [Phase 02d § Public renderer](phase-02d-walking-skeleton.md#public-renderer), Server Components over the typed SDK | Navigation, SEO metadata beyond what G40 in [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register) settles, tenant-authored error pages, redirect handling, full page composition | | Lesson bodies drawn through their content type's composite, over the primitive subset [Phase 02d](phase-02d-walking-skeleton.md) implements | The complete two-tier block registry with safe-render placeholders | | Branding tokens read from `TenantSetting` | The branding configuration surface that writes them | | The `apps/web` Vitest suite [Phase 02d](phase-02d-walking-skeleton.md) extends | The browser-level end-to-end suite | @@ -263,7 +264,9 @@ browser suite against a mis-provisioned stack is worse evidence than no suite. consecutive runs are independent of one another. - Both seed tenants — the English school and the yoga studio — produce visually and structurally different published sites from the same binary, now with navigation, SEO - and full page composition rather than the two skeleton pages. + and full page composition rather than the skeleton pages Phase 02d ships, whose set is + G25 in + [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register). ## Risks diff --git a/docs/roadmap/phase-07-enrollment-learner-portal.md b/docs/roadmap/phase-07-enrollment-learner-portal.md index 94890005..c772dee0 100644 --- a/docs/roadmap/phase-07-enrollment-learner-portal.md +++ b/docs/roadmap/phase-07-enrollment-learner-portal.md @@ -190,6 +190,14 @@ consumes the stream for reporting. unentitled request returns 404, not 403 — the existence of another tenant's or another learner's content is not disclosed. - Admin and instructor preview capabilities are separate from learner access. +- Whether the lesson content [Phase 02d](phase-02d-walking-skeleton.md) publishes + anonymously stays on the public path once enrollment exists, or moves behind + `CourseAccess` in whole or in part — and what its public URL then answers — is open. + This phase's decision pass answers it before the first access check ships; + [Phase 02d § Explicitly not in this phase](phase-02d-walking-skeleton.md#explicitly-not-in-this-phase) + assigns the question here. What "published" means to an anonymous reader in + Phase 02d is G3 in + [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register). ### Isolation and Audit diff --git a/docs/standards/01-architecture-standards.md b/docs/standards/01-architecture-standards.md index 9c3bfcc6..afe3704c 100644 --- a/docs/standards/01-architecture-standards.md +++ b/docs/standards/01-architecture-standards.md @@ -152,6 +152,14 @@ Allowed patterns (see [Cross-Module Contracts](../architecture/10-cross-module-c 3. Integration event — written to the outbox in the same transaction; consumed asynchronously. 4. Read-model projection — public read-only table owned by one module. +> **Open in Phase 02d.** No shipped `Application.Contracts` assembly declares a service +> interface — the contracts so far are MediatR requests — and no module calls another's +> yet. Whether +> the Customization contract an Education lesson writer calls is an interface or a +> MediatR query, and how it is classified, is G12 in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register); +> the pass that closes it edits item 1 with its answer. + Forbidden patterns: - Cross-module EF navigation properties. - Cross-module raw SQL joining tables owned by different modules. diff --git a/docs/standards/02-backend-coding.md b/docs/standards/02-backend-coding.md index 665eac74..be26b6f6 100644 --- a/docs/standards/02-backend-coding.md +++ b/docs/standards/02-backend-coding.md @@ -178,17 +178,26 @@ Exceptions stay for things like "database is down" or "the program is in a bug s Each use case is a command or query: ```csharp -public sealed record PublishCourseCommand(CourseId CourseId, UserId ActorId) : IRequest<Result<CourseVersionId>>; +public sealed record PublishCourseCommand( + Guid CourseId, // module-local id: crosses the contract as Guid, per § Types + UserId ActorId) // SharedKernel id: typed everywhere + : IRequest<Result<Guid>>; -public sealed class PublishCourseHandler : IRequestHandler<PublishCourseCommand, Result<CourseVersionId>> +public sealed class PublishCourseHandler : IRequestHandler<PublishCourseCommand, Result<Guid>> { - public async Task<Result<CourseVersionId>> Handle(PublishCourseCommand command, CancellationToken ct) + public async Task<Result<Guid>> Handle(PublishCourseCommand command, CancellationToken ct) { // ... } } ``` +> **Open in Phase 02d.** The example's names are illustrative. Phase 02d writes the +> first Education commands, and whether a course version exists yet, which command sets +> a publication state and whether publishing is its own command are G2, G3 and G11 in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The decision pass that closes them edits this example with its answer. + Rules: - Handlers are thin; orchestrate domain methods and persistence. - One transaction per handler. diff --git a/docs/standards/03-frontend-coding.md b/docs/standards/03-frontend-coding.md index 31011957..c313d680 100644 --- a/docs/standards/03-frontend-coding.md +++ b/docs/standards/03-frontend-coding.md @@ -106,10 +106,21 @@ export function CourseCard({ course, onEnroll }: CourseCardProps) { ## Styling - Tailwind CSS for utility-first styling. -- Design tokens defined in `packages/ui/tokens/`; tenant theme overrides applied at layout level. +- Design tokens are the `--ls-*` CSS custom properties that + [Frontend Architecture Standards § Tenant Branding](07-frontend-architecture.md#tenant-branding) + names. Their defaults are declared in `apps/web/src/app/globals.css`, and the shared + Tailwind preset in `packages/config/tailwind` reads them. `packages/ui` holds no + tokens, because [ADR-0009](../decisions/0009-frontend-single-app-first.md) extracts a + shared package only when duplication is real. Tenant theme overrides are applied at + layout level. - No inline `style={{}}` except for runtime-computed values (e.g. progress bar width). - `clsx` / `tailwind-merge` for conditional class composition. +> **Open in Phase 02d.** Which tenant tokens exist and the value each accepts is G16; +> how they reach the server-rendered HTML is G42. Both are in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register), +> and the pass that closes each gate edits this section with its answer. + ## Server Actions - Live in `app/.../actions.ts`. @@ -151,6 +162,15 @@ export function CourseCard({ course, onEnroll }: CourseCardProps) { - Never `alert()`. Use toast or modal system. - Error boundaries at route-group level for graceful fallbacks. +> **Open in Phase 02d.** `apps/web` has no `logger` wrapper and no Sentry client yet. +> Which parts of +> [Observability Standards § Frontend Observability](10-observability.md#frontend-observability) +> ship in Phase 02d, the wrapper's Sentry capture among them, and which phase owns the +> rest are G35. Whether the `(public)` route group ships its own error, loading and +> not-found files in Phase 02d is G40. Both are in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register), +> and the pass that closes each gate edits this section with its answer. + ## Forbidden - `any` without a comment explaining why. @@ -197,6 +217,16 @@ features/<feature>/ types.ts ``` +> **Open in Phase 02d.** Three questions these trees answer are open for the public +> renderer. Where the UI string catalogue lives is G39: `packages/i18n/` is one of +> several homes the corpus names, and `frontend/packages` holds no such package. What +> `packages/sdk` becomes once regeneration fills `paths` is G31; today it holds +> generated types beside hand-written factories. Where the lesson page's composite and +> primitive field components live — `packages/ui/`, a route group's or a feature's +> `components/`, or elsewhere — is G41. Each is a row in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register), +> and the pass that closes it edits this section with its answer. + ## Comments - Comment the *why* of non-obvious code, not the *what*. diff --git a/docs/standards/04-api-design.md b/docs/standards/04-api-design.md index 71e7bb69..c81d408d 100644 --- a/docs/standards/04-api-design.md +++ b/docs/standards/04-api-design.md @@ -30,6 +30,14 @@ Examples: Platform-admin endpoints live under `/api/v1/platform/...` and require platform-admin scope. +> **Open in Phase 02d.** The template has one identity slot, `{id?}`, and the course +> examples fill it with an id. Phase 02d's public detail reads address a course by slug. +> Whether they share that slot with Phase 05's authoring `/courses/{id}`, take a +> distinct public prefix or use `/courses/by-slug/{slug}` is G26 in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> It is answered in the decision pass of the packet that ships the public reads, which +> edits this section with the answer before the OpenAPI baseline is stored. + ## Versioning The full versioning policy lives in @@ -193,6 +201,14 @@ reconciliation matrix are the separate case — no tenant context resolves at al is the authority for why the ceiling holds and what a forged host reaches under it. The matrix is not restated here. +> **Open in Phase 02d.** Which audit class this phase's marked requests register, and +> whether a rule makes `Off` the only permitted one; whether the phase's rows permit +> `GET` alone or `GET` and `HEAD`, and what the permitted-methods check compares a row +> against; and what mechanically stops a marked request from writing are G28 in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The default above is ADR-0036's; G28 decides what this phase's rows declare under it. +> The pass that closes it edits this section with its answer before the first row lands. + The set is this table and nothing else: | Request type | Permitted methods | Why | Owning phase | @@ -364,6 +380,14 @@ statuses it can answer before the action runs. Mutable resources expose `ETag` (or `version` field). +> **Open in Phase 02d.** Whether the anonymous public reads emit an `ETag` and honour +> `If-None-Match` — a composite read cannot use +> [ADR-0039](../decisions/0039-optimistic-concurrency-token.md)'s one derivation without +> publishing `row_version` — is G27 in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The decision pass of the packet that ships the public reads answers it and adds this +> section's sentence on anonymous read contracts, which is owed under either answer. + ``` GET /api/v1/courses/{id} → ETag: "7" PATCH /api/v1/courses/{id} @@ -421,7 +445,17 @@ SDK has no branch for. the serializer omits defaults. - TypeScript SDK `@learnstack/sdk` is generated from this spec; [Standards 07 § SDK](07-frontend-architecture.md) owns how and when. -- Breaking OpenAPI changes fail CI unless the version bumps. +- Breaking OpenAPI changes fail CI unless the version bumps — once the `openapi diff` + job is active. Today that job is a placeholder behind the unset + `vars.ENABLE_OPENAPI_DIFF` and reports as skipped, so no breaking change fails CI yet. + +> **Open in Phase 02d.** How that job activates and what it can see are G31 in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register): +> the committed snapshot and the base copy it is diffed against, the `oasdiff` version +> and fail level, which [ADR-0024](../decisions/0024-api-versioning-policy.md) breaking +> changes that level detects and which it cannot see, and whether the job keeps its +> skip condition. It is answered in the decision pass of the packet that ships the +> public reads, which edits this section with the answer. ## Request and Response Limits @@ -436,7 +470,7 @@ a limit, and the first version of this table was four of those. | URL length | 8 KiB | Kestrel (`MaxRequestLineSize`), server default | | Multipart upload (excluding files) | — | No endpoint yet; [Phase 04](../roadmap/phase-04-cms-media-pages.md) | | File upload, per content type | see [architecture/16 § Validation](../architecture/16-media-pipeline.md) | No endpoint yet; [Phase 04](../roadmap/phase-04-cms-media-pages.md) | -| Rate limit (anonymous) | 60 req/min per peer | `AddLearnStackRateLimiting` | +| Rate limit (anonymous) | 60 req/min per peer | `AddLearnStackRateLimiting`. A request over the trusted hop is partitioned on its socket peer like any other, so every visitor of the server-rendered pages shares one partition; whether the hop changes the key or the budget is G34 in [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register), and the decision pass that closes it edits this row with its answer | | Rate limit (authenticated) | 600 req/min per token | No token to key on yet; [Phase 02b](../roadmap/phase-02b-events-auth.md) | | Rate limit (write endpoints) | 60 req/min per token | No token to key on yet; [Phase 02b](../roadmap/phase-02b-events-auth.md) | diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index 2b0e7276..48969832 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -256,6 +256,13 @@ fence in § Translation satellite tables below; no shipped chain creates one yet belongs to and which is meaningless without it. A cascade from anything that is *not* a root's own child is still a decision, not a convenience, and it needs a record. +> **Open in Phase 02d.** The `lessons` fence above illustrates the composite key, not +> Education's model. Which aggregate `Lesson` belongs to, what its foreign key targets, +> and whether it cascades as a child inside an aggregate — a third shape in the class +> above — or restricts as its own root are G2 in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register); +> the pass that closes it edits this section with its answer. + **The circular reference, and why it is still composite.** `tenants.default_organization_id` points at `organizations`, which points back at `tenants`. This direction is **not** covered by the self-keyed exception above — @@ -410,9 +417,13 @@ organization, nor `WITH CHECK`, which admits `organization_id IS NULL` from any — so nothing in this template forces a satellite's `organization_id` to equal its parent's. The insert-time control is G7 in [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register), -answered before the first Education migration. `organization_id` is deliberately -**absent** from the slug unique key — see § Constraints and -[Localization Standards § Pattern A](08-localization.md). +answered before the first Education migration. So are four things this template shows +only one way: the `locale` column's type and stored spelling (G6); the slug's shape, +width and `CHECK` backstop (G9); whether a satellite maps `deleted_at` (G2); and how +`fn_organization_id_immutable`, which reports `OLD.id`, serves a satellite that has no +`id` (G8). The pass that closes each gate edits this section with its answer. +`organization_id` is deliberately **absent** from the slug unique key — see +§ Constraints and [Localization Standards § Pattern A](08-localization.md). The foreign key is composite on `tenant_id` for the reason in § Foreign keys between tenant-owned tables. A composite key that also carried `organization_id` would not work: diff --git a/docs/standards/06-testing.md b/docs/standards/06-testing.md index 5035fc2a..a82c9b80 100644 --- a/docs/standards/06-testing.md +++ b/docs/standards/06-testing.md @@ -131,11 +131,20 @@ frontend tooling table below. - Cover the MVP vertical slice (tenant → page → course → enrollment → live session). - ~10 high-signal scenarios. -- **Owner:** the first flow arrives with the first rendered surface, in +- **Owner:** the first flow arrives in [Phase 06](../roadmap/phase-06-renderer-admin-studio.md), which also wires the axe accessibility checks that run through the same harness. - [Phase 02d](../roadmap/phase-02d-walking-skeleton.md) puts two tenants in a - browser but gates on a human opening them, not on a Playwright run. + [Phase 02d](../roadmap/phase-02d-walking-skeleton.md) renders the first tenant + surfaces, puts two tenants in a browser and gates on a human opening them, not on a + Playwright run. + +> **Open in Phase 02d.** Whether automated evidence 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 — is G38 (c), and what +> discharges ADR-0036's "Phase 02d's browser test" is G33, both in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The decision pass that closes either gate edits this section only if its answer moves +> a browser smoke or the Playwright ownership into Phase 02d, citing the row. ## Frontend Test Types @@ -154,6 +163,13 @@ Rules: - Playwright covers the same golden flows the backend E2E covers, from the user's perspective. - Visual regression covers the public renderer and page-builder block output. +> **Open in Phase 02d.** Phase 02d ships the platform's first public pages. The frontend +> case set for its code is G38, and which accessibility checks on those pages fail a +> build — including axe under jsdom rather than through Playwright, where the table +> above places it — is G43, both in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The pass that closes either gate edits this section where its answer departs from it. + ## Tenant Isolation Tests **Mandatory** for every PR touching tenant-owned data: diff --git a/docs/standards/07-frontend-architecture.md b/docs/standards/07-frontend-architecture.md index 49d48a52..a65b000e 100644 --- a/docs/standards/07-frontend-architecture.md +++ b/docs/standards/07-frontend-architecture.md @@ -65,6 +65,12 @@ Migration to multiple apps within this repo (e.g. extracting `(studio)` into ## Tenant Resolution +> **Open in Phase 02d.** For the public site, whether the edge resolves a tenant and +> what it carries inward (G25, G36), and whether the anonymous path sets any cookie +> (G21), are open in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The pass that closes each edits this section with its answer. + ```mermaid flowchart TD req[Request lands at edge] @@ -98,6 +104,11 @@ Rules: ## Locale Resolution +> **Open in Phase 02d.** How the locale reaches the API on the public reads — the query +> parameter or `X-Locale` — is G30 in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The pass that closes it edits this section with its answer. + - Public-site URL: `/{locale}/...`. - Default locale from the tenant's default `tenant_locales` row, which the Tenancy module owns ([ADR-0008](../decisions/0008-localization-schema.md)). @@ -108,6 +119,11 @@ Rules: The SDK is the only allowed way to talk to the API from frontend code. +> **Open in Phase 02d.** The server SDK's transport and options (G35), and the surface +> the package exposes once regeneration makes `paths` non-empty (G31), are open in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The pass that closes each edits this section with its answer. + - **Generated** from the backend's `/openapi/v{N}.json` by `openapi-typescript` into `src/generated/schema.d.ts`, which is **checked in** so a reviewer sees the contract the app compiles against. `pnpm --filter @learnstack/sdk generate` @@ -147,8 +163,18 @@ export default async function CourseListPage() { - `params` and `searchParams` server-side; thread through carefully. - Each route group has its own `layout.tsx`, `loading.tsx`, `error.tsx`. +> **Open in Phase 02d.** Which of these files the `(public)` group ships in Phase 02d, +> and any dated carve-out that needs, is G40 in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). + ## Public Site Renderer +> **Open in Phase 02d.** How tenant-varying `(public)` routes render and which caches +> may hold tenant data (G37), and where composite and primitive components live (G41), +> are open in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The pass that closes each edits this section with its answer. + - Renders **published** pages, courses, blog content. - Server-side rendering with `revalidate` based on tenant + content type. - Block rendering pulls from a block registry (`packages/blocks`); blocks register a React component plus a JSON schema. @@ -170,6 +196,11 @@ export default async function CourseListPage() { ## Tenant Branding +> **Open in Phase 02d.** Which branding keys exist and the value each accepts (G16), and +> how validated values reach the server-rendered document (G42), are open in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The pass that closes each edits this section with its answer. + - Tenant theme tokens loaded at the layout level via RSC. - Tokens map to CSS variables; Tailwind reads them via `--ls-primary`, `--ls-bg`, etc. - Theme JSON shape part of tenant settings; tenant-admin editor surfaces it. diff --git a/docs/standards/08-localization.md b/docs/standards/08-localization.md index 65da7b5c..f6054c7b 100644 --- a/docs/standards/08-localization.md +++ b/docs/standards/08-localization.md @@ -27,6 +27,13 @@ Localization covers: across organizations — see [§ Pattern A](#pattern-a--side-translation-table-default-for-content-shaped-entities). - Fallback chain: requested → tenant default → field-level fallback (if allowed) → render-safe missing-content state. +> **Open in Phase 02d.** Whether a schema marker such as `isLocalized` exists (G4), +> whether a read resolves under a disabled locale and what a tenant with no locale rows +> serves (G13), and which document owns the display fallback chain — this list and +> [Localization § Fallback Rules](../architecture/12-localization.md#fallback-rules) +> state different ones (G24) — are open in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). + ## URL Strategy Public URLs: @@ -180,6 +187,13 @@ var msg = _stringLocalizer["course.publish.success"]; - Always store the full code, not a truncated form. - An enum-like registry of supported locales lives in `LearnStack.SharedKernel.Locales`. +> **Open in Phase 02d.** The shipped `LocaleTag` canonicalizes a script subtag +> Title-cased and a region uppercased (`tr-TR`, `zh-Hans`), which "Lowercase" does not +> describe, and no `LearnStack.SharedKernel.Locales` namespace exists. Which spelling +> content tables and request parameters use (G6) and whether a platform registry bounds +> a tenant's enabled set (G13) are open in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). + ## Right-to-Left - The platform supports RTL languages from the start. diff --git a/docs/standards/09-error-handling.md b/docs/standards/09-error-handling.md index 6cdb9313..1d419d01 100644 --- a/docs/standards/09-error-handling.md +++ b/docs/standards/09-error-handling.md @@ -112,7 +112,7 @@ matching localization key adds the `lockey_` prefix. | `audit_unavailable` | A MUST-class audit row could not be written durably | 503 | | `audit_unclassified_operation` | Operation absent from the audit catalogue | 500 | | `recording_consent_required` | Live session requires consent | 409 | -| `unsupported_locale` | Locale not enabled for tenant | 400 | +| `unsupported_locale` | Locale not enabled for tenant. Whether Phase 02d's public reads answer a not-enabled locale with this code or with `not_found` is G30 in [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register), and the pass that closes it edits this row with its answer | 400 | | `feature_disabled` | Feature flag off for tenant | 403 | | `method_not_allowed` | Route matched, method did not — *framework-minted* | 405 | | `payload_too_large` | Request body over the published limit — *framework-minted* | 413 | diff --git a/docs/standards/10-observability.md b/docs/standards/10-observability.md index 2333eb8c..86073158 100644 --- a/docs/standards/10-observability.md +++ b/docs/standards/10-observability.md @@ -304,6 +304,13 @@ In addition to system metrics, business KPIs: - Client errors include `correlation_id` from the last server request. - Trace propagation: server-issued `traceparent` injected into the page; client follow-up fetches continue the trace. +> **Open in Phase 02d.** `apps/web` wires none of this yet: it has no Sentry client, no +> web-vitals hook and no trace propagation. Which of these rules Phase 02d ships, +> including whether its server SDK transport forwards `traceparent` to the API, and +> which phase owns the rest are G35 in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The pass that closes G35 edits this section with its answer. + ## Dashboards The first set of dashboards (Phase 11 deliverable): diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index 0b115605..0863db10 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -315,6 +315,15 @@ yet: > the set of setters is closed and a closed set is worth stating whole; it is not evidence > that a seventh short transaction runs on any Packet 7 request path. +> **Open in Phase 02d.** One shipped caller this table does not list already announces +> `app.tenant_id`: `SeedRunner`'s ownership check opens a transaction of its own and +> calls `IUnitOfWork.SetTenantContextAsync` on it, and nothing mechanical closes that +> method's caller set. Whether the seeder is admitted here or routed through `ISender` +> is G15, and whether the customization projection's loader or the settings accessor's +> becomes an out-of-band setter is G22 and G23, all in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The pass that closes each edits this section with its answer. + **Both audit writers announce both variables.** `WriteStandaloneAsync` and `WriteBestEffortAsync` issue `app.tenant_id` **and** `app.organization_id` from the draft, as the first statements of their short transaction, because `audit_log` is @@ -477,6 +486,14 @@ anonymous row is enforced, in process, keyed on the socket peer — see [Standards 04 § Request and Response Limits](04-api-design.md) for what enforces which row today. +> **Open in Phase 02d.** Its server-rendered pages call the API from the renderer's +> peer, so every visitor of both seed tenants shares that peer's anonymous partition. +> How the limiter treats a request arriving over the authenticated trusted hop — the +> partition key, the budget, and any visitor address the renderer states — is G34 in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The pass that closes it edits the anonymous row and the paragraph above with its +> answer. + | Surface | Limit | |---------|-------| | `/api/v1/auth/*` (login, password reset, register) | 5 req/min per IP | @@ -647,7 +664,9 @@ Security-relevant durability rules: - Storing passwords or password derivatives in any LearnStack table. - Storing third-party tokens in plain text — encrypt at rest if storage is unavoidable. - Trusting `tenant_id` or `organization_id` from a request body or query param. Both - come from authenticated context only. + come from the resolved tenant context only + ([API Standards § Tenant Context](04-api-design.md#tenant-context)), which an + anonymous request resolves from its effective host. - Implementing custom crypto. Use established libraries. - Disabling RLS in production for any reason short of an investigated incident with an ADR. diff --git a/docs/standards/12-infrastructure.md b/docs/standards/12-infrastructure.md index b92ce670..270e6f4f 100644 --- a/docs/standards/12-infrastructure.md +++ b/docs/standards/12-infrastructure.md @@ -78,14 +78,24 @@ livekit-egress # Phase 08c (recording / consent / cost model) otel-collector # Phase 11 (Production hardening — observability stack) ``` -- Application projects run **outside** containers during active development; the Dapr - sidecar still runs alongside via `dapr run` or compose. +- Application projects run **outside** containers during active development. No Dapr + sidecar runs beside them in the default loop: Valkey, Kafka, kafka-ui, Vault, APISIX + and both Dapr services sit behind the compose `gated` profile, which `make dev` does + not start and `make dev-gated` does, and nothing the backend runs today calls them + ([ADR-0035](../decisions/0035-demand-gated-infrastructure.md#the-gated-set)). - CI runs the same image tags as developers. - `.env.example` is the source of truth for required env vars; secrets in real environments come from Vault via `ISecretProvider`, not env files. - `infra/apisix/config.yaml` is the canonical APISIX standalone config; routes / plugins hot-reload on file change. +> **Open in Phase 02d.** Where the API and the web app run when a browser renders both +> seed tenants — on the workstation loopback, in containers or behind the gated APISIX — +> how one trusted-hop secret reaches both processes, and what `make demo` starts, are +> G33 and G45 in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The passes that close them edit this section with their answers. + ### Healthchecks and the readiness gate **Derives from:** [ADR-0002 Initial Architecture](../decisions/0002-initial-architecture.md) diff --git a/docs/standards/13-documentation.md b/docs/standards/13-documentation.md index 488cf82d..c90ebe63 100644 --- a/docs/standards/13-documentation.md +++ b/docs/standards/13-documentation.md @@ -21,7 +21,7 @@ docs/ roadmap/ phase-NN-topic.md (with phase-NNa / phase-NNb splits for parallel tracks) standards/ NN-topic.md engineering rules runbooks/ *.md operations procedures (Phase 11+) - modules/ NN-module/ per-module specs (created with the first module impl) + modules/ <module>/ per-module specs (one directory per module, created with its first spec) glossary.md terminology ``` @@ -182,7 +182,10 @@ Common diagram types: ## Per-Module Specifications -When a module reaches "design stable, ready to implement", it gets a spec under `docs/modules/<module>/` (this directory is created with the first module spec; it does not exist during pre-implementation) containing **at minimum**: +When a module reaches "design stable, ready to implement", it gets a spec under +`docs/modules/<module>/` — one directory per module, created with that module's first +spec, the first being [Tenancy](../modules/tenancy/README.md)'s in Phase 02a Packet 6 — +containing **at minimum**: - **Overview** — what the module owns, what it does not. - **Entity-relationship diagram** (Mermaid `erDiagram`) — aggregate roots, owned entities, cross-module id references. diff --git a/docs/standards/15-performance.md b/docs/standards/15-performance.md index ba16af81..fb47a857 100644 --- a/docs/standards/15-performance.md +++ b/docs/standards/15-performance.md @@ -43,6 +43,14 @@ Budgets are reviewed quarterly against measured production metrics. - TTL chosen per content type; default 5 minutes for catalog, 1 minute for course detail. - Cache hit ratio per cache name surfaced as a metric. +> **Open in Phase 02d.** That phase ships the first course-catalog reads and the first +> pages rendered from them, and no Education publish event to invalidate a cache with. +> Whether those reads are cached at all, with what directive and what freshness, is G27; +> which Next.js caches may hold the rendered pages is G37. Both are in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> A pass whose answer caches edits this section with it, and the pass that closes the +> later of the two gates removes this note. + ### Pagination - All list endpoints are paginated as @@ -104,6 +112,12 @@ Budgets are reviewed quarterly against measured production metrics. - Dashboards track LCP, INP, CLS, FCP per route. - Regression on a critical route is a Sev-2 issue. +> **Open in Phase 02d.** Whether web-vitals reporting ships with that phase's public +> pages, and which phase owns it if not, is G35 in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The pass that closes it edits this section with its answer, together with +> [Observability Standards § Frontend Observability](10-observability.md#frontend-observability). + ## Live Classroom - Join time < 1.5 s p95. diff --git a/docs/standards/16-accessibility.md b/docs/standards/16-accessibility.md index d9517a54..ab6d9fa7 100644 --- a/docs/standards/16-accessibility.md +++ b/docs/standards/16-accessibility.md @@ -50,6 +50,9 @@ LearnStack is an education platform; learners with disabilities are a first-clas - UI components and graphical objects ≥ 3:1. - Never rely on color alone to convey meaning; pair with text, icon, or shape. - Tenant theme tokens are validated for contrast before saving (Admin Studio surfaces a warning). + Whether a failing pair refuses a write that has no Studio screen, or records a + warning, is G16 (d) in + [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). ### Images and Media @@ -90,6 +93,15 @@ LearnStack is an education platform; learners with disabilities are a first-clas - Lighthouse accessibility audit runs in CI for public routes. - Manual keyboard walkthroughs for new screens in the PR review. +> **Open in Phase 02d.** Its public pages are the first routes this section binds. +> Whether the Lighthouse job activates there, and whether its accessibility audit is +> asserted, is G44; which checks fail a build on those pages — route tests, `jsx-a11y` +> at error severity or component axe — is G43. Both are in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register), +> and the passes that close them edit this section. The Playwright axe run is +> [Phase 06](../roadmap/phase-06-renderer-admin-studio.md)'s, per +> [Testing Standards § End-to-End Tests](06-testing.md#end-to-end-tests). + ## Testing - Keyboard navigation for new screens. diff --git a/docs/standards/17-code-review.md b/docs/standards/17-code-review.md index 03eb64f0..83fb8053 100644 --- a/docs/standards/17-code-review.md +++ b/docs/standards/17-code-review.md @@ -68,6 +68,14 @@ Before requesting reviews, run the relevant checklist on your own diff: - [ ] Accessibility: every interactive element keyboard-reachable; labels associated; color contrast checked; axe-core tests pass. - [ ] Bundle size delta acceptable on public routes (< 200 KB gzipped per route). +> **Open in Phase 02d.** No axe-core suite exists in `frontend/` today, and +> [Testing Standards § End-to-End Tests](06-testing.md#end-to-end-tests) assigns the +> Playwright axe run to [Phase 06](../roadmap/phase-06-renderer-admin-studio.md). Which +> accessibility checks fail a build on Phase 02d's public pages — route tests, +> `jsx-a11y` at error severity or component axe — is G43 in +> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register), +> and the pass that closes it edits the accessibility box with its answer. + If a checkbox doesn't apply to your diff, omit it. If you cannot tick a checkbox, fix the gap before requesting review. ## Priority Order diff --git a/docs/standards/19-permissions.md b/docs/standards/19-permissions.md index c30dfcb0..f52c64b5 100644 --- a/docs/standards/19-permissions.md +++ b/docs/standards/19-permissions.md @@ -140,7 +140,8 @@ Permission-aware UI hides actions the user cannot perform — but the API is aut ## Permission Matrix Template -Every module ships this matrix as part of its module spec under `docs/modules/<module>/permissions.md` (the directory is created with the first module spec; it does not exist during pre-implementation): +Every module ships this matrix as part of its module spec under +`docs/modules/<module>/permissions.md`. The rows below are an example of its shape: | Resource | read | write | delete | admin | Default role grants | |----------|:----:|:-----:|:------:|:-----:|---------------------| diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index 097f5d5b..6b2a1b6d 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -130,7 +130,7 @@ Rules: | Cache | `InMemoryCacheService` | `DaprCacheService` → Valkey | `DaprCacheService` → Valkey | `DaprCacheService` → Valkey | `DaprCacheService` → Valkey | | Secrets | `ConfigurationSecretProvider` | `DaprSecretProvider` → Vault | `DaprSecretProvider` → Vault | `DaprSecretProvider` → Vault | `DaprSecretProvider` → Vault or file | | Entitlement | `NullEntitlementProvider` | `HubEntitlementProvider` | `HubEntitlementProvider` | `HubEntitlementProvider` (phone-home) | `SignedLicenseKeyEntitlementProvider` | -| Host → tenant | Config / single tenant | Hub-mirrored projection | Hub-mirrored projection | Hub-mirrored projection | Config / `.lic` claim | +| Host → tenant | `platform_host_to_tenant`, whose rows the seeder writes through `MapHostToTenantCommand` | Hub-mirrored projection | Hub-mirrored projection | Hub-mirrored projection | Config / `.lic` claim | | Phone-home | n/a | enabled | enabled | enabled (daily, 30-day grace) | disabled | | Error tracking ([ADR-0032](../decisions/0032-exception-handling-logging-and-observability.md)) | `NoOpErrorTracker` | `SentryErrorTracker` | `SentryErrorTracker` | `SentryErrorTracker` (optional; `NoOp` if no DSN) | `LocalFileErrorTracker` | | OTLP exporter target ([ADR-0032](../decisions/0032-exception-handling-logging-and-observability.md)) | local OTel Collector (dev compose) | central Collector | central Collector | customer-managed Collector | local file `/var/learnstack/otel/` | @@ -260,6 +260,14 @@ drifts: | `{tenant_id}:tenancy:settings` | `CacheKey.ForTenant(tenantId, "tenancy", "settings")` | | `{tenant_id}:audit:config` | `CacheKey.ForTenant(tenantId, "audit", "config")` | +**`{tenant_id}:tenancy:settings` has a key and no reader yet.** The family and its +`cache.name` mapping are shipped; nothing caches a settings read. Whether settings are +cached at all, how a cached read keeps one organization's overrides from reaching +another, and what bounds staleness before the `learnstack.tenancy.settings` event +exists, are G23 in +[Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +The pass that closes it edits both `tenancy:settings` rows above with its answer. + **`{tenant_id}:audit:config` has no eager invalidation, and that is a stated gap rather than an omission.** The projection is read by `IAuditConfigService` on the classification path, and nothing writes `audit_config` in Phase 02a Packet 9 — both runtime roles hold @@ -546,7 +554,9 @@ are the Hub's public API, governed by the Hub repository. plane converts a Hub outage into a tenant-marketing-site outage. - A cache miss re-reads the table. An unknown host is a 404, not a Hub lookup. - The frontend edge calls the resolver via a thin API endpoint; the backend uses it - directly for inbound request resolution. + directly for inbound request resolution. Whether Phase 02d's renderer calls that + endpoint, or gets its per-host data another way, is G25 in + [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). - Custom-domain activations on Hub push a new host-mapping set; LearnStack updates `platform_host_to_tenant` and invalidates the resolver cache. Once the event-bus adapter lands, the same update also arrives as diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 9a98c75b..ccd0a656 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -3501,6 +3501,14 @@ structural test proves — and what it does not. header produced **zero** rejections against eleven without it, and the composition root refuses to start in that configuration now. - **Phase:** 02a (Packet 4). +- **Note:** the partition key is open for one class of request. + [Phase 02d](../roadmap/phase-02d-walking-skeleton.md)'s server-rendered reads reach + the API over the authenticated trusted hop from the renderer's peer, so every visitor + of both seed tenants shares one partition. How the limiter keys and budgets such a + request is G34 in + [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). + An answer keyed on a visitor address the renderer states changes the "never comes from + a header" clause above, and the pass that closes G34 edits this entry. #### `Tenant_Headers_Are_Never_A_Resolution_Source` @@ -3687,7 +3695,12 @@ structural test proves — and what it does not. leg and the tenant-owned-write leg are Registered. - **Phase:** 02a (Packet 7) for the enumeration legs. The permitted-methods and tenant-owned-write legs arrive with the first `[PublicSurface]` request type, in - [Phase 02d](../roadmap/phase-02d-walking-skeleton.md). + [Phase 02d](../roadmap/phase-02d-walking-skeleton.md). Which methods that phase's rows + permit, `GET` alone or the `GET` / `HEAD` default, what the methods leg compares a row + against, and whether the write leg is a structural scan, a `READ ONLY` unit of work or + both are G28 in + [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register); + the pass that closes it edits this entry with its answer. - **Note:** the two directions are not equally vacuous, and the existing note above covers only one of them. **Marked set → table** is vacuous while no type carries the marker. **Table → marked set** is live from the day it ships: the table may not name a type that diff --git a/docs/standards/README.md b/docs/standards/README.md index 347719c2..ba9ca5ee 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -88,15 +88,15 @@ included. | 05 | [Database](05-database.md) | **Active** | Packet 6 applied it — two migration chains, ten tables — and Packets 8 and 9 took it to four chains and seventeen tables: the four-role model, and the canonical RLS template this document owns — `ENABLE` **and** `FORCE`, one `AND`-ed policy per table, an explicit `WITH CHECK` — asserted against a real PostgreSQL as `learnstack_app`. Its § Concurrency, § Table classes, § Indexes and § GRANT matrix each have a test that fails without them. Partitioning and the retention job are still ahead. | | 06 | [Testing](06-testing.md) | **Active** | Unit, architecture and contract suites, and the Docker-free integration tests, run in the required `backend` job — Packet 4 removed the filter that used to exclude the integration assembly, which by then held the only tests that could catch an unversioned route. The Docker-bound `backend-integration` job activated in Packet 6 with the four-role provisioning suite; the split is by `[Trait("Requires","Docker")]` and the two jobs' filters are exact complements. The `backend-integration` job is not yet a required check ([CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main)). | | 07 | [Frontend Architecture](07-frontend-architecture.md) | **Active** | The one-app rule is mechanical — `Frontend_Has_Only_The_Web_App` fails a second application in this repository — and the route groups it prescribes exist as layouts. The rest, the server/client split and the tenant context an SDK call carries, is exercised first in [Phase 02d](../roadmap/phase-02d-walking-skeleton.md): Active for what ships, and the phase that adds components is the one that tests them. | -| 08 | [Localization](08-localization.md) | **Active** | Packet 6 shipped `tenant_locales` and the slug schema, and "exactly one default locale per tenant" is enforced twice: a partial unique index `UNIQUE (tenant_id) WHERE is_default` and an aggregate guard that carries the message. `LocalizedText` and `LocalizedMessage` ship with their own cases, and every error the API returns is keyed rather than written. The i18n **runtime** — routing, negotiation, formatting — lands in [Phase 04](../roadmap/phase-04-cms-media-pages.md). | +| 08 | [Localization](08-localization.md) | **Active** | Packet 6 shipped `tenant_locales` and the slug schema, and "exactly one default locale per tenant" is enforced twice: a partial unique index `UNIQUE (tenant_id) WHERE is_default` and an aggregate guard that carries the message. `LocalizedText` and `LocalizedMessage` ship with their own cases, and every error the API returns is keyed rather than written. The i18n **runtime** — routing, negotiation, formatting — lands in [Phase 04](../roadmap/phase-04-cms-media-pages.md). How much of it Phase 02d builds first is open: the locale-less redirect is G36, and the UI message layer and its library G39, in [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register); the pass that closes each gate edits this row with its answer. | | 09 | [Error Handling](09-error-handling.md) | **Active** | L1 `IExceptionHandler`, the exception hierarchy, `ProblemDetailsFactory` and `HttpStatusMap` shipped in Packet 3. | | 10 | [Observability](10-observability.md) | **Active** | Serilog → OTLP, OpenTelemetry SDK, `TenantContextSpanProcessor` and the redaction enrichers shipped in Packet 3. | | 11 | [Security](11-security.md) | **Active** | No authentication yet, and the isolation half of this document is live and mechanical. Row Level Security with the four roles and the isolation suite that runs as `learnstack_app`; the tenancy edge, the trusted-hop predicate and the anonymous rate limiter from Packet 4; and, since Packet 10, § The out-of-band setters is mechanical — every announcer of a session variable is one the table names and each reader opens its transaction read-only, with `App_Role_Cannot_Enumerate_Tenants`, `App_Role_Cannot_Enumerate_Host_Map` and `Tenant_A_Cannot_Repoint_Tenant_B_Host` proving the role cannot read or repoint what the policies bar. Authentication and authorisation land in [Phase 02b](../roadmap/phase-02b-events-auth.md) and [Phase 03](../roadmap/phase-03-identity-admin.md). Three sections are **not** enforced by anything today and are the document's own carve-out: § Transport, § HTTP Headers and § CORS — nothing sets HSTS, `nosniff`, a CSP or an origin policy, at the edge or in either app. [Phase 11 § Secure headers](../roadmap/phase-11-production-hardening.md) owns them, at APISIX and in the ASP.NET layer beside it. | | 12 | [Infrastructure](12-infrastructure.md) | **Active** | Compose stack, `Makefile`, CI workflow, pre-commit hooks and secret scanning all live since Phase 01. | | 13 | [Documentation](13-documentation.md) | **Active** | Governs this corpus; the CI link audit walks changed Markdown. | | 14 | [Git Workflow](14-git-workflow.md) | **Active** | Conventional Commits, hooks and required checks are live. Two branch-protection settings — `Require approvals` and `Do not allow bypassing` — are **deferred by maintainer decision (2026-08-10)** while the repository has one active contributor; the trigger and what activating them involves are recorded in [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md). The required-check list itself has two outstanding edits, recorded in [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main); [Phase 02d](../roadmap/phase-02d-walking-skeleton.md) makes both no later than its first Education migration. | -| 15 | [Performance](15-performance.md) | **Adopted** | No budget is measured and no load test exists. Enforcement lands in [Phase 11](../roadmap/phase-11-production-hardening.md). | -| 16 | [Accessibility](16-accessibility.md) | **Adopted** | No user interface to audit. First surfaces render in [Phase 02d](../roadmap/phase-02d-walking-skeleton.md); automated axe checks in [Phase 06](../roadmap/phase-06-renderer-admin-studio.md). | +| 15 | [Performance](15-performance.md) | **Adopted** | No budget is measured and no load test exists. Enforcement lands in [Phase 11](../roadmap/phase-11-production-hardening.md). Whether Phase 02d activates the Lighthouse budget job, what it asserts and what this row says afterwards are G44 in [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register); any change to this row lands in the pull request that brings the enforcement G44 settles. | +| 16 | [Accessibility](16-accessibility.md) | **Adopted** | No user interface to audit. First surfaces render in [Phase 02d](../roadmap/phase-02d-walking-skeleton.md); automated axe checks in [Phase 06](../roadmap/phase-06-renderer-admin-studio.md). Which accessibility checks fail a build on Phase 02d's pages — route tests, `jsx-a11y` at error severity, axe under jsdom or Lighthouse's accessibility audit — and what this row says afterwards are G43 and G44 in [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register); this note is updated in the pull request that lands those pages, and any status change with its enforcer. | | 17 | [Code Review](17-code-review.md) | **Active** | Applied to every pull request merged so far; the zero-tolerance blocker list is in live use. | | 18 | [Audit Coverage](18-audit-coverage.md) | **Active** | Packet 9 lit the write path under [ADR-0033](../decisions/0033-audit-durability-model.md) and [ADR-0044](../decisions/0044-audit-write-path.md): classification at step 3, MUST rows on the business transaction before `COMMIT`, `audit_log` / `audit_config` with their append-only layers, and the catalogue ↔ matrix join enforced in both directions and per request type by `AuditCoverageTests`. Retention and partitioning are [Phase 11](../roadmap/phase-11-production-hardening.md)'s. | | 19 | [Permissions](19-permissions.md) | **Adopted** | No permission key, policy or role exists. Lands in [Phase 03](../roadmap/phase-03-identity-admin.md). | diff --git a/frontend/apps/web/.env.local.example b/frontend/apps/web/.env.local.example index 94fba33b..5fddd1d1 100644 --- a/frontend/apps/web/.env.local.example +++ b/frontend/apps/web/.env.local.example @@ -12,6 +12,13 @@ # Tenant-facing gateway (APISIX in dev). Production routes the same URL # through the per-tenant custom-domain layer (ADR-0022). +# +# Open in Phase 02d: APISIX starts only under `make dev-gated`. Where Next.js and +# the API run relative to each other, what the server-only API origin is and how +# it is configured, and how the hop secret reaches this process are G33 and G35 in +# Phase 02d's decision register, +# ../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register +# The passes that close them edit this file with their answers. NEXT_PUBLIC_API_BASE_URL=http://localhost:9080 # Keycloak OIDC discovery (Phase 02b wires the actual NextAuth / Auth.js diff --git a/frontend/apps/web/README.md b/frontend/apps/web/README.md index fc3e183d..c87aaff4 100644 --- a/frontend/apps/web/README.md +++ b/frontend/apps/web/README.md @@ -14,11 +14,17 @@ don't collide at `/`: | Route group | URL prefix | Purpose | Phase that fills it in | | ------------------ | ---------- | ------------------------------------------------ | ---------------------- | -| `(public)/` | `/` | Tenant-facing public site | 04 / 06 | +| `(public)/` | `/` | Tenant-facing public site | 02d / 04 / 06 | | `(studio)/studio/` | `/studio` | Admin + content studio | 04 / 06 | | `(portal)/portal/` | `/portal` | Learner + instructor portal | 07 | | `api/` | `/api/*` | Thin BFF route handlers (`/api/healthz` shipped) | 02a+ | +> **Open in Phase 02d.** Which `(public)` pages ship first, what becomes of the platform +> placeholder at `(public)/page.tsx`, and what `/studio` and `/portal` answer on a +> tenant host are G25 and G40 in +> [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The decision passes of P02d-4 and P02d-6 edit this section with their answers. + There is **no `extensions/` folder for vertical-provided components** — per [ADR-0018](../../../docs/decisions/0018-tenant-driven-customization-model.md), tenant-specific renderers are composite renderer keys resolved by diff --git a/frontend/apps/web/src/app/globals.css b/frontend/apps/web/src/app/globals.css index 0fb83c1b..8a936f39 100644 --- a/frontend/apps/web/src/app/globals.css +++ b/frontend/apps/web/src/app/globals.css @@ -2,9 +2,10 @@ @tailwind components; @tailwind utilities; -/* Tenant theme tokens default to neutral values; layouts override per tenant - * via inline <style> from server-fetched tenant settings (Standards 07 § - * Tenant Branding). */ +/* Tenant theme tokens default to neutral values. How a layout overrides them + * per tenant from validated tenant settings is G42 in Phase 02d's decision + * register (docs/roadmap/phase-02d-walking-skeleton.md; Standards 07 § Tenant + * Branding). */ :root { --ls-primary: #1f6feb; --ls-bg: #ffffff; diff --git a/scripts/seed.sh b/scripts/seed.sh index 45e1e5bb..0d460c36 100755 --- a/scripts/seed.sh +++ b/scripts/seed.sh @@ -265,6 +265,10 @@ fi green " ✓ demo-english and demo-yoga present." +# Open in Phase 02d. The hostnames below mirror SeedData. Whether they stay, and the +# step a browser needs to reach them, is G32; what `make demo` prints is G45. The pass +# that closes each gate edits this block with its answer. Both gates are in +# docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register cat <<'HOSTS' Both tenants resolve by host. Add them to /etc/hosts to reach either in a From 91450da724a701e3131581803cc1af1bfb94685b Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 14 Sep 2026 10:53:25 +0300 Subject: [PATCH 5/6] docs: close the carriers the second review of 4880fac found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two skills still said the Lighthouse job is deferred to Phase 02d, which states G44's open activation question as settled; both now name the gate. The Tenancy spec described TenantLocale and TenantFeatureFlag as having public factories, top-level DbSets and no navigation from Tenant — the state before Packet 7 — in the present tense, which could lead Phase 02d's locale command past the root; it now describes the shipped split. local-dev-setup named .env.example as the API port's source of truth, which the same commit's .env.example comment contradicts: dotnet run takes the port from the launch profile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .claude/skills/add-page-block/SKILL.md | 7 +++++-- .claude/skills/local-dev-setup/SKILL.md | 6 +++--- .claude/skills/standards-check/SKILL.md | 6 ++++-- docs/modules/tenancy/README.md | 24 +++++++++--------------- 4 files changed, 21 insertions(+), 22 deletions(-) diff --git a/.claude/skills/add-page-block/SKILL.md b/.claude/skills/add-page-block/SKILL.md index 9281e80b..24150fdf 100644 --- a/.claude/skills/add-page-block/SKILL.md +++ b/.claude/skills/add-page-block/SKILL.md @@ -217,8 +217,11 @@ the page. [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 until CI's - `lighthouse budget` job, deferred to Phase 02d, is active. + 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. ## Validation diff --git a/.claude/skills/local-dev-setup/SKILL.md b/.claude/skills/local-dev-setup/SKILL.md index cf68b75e..201d2867 100644 --- a/.claude/skills/local-dev-setup/SKILL.md +++ b/.claude/skills/local-dev-setup/SKILL.md @@ -185,8 +185,8 @@ Three properties of that inventory matter while you are setting up: silently leaves them running, which is why every teardown target carries `--profile '*'`. - **Neither application host is a compose service.** `LearnStack.Api` runs on - the workstation via `dotnet run` on the `ASPNETCORE_URLS` port in - `.env.example` (5080), and `apps/web` runs via `pnpm dev` on 3000. + the workstation via `dotnet run` on the launch profile's `applicationUrl` + (5080), and `apps/web` runs via `pnpm dev` on 3000. To read the resolved truth rather than any document, ask the stack: @@ -236,7 +236,7 @@ its way through. ```bash # API health -# 5080 is ASPNETCORE_URLS in .env.example - the single source of truth for it. +# 5080 is the `http` launch profile's applicationUrl (Properties/launchSettings.json). curl -fsS http://localhost:5080/healthz | jq # APISIX (gateway pass-through; only after `make dev-gated` and while the API runs) diff --git a/.claude/skills/standards-check/SKILL.md b/.claude/skills/standards-check/SKILL.md index 2b8b6f7c..7f0c1d08 100644 --- a/.claude/skills/standards-check/SKILL.md +++ b/.claude/skills/standards-check/SKILL.md @@ -318,8 +318,10 @@ domain the diff doesn't touch. [15-performance.md § Initial Budgets](../../../docs/standards/15-performance.md#initial-budgets) and [§ Bundle Size](../../../docs/standards/15-performance.md#bundle-size) respected. **Not mechanically checkable yet** — `ci.yml`'s `lighthouse-budget` job is a - placeholder deferred to Phase 02d. Judge by reading; do not report a pass or a - failure as measured. + placeholder. Whether it activates in Phase 02d is G44 in + [Phase 02d's decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register); + the pass that closes it edits this paragraph. Judge by reading; do not report a pass + or a failure as measured. - [ ] Backend latency budget per module respected. #### `16-accessibility.md` diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index 33831c0f..c4d16e46 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -78,22 +78,16 @@ promotion below. No command writes `TenantDomain` or `TenantSetting` yet. `PlatformHostMapping` and `PlatformEntitlement` are projections rather than aggregates: nothing in this module mutates them through a root. -**The other four resolve two ways, and Packet 7 settles them as promotion.** -`TenantDomain`, `TenantSetting`, `TenantLocale` and `TenantFeatureFlag` each have -a public factory, a top-level `DbSet` on `TenancyDbContext`, and no navigation -from `Tenant` — so there is no path through a root, which +**The other four split two ways, and Packet 7 settled the split.** `TenantDomain` and +`TenantSetting` are root-shaped — a surrogate Vogen id, `AuditableEntity`, their own +`row_version` and RLS policy, and a top-level `DbSet` on `TenancyDbContext` — so they +are aggregate roots in their own right. `TenantLocale` and `TenantFeatureFlag` have +composite natural keys and no id, so they cannot be `IAggregateRoot<TId>` under any +reading: they are navigations owned by `Tenant`, with internal factories and no +top-level `DbSet`, so every state change goes through the root, as [Standards 01 § Aggregate Ownership](../../standards/01-architecture-standards.md) -requires for state changes inside an aggregate. They also split: -`TenantDomain` and `TenantSetting` are root-shaped already (a surrogate Vogen id, -`AuditableEntity`, `row_version`, their own RLS policy), while `TenantLocale` and -`TenantFeatureFlag` have composite natural keys and no id at all and therefore -cannot be `IAggregateRoot<TId>` under any reading. - -So the first pair becomes aggregate roots in their own right and the second -becomes navigations inside `Tenant` — four roots in Tenancy, with a write to -`TenantLocale` or `TenantFeatureFlag` bumping `Tenant.row_version` and the two -promoted roots carrying their own. -[Packet 7](../../roadmap/phase-02a-kernel-tenancy.md) lands the promotion, and none of +requires, and a write to either bumps `Tenant.row_version`. +[Packet 7](../../roadmap/phase-02a-kernel-tenancy.md) landed the promotion, and none of its three commands touches `TenantDomain`, `TenantSetting`, `TenantLocale` or `TenantFeatureFlag`. The first commands that do — the locale and setting commands raising `tenancy.locale.write` and `tenancy.setting.write` — are Phase 02d's, and From 6c58343e91d91d377e46d06c43b3bbf767ebb8fd Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 14 Sep 2026 11:23:01 +0300 Subject: [PATCH 6/6] docs: close the valid CodeRabbit findings on PR 21 Two review gates could not be met: add-page-block's Validation and the Code Review Standards checklist both required axe-core tests to pass while no suite runs them. They now name the manual checks that apply today and leave automated axe to its owner. The code-review skill now says the API rejects a mismatched tenant or organization header with a 404, as ADR-0036 decides. Phase 07 placed tenant-defined custom fields on the global User, which Phase 03's ownership table moves to MembershipProfile because users carries no tenant boundary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .claude/skills/add-page-block/SKILL.md | 7 +++++-- .claude/skills/code-review/SKILL.md | 3 ++- docs/roadmap/phase-07-enrollment-learner-portal.md | 3 ++- docs/standards/17-code-review.md | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.claude/skills/add-page-block/SKILL.md b/.claude/skills/add-page-block/SKILL.md index 24150fdf..836f5ee6 100644 --- a/.claude/skills/add-page-block/SKILL.md +++ b/.claude/skills/add-page-block/SKILL.md @@ -232,8 +232,11 @@ the page. editor. - For a tenant block: a page can be authored that uses the new block; the renderer dispatches correctly. -- Accessibility (`axe-core`) and contrast checks pass — Step 7 says what runs `axe-core` - and names the Phase 02d gate open on it. +- Accessibility: a keyboard walkthrough of the block and a contrast check of its text + 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. ## Common pitfalls diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index 9b5e39d8..50a03a93 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -164,7 +164,8 @@ This is the lens that generic reviewers miss. Walk: - For frontend changes: route group is correct, SDK is the only API path, middleware-resolved `x-tenant-id` / `x-organization-id` honoured, no hand-rolled `fetch('/v1/...')`. The API treats an `X-Tenant-Id` or `X-Organization-Id` - it receives as an assertion, never a source + it receives as an assertion, never a source: it validates the header against its own + resolution and answers a mismatch with `404` ([ADR-0036](../../../docs/decisions/0036-tenant-resolution-trusted-inputs.md#one-header-names-a-host-and-it-is-still-not-a-source)). Whether the frontend holds a tenant or organization id at all, and what the middleware carries inward, is G25 and G36 in diff --git a/docs/roadmap/phase-07-enrollment-learner-portal.md b/docs/roadmap/phase-07-enrollment-learner-portal.md index c772dee0..20fd3b46 100644 --- a/docs/roadmap/phase-07-enrollment-learner-portal.md +++ b/docs/roadmap/phase-07-enrollment-learner-portal.md @@ -140,7 +140,8 @@ Built on the Studio shell, permission system, and typed SDK from - Lesson resources. - Progress summary (driven by `TenantCompletionRule`). - Profile basics (with optional tenant-defined custom fields via `TenantCustomFieldDef` - on `User`). + on `MembershipProfile`, per + [Phase 03 § Tenant Data Ownership](phase-03-identity-admin.md#tenant-data-ownership-dsar-boundary-and-pii-classification)). ### Progress Tracking diff --git a/docs/standards/17-code-review.md b/docs/standards/17-code-review.md index 83fb8053..9ff8a4f0 100644 --- a/docs/standards/17-code-review.md +++ b/docs/standards/17-code-review.md @@ -65,7 +65,7 @@ Before requesting reviews, run the relevant checklist on your own diff: ([Security Standards § Multi-Tenant + Organization Isolation Review Checklist](11-security.md#multi-tenant--organization-isolation-review-checklist)). - [ ] New forms use React Hook Form + Zod and render errors at the field level. - [ ] New routes have `error.tsx` and `loading.tsx` where appropriate. -- [ ] Accessibility: every interactive element keyboard-reachable; labels associated; color contrast checked; axe-core tests pass. +- [ ] Accessibility: every interactive element keyboard-reachable; labels associated; color contrast checked; axe-core tests pass where a suite runs them — none does yet (see the note below). - [ ] Bundle size delta acceptable on public routes (< 200 KB gzipped per route). > **Open in Phase 02d.** No axe-core suite exists in `frontend/` today, and