From ac91d853aab8710ec27a20ddfdc6b554d4e28bc9 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Mon, 14 Sep 2026 23:28:50 +0300 Subject: [PATCH 01/28] docs(roadmap): close P02d-1 after PR 22 merge Record the merged delivery and successful final-head and main CI runs so current status no longer points readers at a pending maintainer review. Align the entry documents and identify P02d-2's decision pass as next, while preserving historical evidence and the later packets' open gates. --- CLAUDE.md | 13 ++++--- README.md | 9 +++-- docs/modules/education/README.md | 6 ++- docs/roadmap/README.md | 2 +- docs/roadmap/phase-02d-walking-skeleton.md | 43 +++++++++++++++++++++- 5 files changed, 60 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f940da65..67d8fc21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,10 +60,13 @@ reader. The whole .NET suite runs with **zero skips**, which the runner now refu 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. **P02d-1 is complete**: Education's domain, schema and isolation -proofs pass, all three steps completed two independent agent review rounds, and the -five live required checks pass on [PR #22](https://github.com/HodeTech/LearnStack/pull/22). Education -commands and seed writes belong to P02d-2; public reads belong to P02d-4. +its decision pass. **P02d-1 is complete and merged** through +[PR #22](https://github.com/HodeTech/LearnStack/pull/22) on 2026-09-14. Education's +domain, schema and isolation proofs pass; all three steps completed two independent +agent review rounds. The [merge closeout](docs/roadmap/phase-02d-walking-skeleton.md#merge-and-closeout-2026-09-14) +records verification of the final PR head and merge commit. **Next: P02d-2's decision +pass**, then Education commands and seed writes. That packet has not started; public +reads belong to P02d-4. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` (core + 7 modules × 4 projects + 4 test projects including the @@ -242,7 +245,7 @@ and `Organization` aggregates and `TenancyDbContext`; Customization — `CustomizationDbContext`; Audit — `AuditEntry`, `AuditConfig` and `AuditDbContext`; and Education — separate `Course` and `Lesson` roots, their contained translations and `EducationDbContext`. Content, Identity and Media remain scaffolded. -P02d-1's implementation, agent reviews and required PR checks are complete. +P02d-1 is merged; its implementation, agent reviews and required PR checks are complete. Command and public-read surfaces belong to the later packets. Other module-level references in the docs (e.g. `ILiveClassProvider`, `ITenantSearch`) still describe intended shape owned by their named phases. diff --git a/README.md b/README.md index bbd660d1..0ad98c9c 100644 --- a/README.md +++ b/README.md @@ -58,10 +58,11 @@ items, rules, custom fields and notification templates; their delivery is tracke ## Where it is today **Phase 01 and Phase 02a are complete. Phase 02d is in progress.** -[P02d-1](docs/roadmap/phase-02d-walking-skeleton.md) delivers the Education domain, -schema and isolation proofs. **P02d-2** owns course and lesson command handlers and -seed writes; **P02d-4** owns anonymous public API reads. Browser rendering follows -in P02d-5–7. +[P02d-1](docs/roadmap/phase-02d-walking-skeleton.md#merge-and-closeout-2026-09-14) is +**complete and merged**: Education domain, schema and isolation proofs. +**Next is P02d-2's decision pass**, followed by course and lesson command handlers and +seed writes. **P02d-4** owns anonymous public API reads. Browser rendering follows +in P02d-5–7; none of these later packets has started. | Area | Delivered now | Next milestone | |---|---|---| diff --git a/docs/modules/education/README.md b/docs/modules/education/README.md index fe66e8c5..58728061 100644 --- a/docs/modules/education/README.md +++ b/docs/modules/education/README.md @@ -1,7 +1,9 @@ # Education Module -**Status:** P02d-1 complete — 2026-09-14. Domain, persistence, isolation proofs, -both agent review rounds per step and all five required PR checks are complete. +**Status:** P02d-1 complete and merged — 2026-09-14. Domain, persistence, isolation +proofs and both agent review rounds per step are complete. The +[merge closeout](../../roadmap/phase-02d-walking-skeleton.md#merge-and-closeout-2026-09-14) +records all five required checks on the final PR head and merge commit. The [decision pass](../../roadmap/phase-02d-walking-skeleton.md#p02d-1-decision-pass-2026-09-14) records the accepted scope. Commands, audit catalogue entries and seed writes remain planned for P02d-2; public reads remain planned for P02d-4. diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index f401e3a6..99d64187 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -44,7 +44,7 @@ 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: Two-Tenant Walking Skeleton](phase-02d-walking-skeleton.md) — **in progress** (see its Status block) +- [Phase 02d: Two-Tenant Walking Skeleton](phase-02d-walking-skeleton.md) — **in progress**; P02d-1 complete and merged, next is P02d-2's decision pass (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) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 4878a64a..8096c5fb 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -9,7 +9,7 @@ > | Packet | Title | State | > |---|---|---| > | P02d-0 | Kickoff | ✅ this plan | -> | P02d-1 | Education schema and database-level isolation | ✅ complete — 2026-09-14; [delivery record](#delivery-record-p02d-1) | +> | P02d-1 | Education schema and database-level isolation | ✅ complete and merged — 2026-09-14; [merge closeout](#merge-and-closeout-2026-09-14) | > | P02d-2 | Writers and seed | not started | > | P02d-3 | Read internals | not started | > | P02d-4 | Public read API and contract checks | not started | @@ -17,6 +17,10 @@ > | P02d-6 | Public renderer | not started | > | P02d-7 | Demo, full-stack CI and exit | not started | +**Next: P02d-2's decision pass.** The schema prerequisite is merged. Its remaining +gate parts in [the packet table](#packets-and-decision-gates) still require explicit +acceptance before implementation; this closeout accepts none of them. + ## Goal Put a working education site in a browser — twice, on two hosts, for two tenants in @@ -2120,3 +2124,40 @@ or skips. The nonempty-run checker verifies every assembly. The earlier 2,366-ca and coverage figures above remain dated evidence for their original heads. This follow-up changes no production source or migration. [PR #22](https://github.com/HodeTech/LearnStack/pull/22) remains the current check-rollup and review surface. + +#### Merge and closeout (2026-09-14) + +[PR #22](https://github.com/HodeTech/LearnStack/pull/22) merged into `main` at +**20:22:17 UTC**, with final PR head `bc181702e379fec99da43015b2ee357ad772d6d1` +and merge commit `1d3a0f717523bebbf8c397ae9facba7c80eafae9`. The merge tree is +identical to the final PR head. `development` was fast-forwarded to that merge +commit without switching branches, rewriting history or changing file contents. + +- [x] P02d-1's accepted decision parts, all three implementation steps and their + two review rounds are complete, as recorded above. +- [x] The final review's three verified documentation findings are resolved in + `bc18170`: README delivery status, the Course/Lesson glossary distinction between + Phase 02d and Phase 05, and Phase 02b G19's stale module count. +- [x] The README refresh and the requested removal of mandatory commit coauthor + attribution (`dde8e0b`) are included in the merged head. +- [x] The final PR-head and merge-commit CI runs both completed successfully. + +| Verified revision | CI evidence | Result | +|---|---|---| +| Final PR head `bc18170` | [Run 34892449509](https://github.com/HodeTech/LearnStack/actions/runs/34892449509) | All five required jobs succeeded | +| `main` merge commit `1d3a0f7` | [Run 34892508893](https://github.com/HodeTech/LearnStack/actions/runs/34892508893) | All five required jobs succeeded | + +The live required-check list still has the five contexts recorded in Step 3, with +GitHub Actions `app_id: 15368` and `strict: true`. The merge run verifies **2,381 +backend tests**: 1,456 unit, 177 architecture, 171 Docker-free integration, +576 Docker integration and one contract, with zero failures or skips. The frontend, +meta and secret-scan jobs also pass. The OpenAPI and Lighthouse placeholders remain +outside the required set, with their existing P02d-4 and P02d-7 decision gates. +Both completed runs were verified at **20:25 UTC**. + +**P02d-1 is closed. Phase 02d remains in progress.** P02d-2 through P02d-7 have not +started. P02d-2 first resolves its writer, customization-contract, locale, branding, +seed-context and data-safety decisions, then implements the commands, audit wiring +and repeatable tenant-specific seed. The packet table and decision register above +own its exact scope and open gate parts. Public reads, rendering and the browser +demo remain the later packets' work; this merge does not complete those surfaces. From 0bd9328d8829482cd1f1e06a06b67871cf87802d Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 17 Sep 2026 16:52:08 +0300 Subject: [PATCH 02/28] docs: clarify deployment readiness and marketplace proposal Align current support claims with composition code and make Deployment Models the readiness reference for the vision, roadmap and agent guide. Keep ADR-0049 Proposed. Separate the requested planning hold from G3's accepted contract, distinguish marketplace commerce from Hub billing, and record approval boundaries and obligations before first consumers. Validate 177 architecture tests with zero skips, 1222 local links and 317 anchors. Preserve Accepted ADRs and frozen delivery records. ADR: 0034, 0035, 0048, 0049 --- CLAUDE.md | 15 +- docs/architecture/01-platform-vision.md | 9 +- docs/architecture/25-deployment-models.md | 115 +++-- ...nstitution-sites-and-course-marketplace.md | 467 ++++++++++++++++++ docs/decisions/README.md | 14 + docs/glossary.md | 2 + docs/roadmap/README.md | 13 +- docs/roadmap/phase-02a-kernel-tenancy.md | 24 +- docs/roadmap/phase-02d-walking-skeleton.md | 18 + docs/roadmap/phase-09b-hub-billing.md | 10 +- 10 files changed, 621 insertions(+), 66 deletions(-) create mode 100644 docs/decisions/0049-institution-sites-and-course-marketplace.md diff --git a/CLAUDE.md b/CLAUDE.md index 67d8fc21..395e40f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,9 +22,10 @@ capability invocation (running submitted code, scoring speech) are platform features gated by plan — they need a release, not a customization row. Link to that section; do not restate it. -LearnStack ships in three production deployment modes — SaaS, Dedicated, -Self-Hosted — backed by the companion **LearnStack Hub** control plane -(separate repository, see +LearnStack targets three production deployment modes — SaaS, Dedicated, +Self-Hosted — with current readiness recorded in +[Deployment Models](docs/architecture/25-deployment-models.md#supported-today-versus-prepared-seam). +The companion **LearnStack Hub** control plane lives in a separate repository (see [ADR-0019](docs/decisions/0019-learnstack-hub.md)). On developer workstations the Hub repo is the sibling directory `../LearnStack-Hub`; GitHub: https://github.com/HodeTech/LearnStack-Hub. The Hub repository @@ -321,7 +322,13 @@ let the entry point pick it. - **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. +- **One binary, five `DeploymentMode` values.** 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`). + [Deployment Models § Supported today versus prepared seam](docs/architecture/25-deployment-models.md#supported-today-versus-prepared-seam) + owns the current foundation wiring, remaining adapters and production-readiness + boundary; a selectable enum value is not a supported deployment. ## Conventions when editing docs diff --git a/docs/architecture/01-platform-vision.md b/docs/architecture/01-platform-vision.md index e620b9ae..91246039 100644 --- a/docs/architecture/01-platform-vision.md +++ b/docs/architecture/01-platform-vision.md @@ -31,8 +31,8 @@ What is **not** fixed is the subject those businesses teach. The same code paths - An art workshop with portfolio uploads and peer-review assessments. - A certification body, an exam-prep provider, or a domain not anticipated above. -LearnStack ships **one codebase, one set of container images, one Helm chart** that -serves all of these customers. The differentiator across customers is their **data** +LearnStack targets **one codebase, one set of container images, one Helm chart** for +all of these customers. The differentiator across customers is their **data** (content, content type definitions, page block schemas, scoring rules, level taxonomies, custom fields) — not their code. LearnStack engineers never write per-vertical code. That claim holds inside a stated edge — see @@ -56,8 +56,9 @@ Education businesses need infrastructure that is: ([24-learnstack-hub.md](24-learnstack-hub.md)). - **Deployment-flexible.** Same codebase deploys as SaaS, Dedicated (LearnStack-managed single-tenant), or Self-Hosted ([25-deployment-models.md](25-deployment-models.md)). - `Development` and `SaaS` are wired end to end today; the other three `DeploymentMode` - values are prepared seams until Phase 11 builds their adapters and integration suites + [Deployment Models § Supported today versus prepared seam](25-deployment-models.md#supported-today-versus-prepared-seam) + owns current readiness. Foundation wiring does not imply that production adapters + or supported Dedicated/Self-Hosted releases have shipped ([ADR-0035](../decisions/0035-demand-gated-infrastructure.md)). ## The three layers diff --git a/docs/architecture/25-deployment-models.md b/docs/architecture/25-deployment-models.md index a9e314dc..48f9937d 100644 --- a/docs/architecture/25-deployment-models.md +++ b/docs/architecture/25-deployment-models.md @@ -1,25 +1,32 @@ # Deployment Models **Derives from:** [ADR-0020](../decisions/0020-triple-deployment-hybrid-license.md), -[ADR-0019](../decisions/0019-learnstack-hub.md). +[ADR-0019](../decisions/0019-learnstack-hub.md), +[ADR-0035](../decisions/0035-demand-gated-infrastructure.md). -LearnStack supports three deployment models from **one codebase, one Helm chart, one set +LearnStack targets three deployment models from **one codebase, one Helm chart, one set of container images**. The differentiator across modes is configuration + component wiring + `IEntitlementProvider` implementation choice — never application code. -> **Support state (2026-08-08).** The `DeploymentMode` enum has five values and the -> composition root branches on all five. Only **`Development`** and **`SaaS`** are wired -> and tested end to end today. `Dedicated`, `SelfHostedOnline`, and -> `SelfHostedAirGapped` are **prepared seams, not supported deployments**, until +> **Support state (2026-09-17).** The `DeploymentMode` enum has five values and the +> composition root branches on all five. **`Development`** and **`SaaS`** have wired +> and tested foundation paths; this is not a production-readiness claim. Entitlements +> still use `NullEntitlementProvider` in every mode. `Dedicated`, `SelfHostedOnline` +> and `SelfHostedAirGapped` are **prepared seams, not supported deployments**, until > [Phase 11](../roadmap/phase-11-production-hardening.md) builds their adapters and > integration suites, per > [ADR-0035](../decisions/0035-demand-gated-infrastructure.md). See > [§ 5](#5-deploymentmode-configuration) for what "prepared seam" means concretely. The -> rest of this document describes the target topologies; it is a design document for all -> five, and a description of running systems for two. +> rest of this document describes target topologies. The production Helm chart, image +> release pipeline, licence adapters and migration runbooks below are not shipped +> artifacts; their examples must not be treated as operational instructions. ## 1. The three modes +The diagram and comparison table describe target topologies and provider choices. +The [current support matrix](#supported-today-versus-prepared-seam) distinguishes them +from shipped foundation wiring. + ```mermaid flowchart TB subgraph SaaS["SaaS — LearnStack hosted, many tenants"] @@ -211,7 +218,7 @@ Customer responsibilities (air-gapped): - Customer manages their own Vault (or alternative secret store). - Customer manages their own backups, DR, monitoring. -LearnStack ships: +Target LearnStack deliverables for the supported Phase 11 air-gapped release: - Helm chart with air-gapped mode pre-configured. - Pre-signed license keys per agreement. - Documentation runbook (`docs/operations/hub-on-prem-setup.md`). @@ -220,7 +227,7 @@ LearnStack ships: ## 5. `DeploymentMode` configuration The LearnStack process reads `Deployment:Mode` from configuration at startup. Five -values: +values exist; the provider comments below describe the target, not today's registration: ```csharp public enum DeploymentMode @@ -233,24 +240,30 @@ public enum DeploymentMode } ``` -`Program.cs` wires the entitlement provider based on this value (see ADR-0020). Modules -never read the enum — `Modules_Do_Not_Reference_DeploymentMode` enforces that the -composition root branches once. +The composition root uses this value for mode-specific wiring. Entitlements currently +use `NullEntitlementProvider` in every mode; the provider choices shown above are +ADR-0020's target. Modules never read the enum — +`Modules_Do_Not_Reference_DeploymentMode` enforces that the composition root owns the +selection. ### Supported today versus prepared seam | Value | State | What that means concretely | |---|---|---| -| `Development` | **Supported** | Wired, run daily, covered by the integration suite | -| `SaaS` | **Supported** | Wired and covered end to end from [Phase 02c](../roadmap/phase-02c-hub-foundation.md) | -| `Dedicated` | Prepared seam | The branch exists and resolves to the default implementations; no dedicated-topology integration suite, no operational runbook | -| `SelfHostedOnline` | Prepared seam | Same; the phone-home path needs the Hub adapter and its failure-mode tests | -| `SelfHostedAirGapped` | Prepared seam | Same, plus a signed-licence provider and a no-egress telemetry target that do not exist yet | - -A prepared seam is a **branch point with no adapter behind it**: the value is accepted, -the composition root routes it, and the implementations it selects are the same defaults -`Development` gets. It is honest to design for it; it is not honest to sell it. Each seam -becomes a supported mode in [Phase 11](../roadmap/phase-11-production-hardening.md) when +| `Development` | **Supported foundation** | Foundation host-boot path covered by the integration suite | +| `SaaS` | **Supported foundation** | Foundation host-boot path covered by the integration suite; real Hub entitlement integration belongs to [Phase 02c](../roadmap/phase-02c-hub-foundation.md) and has not shipped | +| `Dedicated` | Prepared seam | Shares SaaS's Sentry error tracker and refuses startup without a Sentry DSN; uses the default entitlement provider; no dedicated-topology integration suite or operational runbook | +| `SelfHostedOnline` | Prepared seam | Uses Sentry when a DSN is supplied and NoOp error tracking otherwise; uses the default entitlement provider; real phone-home and its failure-mode tests are absent | +| `SelfHostedAirGapped` | Prepared seam | Local exception-file capture and suppression of network OTLP exporters exist; the signed-licence adapter, full telemetry file exporters and no-egress operational suite are not shipped | + +A prepared seam accepts the value and supplies foundation wiring, including some +mode-specific behavior, but lacks the adapters and operational proofs needed for a +supported deployment. The entitlement provider remains the same default used in +`Development`. ADR-0035's "wired end to end" statement is read together with its +explicit default-provider and adapter-trigger table: it does not claim Hub entitlement +integration or full production readiness. It is honest to design for a seam; it is not +honest to sell it. Each seam becomes a supported mode in +[Phase 11](../roadmap/phase-11-production-hardening.md) when its trigger fires — for `SelfHostedAirGapped`, that trigger is a signed Self-Hosted contract ([ADR-0035](../decisions/0035-demand-gated-infrastructure.md)). @@ -261,15 +274,17 @@ an abstraction. ### Other config settings switched by mode -- **Hub URL** — pointed at LearnStack-hosted (SaaS / Dedicated / SelfHostedOnline) OR - customer-hosted (rare) OR not set (SelfHostedAirGapped). +- **Hub URL** — target: LearnStack-hosted for SaaS / Dedicated / SelfHostedOnline, + customer-hosted only where separately arranged, or absent for SelfHostedAirGapped. + The current default entitlement provider makes no Hub call. - **Secret store** — `ConfigurationSecretProvider` today in every mode; the Vault-backed provider is demand-gated to Phase 11 behind `ISecretProvider`, triggered when a production secret must rotate without a redeploy, or more than one operator needs access to production secrets. -- **Telemetry sink** — LearnStack OTel collector (SaaS / Dedicated) OR customer OTel - (Self-Hosted). `SelfHostedAirGapped` wires no network exporter at all; its file target - lands in Phase 11. +- **Telemetry sink** — non-air-gapped modes currently use the configured OTLP endpoint; + `SelfHostedAirGapped` wires no network exporter. The target operator is LearnStack + for SaaS / Dedicated and the customer for Self-Hosted. Full telemetry file exporters + for the air-gapped mode belong to Phase 11; local exception-file capture exists today. - **Event transport** — `InProcessEventBus` today in every mode; the Dapr pub/sub component and its Kafka backend land in Phase 11, triggered by a second process needing to consume an integration event. See @@ -277,7 +292,9 @@ an abstraction. ## 6. Same Helm chart -The LearnStack Helm chart (`deploy/helm/`) supports all modes via `values.yaml`: +The target LearnStack Helm chart (`deploy/helm/`) configures all modes through +`values.yaml`. +The chart is not implemented yet; these are configuration sketches for Phase 11: ```yaml # values-saas.yaml @@ -330,8 +347,10 @@ Same chart. Same templates. Different values. ## 7. Deployment-mode-conditional behaviour -A small list of behaviours change across modes. This is the **target** table; the two -supported modes reach it today and the three seams reach it in Phase 11. +A small list of behaviours change across modes. This is the **target** table. +`NullEntitlementProvider` remains registered in every mode today; Hub and signed-licence +adapters follow the owners and triggers in ADR-0035. The table does not claim those +adapters or the production deployment automation are already running. | Behaviour | SaaS | Dedicated | Self-Hosted Online | Self-Hosted Air-Gapped | |-----------|------|-----------|--------------------|------------------------| @@ -346,26 +365,34 @@ supported modes reach it today and the three seams reach it in Phase 11. ## 8. Migration between modes -Customer-driven path (rare but supported): +Planned customer-driven paths require Phase 11's migration runbooks and restore proofs: - **SaaS → Dedicated**: Hub-orchestrated. New dedicated cluster provisioned; customer's - tenant data exported (RLS-scoped `pg_dump`), restored into dedicated Postgres; DNS - cutover; tenant_id remains the same. + tenant data exported through a tenant-scoped export procedure, restored into dedicated + Postgres; DNS cutover; tenant_id remains the same. - **Dedicated → Self-Hosted**: Customer takes over operation. LearnStack provides customer with Helm chart values + Postgres dump + Keycloak realm export. - **Self-Hosted → SaaS**: Reverse import. Less common; one-time engagement. -Cross-mode migration is engineering-assisted, not automated. The architecture supports -the move because the data model is consistent across modes. +Cross-mode migration is designed as engineering-assisted, not automated. A consistent +data model is a prerequisite, not a proven export procedure. The runbook must account +for tenant-scoped rows, shared identity references, media and outstanding operations. +PostgreSQL's `pg_dump` does not apply row security by default; its +[`--enable-row-security` option](https://www.postgresql.org/docs/18/app-pgdump.html) +also has role and restore-format constraints. A shared Keycloak realm export is not a +tenant-scoped identity export or a +[complete backup](https://www.keycloak.org/server/importExport). ## 9. Single container image -All LearnStack environments run the same container image +The target release model runs all LearnStack environments on the same container image (`learnstack/learnstack-api:`). Dapr sidecar image (`daprio/daprd:`) is also identical across modes. -CI builds one image per merge to `main`; tags with the git sha and a SemVer release tag -when a release is cut. Helm chart references the image by tag. +Phase 11 owns the production image pipeline; today's CI does not publish these images. +The target pipeline builds one image per merge to `main`, tags it with the git sha and +a SemVer release tag when a release is cut, and references it from Helm by tag. +These image names are illustrative: ``` ghcr.io/learnstack/learnstack-api:v1.0.0 @@ -376,11 +403,14 @@ ghcr.io/learnstack/learnstack-web:v1.0.0 ghcr.io/learnstack/learnstack-hub-operator-portal:v1.0.0 ``` -For Self-Hosted Air-Gapped, customers pull images into their own registry (script in -`tools/airgapped-image-bundle.sh`). +For Self-Hosted Air-Gapped, the planned bundle process loads images into the customer's +own registry. `tools/airgapped-image-bundle.sh` names an intended Phase 11 artifact; +the script does not exist yet. ## 10. Release & upgrade cadence +Target operating policy, not an implemented deployment pipeline: + | Mode | Cadence | Trigger | |------|---------|---------| | SaaS | Continuous (every merge to main becomes a deployable build) | Automated GitOps; staged rollout: dev → staging → 10% prod → 100% prod | @@ -428,3 +458,6 @@ To be authored: - [04-technical-architecture.md](04-technical-architecture.md) — overall stack. - [Phase 11: Production Hardening, Operations, and Scale](../roadmap/phase-11-production-hardening.md) — owns resource fairness and the three prepared seams. +- [ADR-0049 — Institution Sites and an Optional Course Marketplace](../decisions/0049-institution-sites-and-course-marketplace.md) + — **Proposed** product direction; does not change this support matrix or authorize + cross-installation marketplace participation. diff --git a/docs/decisions/0049-institution-sites-and-course-marketplace.md b/docs/decisions/0049-institution-sites-and-course-marketplace.md new file mode 100644 index 00000000..11723904 --- /dev/null +++ b/docs/decisions/0049-institution-sites-and-course-marketplace.md @@ -0,0 +1,467 @@ +# ADR-0049: Institution Sites and an Optional Course Marketplace + +## Status + +Proposed — 2026-09-15. Product and architecture direction for maintainer review. +This draft accepts no P02d-2 gate and authorizes no implementation. The existing +Accepted ADRs remain binding unless a maintainer-approved decision supersedes them +under the repository's ADR governance. + +**Date:** 2026-09-15 +**Updated:** 2026-09-17 +**Deciders:** Cemil (repository maintainer; approval pending) + +## Decision Drivers + +- The maintainer is considering a full course marketplace: a shared catalog, + platform checkout, commission and payouts to education institutions. +- The target includes sales in Turkey and internationally. Whether sellers are + initially Turkish institutions or institutions from multiple countries remains + open; international buyers and international seller payouts are separate scopes. +- There is no current Self-Hosted customer commitment. The scenario must remain + viable without turning a hypothetical integration into a launch dependency. +- Institutions may also need their own branded site, custom domain or platform + subdomain. Marketplace participation must not require abandoning that site. +- [P02d-1](../roadmap/phase-02d-walking-skeleton.md#merge-and-closeout-2026-09-14) + already supplies tenant-owned courses and lessons with database isolation. + P02d-2 is about to introduce their first application writers and seed data. +- [ADR-0048](0048-walking-skeleton-publication.md) currently associates publication + with anonymous lesson-body eligibility. A paid or private course needs an explicit + access boundary before a public reader relies on that association. +- Shared discovery must not broaden an institution's access to another institution's + learners, lesson bodies, finances or administration. + +## Considered Options + +1. **Institution sites with optional marketplace participation** (recommended). + Shared education capabilities and ownership; separate presentation, discovery and + commercial policies for each channel. +2. **Replace institution sites with a marketplace** (not recommended). Simpler public + positioning, but removes the branded-site use case and the ability to serve an + institution's existing audience independently. +3. **Keep only the current institution-site product** (not recommended for the new + request). Fits the Accepted roadmap but supplies no shared course marketplace. +4. **Create an independently editable platform-owned copy of every course** (rejected). + Creates competing authoring identities, progress and access grants. A deliberately + licensed, versioned delivery replica is a different topology; it is not selected + here and requires the distribution contract described under Deployment scope. + +## Decision + +The proposed decision is: LearnStack provides institution-owned sites and an optional +[Course Marketplace](../glossary.md#billing) over one education foundation. It adds a +LearnStack-branded education distribution product to the institution-site platform. + +On acceptance, the binding product boundaries are exactly these: + +1. An institution remains a tenant and owns its courses, lessons and operating data; + its branches remain organizations under ADR-0017. +2. Institution sites work independently of marketplace participation. Institution + admission and individual listings are explicit opt-ins. +3. A sales channel neither transfers content ownership nor grants lesson-body access. +4. The initial Course Marketplace serves centrally operated SaaS institutions. + Dedicated and Self-Hosted participation is excluded from that initial scope. +5. Course Marketplace content and learner commerce stay outside the Hub's tenant + subscription and deployment-metadata boundary. + +These are proposed boundaries, not shipped behavior. The open contracts below are not +implicitly accepted with this paragraph, and no implementation can rely on them while +they remain open. The [acceptance checklist](#implementation-notes) names the decisions +needed to make this draft ready for acceptance. + +## Open Implementation Contracts + +The following candidate contracts and scope alternatives require explicit resolution. +Conditional P02d-2/P02d-4 obligations apply only if their protected-content scope is +approved through the G3 process below. Existing Accepted constraints continue to apply +regardless of this proposal. + +### Institution and channel boundaries + +This table describes target behavior. Course versions and learner access/progress +are future capabilities owned by +[Phase 05](../roadmap/phase-05-education-learning-content.md), +and [Phase 07](../roadmap/phase-07-enrollment-learner-portal.md). +[Phase 09](../roadmap/phase-09-billing-integrations-analytics.md) owns institution +storefront commerce in the current plan; Course Marketplace commerce needs the +separate ownership decision below. None is already supplied by P02d-1. + +| Concern | Proposed ownership and behavior | +|---|---| +| Institution | A `Tenant`; its branches and campuses remain `Organization` values under [ADR-0017](0017-tenant-organization-hierarchy.md) | +| Content | Education retains the tenant-owned course and lesson identities, translations and exact customization bindings | +| Branded site | Institution branding, navigation and catalog presentation; custom domain remains optional | +| Marketplace | LearnStack-branded discovery, approved institution profiles and opt-in course listings | +| Sales terms | Separate from lesson content; identify the seller, course or version, channel, price, currency and commercial terms | +| Learner access | A grant for the owning institution's course/version; independent of which authorized channel originated the purchase | +| Progress | Within one installation, shared for the same learner and course-version enrollment; cross-installation progress requires an additional identity and delivery contract | + +The same course can have different channel offers without duplicating its learning +content. An institution profile is not the institution's entire private record, and a +public instructor profile requires an explicit publication boundary. + +An independent instructor can later be evaluated as a seller without reusing +`Organization` for unrelated businesses. Institution-only seller admission is an +explicit initial-scope proposal for maintainer approval. If individual sellers are +required at launch, that scope and their onboarding must be decided before the +marketplace plan is accepted. + +### Publication, discovery and access + +Three separate questions govern a course: + +- Is its content published and ready for the applicable read path? +- In which channel, if any, may its public catalog information be discovered? +- May this caller read this lesson body or retrieve its protected media? + +An approved catalog listing does not answer the third question. Free access, anonymous +previews and access requiring an enrollment are also distinct cases; a zero price +does not necessarily mean anonymous access. + +The public API, renderer, caches and media delivery enforce the same access policy. +Hiding a link or a button is insufficient. An unavailable access evaluator refuses +protected content; it cannot fall back to the public path. + +**Accepted baseline.** [G3's publication/transition contract](../roadmap/phase-02d-walking-skeleton.md#p02d-1-accepted-answers) +closed on 2026-09-14 through ADR-0048. P02d-2 inherits that public-only contract; +only command names and seeded states remain open under G3. This draft does not reopen +the closed part. The [pending proposal record](../roadmap/phase-02d-walking-skeleton.md#pending-course-marketplace-proposal) +keeps the request to reconsider it distinct from an approved change. + +If the maintainer elects protected-content authoring in P02d-2, its decision pass must +first approve reopening that part of G3. The alternatives for that approval are: + +- A minimal, persisted content-access policy before the first writers, with a forward + migration over P02d-1's schema. This is the recommended option if P02d-2 is explicitly + expanded to protected-content authoring. Approving the hybrid direction alone does + not select it. It needs no prices, channels, orders or federation identifiers in + Education. +- Retaining the current public-only skeleton contract explicitly. Protected content + authoring remains unavailable until a separately owned decision and migration land + before its first writer or reader. This is not an implicit promise that the current + `published` flag can later protect paid content. + +The first option needs a new superseding ADR replacing +[ADR-0048](0048-walking-skeleton-publication.md)'s anonymous-access implication. It +specifies exact policy, defaults, preview rules, migration treatment, command validation +and fail-closed behavior until +[Phase 07](../roadmap/phase-07-enrollment-learner-portal.md) provides Course Access. +Once that change is approved, append a dated G3 supersession entry to the phase's +decision record, link the new ADR from G3's status and the new entry, and update +affected packet criteria. Preserve the original question, accepted answer and delivery +record. Until those approval records exist, the current G3 answer remains binding. +If protected authoring is approved, P02d-4 must deny restricted bodies while no +authenticated grant reader exists. +Changing that contract is a new decision, not an erratum to a statement that was false +when written. This direction draft neither chooses the migration nor supersedes the +Accepted security contract. + +### Shared discovery without shared private data + +The candidate marketplace read path uses a dedicated projection of approved public +listing fields and their source identifiers. Source modules supply those fields through +[ADR-0010](0010-cross-module-communication.md)'s application contracts and durable +integration events; the catalog consumer is its **read-model projection** mechanism. +Durable delivery here is a future producer/consumer contract, not a new broker +requirement for P02d-2. ADR-0035 still gates a transport adapter on its named trigger. +A marketplace query does not remove Education's tenant filters +or borrow a platform-admin connection to enumerate private tables. + +Listing withdrawal, source deletion and seller suspension have explicit propagation +and invalidation rules. Checkout revalidates the authoritative offer and seller +eligibility; a stale search result is never authorization to charge or grant access. +The projection's storage roles, audit classification and global read boundary need +their own accepted contract before that surface is implemented. + +`learnstack.com` is the maintainer's illustrative Course Marketplace address, not an +allocated domain, a configured platform host or a replacement for existing +`learnstack.app`, `learnstack.dev` or local development host conventions. Selecting its +actual host and route classification remains open. On that proposed platform surface, +an institution profile path identifies a public marketplace resource, not an authority +to switch the caller's tenant. Full institution-site rendering under a path on the +same host, if selected, needs a separate trusted-resolution decision against +[ADR-0036](0036-tenant-resolution-trusted-inputs.md). The existing host resolver must +not gain an unchecked path or header fallback. + +For a genuinely tenantless platform-host request, the existing context remains +**unresolved**; no tenant id is invented. In `TenantContextBehavior`, gate 1 admits +that request only with `[AllowsUnresolvedTenantContext]`. `[PublicSurface]` addresses +the separate gate for a **resolved `HostOnly`** context; neither marker implies the +other. The first catalog endpoint needs an accepted request/host matrix and an explicit +extension of the closed unresolved-request allow-list, authorization and audit +classification. A marker alone grants no database access. +The gates are nested: admitting an unresolved request does not run the `HostOnly` +gate. The contract must deliberately select platform-host, tenant-host or both +surfaces and prove their admission rules; applying both markers by default is not +that decision. An unresolved public read cannot invent a tenant-scoped audit row. + +That contract must select the projection's table class and least-privilege reader role +before its first migration. `TenantId.PlatformSentinel` is never an announced request +tenant, under [ADR-0044](0044-audit-write-path.md); removing tenant filters or using a +platform-admin connection to read Education is not an alternative. Existing +`platform_host_to_tenant` and `platform_killswitches` are bounded platform-scoped +precedents, not blanket permission to add a global table. No new endpoint, table class +or role is approved by this draft. + +### Identity and teaching experience + +The existing [global User and tenant Membership design](../architecture/13-identity-and-auth.md#multi-tenant-identity-model) +fits learners buying from multiple institutions and instructors working with several +institutions within one installation. Those application capabilities are not implemented +yet. Dedicated and Self-Hosted installations may use independent identity issuers; +their local user identifiers and email addresses do not establish a shared learner. +Federation requires a verified issuer/subject binding to the central identity, with +explicit course-version and grant ownership. It must not let a customer-controlled +issuer mint central marketplace privileges. + +A learner's combined library is an authorized projection of their own grants. It +does not expose an institution's roster to another institution. Cross-domain sign-in +uses the identity provider's supported redirect flow with origin-scoped sessions; +the proposal does not assume a cookie can be shared with arbitrary custom domains. + +### Commerce and the Hub boundary + +The domain serving a page does not decide who collects payment. +[Phase 09b's division of responsibility](../roadmap/phase-09b-hub-billing.md#division-of-responsibility) +and [ADR-0019](0019-learnstack-hub.md) currently distinguish two billing relationships. +This proposal adds a **third commercial relationship**; it is not already covered by +either existing payment port: + +| Relationship | Standing and owner | +|---|---| +| Learner pays an institution through its storefront | Accepted Phase 09 scope, LearnStack core | +| Institution pays the LearnStack vendor for its software subscription | Accepted Phase 09b scope, Hub | +| Learner pays through the Course Marketplace; commission and institution payouts follow | Proposed new commerce scope outside Hub; authoritative module/service and delivery phase remain acceptance blockers | + +Each learner order records its channel, seller, offer and payment arrangement +immutably. The proposed third relationship includes seller payables, refunds, +reconciliation and provider-backed payouts; Phase 09's existing storefront primitives +do not implement that relationship. + +Seller, offer, payable and payout are descriptive proposal terms here, not newly +accepted Billing aggregate names. Reusing or translating Phase 09's order and +payment contracts belongs to the commerce-ownership decision. + +They can share payment and order primitives while preserving their accounting +boundaries. Refunds and disputes follow the arrangement captured at purchase time, +not the institution's current settings. Provider charge models make this separation +material: responsibility for fees, refunds and disputes varies with the selected +flow ([Stripe Connect charge models](https://docs.stripe.com/connect/integration-recommendations)). +This reference is evidence about the distinction, not a provider selection. + +Marketplace commerce belongs on the LearnStack product side. Its authoritative module +or service, global data scope and audit boundary must be named before this proposal is +accepted as an implementation plan; this draft does not select a new runtime. The Hub +continues to own institutions' LearnStack subscriptions and deployment metadata, under +[ADR-0034](0034-hub-contract-surface-invariant.md). It receives no learner orders or +course content through an expanded entitlement payload. The existing +[Hub Marketplace](../glossary.md#billing) belongs to Phase 12, not this proposal. +[Phase 12's unresolved ADR-0034 collision](../roadmap/phase-12-hub-marketplace.md#scope-on-the-learnstack-side) +is the precedent: publication does not automatically turn tenant-authored material into +Hub metadata. Course listings and instructor profiles remain on the LearnStack product +side in this proposal; seller commercial records are not added to the Hub's permitted +metadata by inference. Any alternative that puts them there needs its own cross-repo +decision under ADR-0034. This proposal does not settle Phase 12's bundle question. + +### Deployment scope + +The first shared marketplace targets LearnStack's centrally operated SaaS deployment. +Institution sites remain independent of marketplace participation. The other deployment +modes are still prepared seams, not supported releases, under +[ADR-0035](0035-demand-gated-infrastructure.md) and the +[deployment architecture](../architecture/25-deployment-models.md). + +The rows distinguish network topologies within the existing enum values; they add no +`DeploymentMode` value or module-level mode branch. + +| Deployment mode and network topology | Proposed central marketplace boundary | +|---|---| +| `Dedicated` — LearnStack-operated | Separate database and potentially separate identity issuer; needs an explicit cross-installation contract despite LearnStack operating it | +| `SelfHostedOnline` — publicly reachable learning surface | Possible opt-in external seller source only after identity, offer validation, delivery, reconciliation and support obligations are accepted | +| `SelfHostedOnline` — private/VPN-only learning surface | General buyers cannot reach the local learning surface; central checkout requires a separately accepted delivery arrangement | +| `SelfHostedAirGapped` | Local institution use; no live central catalog synchronization, checkout or grant delivery dependency | + +For connected installations, compare remote fulfillment, a licensed central delivery +replica, and referral to the institution's own checkout before selecting a topology. +A referral alone does not satisfy platform checkout, commission and seller payouts. +A replica needs explicit export permission, an authoritative authoring source, immutable +version mapping, media rights, residency, withdrawal and continuity for existing buyers. +Neither arrangement is included in the proposed initial marketplace. + +A customer-operated database, identity provider or signed event is not authoritative +evidence of central payment, seller eligibility or learner identity. Before federation, +define authenticated installation registration separately from tenant ownership, one +recognized active source at migration cutover, and handling of restored clones and +stale credentials. A shared codebase and globally unique identifiers supply neither +that trust boundary nor delivery availability. + +Institution software licensing, marketplace seller eligibility and a learner's course +grant have separate lifecycles. Licence expiry, seller disconnect, refund and security +revocation need explicit effects on new sales, existing access and outstanding money. +The Hub entitlement projection is not a course-order or fulfillment protocol. + +No new microservice, repository or infrastructure adapter is required merely to +accept the direction. Frontend separation follows +[ADR-0009](0009-frontend-single-app-first.md)'s measured split triggers. + +## Context + +The current [platform vision](../architecture/01-platform-vision.md) describes +LearnStack as infrastructure rather than an education product of its own. Its +[Non-goals](../architecture/01-platform-vision.md#non-goals) exclude an independent +instructor marketplace, and +[MVP scope § Deferred](../architecture/05-mvp-scope.md#deferred) places marketplace +features outside the roadmap. An institution-only seller policy +does not avoid the broader positioning change: LearnStack-branded discovery and +checkout add a product-facing role. +The current [Billing roadmap](../roadmap/phase-09-billing-integrations-analytics.md) +describes institution storefront payments, not platform commission and seller payouts. +Hybrid delivery is a product-scope expansion, not an already-supported configuration. + +P02d-1's isolation and content ownership are reusable. The absent command, API and +frontend consumers make this a useful decision point, but do not make marketplace +identity, moderation or commerce implemented. + +### Why these alternatives differ + +Replacing institution sites discards the existing brand and standalone-deployment use +case without evidence that institutions prefer it. Retaining only institution sites is +the valid Accepted baseline, but does not answer the request for shared platform +checkout and payouts. The hybrid proposal preserves that baseline while adding an +explicitly separate commercial channel. + +An independently editable platform-owned course copy splits authorship: edits, +withdrawal, version identity and learner progress acquire competing authorities. No +requirement currently justifies that split. A licensed immutable delivery replica +differs: its authoring source remains explicit, and export rights, version mapping and +withdrawal need a distribution contract before it can be selected. + +### Evidence that would change the proposal + +- Institutions unwilling to opt in, or buyers gaining no useful discovery advantage, + favor retaining the institution-site product without a Course Marketplace. +- Demonstrated marketplace-only demand with no branded-site need would reopen the + two-channel product cost, rather than make sites an unconditional permanent burden. +- Provider eligibility, delivery responsibility or support economics that cannot meet + the intended country/seller model block that commercial scope before implementation. +- A signed Self-Hosted requirement with incompatible connectivity or residency needs + reopens external delivery choices; a hypothetical customer does not trigger them. + +These are decision-review triggers, not claims that market validation has occurred. + +### One-way-door assessment before P02d-2 + +[ADR-0035](0035-demand-gated-infrastructure.md)'s test and +[Decision Timing](../roadmap/README.md#decision-timing) apply to the next consumer, not +to every possible future feature at once: + +| Boundary | Would waiting change code written in the meantime? | Required disposition before that code | +|---|---|---| +| Publication versus protected-content access | Yes, if P02d-2 writes protected content or P02d-4 exposes it under the old public contract | Resolve G3 through the explicit process above before protected authoring; public-only work otherwise stays under ADR-0048 | +| Tenant-owned source identity and organization scope | Already structural in P02d-1; moving content to a platform tenant would change writers, grants and references | Retain tenant ownership and parent-derived scope in P02d-2; do not add channel-controlled ownership or accept a tenant id from a listing | +| Global catalog and commerce storage, role and request context | Yes for their first tables, queries and writer contracts; no existing Education query needs broader visibility merely because a separate projection is added | Accept those contracts before the first catalog/commerce migration, request or export writer; do not add a sentinel tenant or broaden existing RLS | +| Source publication/export contract | Yes once a writer promises marketplace listing updates | Decide consent, stable source ids, revision ordering, withdrawal and durable delivery before adding that producer contract; P02d-2 publication promises only tenant-local publication | +| Cross-installation identity and fulfillment | Yes for the first external listing, order or grant; P02d-2 has no such consumer | Keep external participation outside the initial scope; name and accept its delivery owner before admitting an external source | + +P02d-2 must not encode `published = marketplace-listed`, platform-owned course copies, +global listing ids as tenant authority, or prices/payables in Education. A later +projection may backfill approved source data through tenant-scoped contracts; its +existence does not require rewriting every Education filter or migration. Its own +isolation and global-read decisions cannot wait until after that projection is written. +These missing product capabilities are not described as demand-gated adapters with +imaginary default implementations. + +## Consequences + +### Positive + +- Institutions can retain their brand and audience while optionally obtaining + marketplace distribution. +- Within one installation, content and learner progress retain one owning record across + presentation channels. +- Tenant isolation and the customization model remain useful foundations. + +### Negative + +- LearnStack takes on two product experiences and marketplace operations: seller + onboarding, moderation, support, refunds, disputes and payout reconciliation. +- Channel pricing, attribution, support responsibility and catalog duplication need + explicit product rules; implementation cannot infer them from a hostname. +- Supporting both channels does not demonstrate market demand or Amazon-scale + capacity. Those require commercial evidence and measured workloads. + +## Implementation Notes + +**Acceptance blockers — all open.** Before this ADR is marked Accepted, the maintainer +must approve its product boundaries and the decision pass must record: + +| Open item | Required accepted record | +|---|---| +| Product positioning | Exact revisions to `CLAUDE.md` § What this is, the vision introduction and Non-goals, MVP scope and the repository README introduction; preserve the genericity boundary | +| Delivery ownership | A named Course Marketplace phase with packet sequencing, owning module/service and exit criteria in the roadmap; explicitly decide whether Phase 09 expands or a new phase owns the capability. Phase 12 is not a substitute | +| Authoritative commerce and public-read boundary | Name the owning module/service and the boundary between tenant source data, the public projection and commerce. Assign the detailed request/host matrix, table classes, audit and reader-role contract to that phase before their first consumers | +| P02d-2 access scope | Retain accepted public-only G3, or approve the explicit reopening and superseding access ADR before protected-content writers | +| Initial commercial scope | Seller eligibility, platform/seller/buyer country combinations, payment and invoicing responsibility, fulfillment responsibility and the owner of the remaining commerce rules | + +This is an acceptance checklist, not a list of work silently deferred to unnamed +phases. No Course Marketplace implementation is assigned to an existing phase until +that roadmap decision is approved. + +After those decisions are accepted, the implementation pass: + +1. Applies the approved positioning and roadmap changes together; updates Phase 09b's + money-flow explanation without transferring course commerce to Hub. +2. Completes P02d-2's remaining G3 command/seed-state details and other open gates. + If protected authoring was selected, records the dated G3 supersession and new + access ADR as described above before writing code. Otherwise preserves ADR-0048. +3. Keeps P02d-2's fixture ownership tenant-local. Explicit free/public examples prove + the skeleton; any protected example must have an explicit denial contract before + P02d-4's public readers. Marketplace orders and payouts are not fake seed outcomes. +4. Re-scopes P02d-4–6 where the accepted access and route decisions require it before + OpenAPI and frontend contracts are frozen. Retain their tenant-isolation proofs. +5. Uses Phase 02b's durable events, Phase 03's identity, Phase 05's versioned content, + Phase 07's access grants and Phase 09's commerce ownership as inputs to the revised + sequencing, not reasons to implement an unauthorized shortcut in P02d-2. + +The following business choices remain open before commerce implementation: the initial +platform/seller/buyer country and currency combinations, eligible seller types, +payment/invoicing responsibility, commission and attribution, refunds and payout timing. +Evaluate one seller per checkout as a scope-reduction option once payment and +fulfillment responsibilities are known; it still needs real platform payment, +commission and seller payout. Multi-seller +checkout is a separate scope decision. No provider or legal arrangement is selected by +this draft. + +## Architecture Tests + +Existing tenant/organization isolation, module-boundary, audit and context-provenance +tests remain required. The implementation decision pass assigns these additional +behavioral proofs to their first consumers: + +- Publication or listing alone cannot expose a restricted lesson body or media URL. +- Marketplace participation and withdrawal cannot change content ownership. +- A projection contains only the declared public fields; a tenant cannot read another + tenant's private source records. +- A public catalog request admits only its accepted host/context combinations and + uses its read-only projection policy; neither marker nor the platform sentinel + becomes authority to query private Education records or fabricate a tenant context. +- An equivalent valid access grant works across authorized presentation channels; + a different learner's grant does not. +- Checkout refuses an ineligible seller or offer despite a stale listing, and replayed + payment events cannot duplicate access grants, payable entries or payouts. +- A refund affects only access attributable to its durable order/fulfillment reference; + independently justified access survives. The access contract must define that + attribution and effective-access calculation before the first billing-source grant; + `source = billing` alone cannot distinguish separate purchases. +- Before any external seller participates, prove source and identity binding, recovery + after ambiguous fulfillment, and rejection of restored installations' stale authority. + +These are proposed proof obligations, not registered or passing tests. + +## References + +- [Education module](../modules/education/README.md) +- [Phase 02d packet and gate register](../roadmap/phase-02d-walking-skeleton.md#packets-and-decision-gates) +- [Course Access](../glossary.md#enrollment--access) +- [Tenant isolation](../architecture/09-tenant-isolation.md) +- [Tenant customization](../architecture/32-tenant-customization-model.md) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index f444d440..8514c642 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -66,6 +66,20 @@ an amendment is not a lifecycle status change. | 0045 | [The Entitlement and Feature-Flag Socket](0045-entitlement-and-feature-flag-socket.md) | **Amendment 1 (2026-09-08)** reads it against the Hub's merged code: the limit vocabulary is the Hub's, `expires_at`/`valid_until` are nullable, the generation guard admits the equal case, and `platform_killswitches` ships **unwritten** because `DenyAllPlatformAdminGate` makes a writer unreachable until Phase 03. **Amendment 2 (2026-09-11):** a registry's membership is the vocabulary the contract names — fourteen Hub features, nine `limits.*`, two tenant flags, three killswitches — and enforcement, not membership, waits for a consumer. **Amendment 3 (2026-09-11):** a key's fail-open/fail-closed class decides only when no projection exists; one past its grace window is read-only, as ADR-0021 decides. **Amendment 4 (2026-09-11):** of the projection's names, `PlanCode` differs from the wire (`tier`) and `ExpiresAt` from the column (`valid_until`) — § 1 had called both wire differences. Declares the port twenty documents name and none define. `IEntitlementProvider.GetAsync(TenantId)` + `RefreshAsync(projection)`, the projection carrying every field `entitlement-v1.schema.json` requires — **`compliance` and `generation` included**, both already `NOT NULL` columns — and the refresh **generation-guarded inside the write statement**, so a stale push cannot resurrect a revoked plan. `IFeatureFlags` is the only module-facing read and **composes over the provider** rather than querying `platform_entitlement_cache`, which is the only reading under which the Phase 02a criterion — swapping the provider changes the answer — can be true. Limits: **`-1` unlimited, `0` denied**, `long` not `long?`; the inverse reading would have made the degraded read-only mode grant unlimited. `NullEntitlementProvider` is registered in **every** deployment mode, per ADR-0035's default-implementation gate rather than ADR-0020's Development-only switch. Killswitches leave `tenant_feature_flags` for a platform-scoped `platform_killswitches`: a foreign key to `tenants` is a constraint no role or `BYPASSRLS` moves, so the sentinel could never have satisfied it. Push-primary, **not** push-only — ADR-0034's `license/verify` fallback stands; the no-Hub absolute belongs to host resolution | | 0048 | [Publication Before Course Versioning](0048-walking-skeleton-publication.md) | Accepted 2026-09-14: independent course/lesson publication, combined anonymous-read eligibility and Phase 05 preservation obligations | +## Proposed ADRs + +| # | Title | Topic | Target phase / decision point | +|---|---|---|---| +| 0049 | [Institution Sites and an Optional Course Marketplace](0049-institution-sites-and-course-marketplace.md) | Proposed 2026-09-15: hybrid product direction; accepts no gate and does not supersede ADR-0048 | P02d-2 decision pass, before its first application writers | + +The target records the maintainer's request to settle product direction before +P02d-2 implementation planning. This is a planning hold, not a new architecture gate +or automatic ADR acceptance. Retaining the Accepted scope is one possible outcome; +this draft cannot reopen G3. The +[phase record](../roadmap/phase-02d-walking-skeleton.md#pending-course-marketplace-proposal) +tracks that pending choice. On acceptance, move the row to Active ADRs instead of +duplicating it. The draft SLAs below remain unchanged. + ## Superseded ADRs - **ADR-0014 — Adopt Dapr for Cross-Cutting Infrastructure** — superseded by diff --git a/docs/glossary.md b/docs/glossary.md index 6d304c08..0693a213 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -96,6 +96,8 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | Term | Definition | |------|------------| +| **Course Marketplace** | The proposed learner-facing channel for discovering and buying institutions' courses through platform checkout, with commission and seller payouts. [ADR-0049](decisions/0049-institution-sites-and-course-marketplace.md) is Proposed; this is not accepted roadmap scope or the Hub Marketplace. | +| **Hub Marketplace** | The optional, post-MVP exchange of reusable tenant customization bundles owned by [Phase 12](roadmap/phase-12-hub-marketplace.md). Its initial scope is free-only and its ADR-0034 content-boundary decision remains open; it is distinct from the Course Marketplace. | | **Product** | A sellable platform item. | | **Plan (tenant storefront)** | A package or subscription definition referencing one or more products. Lives in the LearnStack core `Billing` module — what a tenant sells to its own learners. Distinct from the Hub-side `Plan` (see *Hub & Licensing*) that governs the tenant's own LearnStack subscription. | | **Price** | A currency / interval / amount combination attached to a plan. | diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 99d64187..ae5dca42 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -249,10 +249,9 @@ At the end of this roadmap, LearnStack can: recording consent, and recording metadata. - Extend payment, notifications, search, storage, analytics, and live-classroom providers through adapters. -- Run with `NullEntitlementProvider` (no Hub), `HubEntitlementProvider` (SaaS / - Dedicated), or `SignedLicenseKeyEntitlementProvider` (Self-Hosted, air-gappable) - without code changes — only `DeploymentMode` configuration. `Development` and `SaaS` - are wired end to end **today**; `Dedicated`, `SelfHostedOnline` and - `SelfHostedAirGapped` are **prepared seams, not supported deployments**, until - [Phase 11](phase-11-production-hardening.md) builds their adapters and integration - suites ([ADR-0035](../decisions/0035-demand-gated-infrastructure.md)). +- Target interchangeable entitlement providers selected by `DeploymentMode`, without + module changes: `NullEntitlementProvider`, a Hub-backed provider and a signed-licence + provider. [Deployment Models § Supported today versus prepared seam](../architecture/25-deployment-models.md#supported-today-versus-prepared-seam) + owns current wiring and readiness; these target choices do not claim the adapters + have shipped. Owners and triggers remain + [ADR-0035](../decisions/0035-demand-gated-infrastructure.md)'s. diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index b964930d..e70b9271 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -323,6 +323,13 @@ SDK generation ships as a wired-but-empty scaffold — there are no endpoints to generate from until [Phase 02d](phase-02d-walking-skeleton.md). **Packet 5 — Foundation ports and default implementations ✅** ([delivery record](#delivery-record-packet-5)) + +> **Clarification — 2026-09-17.** The shared defaults below describe this packet's +> event-bus, cache and secret ports, not every mode-specific provider. Error tracking +> already differs by mode. Current support claims live in +> [Deployment Models](../architecture/25-deployment-models.md#supported-today-versus-prepared-seam). +> The shipped packet's scope and record are unchanged. + `IEventBus` / `ICacheService` / `ISecretProvider` in `LearnStack.SharedKernel`, with `InProcessEventBus` / `InMemoryCacheService` in `LearnStack.Infrastructure` — `ISecretProvider` and `ConfigurationSecretProvider` @@ -1045,12 +1052,11 @@ here with **working default implementations**; the vendor adapters ship on a tri Hub; an anonymous page load must not depend on a control plane being reachable ([ADR-0034](../decisions/0034-hub-contract-surface-invariant.md)). -Composition-root branching on `DeploymentMode` is present and exercised, with -`Development` and `SaaS` wired end to end. `Dedicated`, `SelfHostedOnline` and -`SelfHostedAirGapped` resolve to the same defaults and are **prepared seams, not -supported deployments**, until [Phase 11](phase-11-production-hardening.md) builds -their adapters and integration suites. Modules never read `DeploymentMode` -(`Modules_Do_Not_Reference_DeploymentMode`). +Composition-root branching on `DeploymentMode` is present and exercised. The foundation +ports in this section use shared defaults; other providers may differ by mode. +[Deployment Models § Supported today versus prepared seam](../architecture/25-deployment-models.md#supported-today-versus-prepared-seam) +owns current readiness and the remaining adapters and integration suites. Modules never +read `DeploymentMode` (`Modules_Do_Not_Reference_DeploymentMode`). **Not in this phase**, per ADR-0035's trigger table: the Dapr sidecar and its pub/sub, state and secret components; Kafka; APISIX; Vault. @@ -1460,9 +1466,9 @@ Per [API Standards](../standards/04-api-design.md): **`DeploymentMode`-based composition** (Development / SaaS / Dedicated / SelfHostedOnline / SelfHostedAirGapped) per [ADR-0020](../decisions/0020-triple-deployment-hybrid-license.md). All five values - exist and the composition root branches on all five; `Development` and `SaaS` are - wired end to end, and the remaining three are prepared seams until - [Phase 11](phase-11-production-hardening.md). + exist and the composition root branches on all five. See + [Deployment Models § Supported today versus prepared seam](../architecture/25-deployment-models.md#supported-today-versus-prepared-seam) + for the distinction between foundation wiring and a supported deployment. - 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 diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 8096c5fb..8abbd5e0 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -362,6 +362,24 @@ with or after the earlier one, never contradicting it: - G39 and G40 — routing and strings. - G44 and G45 — one stack entrypoint. +### Pending Course Marketplace proposal + +**Pending direction review — 2026-09-17.** At the maintainer's request, P02d-2's +decision pass first considers institution sites alongside a +[Course Marketplace](../decisions/0049-institution-sites-and-course-marketplace.md). +ADR-0049 is Proposed; this note accepts no gate and assigns no marketplace delivery +scope to P02d-2. Its +[acceptance checklist](../decisions/0049-institution-sites-and-course-marketplace.md#implementation-notes) +records the unresolved product, delivery and commercial decisions. + +G3's publication/transition answer accepted on 2026-09-14 remains binding under +ADR-0048. Only G3's command names and seeded states remain for P02d-2. If protected +authoring is explicitly selected, the +[proposed reopening process](../decisions/0049-institution-sites-and-course-marketplace.md#publication-discovery-and-access) +requires maintainer approval, a superseding access ADR and a dated G3 supersession +entry before implementation. The original question, accepted answer and delivery +record remain intact. Resolving the product direction alone does not reopen G3. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/docs/roadmap/phase-09b-hub-billing.md b/docs/roadmap/phase-09b-hub-billing.md index 796078b3..2200986e 100644 --- a/docs/roadmap/phase-09b-hub-billing.md +++ b/docs/roadmap/phase-09b-hub-billing.md @@ -17,7 +17,7 @@ repository ([ADR-0019](../decisions/0019-learnstack-hub.md), ## Division of responsibility -Two different billing relationships exist. Conflating them is the most common reading +The Accepted plan defines two billing relationships. Conflating them is a common reading error in this corpus, so it is written out: | Money flows | Phase | Where the code lives | @@ -25,6 +25,14 @@ error in this corpus, so it is written out: | A learner pays a **tenant** for a course | [Phase 09](phase-09-billing-integrations-analytics.md) — storefront billing | LearnStack core, behind `IPaymentProvider` | | A tenant pays the **LearnStack vendor** for the platform | Phase 09b — platform billing | `learnstack-hub`, behind `IHubPaymentProvider` | +**Separate proposal, not a third accepted row:** +[ADR-0049](../decisions/0049-institution-sites-and-course-marketplace.md#commerce-and-the-hub-boundary) +proposes learner payment through a Course Marketplace, followed by commission and +institution payouts. That is a third commercial relationship; it is neither Hub +subscription billing nor an existing Phase 09 capability. Its ownership and delivery +phase must be accepted separately. ADR-0019's existing two-port boundary remains +binding while the proposal is under review. + The two ports share a shape — idempotency key, webhook signature verification, status mapping — and never run in the same process. Self-Hosted tenants skip this phase entirely: they buy a licence key rather than a subscription. From 6bbcb1e354aa9e086fbe59aa7a6b55cff2a76859 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 1 Oct 2026 23:05:38 +0300 Subject: [PATCH 03/28] docs: address marketplace direction review findings Separate the Proposed direction from delivery scoping. Record live product, operations, privacy and first-consumer recovery obligations without accepting a marketplace module or changing the public-only G3 baseline. Align Phase 09 credit-pack criteria with its no-consumption-ledger scope. ADR: 0018, 0019, 0034, 0048, 0049 --- docs/architecture/23-data-protection.md | 8 + .../34-course-marketplace-scoping.md | 397 ++++++++++++ ...nstitution-sites-and-course-marketplace.md | 573 +++++------------- docs/decisions/README.md | 3 + docs/roadmap/phase-02d-walking-skeleton.md | 7 + docs/roadmap/phase-03-identity-admin.md | 8 + ...phase-09-billing-integrations-analytics.md | 16 +- 7 files changed, 592 insertions(+), 420 deletions(-) create mode 100644 docs/architecture/34-course-marketplace-scoping.md diff --git a/docs/architecture/23-data-protection.md b/docs/architecture/23-data-protection.md index fc3a66a0..b1a1883c 100644 --- a/docs/architecture/23-data-protection.md +++ b/docs/architecture/23-data-protection.md @@ -185,6 +185,14 @@ These are tracked as Phase-11+ work and are not implementation blockers for the ## Processor Agreements +**Proposed marketplace impact (2026-10-01).** The arrangement below describes the +institution service. It is not an accepted legal-role assignment for central buyer, +order, fraud or support processing. [ADR-0049](../decisions/0049-institution-sites-and-course-marketplace.md) +requires a purpose-based role, consent, DSAR, retention, transfer and media-rights +decision before the first marketplace PII writer. Its +[scoping companion](34-course-marketplace-scoping.md#privacy-residency-and-media) +records those open boundaries; no new legal role or retention period is selected here. + For tenants subject to KVKK / GDPR, LearnStack acts as a **data processor** while the tenant is the **data controller**. The processor agreement template lives outside this repository (legal). Engineering-side commitments: - Sub-processor list maintained in the tenant onboarding pack (Keycloak host, LiveKit host, S3 / SeaweedFS provider, email provider, SMS provider, payment provider). diff --git a/docs/architecture/34-course-marketplace-scoping.md b/docs/architecture/34-course-marketplace-scoping.md new file mode 100644 index 00000000..a1128d7f --- /dev/null +++ b/docs/architecture/34-course-marketplace-scoping.md @@ -0,0 +1,397 @@ +# Course Marketplace — Proposed Scope and Delivery Contracts + +**Status: proposal, not an accepted architecture or delivery commitment.** +This companion to [ADR-0049](../decisions/0049-institution-sites-and-course-marketplace.md) +holds the detailed scope analysis. The ADR owns the proposed direction and acceptance +checklist. Existing Accepted decisions remain binding. No marketplace module, phase, +table, endpoint, feature key, payment provider or deployment adapter is implemented or +approved here. + +**Review date:** 2026-10-01. The maintainer's target remains institution sites plus a +full marketplace, with Turkey and international sales. Initial seller geography is +deliberately undecided. Recommendations below need approval; legal and provider +feasibility are separate checks, not consequences of a maintainer preference. + +## Proposed delivery ownership + +**Recommendation for approval:** a product-side `Marketplace` module inside the +existing modular monolith, with catalog/operations and commerce responsibilities. +Propose a new **P09a — Course Marketplace pilot** milestone; it is not yet registered +in the roadmap. Phase 09 keeps institution-storefront billing, Phase 09b keeps Hub +software billing, and Phase 12 keeps the Hub customization-bundle market. + +| Owner | Boundary and dependencies | +|---|---| +| Education | Tenant-owned authoring, lessons and versions; versions belong to [Phase 05](../roadmap/phase-05-education-learning-content.md). No marketplace prices, inventory or payout state | +| Proposed Marketplace | Approved public profiles/listings, seller eligibility and moderation; channel offers and central orders, payables, refunds, payouts and reconciliation. Exact aggregates and global table classes require the commerce/security decisions | +| Billing | Existing [Phase 09](../roadmap/phase-09-billing-integrations-analytics.md) institution orders/payment contracts. Reuse of provider capabilities across the two commerce boundaries requires an approved application contract; no second owner for the same order | +| Enrollment | Sole owner of course access, enrollment and cohorts under [Phase 07](../roadmap/phase-07-enrollment-learner-portal.md); owns any accepted cohort-seat inventory, not implied by `CourseAccess` | +| Scheduling | [Phase 08b](../roadmap/phase-08b-scheduling.md) session availability, capacity, reservations, cancellation and rescheduling; shared site/marketplace capacity contract is still open | +| Identity / Audit | [Phase 03](../roadmap/phase-03-identity-admin.md) identity and authorization inputs; separate staff-access and data-protection decisions before their marketplace consumers | +| Hub | Software subscriptions and permitted tenant metadata under [ADR-0019](../decisions/0019-learnstack-hub.md); crossings remain bounded by [ADR-0034](../decisions/0034-hub-contract-surface-invariant.md). No learner commerce or source content | + +Candidate P09a sequencing is: approve the scope and feasibility packet; implement +public catalog and seller operations; implement the selected offer's central checkout, +delivery and reconciliation; then run a bounded live pilot. Each packet has security +and failure proofs before its first consumer. Full checkout uses Phase 03 identity, +Phase 05 version identity, Phase 07 enrollment/access and Phase 09 payment inputs; +a live offer additionally needs the accepted capacity and classroom delivery contracts +in Phases 08b/08c. A referral pilot can omit central commerce dependencies, but its +scope must be explicitly selected. + +Live sales also require the applicable production-readiness controls and exit proofs +in [Phase 11](../roadmap/phase-11-production-hardening.md), including recovery, +security and operational readiness. The approved sequencing must either satisfy that +gate or explicitly bring the required controls into the pilot's earlier delivery plan. +Do not assume foundation wiring is a production release, or require every demand-gated +adapter regardless of its trigger. + +The proposed exit is an end-to-end purchase of the selected product, verified delivery, +recoverable payment/refund/payout handling, approved operational/privacy boundaries +and a recorded pilot stop/go decision. Broad rollout requires positive pilot evidence. +Approve the final phase name, dependencies, packets and exits before publishing its +roadmap file. No new service, repository or frontend app follows from this proposal; +[ADR-0009](../decisions/0009-frontend-single-app-first.md) still governs frontend splits. + +## First sale and delivery + +A course/version identifies educational content, not every sellable delivery promise. +The same version can support several offers, but the first pilot selects **one** unit: + +| Candidate | What the learner buys | Required delivery contract | +|---|---|---| +| Content access | Access to the identified version | Phase 07 course grant and protected content/media evaluation | +| Dated cohort place | Enrollment in a named cohort, schedule and any associated content | Phase 07 enrollment/cohort-seat authority; Phase 08b scheduling; capacity shared across institution site and marketplace | +| Session reservation | A specified live session or time slot | Phase 08b availability, temporary hold/expiry if used, confirmation, cancellation and rescheduling | +| Consumable session pack | A balance redeemable against future sessions | Separate ledger ADR and named release; no existing phase delivers consumption/refund/expiry accounting | + +**Recommendation for a live-focused pilot:** evaluate a dated cohort place first. +This is not a selected product and not ready to sell until its inventory and delivery +contract exists. Recorded content is the smaller delivery scope; session packages +must not be selected on the false assumption that Phase 09 supplies their ledger. + +For any live offer, decide before checkout: authoritative seat/reservation owner, +branch, instructor, dates/time zone, teaching language, hold/confirmation behavior, +cancellation, schedule changes, no-shows and content-access inclusion. Institution +and marketplace writers consume the **same** authority; two grants do not prove two +available seats. If the last seat is bought through one channel, the other cannot +confirm it independently. Define recovery when payment succeeds but enrollment or +reservation fails, including buyer notification and refund obligations. + +Prices and capacity do not belong on Education's course or lesson aggregates. +`OrderPaidV1` and `source = billing` in the existing plan do not prove live delivery +or identify which purchase justifies a grant. Resolve product-specific fulfillment +and durable order/fulfillment attribution before Phase 07's first billing consumer +contract/grant, with matching Phase 09 and P09a producer contracts. An alternative +requires a separately versioned marketplace consumer and migration before its first +grant. Refunds remove only that justification; independently justified access survives. + +## Publication, discovery and access + +Content publication, listing discoverability and caller access are separate questions. +Free enrollment, anonymous previews and restricted content are also separate; price +zero is not an access policy. Public APIs, renderers, caches and protected media enforce +the same policy. Failure of a protected-access evaluator denies access. + +The current public-only G3 answer and [ADR-0048](../decisions/0048-walking-skeleton-publication.md) +remain binding. If protected authoring is requested in P02d-2, obtain explicit approval +to reopen G3 and write a superseding ADR before code. It selects persisted policy, +defaults, preview behavior, forward migration, command validation and fail-closed reads +until Phase 07 supplies grants. Append a dated G3 supersession and update affected +packet criteria while preserving the accepted question, answer and delivery history. +A hybrid-direction approval alone cannot make that change. + +The alternative is an explicitly public-only skeleton. It supplies no private/paid +authoring promise; a later protected-content owner must land the decision and migration +before its first writer/reader. No prices, channel IDs, orders or federation identifiers +are needed in P02d-2 for either alternative. Public-only work is technically independent +of marketplace commerce, but the maintainer's existing planning hold remains in effect +until they release or resolve it. + +## Public catalog and search + +The candidate catalog is a dedicated projection containing **only approved public +fields** and stable source/revision identifiers, not a view exposing private Education +rows. An institution profile is not its private tenant record. Instructor biography, +image and discoverable attributes need explicit publication permission; moderation of +a listing grants no private lesson or learner access. + +[ADR-0010](../decisions/0010-cross-module-communication.md)'s fourth mechanism, +read-model projection, owns the read path. Source application contracts and durable +outbox events supply approved changes. Consent/export, revision ordering, replay, +withdrawal, deletion and suspension propagation require a producer contract before its +first writer. A new broker is not a P02d-2 prerequisite; ADR-0035 still gates transport +adapters. Checkout revalidates authoritative eligibility and offer terms despite stale +search results. + +[ADR-0012](../decisions/0012-search-strategy.md) and +[Search architecture](20-search.md) distinguish tenant search from privileged platform +search. `ITenantSearch` retains its tenant boundary. `IPlatformSearch` is an audited +administrative capability, not the public catalog port. Public search needs a separate +accepted contract over the minimized projection; it does not reuse a master key, +drop tenant filters or borrow a platform-admin connection. Phase 09's tenant-search +isolation work stays intact. Register the public index/query/writer boundary before +P09a's first search consumer; this proposal does not amend ADR-0012. + +Shared marketplace facets need their own controlled meanings and localization. +Do not equate different institutions' level/taxonomy values merely because both are +called "beginner". Explicit mappings for public discovery do not rewrite tenant +taxonomies or access rules. + +`learnstack.com` is the maintainer's illustrative address, not an allocated domain, +configured platform host or replacement for `learnstack.app`, `learnstack.dev` or local +hosts. An institution profile path identifies a public resource, not authority to +switch tenant. Full institution-site rendering under that path, if requested, needs a +separate [ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md) decision. + +For genuinely tenantless platform-host requests, context remains **unresolved**. +Gate 1 in `TenantContextBehavior` requires `[AllowsUnresolvedTenantContext]`; +`[PublicSurface]` governs the separate, resolved `HostOnly` gate. The gates are nested, +not two annotations automatically applied together. Select platform-host, tenant-host +or both in the accepted request matrix and explicitly extend the closed unresolved +allow-list. Neither marker grants database access. + +Select projection table class, least-privilege reader/writer roles and audit +classification before its first migration. Do not announce `TenantId.PlatformSentinel` +as a request tenant or fabricate tenant-scoped audit for an unresolved reader +([ADR-0044](../decisions/0044-audit-write-path.md)). Existing host-mapping and killswitch +tables are bounded precedents, not blanket permission for global data. The projection +writer's approved-field boundary matters as much as its read policy. + +## Participation and genericity + +Commerce ledgers, capacity state and external payments are platform code under +[ADR-0018](../decisions/0018-tenant-driven-customization-model.md), not tenant-authored +customization. Public descriptions and tenant presentation can remain data. + +Separate plan availability, institution eligibility, listing consent, moderation and +operational suspension. Being on an eligible plan does not approve a seller or listing. +Conversely, existing decisions do not require participation to be sold as an additional +paid tier: it could be included in all eligible SaaS plans or limited to selected ones. + +The owning P09a decision uses [ADR-0021](../decisions/0021-feature-based-entitlement.md) +and [ADR-0045](../decisions/0045-entitlement-and-feature-flag-socket.md) for any feature, +limit and killswitch contract. Follow `add-feature-key` for accepted registry additions +before their consumers. Do not invent a key or implement entitlement bypasses now. +Seller withdrawal, security suspension, licence expiry and disconnection have distinct +effects on new sales, existing access and outstanding money; specify them together. + +## Marketplace operations and support + +**Recommended business split, pending approval:** LearnStack handles seller/listing +admission, marketplace checkout support and payment/refund/dispute coordination. +Institutions handle teaching, delivery and their own site customers. Record escalation, +cancellation authority, buyer notification, response targets and financial-loss +responsibility; "institution site independence" does not settle these duties. + +The P09a staff-access decision names the backoffice owner, human staff population, +realm/token audience, permissions, resource scope and audit responsibility. Existing +rules separate `learnstack-hub` operators from `learnstack` tenant-facing endpoints +([ADR-0004](../decisions/0004-authentication-strategy.md), +[Security](../standards/11-security.md)). Existing internal-API crossings are bounded; +marketplace moderation is not automatically one of them. A changed realm admission or +new Hub crossing needs its own accepted decision; no third realm or service is assumed. + +Default review access covers approved public listing fields. Private lesson inspection, +buyer order access and learner data require separate least-privilege permissions, +purpose/reason, resource and time scope, PII controls and an auditable exceptional +access contract. A tenant administrator cannot gain cross-tenant moderation by opt-in. +A listing approval is not a grant to inspect private education or all students. + +Trust/safety rules cover seller verification, misleading or unlawful listings, +suspension/appeal, instructor publication rights and buyer complaints. Specify +attribution, ranking and promotions, channel pricing and who may contact the learner. +A marketplace purchase does not transfer the institution's entire customer relationship +to LearnStack. + +## Privacy, residency and media + +[Data Protection](23-data-protection.md#processor-agreements) describes the institution +service's tenant-controller / LearnStack-processor arrangement. **Inference requiring +legal assessment:** central buyer accounts, order history, fraud assessment and platform +support can have different purposes and responsibilities. Do not extend that blanket +arrangement merely because a course remains tenant-owned. The EDPB evaluates roles per +processing activity and actual purpose/means, not just entity or contract labels +([final Guidelines 07/2020](https://www.edpb.europa.eu/system/files/documents/2023-10/EDPB_guidelines_202007_controllerprocessor_final_en.pdf)). + +Before P09a's first identity/order/support PII writer, approve a purpose matrix: +data categories, authority and legal roles; consent/legal basis; platform/institution +recipients; access, export, erasure, retention and payment-record exceptions; +subprocessor and incident responsibility. Update Architecture 23 and +[Phase 03's DSAR boundary](../roadmap/phase-03-identity-admin.md) together. + +Resolve what happens to central purchases when a tenant membership is erased, what +an institution's export may include, and how a global account closure treats retained +financial records. Institution marketing permission does not automatically authorize +platform marketing. Ownership, legal controllership and database table class are +different decisions; none supplies another by inference. + +One regional installation limits source topology; it does not prove no international +transfer. Assess buyer, seller, support access, payment/identity/storage subprocessors, +disaster recovery and media distribution against the actual permitted regions and +rights. Use the current [KVKK transfer framework](https://www.kvkk.gov.tr/Icerik/2053/Yurtdisina-Aktarim) +where applicable. Media export, previews, instructor images, withdrawal and existing +buyer continuity need explicit rights even without external-source federation. + +## Installation and identity scope + +**Recommended launch boundary:** one identified LearnStack-operated SaaS installation, +one selected region and institution sellers. Region and seller countries are unresolved. +[Residency architecture](23-data-protection.md#data-residency) permits separate regional +SaaS instances; `SaaS` alone therefore identifies neither one database nor one issuer. +International buyers are a commercial/transfer decision, not an instruction to federate +seller installations. + +| Mode / topology | Proposed source participation | +|---|---| +| Selected regional `SaaS` installation | Initial candidate scope | +| Another `SaaS` installation, even LearnStack-operated | Excluded initially; cross-installation contract required | +| `Dedicated` | Excluded initially; separate database and possibly issuer | +| `SelfHostedOnline`, public learning surface | Future remote fulfillment, licensed replica or referral only after a delivery/trust decision | +| `SelfHostedOnline`, private/VPN-only | Buyers cannot rely on direct local delivery; needs a different accepted arrangement | +| `SelfHostedAirGapped` | Independent local institution use; no live central synchronization, checkout or grant-delivery dependency | + +These are topologies within existing enum values, not new deployment modes or +module-level branches. Prepared seams are not supported releases; see +[Deployment Models](25-deployment-models.md#supported-today-versus-prepared-seam). + +Within one installation the planned [global User / tenant Membership model](13-identity-and-auth.md#multi-tenant-identity-model) +can serve a learner across institutions. A combined library projects only their grants, +not cross-tenant rosters. Supported OIDC redirects and origin-scoped sessions handle +custom domains; arbitrary domains cannot share a session cookie by assumption. + +External identity needs verified issuer/subject binding, not email equality. A customer +issuer cannot mint central privileges. Before any external source, decide authenticated +installation registration, source/tenant mapping, version identity, offer validation, +delivery acknowledgements, reconciliation and support. A restored clone, stale +credential or customer-signed event is not evidence of central payment or active +source authority. +At migration cutover recognize one active source and invalidate the former authority. + +Compare remote fulfillment, referral and immutable licensed delivery replicas before +admitting an external source. Referral alone does not meet central-checkout goals. +Replicas need source/export permission, immutable version mapping, media rights, +residency, withdrawal and continued service to existing buyers. No connector or replica +is selected or needed for P02d-2. + +## Financial and commercial contract + +The approved P09a scope must identify the contractual education seller, invoice issuer, +payment collector and provider/card-network merchant of record. Those roles can differ; +a UI hostname does not select them. Stripe's +[merchant-of-record contract](https://docs.stripe.com/connect/merchant-of-record) +illustrates that charge configuration changes that provider role; it is not a provider +choice or a legal conclusion for LearnStack. + +Record platform/seller/buyer countries, currencies, institution seller types, KYC, +tax/invoicing and any applicable payment-intermediation or funds-handling requirements. +Provider and legal validation are needed before commerce implementation, not just a +generic `IPaymentProvider` interface. Turkey/international comparisons remain open: + +- Turkish institution sellers / international buyers: international payment acceptance, + refunds, currencies and applicable buyer obligations still need validation. +- Multiple-country sellers / international buyers: additionally validate onboarding, + each payout corridor, settlement/currency restrictions and provider loss allocation. + +Stripe's [business-country availability](https://stripe.com/global) does not list +Turkey for standard payment acceptance as reviewed on 2026-10-01. +[Connect cross-border payouts](https://docs.stripe.com/connect/cross-border-payouts) +have their own platform/account restrictions; international cards do not prove every +seller can receive a payout. [iyzico's marketplace documentation](https://docs.iyzico.com/urunler/pazaryeri) +describes sub-sellers, commission and foreign-currency payments, but does not establish +eligibility for every intended foreign seller. No provider is selected here. + +Determine commission/fees, attribution, cancellation/refund policy, payout timing, +reserves or other accepted loss allocation, reconciliation and support duties from the +purchase-time arrangement. Provider refund/dispute/negative-balance responsibilities +vary by [charge model](https://docs.stripe.com/connect/integration-recommendations). +For applicable Turkish transactions, assess the intermediary's information, complaint +and record duties against the +[Ministry's distance-contract guidance](https://tuketici.ticaret.gov.tr/yayinlar/tuketici-bilgi-rehberi/mesafeli-sozlesmeler-hakkinda-bilgilendirme); +a referral pilot is not assumed to remove all intermediary responsibilities. + +**Recommendation:** one seller per checkout initially. That reduces splitting but still +requires central payment, commission, seller liability and payout. Payment collection, +delivery confirmation and money release are separate transitions: iyzico's +[approval API](https://docs.iyzico.com/urunler/pazaryeri/pazaryeri-entegrasyonu/onay) +is one concrete example. The eventual provider contract chooses their ordering and +recovery; it must not promise an atomic transaction spanning provider and database. + +## Pilot evidence + +The proposed P09a pilot needs approved scope and an explicit decision record **before +live sales**: participating institutions/offers/commission, selected product and +capacity contract, provider/legal eligibility, protected delivery, support/refund +readiness, applicable Phase 11 launch controls and reconciliation/recovery proofs. +A manual or referral discovery experiment can inform demand without pretending to +implement a central checkout. + +Record measurement owners, cohort/sample and observation window, numeric stop/go +thresholds, and which costs count before the pilot starts. Measure opt-in supply, +incremental buyer discovery/conversion, delivered purchases, cancellation/refund/dispute +rates, support burden and contribution after payment fees, losses, acquisition and +delivery obligations. No numbers or demand evidence are invented in this draft. + +Do not require live-pilot results before building its bounded implementation; require +feasibility before implementation, readiness before live sales, and positive evidence +before broad rollout. Unviable provider corridors, delivery promises or contribution +economics stop or re-scope that offer rather than authorize unchecked expansion. + +## One-way-door assessment + +[ADR-0035](../decisions/0035-demand-gated-infrastructure.md) and +[Decision Timing](../roadmap/README.md#decision-timing) apply to the next consumer: + +| Boundary | Required before the first affected code | +|---|---| +| Protected publication | Approved G3 reopening, superseding ADR and migration before protected P02d-2 writers/readers; otherwise public-only baseline | +| Source identity and organization scope | Keep P02d-1 tenant ownership and parent-derived scope; listings are not tenant authority | +| Global catalog / commerce | Accept table classes, roles, host/context, audit and export rules before P09a migration or reader/producer; no broader Education filters | +| Source publication/export | Consent, revisions, withdrawal and durable delivery before the first listing producer; P02d-2 promises tenant-local publication only | +| Live capacity and fulfillment | Shared authority, holds/confirmation, cancellation and payment/delivery-failure recovery before the first live offer | +| Purchase-attributed access | Grant/fulfillment attribution before Phase 07's first billing consumer; coordinate Phase 09/P09a producers or explicitly version the new consumer before its first grant | +| Financial/PII writers | Country/provider/role feasibility, purpose-based privacy and recoverable accounting contracts before central commerce | +| External source | Accepted identity, trust, delivery and lifecycle contract before any other installation participates; excluded from initial P09a | + +Later projection backfill can use tenant-scoped source contracts. Marketplace design +does not require rewriting every Education migration, but its own irreversible choices +cannot wait until after its producers/readers exist. These absent product capabilities +are not demand-gated adapters with imaginary defaults. + +## Proposed proof obligations + +These are **future behavioral obligations, not registered or passing tests**. P09a's +decision packet assigns implementations and tests before each consumer; applicable +existing isolation, module and audit tests continue to run. + +- Listing/publication cannot expose restricted lesson bodies or protected media. + Field minimization and writer permissions keep private data out of the projection. +- Participation/withdrawal cannot transfer content ownership. Public catalog admission + obeys its host/context matrix and read-only policy; no invented tenant or privileged + source query. Tenant and administrative search boundaries remain intact. +- Valid access works across authorized channels for its learner/version; another + learner's grant fails. Refund removes only its purchase justification. +- Both sales channels contend for the same last live place; at most one confirmation. + Expired holds, instructor cancellation and payment-without-delivery resolve according + to the accepted buyer contract, not just a successful course grant. +- Checkout rejects stale/ineligible offers and sellers. Duplicate payment/delivery + events cannot duplicate grants, payable movements, reservations or provider payouts. +- A crash after provider completion but before local persistence, or a lost response, + leaves a recoverable unknown outcome. Retry uses the provider's idempotency/status + contract; it cannot blindly charge or transfer again. +- Refund before a delayed payment event does not resurrect access or release money. + Reordered events converge according to authoritative state; Stripe explicitly + [does not guarantee event order](https://docs.stripe.com/webhooks#event-ordering). +- Paid-but-failed enrollment/reservation is reconciled, compensated or escalated under + the chosen contract. Provider success alone cannot mark fulfillment complete. +- Failed refund/transfer, post-payout refund, insufficient balance and partial + settlement produce reconciled liabilities and bounded retries/escalation; money + movements cannot disappear into a generic success flag. +- Reconciliation compares provider and local order/delivery/payable/transfer facts; + auditable repair cannot create double access, charge, refund or payout. +- Staff approval, suspension and exceptional private inspection enforce permission, + purpose/resource scope and audit; tenant admins gain no platform authority. +- Privacy proofs cover membership erasure versus central retained orders, tenant export + minimization and independently scoped platform/institution consent. +- Before external participation, prove issuer/source binding, delivery recovery and + rejection of restored clones or stale authority in addition to all initial proofs. diff --git a/docs/decisions/0049-institution-sites-and-course-marketplace.md b/docs/decisions/0049-institution-sites-and-course-marketplace.md index 11723904..2703e6fc 100644 --- a/docs/decisions/0049-institution-sites-and-course-marketplace.md +++ b/docs/decisions/0049-institution-sites-and-course-marketplace.md @@ -2,466 +2,213 @@ ## Status -Proposed — 2026-09-15. Product and architecture direction for maintainer review. -This draft accepts no P02d-2 gate and authorizes no implementation. The existing -Accepted ADRs remain binding unless a maintainer-approved decision supersedes them -under the repository's ADR governance. +Proposed — 2026-09-15. Product direction for maintainer review; updated 2026-10-01 +after two external reviews and verification against the Accepted corpus and code. +This draft accepts no gate, authorizes no implementation and supersedes no ADR. **Date:** 2026-09-15 -**Updated:** 2026-09-17 **Deciders:** Cemil (repository maintainer; approval pending) ## Decision Drivers -- The maintainer is considering a full course marketplace: a shared catalog, - platform checkout, commission and payouts to education institutions. -- The target includes sales in Turkey and internationally. Whether sellers are - initially Turkish institutions or institutions from multiple countries remains - open; international buyers and international seller payouts are separate scopes. -- There is no current Self-Hosted customer commitment. The scenario must remain - viable without turning a hypothetical integration into a launch dependency. -- Institutions may also need their own branded site, custom domain or platform - subdomain. Marketplace participation must not require abandoning that site. +- The maintainer's target is a full course marketplace: shared discovery, platform + checkout, commission and institution payouts alongside institution-owned sites. +- Turkey and international sales are both targets. Initial seller countries, + platform country, payment corridors and provider eligibility remain undecided. + International buyers do not imply support for international seller payouts. +- There is no committed Self-Hosted customer. A hypothetical deployment must not + create a launch dependency. +- LearnStack serves institutions that teach live. Buying content access, a cohort + place, a session reservation or a consumable pack creates different obligations. - [P02d-1](../roadmap/phase-02d-walking-skeleton.md#merge-and-closeout-2026-09-14) - already supplies tenant-owned courses and lessons with database isolation. - P02d-2 is about to introduce their first application writers and seed data. -- [ADR-0048](0048-walking-skeleton-publication.md) currently associates publication - with anonymous lesson-body eligibility. A paid or private course needs an explicit - access boundary before a public reader relies on that association. -- Shared discovery must not broaden an institution's access to another institution's - learners, lesson bodies, finances or administration. + shipped tenant-owned courses and lessons with isolation. Their first application + writers are next; [ADR-0048](0048-walking-skeleton-publication.md) still governs + public-only publication. +- Shared discovery must not expose private lessons, learners, finances or operations + across institutions. Marketplace economics and operational readiness are unproven. ## Considered Options -1. **Institution sites with optional marketplace participation** (recommended). - Shared education capabilities and ownership; separate presentation, discovery and - commercial policies for each channel. -2. **Replace institution sites with a marketplace** (not recommended). Simpler public - positioning, but removes the branded-site use case and the ability to serve an - institution's existing audience independently. -3. **Keep only the current institution-site product** (not recommended for the new - request). Fits the Accepted roadmap but supplies no shared course marketplace. -4. **Create an independently editable platform-owned copy of every course** (rejected). - Creates competing authoring identities, progress and access grants. A deliberately - licensed, versioned delivery replica is a different topology; it is not selected - here and requires the distribution contract described under Deployment scope. +1. **Institution sites plus an optional full Course Marketplace** (recommended). + Reuses tenant-owned education; adds a distinct discovery and commercial channel. +2. **Discovery and referral only** (pilot alternative, not selected). Institutions + handle checkout. Reduces central payment and payout scope but still needs consent, + moderation, privacy and attribution rules; does not meet the full target alone. +3. **Replace institution sites with the marketplace** (not recommended). Removes + independent branding and customer relationships without evidence of that demand. +4. **Retain only institution sites** (valid Accepted baseline). Avoids a new business + model, but does not satisfy the marketplace request. +5. **Independently editable platform-owned course copies** (rejected). Creates + competing authoring, access and progress authorities. An immutable licensed delivery + replica is a different, unselected distribution topology. +6. **Put course commerce in Hub / Phase 12** (rejected under current decisions). + Conflicts with [ADR-0034](0034-hub-contract-surface-invariant.md)'s content boundary; + Hub's customization-bundle market is a different product. Changing that boundary + would require a separate cross-repository decision. ## Decision The proposed decision is: LearnStack provides institution-owned sites and an optional [Course Marketplace](../glossary.md#billing) over one education foundation. It adds a -LearnStack-branded education distribution product to the institution-site platform. +LearnStack-branded distribution product to the institution-site platform. On acceptance, the binding product boundaries are exactly these: -1. An institution remains a tenant and owns its courses, lessons and operating data; - its branches remain organizations under ADR-0017. -2. Institution sites work independently of marketplace participation. Institution - admission and individual listings are explicit opt-ins. -3. A sales channel neither transfers content ownership nor grants lesson-body access. -4. The initial Course Marketplace serves centrally operated SaaS institutions. - Dedicated and Self-Hosted participation is excluded from that initial scope. -5. Course Marketplace content and learner commerce stay outside the Hub's tenant - subscription and deployment-metadata boundary. - -These are proposed boundaries, not shipped behavior. The open contracts below are not -implicitly accepted with this paragraph, and no implementation can rely on them while -they remain open. The [acceptance checklist](#implementation-notes) names the decisions -needed to make this draft ready for acceptance. - -## Open Implementation Contracts - -The following candidate contracts and scope alternatives require explicit resolution. -Conditional P02d-2/P02d-4 obligations apply only if their protected-content scope is -approved through the G3 process below. Existing Accepted constraints continue to apply -regardless of this proposal. - -### Institution and channel boundaries - -This table describes target behavior. Course versions and learner access/progress -are future capabilities owned by -[Phase 05](../roadmap/phase-05-education-learning-content.md), -and [Phase 07](../roadmap/phase-07-enrollment-learner-portal.md). -[Phase 09](../roadmap/phase-09-billing-integrations-analytics.md) owns institution -storefront commerce in the current plan; Course Marketplace commerce needs the -separate ownership decision below. None is already supplied by P02d-1. - -| Concern | Proposed ownership and behavior | -|---|---| -| Institution | A `Tenant`; its branches and campuses remain `Organization` values under [ADR-0017](0017-tenant-organization-hierarchy.md) | -| Content | Education retains the tenant-owned course and lesson identities, translations and exact customization bindings | -| Branded site | Institution branding, navigation and catalog presentation; custom domain remains optional | -| Marketplace | LearnStack-branded discovery, approved institution profiles and opt-in course listings | -| Sales terms | Separate from lesson content; identify the seller, course or version, channel, price, currency and commercial terms | -| Learner access | A grant for the owning institution's course/version; independent of which authorized channel originated the purchase | -| Progress | Within one installation, shared for the same learner and course-version enrollment; cross-installation progress requires an additional identity and delivery contract | - -The same course can have different channel offers without duplicating its learning -content. An institution profile is not the institution's entire private record, and a -public instructor profile requires an explicit publication boundary. - -An independent instructor can later be evaluated as a seller without reusing -`Organization` for unrelated businesses. Institution-only seller admission is an -explicit initial-scope proposal for maintainer approval. If individual sellers are -required at launch, that scope and their onboarding must be decided before the -marketplace plan is accepted. +1. An institution remains a tenant owning its courses, lessons and operating data; + its branches remain organizations under [ADR-0017](0017-tenant-organization-hierarchy.md). +2. Institution sites operate independently. Institution admission and individual + marketplace listings require explicit opt-in. +3. A sales channel transfers neither content ownership nor lesson-body access. + Publication, discovery and caller access have separate contracts. +4. The proposed initial source scope is institutions in **one LearnStack-operated + SaaS installation and one selected region**. Other SaaS installations, Dedicated + and Self-Hosted sources require a later cross-installation contract. +5. Course Marketplace content and learner commerce remain on the LearnStack product + side, outside Hub's software-subscription and permitted tenant-metadata boundary. + +These are recommendations awaiting approval, not shipped capabilities. The +[acceptance checklist](#implementation-notes) must close before this ADR is Accepted. +Its [scoping companion](../architecture/34-course-marketplace-scoping.md) describes +candidate delivery contracts; it does not accept them by reference. + +## Context + +### Product and investment change + +The [vision introduction](../architecture/01-platform-vision.md) and `CLAUDE.md` +describe LearnStack as infrastructure, not an education product of its own. The +[vision Non-goals](../architecture/01-platform-vision.md#non-goals) and +[MVP Deferred scope](../architecture/05-mvp-scope.md#deferred) exclude marketplace +features. Institution-only sellers do not avoid this positioning change. + +Hybrid delivery preserves the branded-site use case. Marketplace-only delivery has +no validated demand advantage. Independent course copies split authorship, withdrawal, +version identity and learner progress; no requirement justifies that split. A licensed +replica retains an authoritative source but needs export and continuity contracts. +Referral is a possible pilot, not an assumed substitute for the requested checkout. + +Commerce, shared inventory and external payments are platform capabilities under +[ADR-0018](0018-tenant-driven-customization-model.md), not `TenantContentType` rows. +Plan-entitlement governance does not select a paid tier or prove seller eligibility; +the [participation contract](../architecture/34-course-marketplace-scoping.md#participation-and-genericity) +keeps those choices separate. ### Publication, discovery and access -Three separate questions govern a course: - -- Is its content published and ready for the applicable read path? -- In which channel, if any, may its public catalog information be discovered? -- May this caller read this lesson body or retrieve its protected media? - -An approved catalog listing does not answer the third question. Free access, anonymous -previews and access requiring an enrollment are also distinct cases; a zero price -does not necessarily mean anonymous access. - -The public API, renderer, caches and media delivery enforce the same access policy. -Hiding a link or a button is insufficient. An unavailable access evaluator refuses -protected content; it cannot fall back to the public path. - -**Accepted baseline.** [G3's publication/transition contract](../roadmap/phase-02d-walking-skeleton.md#p02d-1-accepted-answers) -closed on 2026-09-14 through ADR-0048. P02d-2 inherits that public-only contract; -only command names and seeded states remain open under G3. This draft does not reopen -the closed part. The [pending proposal record](../roadmap/phase-02d-walking-skeleton.md#pending-course-marketplace-proposal) -keeps the request to reconsider it distinct from an approved change. - -If the maintainer elects protected-content authoring in P02d-2, its decision pass must -first approve reopening that part of G3. The alternatives for that approval are: - -- A minimal, persisted content-access policy before the first writers, with a forward - migration over P02d-1's schema. This is the recommended option if P02d-2 is explicitly - expanded to protected-content authoring. Approving the hybrid direction alone does - not select it. It needs no prices, channels, orders or federation identifiers in - Education. -- Retaining the current public-only skeleton contract explicitly. Protected content - authoring remains unavailable until a separately owned decision and migration land - before its first writer or reader. This is not an implicit promise that the current - `published` flag can later protect paid content. - -The first option needs a new superseding ADR replacing -[ADR-0048](0048-walking-skeleton-publication.md)'s anonymous-access implication. It -specifies exact policy, defaults, preview rules, migration treatment, command validation -and fail-closed behavior until -[Phase 07](../roadmap/phase-07-enrollment-learner-portal.md) provides Course Access. -Once that change is approved, append a dated G3 supersession entry to the phase's -decision record, link the new ADR from G3's status and the new entry, and update -affected packet criteria. Preserve the original question, accepted answer and delivery -record. Until those approval records exist, the current G3 answer remains binding. -If protected authoring is approved, P02d-4 must deny restricted bodies while no -authenticated grant reader exists. -Changing that contract is a new decision, not an erratum to a statement that was false -when written. This direction draft neither chooses the migration nor supersedes the -Accepted security contract. - -### Shared discovery without shared private data - -The candidate marketplace read path uses a dedicated projection of approved public -listing fields and their source identifiers. Source modules supply those fields through -[ADR-0010](0010-cross-module-communication.md)'s application contracts and durable -integration events; the catalog consumer is its **read-model projection** mechanism. -Durable delivery here is a future producer/consumer contract, not a new broker -requirement for P02d-2. ADR-0035 still gates a transport adapter on its named trigger. -A marketplace query does not remove Education's tenant filters -or borrow a platform-admin connection to enumerate private tables. - -Listing withdrawal, source deletion and seller suspension have explicit propagation -and invalidation rules. Checkout revalidates the authoritative offer and seller -eligibility; a stale search result is never authorization to charge or grant access. -The projection's storage roles, audit classification and global read boundary need -their own accepted contract before that surface is implemented. - -`learnstack.com` is the maintainer's illustrative Course Marketplace address, not an -allocated domain, a configured platform host or a replacement for existing -`learnstack.app`, `learnstack.dev` or local development host conventions. Selecting its -actual host and route classification remains open. On that proposed platform surface, -an institution profile path identifies a public marketplace resource, not an authority -to switch the caller's tenant. Full institution-site rendering under a path on the -same host, if selected, needs a separate trusted-resolution decision against -[ADR-0036](0036-tenant-resolution-trusted-inputs.md). The existing host resolver must -not gain an unchecked path or header fallback. - -For a genuinely tenantless platform-host request, the existing context remains -**unresolved**; no tenant id is invented. In `TenantContextBehavior`, gate 1 admits -that request only with `[AllowsUnresolvedTenantContext]`. `[PublicSurface]` addresses -the separate gate for a **resolved `HostOnly`** context; neither marker implies the -other. The first catalog endpoint needs an accepted request/host matrix and an explicit -extension of the closed unresolved-request allow-list, authorization and audit -classification. A marker alone grants no database access. -The gates are nested: admitting an unresolved request does not run the `HostOnly` -gate. The contract must deliberately select platform-host, tenant-host or both -surfaces and prove their admission rules; applying both markers by default is not -that decision. An unresolved public read cannot invent a tenant-scoped audit row. - -That contract must select the projection's table class and least-privilege reader role -before its first migration. `TenantId.PlatformSentinel` is never an announced request -tenant, under [ADR-0044](0044-audit-write-path.md); removing tenant filters or using a -platform-admin connection to read Education is not an alternative. Existing -`platform_host_to_tenant` and `platform_killswitches` are bounded platform-scoped -precedents, not blanket permission to add a global table. No new endpoint, table class -or role is approved by this draft. - -### Identity and teaching experience - -The existing [global User and tenant Membership design](../architecture/13-identity-and-auth.md#multi-tenant-identity-model) -fits learners buying from multiple institutions and instructors working with several -institutions within one installation. Those application capabilities are not implemented -yet. Dedicated and Self-Hosted installations may use independent identity issuers; -their local user identifiers and email addresses do not establish a shared learner. -Federation requires a verified issuer/subject binding to the central identity, with -explicit course-version and grant ownership. It must not let a customer-controlled -issuer mint central marketplace privileges. - -A learner's combined library is an authorized projection of their own grants. It -does not expose an institution's roster to another institution. Cross-domain sign-in -uses the identity provider's supported redirect flow with origin-scoped sessions; -the proposal does not assume a cookie can be shared with arbitrary custom domains. +[G3's publication answer](../roadmap/phase-02d-walking-skeleton.md#p02d-1-accepted-answers) +closed on 2026-09-14 through ADR-0048. P02d-2 inherits public-only publication; only +G3's command names and seeded states remain open. This proposal does not reopen it. + +Protected authoring in P02d-2 requires explicit maintainer approval to reopen that +part of G3, then an approved **superseding access ADR**, migration/default/denial +contract and dated G3 supersession before writers. Preserve the original question, +accepted answer and delivery record. P02d-4 must deny restricted bodies until an +authenticated access evaluator exists. A free price or a public listing grants no +protected access. The [access process](../architecture/34-course-marketplace-scoping.md#publication-discovery-and-access) +describes the alternatives; accepting hybrid direction alone chooses neither. + +Public-only P02d-2 has no technical dependency on marketplace commerce. The +[planning hold](../roadmap/phase-02d-walking-skeleton.md#pending-course-marketplace-proposal) +reflects the maintainer's request to settle direction first. Releasing it is a separate +maintainer choice, not an action this review takes. ### Commerce and the Hub boundary -The domain serving a page does not decide who collects payment. -[Phase 09b's division of responsibility](../roadmap/phase-09b-hub-billing.md#division-of-responsibility) -and [ADR-0019](0019-learnstack-hub.md) currently distinguish two billing relationships. -This proposal adds a **third commercial relationship**; it is not already covered by -either existing payment port: +[Phase 09b](../roadmap/phase-09b-hub-billing.md#division-of-responsibility) and +[ADR-0019](0019-learnstack-hub.md) define two existing relationships. This proposal +adds a third: | Relationship | Standing and owner | |---|---| -| Learner pays an institution through its storefront | Accepted Phase 09 scope, LearnStack core | -| Institution pays the LearnStack vendor for its software subscription | Accepted Phase 09b scope, Hub | -| Learner pays through the Course Marketplace; commission and institution payouts follow | Proposed new commerce scope outside Hub; authoritative module/service and delivery phase remain acceptance blockers | - -Each learner order records its channel, seller, offer and payment arrangement -immutably. The proposed third relationship includes seller payables, refunds, -reconciliation and provider-backed payouts; Phase 09's existing storefront primitives -do not implement that relationship. - -Seller, offer, payable and payout are descriptive proposal terms here, not newly -accepted Billing aggregate names. Reusing or translating Phase 09's order and -payment contracts belongs to the commerce-ownership decision. - -They can share payment and order primitives while preserving their accounting -boundaries. Refunds and disputes follow the arrangement captured at purchase time, -not the institution's current settings. Provider charge models make this separation -material: responsibility for fees, refunds and disputes varies with the selected -flow ([Stripe Connect charge models](https://docs.stripe.com/connect/integration-recommendations)). -This reference is evidence about the distinction, not a provider selection. - -Marketplace commerce belongs on the LearnStack product side. Its authoritative module -or service, global data scope and audit boundary must be named before this proposal is -accepted as an implementation plan; this draft does not select a new runtime. The Hub -continues to own institutions' LearnStack subscriptions and deployment metadata, under -[ADR-0034](0034-hub-contract-surface-invariant.md). It receives no learner orders or -course content through an expanded entitlement payload. The existing -[Hub Marketplace](../glossary.md#billing) belongs to Phase 12, not this proposal. -[Phase 12's unresolved ADR-0034 collision](../roadmap/phase-12-hub-marketplace.md#scope-on-the-learnstack-side) -is the precedent: publication does not automatically turn tenant-authored material into -Hub metadata. Course listings and instructor profiles remain on the LearnStack product -side in this proposal; seller commercial records are not added to the Hub's permitted -metadata by inference. Any alternative that puts them there needs its own cross-repo -decision under ADR-0034. This proposal does not settle Phase 12's bundle question. - -### Deployment scope - -The first shared marketplace targets LearnStack's centrally operated SaaS deployment. -Institution sites remain independent of marketplace participation. The other deployment -modes are still prepared seams, not supported releases, under -[ADR-0035](0035-demand-gated-infrastructure.md) and the -[deployment architecture](../architecture/25-deployment-models.md). - -The rows distinguish network topologies within the existing enum values; they add no -`DeploymentMode` value or module-level mode branch. - -| Deployment mode and network topology | Proposed central marketplace boundary | -|---|---| -| `Dedicated` — LearnStack-operated | Separate database and potentially separate identity issuer; needs an explicit cross-installation contract despite LearnStack operating it | -| `SelfHostedOnline` — publicly reachable learning surface | Possible opt-in external seller source only after identity, offer validation, delivery, reconciliation and support obligations are accepted | -| `SelfHostedOnline` — private/VPN-only learning surface | General buyers cannot reach the local learning surface; central checkout requires a separately accepted delivery arrangement | -| `SelfHostedAirGapped` | Local institution use; no live central catalog synchronization, checkout or grant delivery dependency | - -For connected installations, compare remote fulfillment, a licensed central delivery -replica, and referral to the institution's own checkout before selecting a topology. -A referral alone does not satisfy platform checkout, commission and seller payouts. -A replica needs explicit export permission, an authoritative authoring source, immutable -version mapping, media rights, residency, withdrawal and continuity for existing buyers. -Neither arrangement is included in the proposed initial marketplace. - -A customer-operated database, identity provider or signed event is not authoritative -evidence of central payment, seller eligibility or learner identity. Before federation, -define authenticated installation registration separately from tenant ownership, one -recognized active source at migration cutover, and handling of restored clones and -stale credentials. A shared codebase and globally unique identifiers supply neither -that trust boundary nor delivery availability. - -Institution software licensing, marketplace seller eligibility and a learner's course -grant have separate lifecycles. Licence expiry, seller disconnect, refund and security -revocation need explicit effects on new sales, existing access and outstanding money. -The Hub entitlement projection is not a course-order or fulfillment protocol. - -No new microservice, repository or infrastructure adapter is required merely to -accept the direction. Frontend separation follows -[ADR-0009](0009-frontend-single-app-first.md)'s measured split triggers. - -## Context - -The current [platform vision](../architecture/01-platform-vision.md) describes -LearnStack as infrastructure rather than an education product of its own. Its -[Non-goals](../architecture/01-platform-vision.md#non-goals) exclude an independent -instructor marketplace, and -[MVP scope § Deferred](../architecture/05-mvp-scope.md#deferred) places marketplace -features outside the roadmap. An institution-only seller policy -does not avoid the broader positioning change: LearnStack-branded discovery and -checkout add a product-facing role. -The current [Billing roadmap](../roadmap/phase-09-billing-integrations-analytics.md) -describes institution storefront payments, not platform commission and seller payouts. -Hybrid delivery is a product-scope expansion, not an already-supported configuration. - -P02d-1's isolation and content ownership are reusable. The absent command, API and -frontend consumers make this a useful decision point, but do not make marketplace -identity, moderation or commerce implemented. - -### Why these alternatives differ - -Replacing institution sites discards the existing brand and standalone-deployment use -case without evidence that institutions prefer it. Retaining only institution sites is -the valid Accepted baseline, but does not answer the request for shared platform -checkout and payouts. The hybrid proposal preserves that baseline while adding an -explicitly separate commercial channel. - -An independently editable platform-owned course copy splits authorship: edits, -withdrawal, version identity and learner progress acquire competing authorities. No -requirement currently justifies that split. A licensed immutable delivery replica -differs: its authoring source remains explicit, and export rights, version mapping and -withdrawal need a distribution contract before it can be selected. - -### Evidence that would change the proposal - -- Institutions unwilling to opt in, or buyers gaining no useful discovery advantage, - favor retaining the institution-site product without a Course Marketplace. -- Demonstrated marketplace-only demand with no branded-site need would reopen the - two-channel product cost, rather than make sites an unconditional permanent burden. -- Provider eligibility, delivery responsibility or support economics that cannot meet - the intended country/seller model block that commercial scope before implementation. -- A signed Self-Hosted requirement with incompatible connectivity or residency needs - reopens external delivery choices; a hypothetical customer does not trigger them. - -These are decision-review triggers, not claims that market validation has occurred. - -### One-way-door assessment before P02d-2 - -[ADR-0035](0035-demand-gated-infrastructure.md)'s test and -[Decision Timing](../roadmap/README.md#decision-timing) apply to the next consumer, not -to every possible future feature at once: - -| Boundary | Would waiting change code written in the meantime? | Required disposition before that code | -|---|---|---| -| Publication versus protected-content access | Yes, if P02d-2 writes protected content or P02d-4 exposes it under the old public contract | Resolve G3 through the explicit process above before protected authoring; public-only work otherwise stays under ADR-0048 | -| Tenant-owned source identity and organization scope | Already structural in P02d-1; moving content to a platform tenant would change writers, grants and references | Retain tenant ownership and parent-derived scope in P02d-2; do not add channel-controlled ownership or accept a tenant id from a listing | -| Global catalog and commerce storage, role and request context | Yes for their first tables, queries and writer contracts; no existing Education query needs broader visibility merely because a separate projection is added | Accept those contracts before the first catalog/commerce migration, request or export writer; do not add a sentinel tenant or broaden existing RLS | -| Source publication/export contract | Yes once a writer promises marketplace listing updates | Decide consent, stable source ids, revision ordering, withdrawal and durable delivery before adding that producer contract; P02d-2 publication promises only tenant-local publication | -| Cross-installation identity and fulfillment | Yes for the first external listing, order or grant; P02d-2 has no such consumer | Keep external participation outside the initial scope; name and accept its delivery owner before admitting an external source | - -P02d-2 must not encode `published = marketplace-listed`, platform-owned course copies, -global listing ids as tenant authority, or prices/payables in Education. A later -projection may backfill approved source data through tenant-scoped contracts; its -existence does not require rewriting every Education filter or migration. Its own -isolation and global-read decisions cannot wait until after that projection is written. -These missing product capabilities are not described as demand-gated adapters with -imaginary default implementations. +| Learner pays an institution through its storefront | Accepted Phase 09 scope; LearnStack core | +| Institution pays LearnStack for software | Accepted Phase 09b scope; Hub | +| Learner buys through the Course Marketplace; commission and seller payouts follow | Proposed new product-side scope; ownership and milestone require approval | + +Hub owns software subscriptions, plans, licences and permitted tenant metadata under +ADR-0019; ADR-0034 constrains crossings and content. It receives no learner orders, +listings or course content through an enlarged entitlement payload. The +[Phase 12 content question](../roadmap/phase-12-hub-marketplace.md#scope-on-the-learnstack-side) +is a relevant precedent, not authorization for this commerce scope. + +The proposed [module and milestone allocation](../architecture/34-course-marketplace-scoping.md#proposed-delivery-ownership) +keeps commerce outside Hub and Education. It does not select a provider, merchant of +record, legal role or new runtime. Content access alone does not fulfill a live seat. + +### Evidence and decision timing + +Institutions refusing offers or commission, buyers receiving no incremental discovery +benefit, or unsustainable support/payment/refund costs favor the existing site product. +Provider or legal constraints can invalidate a country/seller combination. A committed +external-deployment need can reopen distribution choices; a hypothetical one cannot. + +The [pilot contract](../architecture/34-course-marketplace-scoping.md#pilot-evidence) +requires recorded entry conditions and measurable stop/go thresholds before a live +pilot; successful evidence precedes broad rollout. No market validation is claimed. +The [one-way-door assessment](../architecture/34-course-marketplace-scoping.md#one-way-door-assessment) +assigns each irreversible contract to its first consumer. It does not put speculative +prices, marketplace IDs, stock or payout fields into P02d-2. ## Consequences ### Positive -- Institutions can retain their brand and audience while optionally obtaining - marketplace distribution. -- Within one installation, content and learner progress retain one owning record across - presentation channels. -- Tenant isolation and the customization model remain useful foundations. +- Institutions retain their brand and audience while opting into distribution. +- Within one installation, content and enrollment progress keep one owner across + authorized channels. Isolation and customization remain useful foundations. ### Negative -- LearnStack takes on two product experiences and marketplace operations: seller - onboarding, moderation, support, refunds, disputes and payout reconciliation. -- Channel pricing, attribution, support responsibility and catalog duplication need - explicit product rules; implementation cannot infer them from a hostname. -- Supporting both channels does not demonstrate market demand or Amazon-scale - capacity. Those require commercial evidence and measured workloads. +- LearnStack adds buyer acquisition, seller onboarding, moderation, support, refunds, + disputes and reconciliation to operating two product experiences. +- Live delivery, shared capacity, customer attribution and data-protection roles need + explicit contracts. Neither a hostname nor a payment-success event supplies them. +- This direction proves neither demand, positive unit economics nor Amazon-scale + capacity; those require pilot evidence and measured workloads. ## Implementation Notes -**Acceptance blockers — all open.** Before this ADR is marked Accepted, the maintainer -must approve its product boundaries and the decision pass must record: +**Acceptance blockers — all open.** Before marking this ADR Accepted, record approved +high-level boundaries below and their named delivery owners. Detailed security, privacy +and commerce decisions remain mandatory before their first consumers; this direction +cannot approve an unspecified schema, role or payment arrangement. -| Open item | Required accepted record | +| Open item | Required acceptance record | |---|---| -| Product positioning | Exact revisions to `CLAUDE.md` § What this is, the vision introduction and Non-goals, MVP scope and the repository README introduction; preserve the genericity boundary | -| Delivery ownership | A named Course Marketplace phase with packet sequencing, owning module/service and exit criteria in the roadmap; explicitly decide whether Phase 09 expands or a new phase owns the capability. Phase 12 is not a substitute | -| Authoritative commerce and public-read boundary | Name the owning module/service and the boundary between tenant source data, the public projection and commerce. Assign the detailed request/host matrix, table classes, audit and reader-role contract to that phase before their first consumers | -| P02d-2 access scope | Retain accepted public-only G3, or approve the explicit reopening and superseding access ADR before protected-content writers | -| Initial commercial scope | Seller eligibility, platform/seller/buyer country combinations, payment and invoicing responsibility, fulfillment responsibility and the owner of the remaining commerce rules | - -This is an acceptance checklist, not a list of work silently deferred to unnamed -phases. No Course Marketplace implementation is assigned to an existing phase until -that roadmap decision is approved. - -After those decisions are accepted, the implementation pass: - -1. Applies the approved positioning and roadmap changes together; updates Phase 09b's - money-flow explanation without transferring course commerce to Hub. -2. Completes P02d-2's remaining G3 command/seed-state details and other open gates. - If protected authoring was selected, records the dated G3 supersession and new - access ADR as described above before writing code. Otherwise preserves ADR-0048. -3. Keeps P02d-2's fixture ownership tenant-local. Explicit free/public examples prove - the skeleton; any protected example must have an explicit denial contract before - P02d-4's public readers. Marketplace orders and payouts are not fake seed outcomes. -4. Re-scopes P02d-4–6 where the accepted access and route decisions require it before - OpenAPI and frontend contracts are frozen. Retain their tenant-isolation proofs. -5. Uses Phase 02b's durable events, Phase 03's identity, Phase 05's versioned content, - Phase 07's access grants and Phase 09's commerce ownership as inputs to the revised - sequencing, not reasons to implement an unauthorized shortcut in P02d-2. - -The following business choices remain open before commerce implementation: the initial -platform/seller/buyer country and currency combinations, eligible seller types, -payment/invoicing responsibility, commission and attribution, refunds and payout timing. -Evaluate one seller per checkout as a scope-reduction option once payment and -fulfillment responsibilities are known; it still needs real platform payment, -commission and seller payout. Multi-seller -checkout is a separate scope decision. No provider or legal arrangement is selected by -this draft. +| Positioning | Exact revisions to `CLAUDE.md` § What this is, vision introduction/Non-goals, MVP scope and README; preserve ADR-0018's genericity boundary | +| Delivery ownership | Named marketplace module and phase, packet sequencing and exit criteria; evaluate the companion's proposed Marketplace module / P09a. Phase 12 is not a substitute | +| Pilot product and topology | Content access, cohort place or session reservation; full checkout versus referral pilot; institution seller type, one regional installation and single/multi-seller checkout. Name live capacity, cancellation and delivery-failure owners; no session-pack consumption without its separate ledger ADR/release | +| Commercial feasibility | Platform/seller/buyer countries and currencies; contractual education seller, invoice issuer, collector and provider/card-network merchant of record; provider eligibility, KYC, tax and applicable regulated-funds responsibilities, with legal/provider validation before commerce implementation | +| Participation and public discovery | Plan availability versus seller admission, listing opt-in, moderation and suspension; public-projection owner and a search contract distinct from tenant and privileged platform search | +| Operations and support | Backoffice owner and staff population; approval, suspension, refund/dispute and delivery support responsibilities. Assign realm/audience, permission/resource scope, exceptional private review and reasoned audit contracts before the first staff reader or crossing | +| Privacy and distribution | Purpose-based controller/processor assessment, platform versus institution permissions/consent, DSAR/export/erasure and retention ownership; data residency/transfers and media rights. Include Architecture 23 and Phase 03 in the approval impact set | +| Public-read and commerce security | Owner of the request/host matrix, table classes, reader/writer roles, audit classification and ordered/recoverable fulfillment/payable/payout contract before their first migrations, readers or producers | +| P02d-2 access and planning | Retain public-only G3 or approve its explicit reopening and superseding ADR before protected writers; separately resolve the maintainer's planning hold | +| Pilot evidence | Approve the entry conditions, metric owners and a dated stop/go threshold record before a live pilot; positive evidence is a broad-rollout gate | + +Approval of this draft requires the positioning and named roadmap changes together. +It does not move marketplace orders, payouts or pretend live fulfillment into P02d-2 +seed data. That packet still closes its own command, seed and isolation gates. Any +approved access/route expansion updates P02d-4–6 before their contracts freeze. ## Architecture Tests -Existing tenant/organization isolation, module-boundary, audit and context-provenance -tests remain required. The implementation decision pass assigns these additional -behavioral proofs to their first consumers: - -- Publication or listing alone cannot expose a restricted lesson body or media URL. -- Marketplace participation and withdrawal cannot change content ownership. -- A projection contains only the declared public fields; a tenant cannot read another - tenant's private source records. -- A public catalog request admits only its accepted host/context combinations and - uses its read-only projection policy; neither marker nor the platform sentinel - becomes authority to query private Education records or fabricate a tenant context. -- An equivalent valid access grant works across authorized presentation channels; - a different learner's grant does not. -- Checkout refuses an ineligible seller or offer despite a stale listing, and replayed - payment events cannot duplicate access grants, payable entries or payouts. -- A refund affects only access attributable to its durable order/fulfillment reference; - independently justified access survives. The access contract must define that - attribution and effective-access calculation before the first billing-source grant; - `source = billing` alone cannot distinguish separate purchases. -- Before any external seller participates, prove source and identity binding, recovery - after ambiguous fulfillment, and rejection of restored installations' stale authority. - -These are proposed proof obligations, not registered or passing tests. +Existing isolation, module, audit and context-provenance tests remain required. +The [proof obligations](../architecture/34-course-marketplace-scoping.md#proposed-proof-obligations) +cover public-field minimization, authorized access, shared live capacity, stale offers, +source-scoped refunds, reordered events, ambiguous provider results, delivery failures +and reconciliation from the **first central SaaS commerce implementation**. External +source proofs are additional, not the start of failure handling. + +These are proposed obligations, not registered or passing tests. Their owning phase +registers actual tests in the architecture catalogue when the implementations exist. ## References +- [Proposed marketplace scoping](../architecture/34-course-marketplace-scoping.md) - [Education module](../modules/education/README.md) -- [Phase 02d packet and gate register](../roadmap/phase-02d-walking-skeleton.md#packets-and-decision-gates) +- [P02d packet and gate register](../roadmap/phase-02d-walking-skeleton.md#packets-and-decision-gates) - [Course Access](../glossary.md#enrollment--access) - [Tenant isolation](../architecture/09-tenant-isolation.md) - [Tenant customization](../architecture/32-tenant-customization-model.md) +- [Data protection](../architecture/23-data-protection.md) +- [Identity and DSAR](../roadmap/phase-03-identity-admin.md) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 8514c642..f6a861ee 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -80,6 +80,9 @@ this draft cannot reopen G3. The tracks that pending choice. On acceptance, move the row to Active ADRs instead of duplicating it. The draft SLAs below remain unchanged. +The [scoping companion](../architecture/34-course-marketplace-scoping.md) records the +reviewed delivery alternatives and proposed ownership; it accepts no module or phase. + ## Superseded ADRs - **ADR-0014 — Adopt Dapr for Cross-Cutting Infrastructure** — superseded by diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 8abbd5e0..9517b1b1 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -372,6 +372,13 @@ scope to P02d-2. Its [acceptance checklist](../decisions/0049-institution-sites-and-course-marketplace.md#implementation-notes) records the unresolved product, delivery and commercial decisions. +**Review follow-up — 2026-10-01.** The +[scoping companion](../architecture/34-course-marketplace-scoping.md) separates +live-product delivery, operations, privacy, regional topology and commerce recovery +from this packet. Public-only P02d-2 has no technical dependency on those capabilities; +the maintainer's planning hold remains until they resolve or release it. No review +recommendation selects protected authoring or accepts marketplace delivery scope. + G3's publication/transition answer accepted on 2026-09-14 remains binding under ADR-0048. Only G3's command names and seeded states remain for P02d-2. If protected authoring is explicitly selected, the diff --git a/docs/roadmap/phase-03-identity-admin.md b/docs/roadmap/phase-03-identity-admin.md index 7258169d..fd3f0dae 100644 --- a/docs/roadmap/phase-03-identity-admin.md +++ b/docs/roadmap/phase-03-identity-admin.md @@ -135,6 +135,14 @@ Two rules make the table enforceable: **DSAR boundary.** A data subject access request is scoped to a tenant unless the person themselves asks for the account itself. +**Proposed marketplace impact (2026-10-01).** This institution-service contract does +not assign central order history or marketplace consent to a tenant membership. +[ADR-0049](../decisions/0049-institution-sites-and-course-marketplace.md) requires a +separate purpose-based privacy/export/erasure contract, coordinated with +[Data Protection](../architecture/23-data-protection.md#processor-agreements), before +marketplace identity/order/support writers. No such scope is approved in Phase 03 +by this note. + | Request | Initiated by | Scope | Effect on other tenants | |---|---|---|---| | Tenant-scoped export | Tenant admin, or the person acting inside that tenant | Rows where `tenant_id = ` | None | diff --git a/docs/roadmap/phase-09-billing-integrations-analytics.md b/docs/roadmap/phase-09-billing-integrations-analytics.md index 2f966121..6ce9acaa 100644 --- a/docs/roadmap/phase-09-billing-integrations-analytics.md +++ b/docs/roadmap/phase-09-billing-integrations-analytics.md @@ -274,8 +274,8 @@ schema that can never change. registration, its `docs/modules//audit.md`, and its permission-catalogue rows. - Billing domain primitives and the `OrderPaidV1` producer path. - Payment adapter infrastructure with the manual provider working end to end. -- Credit-pack purchase path, with the balance boundary against - [Phase 07](phase-07-enrollment-learner-portal.md) settled and written down. +- Credit-pack purchase path, with the absence of an in-platform consumption ledger + explicit and the separate ADR/release boundary recorded. - Integration registry with per-tenant provider configuration and health. - Meilisearch adapter behind `ITenantSearch`, with engine-enforced tenant tokens and the closed index topology. @@ -291,8 +291,9 @@ schema that can never change. - Product, plan, and price can be created for a tenant. - The manual payment provider drives an order to paid through the adapter, and a paid order produces a `CourseAccess` in the Enrollment module via `OrderPaidV1`. -- A credit pack can be bought, its balance decremented by a confirmed booking, and - refunded by a cancellation inside the tenant's window. +- A credit-pack `Product` can be bought through the `Order` path. This phase does not + expose, decrement or refund a remaining-session balance; consumption stays outside + the platform until the separate ledger ADR and release described in Scope. - Webhook idempotency is tested: the same provider event delivered twice produces one order state change. - Search runs on Meilisearch through `ITenantSearch` with per-request tenant tokens. @@ -318,9 +319,10 @@ schema that can never change. - **Merging billing and enrollment into the same model.** The bridge is one integration event in one direction. If Phase 09 code reads `CourseAccess` or Phase 07 code reads `Order`, the boundary is gone. -- **A second credit ledger.** The most likely place is a "remaining sessions" counter - added here for convenience while Phase 07 holds the authoritative one. Two counters - disagree the first time a refund races a booking. +- **A premature or duplicate credit ledger.** A `remaining_sessions` counter in + Billing, Enrollment or Scheduling would invent a consumption capability this + roadmap does not deliver. The separate ledger ADR must select one authoritative + balance aggregate and its booking/refund contract before those writers exist. - **Migrating search without the engine-enforced layer.** The tempting version of this phase ships the Meilisearch adapter with the existing application-side filter and leaves tenant tokens for later. That version makes tenant isolation strictly weaker From 7bc8e631d88380299af9b40def0102ec7ced2579 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 2 Oct 2026 01:14:37 +0300 Subject: [PATCH 04/28] docs: prepare P02d-2 decisions and marketplace pilot plan Record the endorsed hybrid direction without treating unresolved commerce or access contracts as Accepted. Prepare the exact protected content and text-card ADRs, all writer/seed gate answers, and four implementation steps for maintainer approval before code. Align product, module, localization, audit and roadmap carriers with that boundary. Preserve the frozen P02d-1 record and Accepted ADRs. Validation: 177 architecture tests pass with zero skips; local links, anchors, prose wrapping and git diff checks pass. ADR: 0049, 0050, 0051 Module: Tenancy, Customization, Education --- .claude/skills/seed-tenant/SKILL.md | 8 + CLAUDE.md | 16 +- README.md | 16 +- docs/architecture/01-platform-vision.md | 15 +- docs/architecture/02-domain-model.md | 6 + docs/architecture/05-mvp-scope.md | 2 +- docs/architecture/12-localization.md | 10 +- docs/architecture/14-frontend-architecture.md | 6 + .../32-tenant-customization-model.md | 13 ++ .../34-course-marketplace-scoping.md | 43 ++-- ...nstitution-sites-and-course-marketplace.md | 35 ++- ...0-publication-and-course-content-access.md | 200 ++++++++++++++++++ .../0051-ordered-text-card-presentation.md | 158 ++++++++++++++ docs/decisions/README.md | 19 +- docs/glossary.md | 12 +- docs/modules/customization/README.md | 29 +++ docs/modules/education/README.md | 65 ++++++ docs/modules/education/audit.md | 19 ++ docs/modules/education/permissions.md | 6 + docs/modules/tenancy/README.md | 103 ++++++++- docs/modules/tenancy/audit.md | 8 + docs/modules/tenancy/permissions.md | 6 + docs/roadmap/README.md | 12 +- docs/roadmap/phase-02d-walking-skeleton.md | 171 +++++++++++++-- docs/roadmap/phase-03-identity-admin.md | 7 + docs/roadmap/phase-04-cms-media-pages.md | 18 +- .../phase-05-education-learning-content.md | 7 + .../phase-07-enrollment-learner-portal.md | 15 ++ .../phase-09a-course-marketplace-pilot.md | 146 +++++++++++++ docs/standards/07-frontend-architecture.md | 6 + docs/standards/08-localization.md | 15 +- docs/standards/16-accessibility.md | 6 + docs/standards/README.md | 2 +- 33 files changed, 1112 insertions(+), 88 deletions(-) create mode 100644 docs/decisions/0050-publication-and-course-content-access.md create mode 100644 docs/decisions/0051-ordered-text-card-presentation.md create mode 100644 docs/roadmap/phase-09a-course-marketplace-pilot.md diff --git a/.claude/skills/seed-tenant/SKILL.md b/.claude/skills/seed-tenant/SKILL.md index 08425278..91e7ab19 100644 --- a/.claude/skills/seed-tenant/SKILL.md +++ b/.claude/skills/seed-tenant/SKILL.md @@ -17,6 +17,14 @@ description: > # Seeding a tenant +**Preparation update — 2026-10-02.** +[P02d-2's package](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-2-decision-package-2026-10-02) +is Proposed, not seeded implementation. It plans nullable organization contexts, +contextual verification through `ISender`, tenant-specific type/taxonomy definitions, +enabled locales, one whole-theme setting and explicit public/restricted content. +Exact ADR-0050/0051 and packet approval precede code. Until implementation, the +current command below still seeds only the shipped provisioning and built-in slice. + ## Purpose Stand up a tenant + its organizations + its host mapping, all as **data**, so: diff --git a/CLAUDE.md b/CLAUDE.md index 395e40f3..9f048d9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,8 +7,12 @@ the conventions you must follow when contributing. ## What this is LearnStack is a **white-label platform for multi-branch education -businesses that teach live** — not a single LMS, and not an education -product of its own. One binary, one schema, and one set of container +businesses that teach live**. Its endorsed product direction adds an optional +LearnStack-branded Course Marketplace while preserving independent institution +sites. [ADR-0049](docs/decisions/0049-institution-sites-and-course-marketplace.md) +and [Phase 09a](docs/roadmap/phase-09a-course-marketplace-pilot.md) remain Proposed; +marketplace architecture, commerce and delivery are not implemented or Accepted. +One binary, one schema, and one set of container images serve a language school, a yoga studio, a music school, or a coding bootcamp. What differs between them is **tenant customization data** loaded at provisioning, not code @@ -65,9 +69,11 @@ its decision pass. **P02d-1 is complete and merged** through [PR #22](https://github.com/HodeTech/LearnStack/pull/22) on 2026-09-14. Education's domain, schema and isolation proofs pass; all three steps completed two independent agent review rounds. The [merge closeout](docs/roadmap/phase-02d-walking-skeleton.md#merge-and-closeout-2026-09-14) -records verification of the final PR head and merge commit. **Next: P02d-2's decision -pass**, then Education commands and seed writes. That packet has not started; public -reads belong to P02d-4. +records verification of the final PR head and merge commit. **P02d-2 preparation is +complete for review**: its [decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-2-decision-package-2026-10-02) +proposes protected content, exact write contracts and a four-step implementation. +ADR-0050/0051 and the package require exact approval before code. Implementation +has not started; public reads belong to P02d-4. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` (core + 7 modules × 4 projects + 4 test projects including the diff --git a/README.md b/README.md index 0ad98c9c..3aed4eef 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,12 @@ and database schema, while keeping their own content, branding and tenant bounda The product design supports a platform subdomain and optional custom domains; a business does not need to bring its own domain. +The endorsed direction adds an **optional Course Marketplace** alongside those +sites: shared discovery, platform checkout, commission and institution payouts. +Its [pilot plan](docs/roadmap/phase-09a-course-marketplace-pilot.md) and +[direction ADR](docs/decisions/0049-institution-sites-and-course-marketplace.md) are +Proposed; payment, seller operations and marketplace delivery are still ahead. + The difference between those businesses lives in [tenant customization data](docs/architecture/32-tenant-customization-model.md). The [platform vision](docs/architecture/01-platform-vision.md) defines the scope and @@ -37,16 +43,17 @@ the boundary between customization and capabilities that require platform code. ## What it does The product vision connects discovery, course content and live teaching in one place. -Three surfaces serve the people on each side of that experience: +The planned surfaces serve the people on each side of that experience: | Surface | Who it serves | Intended experience | |---|---|---| | **Public site** | Visitors and prospective learners | Discover a school, browse its catalog and explore its content. | | **Admin Studio** | Institution staff and instructors | Author content, manage people and organize teaching. | | **Learner portal** | Enrolled learners | Work through lessons, track progress and join live sessions. | +| **Optional Course Marketplace** | Learners and participating institutions | Shared discovery and central checkout; endorsed target, architecture approval pending. | These are planned product capabilities. Today, the frontend contains route scaffolds -for all three surfaces; the status below separates delivered foundations from the +for the first three surfaces; the status below separates delivered foundations from the remaining product work. **Built for different ways of teaching.** Content types and level taxonomies already @@ -60,8 +67,9 @@ items, rules, custom fields and notification templates; their delivery is tracke **Phase 01 and Phase 02a are complete. Phase 02d is in progress.** [P02d-1](docs/roadmap/phase-02d-walking-skeleton.md#merge-and-closeout-2026-09-14) is **complete and merged**: Education domain, schema and isolation proofs. -**Next is P02d-2's decision pass**, followed by course and lesson command handlers and -seed writes. **P02d-4** owns anonymous public API reads. Browser rendering follows +**P02d-2's decision package is prepared for approval**, including two Proposed ADRs +and four implementation steps. Command handlers and seed writes have not started. +**P02d-4** owns anonymous public API reads. Browser rendering follows in P02d-5–7; none of these later packets has started. | Area | Delivered now | Next milestone | diff --git a/docs/architecture/01-platform-vision.md b/docs/architecture/01-platform-vision.md index 91246039..0f359b3a 100644 --- a/docs/architecture/01-platform-vision.md +++ b/docs/architecture/01-platform-vision.md @@ -4,8 +4,14 @@ LearnStack is a **white-label platform for multi-branch education businesses tha live**. Its customer is an education business — a language school with three branches, a yoga studio with four, a coding bootcamp, a music school — that sells courses, teaches them partly or wholly in scheduled live sessions, and wants its own brand on its own -domain rather than a listing inside someone else's marketplace. LearnStack is not itself -an education product. +domain or platform subdomain. Independent institution sites remain the foundation. + +**Endorsed direction — 2026-10-02.** An optional LearnStack-branded Course Marketplace +adds shared discovery, platform checkout, commission and institution payouts to that +foundation. [ADR-0049](../decisions/0049-institution-sites-and-course-marketplace.md) +and the [Phase 09a pilot](../roadmap/phase-09a-course-marketplace-pilot.md) remain +Proposed: exact commercial/security contracts and pilot evidence precede delivery. +This changes the product target, not the shipped capabilities or Accepted MVP exit. Three properties define the fit. A prospect that has none of them is not the target customer: @@ -188,8 +194,9 @@ Two things this boundary does **not** change: ## Non-goals -- **Building a marketplace of independent instructors.** LearnStack is infrastructure; - marketplace is a product on top. +- **An independent-instructor marketplace in the initial pilot.** The endorsed + Course Marketplace target starts with institution sellers; individual sellers + and cross-installation participation are outside proposed Phase 09a scope. - **Writing per-vertical code (English, Yoga, Coding modules).** ADR-0018 forbids domain- specific names in LearnStack modules. The whole point of the PaaS positioning is that LearnStack doesn't ship verticals — customers build theirs. diff --git a/docs/architecture/02-domain-model.md b/docs/architecture/02-domain-model.md index 842bef8b..0ff29fc9 100644 --- a/docs/architecture/02-domain-model.md +++ b/docs/architecture/02-domain-model.md @@ -313,6 +313,12 @@ per ADR-0018, not on `Membership` extension tables. > owns the migration and its verification. P02d-1 accepts this design before its > implementation. +**P02d-2 preparation — 2026-10-02, approval pending.** +[ADR-0050](../decisions/0050-publication-and-course-content-access.md) proposes +Course-level content-access policy inherited by independent Lessons; it is neither +a grant nor a price and is not part of the shipped diagram. Phase 05 preserves and +locates that policy in its versioned model if the proposal is accepted. + ## Assessment | Entity | Aggregate root? | Notes | diff --git a/docs/architecture/05-mvp-scope.md b/docs/architecture/05-mvp-scope.md index 498fc960..67673eae 100644 --- a/docs/architecture/05-mvp-scope.md +++ b/docs/architecture/05-mvp-scope.md @@ -255,7 +255,7 @@ complete. Genericity is already proven, and re-proven on every CI run. | Native mobile apps | Web-first; mobile considered after Phase 11. | Post-MVP backlog (no phase yet) | | Complex reporting dashboards | Read models exist; dashboards beyond the basics are post-MVP. | Phase 11 ships baseline dashboards; advanced reporting is post-MVP backlog | | LTI / xAPI implementation | Integrations module is ready; protocol implementations are post-MVP. | Post-MVP backlog (Integrations module structure lands in Phase 09) | -| Marketplace features | Out of scope. | Not on the roadmap | +| Course Marketplace | Endorsed hybrid target; still outside the Accepted MVP exit. | [Proposed Phase 09a](../roadmap/phase-09a-course-marketplace-pilot.md), under ADR-0049; no delivery authorization yet | | AI features (pronunciation feedback, transcription) | Post-MVP. Hooks in the classroom event stream make later addition straightforward. | Post-MVP backlog (no phase yet) | | Whiteboard, breakout rooms | Post-MVP. | Post-MVP backlog (no phase yet) | | Self-service tenant signup | Tenants are provisioned by Hub admin (SaaS) or CLI (Self-Hosted) in MVP. | Post-MVP backlog (no phase yet) | diff --git a/docs/architecture/12-localization.md b/docs/architecture/12-localization.md index abe251e9..e23c31c1 100644 --- a/docs/architecture/12-localization.md +++ b/docs/architecture/12-localization.md @@ -47,11 +47,11 @@ 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). +No public no-row fallback is implemented. The +[prepared P02d-2 G13 answer](../roadmap/phase-02d-walking-skeleton.md#p02d-2-proposed-answers) +proposes no content locale when rows are absent and refuses disabled membership; +it awaits approval. This replaces the earlier unimplemented platform-`en` proposal +on acceptance. Display-label fallback remains separate from URL/body admission. 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 diff --git a/docs/architecture/14-frontend-architecture.md b/docs/architecture/14-frontend-architecture.md index 57b2b03e..61102c47 100644 --- a/docs/architecture/14-frontend-architecture.md +++ b/docs/architecture/14-frontend-architecture.md @@ -232,6 +232,12 @@ Static export is not used; tenants are resolved at request time and the renderer ## Theming +**P02d-2 proposal — 2026-10-02, approval pending.** The +[whole-theme contract](../modules/tenancy/README.md#whole-theme-setting-and-public-boundary) +selects only tenant-wide color values and no remote subresource. Organization merges, +logo/font URLs and Studio below are Phase 06 targets, not this packet's behavior. +G42 still selects safe HTML injection before P02d-6. + 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) diff --git a/docs/architecture/32-tenant-customization-model.md b/docs/architecture/32-tenant-customization-model.md index fc975d4f..1729bc8d 100644 --- a/docs/architecture/32-tenant-customization-model.md +++ b/docs/architecture/32-tenant-customization-model.md @@ -45,6 +45,12 @@ organization-scoped where it makes sense. ## 2. Generic primitive renderers +**P02d-2 presentation proposal — 2026-10-02, approval pending.** +[ADR-0051](../decisions/0051-ordered-text-card-presentation.md) adds optional root +ordered `x-fields` metadata and a plain-string `default-card` profile. The two seed +types opt in; legacy schemas remain valid. The wider renderer set below is a target, +not implemented Phase 02d coverage. P02d-6 implements only the approved subset. + > **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 @@ -464,6 +470,13 @@ discovering it on a page load. ### 8.1 Validation timing — write time, not read time +ADR-0051's proposed extension preserves the existing four admission gates and adds +explicit semantic descriptor validation. Instance validation remains on Education's +exact-pin write path; read eligibility precedes public descriptor resolution. The +text profile adds no active URL/markup sink. A later schema-valid `uri` is not a +security authorization to fetch or render it; its first consumer needs the shared +Security/Media/Content contract named in that ADR. + | Validation | When | Failure mode | |---|---|---| | The `json_schema` passes the four gates in [ADR-0043 § 2](../decisions/0043-customization-payload-validation.md) — it is JSON, it is inside the LearnStack schema profile, it satisfies the draft 2020-12 meta-schema, and it builds — and every `x-renderer` / `x-taxonomy` extension resolves to a registry entry | **On saving the content type / block / lesson item type** | 400 with Problem Details naming the offending JSON pointer | diff --git a/docs/architecture/34-course-marketplace-scoping.md b/docs/architecture/34-course-marketplace-scoping.md index a1128d7f..7f89c71b 100644 --- a/docs/architecture/34-course-marketplace-scoping.md +++ b/docs/architecture/34-course-marketplace-scoping.md @@ -7,17 +7,19 @@ checklist. Existing Accepted decisions remain binding. No marketplace module, ph table, endpoint, feature key, payment provider or deployment adapter is implemented or approved here. -**Review date:** 2026-10-01. The maintainer's target remains institution sites plus a +**Review date:** 2026-10-02. The maintainer's target remains institution sites plus a full marketplace, with Turkey and international sales. Initial seller geography is -deliberately undecided. Recommendations below need approval; legal and provider -feasibility are separate checks, not consequences of a maintainer preference. +deliberately undecided. The maintainer endorsed the recommendations and authorized +P02d-2 preparation. Exact access, security and commerce contracts still require +approval; legal/provider feasibility does not follow from a product preference. ## Proposed delivery ownership -**Recommendation for approval:** a product-side `Marketplace` module inside the +**Endorsed planning target:** a product-side `Marketplace` module inside the existing modular monolith, with catalog/operations and commerce responsibilities. -Propose a new **P09a — Course Marketplace pilot** milestone; it is not yet registered -in the roadmap. Phase 09 keeps institution-storefront billing, Phase 09b keeps Hub +The proposed [Phase 09a](../roadmap/phase-09a-course-marketplace-pilot.md) records the +pilot packets and gates; it is not an Accepted delivery commitment. +Phase 09 keeps institution-storefront billing, Phase 09b keeps Hub software billing, and Phase 12 keeps the Hub customization-bundle market. | Owner | Boundary and dependencies | @@ -49,8 +51,8 @@ adapter regardless of its trigger. The proposed exit is an end-to-end purchase of the selected product, verified delivery, recoverable payment/refund/payout handling, approved operational/privacy boundaries and a recorded pilot stop/go decision. Broad rollout requires positive pilot evidence. -Approve the final phase name, dependencies, packets and exits before publishing its -roadmap file. No new service, repository or frontend app follows from this proposal; +Approve the phase's contracts before moving its draft into the Accepted roadmap. +No new service, repository or frontend app follows from this proposal; [ADR-0009](../decisions/0009-frontend-single-app-first.md) still governs frontend splits. ## First sale and delivery @@ -65,9 +67,9 @@ The same version can support several offers, but the first pilot selects **one** | Session reservation | A specified live session or time slot | Phase 08b availability, temporary hold/expiry if used, confirmation, cancellation and rescheduling | | Consumable session pack | A balance redeemable against future sessions | Separate ledger ADR and named release; no existing phase delivers consumption/refund/expiry accounting | -**Recommendation for a live-focused pilot:** evaluate a dated cohort place first. -This is not a selected product and not ready to sell until its inventory and delivery -contract exists. Recorded content is the smaller delivery scope; session packages +**Endorsed pilot candidate:** a dated cohort place. It is not ready to sell until its +inventory, delivery and commercial contracts exist. Recorded content is the smaller +delivery scope; session packages must not be selected on the false assumption that Phase 09 supplies their ledger. For any live offer, decide before checkout: authoritative seat/reservation owner, @@ -105,8 +107,10 @@ The alternative is an explicitly public-only skeleton. It supplies no private/pa authoring promise; a later protected-content owner must land the decision and migration before its first writer/reader. No prices, channel IDs, orders or federation identifiers are needed in P02d-2 for either alternative. Public-only work is technically independent -of marketplace commerce, but the maintainer's existing planning hold remains in effect -until they release or resolve it. +of marketplace commerce. The maintainer released the preparation hold on +2026-10-02. The exact +[ADR-0050](../decisions/0050-publication-and-course-content-access.md) access proposal +and P02d-2 decision package still need approval before protected implementation. ## Public catalog and search @@ -166,8 +170,9 @@ customization. Public descriptions and tenant presentation can remain data. Separate plan availability, institution eligibility, listing consent, moderation and operational suspension. Being on an eligible plan does not approve a seller or listing. -Conversely, existing decisions do not require participation to be sold as an additional -paid tier: it could be included in all eligible SaaS plans or limited to selected ones. +The endorsed business preference includes participation in all eligible SaaS plans, +with commission on marketplace sales. Plan availability is not seller admission; +eligibility, feature enforcement and financial terms still need accepted contracts. The owning P09a decision uses [ADR-0021](../decisions/0021-feature-based-entitlement.md) and [ADR-0045](../decisions/0045-entitlement-and-feature-flag-socket.md) for any feature, @@ -178,7 +183,7 @@ effects on new sales, existing access and outstanding money; specify them togeth ## Marketplace operations and support -**Recommended business split, pending approval:** LearnStack handles seller/listing +**Endorsed business split:** LearnStack handles seller/listing admission, marketplace checkout support and payment/refund/dispute coordination. Institutions handle teaching, delivery and their own site customers. Record escalation, cancellation authority, buyer notification, response targets and financial-loss @@ -283,6 +288,10 @@ a UI hostname does not select them. Stripe's illustrates that charge configuration changes that provider role; it is not a provider choice or a legal conclusion for LearnStack. +The endorsed business preference is that institutions sell the education and +LearnStack operates the marketplace. It does not determine those legal/provider +roles or approve an unvalidated country/currency arrangement. + Record platform/seller/buyer countries, currencies, institution seller types, KYC, tax/invoicing and any applicable payment-intermediation or funds-handling requirements. Provider and legal validation are needed before commerce implementation, not just a @@ -310,7 +319,7 @@ and record duties against the [Ministry's distance-contract guidance](https://tuketici.ticaret.gov.tr/yayinlar/tuketici-bilgi-rehberi/mesafeli-sozlesmeler-hakkinda-bilgilendirme); a referral pilot is not assumed to remove all intermediary responsibilities. -**Recommendation:** one seller per checkout initially. That reduces splitting but still +**Endorsed initial target:** one seller per checkout. That reduces splitting but still requires central payment, commission, seller liability and payout. Payment collection, delivery confirmation and money release are separate transitions: iyzico's [approval API](https://docs.iyzico.com/urunler/pazaryeri/pazaryeri-entegrasyonu/onay) diff --git a/docs/decisions/0049-institution-sites-and-course-marketplace.md b/docs/decisions/0049-institution-sites-and-course-marketplace.md index 2703e6fc..09b796f0 100644 --- a/docs/decisions/0049-institution-sites-and-course-marketplace.md +++ b/docs/decisions/0049-institution-sites-and-course-marketplace.md @@ -6,6 +6,13 @@ Proposed — 2026-09-15. Product direction for maintainer review; updated 2026-1 after two external reviews and verification against the Accepted corpus and code. This draft accepts no gate, authorizes no implementation and supersedes no ADR. +**Direction endorsement — 2026-10-02.** The maintainer endorsed the recommendations +and authorized P02d-2 preparation: institution sites plus optional full marketplace, +a dated-cohort pilot, one SaaS installation/region, single-seller checkout, a proposed +Marketplace module / Phase 09a, participation in all eligible SaaS plans and the +platform/institution support split. This releases the planning hold for preparation, +not approval of unseen access or commerce contracts. This ADR remains Proposed. + **Date:** 2026-09-15 **Deciders:** Cemil (repository maintainer; approval pending) @@ -66,7 +73,7 @@ On acceptance, the binding product boundaries are exactly these: 5. Course Marketplace content and learner commerce remain on the LearnStack product side, outside Hub's software-subscription and permitted tenant-metadata boundary. -These are recommendations awaiting approval, not shipped capabilities. The +The direction is endorsed; the architecture remains Proposed, not shipped. The [acceptance checklist](#implementation-notes) must close before this ADR is Accepted. Its [scoping companion](../architecture/34-course-marketplace-scoping.md) describes candidate delivery contracts; it does not accept them by reference. @@ -75,11 +82,13 @@ candidate delivery contracts; it does not accept them by reference. ### Product and investment change -The [vision introduction](../architecture/01-platform-vision.md) and `CLAUDE.md` -describe LearnStack as infrastructure, not an education product of its own. The +Before this proposal, the [vision](../architecture/01-platform-vision.md) and +`CLAUDE.md` described LearnStack as infrastructure, not its own education product. The [vision Non-goals](../architecture/01-platform-vision.md#non-goals) and -[MVP Deferred scope](../architecture/05-mvp-scope.md#deferred) exclude marketplace -features. Institution-only sellers do not avoid this positioning change. +[MVP Deferred scope](../architecture/05-mvp-scope.md#deferred) excluded marketplace +features from the roadmap. The 2026-10-02 endorsement records a hybrid +target in those mutable documents without claiming an Accepted marketplace design. +Institution-only sellers do not avoid this positioning change. Hybrid delivery preserves the branded-site use case. Marketplace-only delivery has no validated demand advantage. Independent course copies split authorship, withdrawal, @@ -109,8 +118,11 @@ describes the alternatives; accepting hybrid direction alone chooses neither. Public-only P02d-2 has no technical dependency on marketplace commerce. The [planning hold](../roadmap/phase-02d-walking-skeleton.md#pending-course-marketplace-proposal) -reflects the maintainer's request to settle direction first. Releasing it is a separate -maintainer choice, not an action this review takes. +reflected the maintainer's request to settle direction first. Their 2026-10-02 +endorsement releases it for preparation. The exact +[access proposal](0050-publication-and-course-content-access.md) and P02d-2 decision +package require approval before protected implementation; commerce feasibility is +not a hidden dependency of this packet. ### Commerce and the Hub boundary @@ -131,7 +143,8 @@ listings or course content through an enlarged entitlement payload. The is a relevant precedent, not authorization for this commerce scope. The proposed [module and milestone allocation](../architecture/34-course-marketplace-scoping.md#proposed-delivery-ownership) -keeps commerce outside Hub and Education. It does not select a provider, merchant of +and [Phase 09a draft](../roadmap/phase-09a-course-marketplace-pilot.md) keep commerce +outside Hub and Education. They do not select a provider, merchant of record, legal role or new runtime. Content access alone does not fulfill a live seat. ### Evidence and decision timing @@ -167,7 +180,9 @@ prices, marketplace IDs, stock or payout fields into P02d-2. ## Implementation Notes -**Acceptance blockers — all open.** Before marking this ADR Accepted, record approved +**Acceptance disposition — 2026-10-02.** Product recommendations are endorsed; the +remaining architecture and commercial acceptance items are open. Before acceptance, +record approved high-level boundaries below and their named delivery owners. Detailed security, privacy and commerce decisions remain mandatory before their first consumers; this direction cannot approve an unspecified schema, role or payment arrangement. @@ -182,7 +197,7 @@ cannot approve an unspecified schema, role or payment arrangement. | Operations and support | Backoffice owner and staff population; approval, suspension, refund/dispute and delivery support responsibilities. Assign realm/audience, permission/resource scope, exceptional private review and reasoned audit contracts before the first staff reader or crossing | | Privacy and distribution | Purpose-based controller/processor assessment, platform versus institution permissions/consent, DSAR/export/erasure and retention ownership; data residency/transfers and media rights. Include Architecture 23 and Phase 03 in the approval impact set | | Public-read and commerce security | Owner of the request/host matrix, table classes, reader/writer roles, audit classification and ordered/recoverable fulfillment/payable/payout contract before their first migrations, readers or producers | -| P02d-2 access and planning | Retain public-only G3 or approve its explicit reopening and superseding ADR before protected writers; separately resolve the maintainer's planning hold | +| P02d-2 access and planning | Preparation hold released on 2026-10-02; ADR-0050 proposes protected access and must receive exact approval with the P02d-2 package before protected writers | | Pilot evidence | Approve the entry conditions, metric owners and a dated stop/go threshold record before a live pilot; positive evidence is a broad-rollout gate | Approval of this draft requires the positioning and named roadmap changes together. diff --git a/docs/decisions/0050-publication-and-course-content-access.md b/docs/decisions/0050-publication-and-course-content-access.md new file mode 100644 index 00000000..58a4154f --- /dev/null +++ b/docs/decisions/0050-publication-and-course-content-access.md @@ -0,0 +1,200 @@ +# ADR-0050: Publication and Course Content Access + +## Status + +Proposed — 2026-10-02. Prepared after the maintainer endorsed protected-content +preparation before P02d-2. Exact policy and migration approval are still pending. + +**Date:** 2026-10-02 +**Deciders:** Cemil (repository maintainer; approval pending) +**Relationship:** Supersedes ADR-0048 on acceptance; it does not supersede it while +this record is Proposed. The existing G3 answer remains binding until that approval. + +## Decision Drivers + +- The endorsed hybrid direction includes paid learning. Publication must not promise + anonymous lesson access for every course before the first writers and public readers. +- P02d-1 shipped independent course/lesson publication, not grants, prices or versions. + Adding those subsystems is unnecessary to persist a safe access policy now. +- Phase 02d has no authenticated grant evaluator. A restricted course must remain + restricted even if a request happens to contain credentials. +- Public course marketing metadata and private lesson content have different exposure + contracts. Hiding a button cannot protect an API response, cache or media URL. +- The migration must handle existing P02d-1 rows without assuming every database is + empty or inferring a public-access choice from a publication flag. + +## Considered Options + +1. **Course-level policy inherited by lessons** (recommended). Adds one explicit + creation-time policy; separates publication from access without a grant subsystem. +2. **Retain published = anonymously readable** (rejected for protected authoring). + Valid public-only baseline, but cannot safely represent the approved paid direction. +3. **Per-lesson policies and preview overrides now** (not selected). Adds policy + composition and authoring workflows without a preview consumer. Phase 05 owns that + authoring decision before its first preview writer; Phase 07 owns grant evaluation. +4. **CourseVersion, enrollment or commerce now** (rejected). Their identities and + lifecycles remain with Phases 05, 07 and the proposed P09a, respectively. + +## Decision + +LearnStack separates independent publication from a course's content-access policy. +The proposed `Course.ContentAccess` is `public` or `enrollment_required`; lessons +inherit their parent's policy. Publication never creates a learner grant or selects +a sales channel. This Decision takes effect only after exact maintainer approval. + +### Storage and first writers + +- Add `courses.content_access text NOT NULL` with a closed two-value CHECK and + database default `enrollment_required`. Invalid or missing policy fails closed. +- The application creation contract requires an explicit policy. Missing, unknown + or malformed input returns `validation_failed`; no application default silently + opts content into public access. Domain validation also enforces the value set. +- P02d-2 selects the policy at course creation. It has no policy-edit, reparenting, + unpublish, delete or preview command. There is no lesson policy column or override. +- Tenant, organization, ids, revision pins and translation ownership retain their + existing isolation and parent-derivation rules. A policy is not a tenant entitlement, + course grant, listing, price, order or instructor permission. + +### Publication lifecycle + +Both roots retain independent `draft` and `published` states and only the +`draft → published` transition. Creation starts in draft; a second publish is an +expected `business_rule_violation`. Publishing one root does not publish the other. +An empty course or incomplete locale coverage does not block publication. + +Translations remain insert-only while their root is draft in this slice. Published +content is not edited or reordered. No CourseVersion or snapshot is created here. +Publishing is MUST-class audited, atomically with its root's state change. Course +publication does not claim a lesson mutation or access grant in audit. + +### Anonymous exposure + +| Surface | Proposed eligibility and data | +|---|---| +| Course catalog / course detail | Published, live course in the admitted tenant/organization and enabled requested locale; declared marketing fields (title, summary, slug, level label) and policy can be public under either policy | +| Lesson list / detail under `public` | Existing parent/child publication, translation, deletion, organization and course-membership checks all pass; body access additionally requires the parent's policy to be `public` | +| Lesson list / detail under `enrollment_required` | No lesson ids, titles, slugs, order, counts, descriptors, bodies or media URLs are exposed anonymously; the course response can declare its restricted policy | +| Unknown or unresolvable policy | No lesson exposure; report the internal inconsistency without disclosing its cause to the caller | + +Restricted course metadata does not make lesson existence public. P02d-4 omits all +restricted lesson inventory; direct lesson lookup returns the same `not_found` +contract as other hidden lessons. Course detail distinguishes restricted content +from an empty public course through policy, never through private lesson counts. +Marketing summaries are explicitly authored; never generate them from protected +lessons. Deny before loading protected descriptors, serializing bodies or minting +media URLs. HTML, RSC, SEO and structured data use the same public DTO boundary. +P02d-6 presents that bounded locked state without a fabricated payment/enrollment +action before its corresponding capability exists. + +Phase 02d's anonymous read surface evaluates no learner grants. Credentials, staff +claims, seeder provenance or marketplace admission cannot unlock it. Phase 07's +authenticated reader must validate the caller's effective access for the correct +tenant/course version before protected data leaves the server. Failure or absence +of the evaluator denies protected access; there is no public fallback. + +### Caches and media + +- Apply eligibility before returning or populating any public representation. A + cached source row is not authorization. Public cache families never hold a + restricted body or a learner-specific grant response. +- P02d-4/5 retain their cache/header gates; this policy is an additional eligibility + input, not permission for shared path-only caching. Phase 07's protected reader + checks access before payloads or `304` responses and starts with private, no-store + responses. It decides caller/version-qualified caching and revocation before caching. +- Do not emit protected body-derived media or bearer URLs through public DTOs, + previews, logs or error details. Phase 04's first protected media producer and + Phase 07's grant reader must enforce access on issuance and retrieval. +- This record does not make an independently public external URL private. The seed + uses no remote media; a licensed protected-media delivery contract precedes such + offers. URL safety validation is separate from a learner's access right. + +### Forward migration and rollback + +All pre-column rows receive `enrollment_required`, regardless of status. Preserve +ids, parent relationships, scope, translations, bodies, pins and publication state. +This deliberately removes their previously planned anonymous body eligibility; +it is not an inference that old content was private or that the database was empty. + +New demo courses explicitly select `public` or `enrollment_required`. Seed reruns +do not overwrite an existing policy or repair mismatched published content silently. +An existing row needing a different choice requires a separately approved migration +or Phase 05 authoring workflow, not a seed bypass. + +Migration down/up must be tested on disposable data. Removing the column would restore +the old public implication; it is unsafe as a live rollback once restricted content or +readers exist. Keep the forward schema and a compatible denying application when +rolling back a release, or stop serving public Education. An old reader that ignores +the retained column is unsafe. Any live downgrade needs a separately reviewed +containment plan; the technical Down method is not an operational authorization. + +### Phase 05 and Phase 07 preservation + +Phase 05's migration preserves access policy alongside ids, translated URLs, bodies, +order, scope and exact pins. It decides the policy's authoritative location in the +versioned structure before writers; no existing restricted version becomes public +because a new version or listing is created. + +Phase 05 owns policy editing, previews and per-lesson exceptions if required. +Any future reparenting writer decides inherited access before moving a lesson; the +existing parent-scope trigger does not protect an access-policy transition. +Phase 07 owns grants, their purchase attribution and effective access. Neither can +infer eligibility from price, publication or Hub licensing. The first grant consumer +records its attribution contract before later billing producers rely on it. + +## Context + +[ADR-0048](0048-walking-skeleton-publication.md) intentionally established a public-only +skeleton in P02d-1. Its lifecycle remains useful, but the anonymously readable +implication changes when protected authoring is selected. This is a new decision, +not a correction of a false historical statement. + +On exact approval, append a dated G3 supersession in +[Phase 02d](../roadmap/phase-02d-walking-skeleton.md#the-decision-register), update G3's +current status and affected packet criteria, and mark ADR-0048 superseded according +to repository governance. Preserve the original accepted answer and delivery history. +Until that approval, this text is a proposal and P02d-2 code cannot rely on it. + +## Consequences + +- A published course can be marketed without exposing its restricted lesson inventory. +- The first writers persist access intent; no future payment flag has to redefine + publication. The database change is additive, but eligibility changes deliberately. +- Legacy published rows become conservatively restricted; automatic public + backfill and silent seed repair are rejected. +- Per-lesson previews, grants and protected media have explicit first-consumer + decisions in named phases; this slice does not claim those capabilities. + +## Implementation Notes + +- P02d-2 Step 1: policy domain/configuration, forward migration and proofs. + Creation commands in Step 3 require the policy; Step 4 seed names both choices. +- P02d-4: shared eligible-read predicate, marketing projection and hidden-lesson + response equivalence; cache and OpenAPI policy representation are decided before API. +- P02d-5/6: no transport/cache bypass; public and locked renderer states. +- Phases 04/05/07: protected media, version/policy evolution and authenticated grants, + respectively. No price, listing, balance or payout belongs in the Education change. + +## Architecture Tests + +Proposed obligations, not implemented tests: + +- New policy requires explicit valid input; storage rejects every other value. +- Migration restricts existing rows and preserves all other data; disposable Down/Up + preserves common columns and backfills restricted again, not lost public choices. +- Public courses with eligible lessons have positive controls; restricted, draft, + deleted, wrong-course and cross-scope lessons have no anonymous exposure. +- Restricted course metadata includes no lesson inventory, counts or descriptors. +- Caches, direct lookup, credentials and media projection cannot bypass access. +- Seed convergence verifies policy exactly; conflicting existing policy fails nonzero. + +Register concrete tests only with their implementation. Existing tenant/organization, +one-root publication, concurrency and audit guards remain required. + +## References + +- [Education module](../modules/education/README.md) +- [P02d-2 decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-2-decision-package-2026-10-02) +- [Course access terminology](../glossary.md#enrollment--access) +- [Phase 05](../roadmap/phase-05-education-learning-content.md) +- [Phase 07](../roadmap/phase-07-enrollment-learner-portal.md) +- [ADR-0049](0049-institution-sites-and-course-marketplace.md) diff --git a/docs/decisions/0051-ordered-text-card-presentation.md b/docs/decisions/0051-ordered-text-card-presentation.md new file mode 100644 index 00000000..3967b69b --- /dev/null +++ b/docs/decisions/0051-ordered-text-card-presentation.md @@ -0,0 +1,158 @@ +# ADR-0051: Ordered Text Card Presentation + +## Status + +Proposed — 2026-10-02. Exact approval is required before P02d-2 presentation code. +Extends ADR-0043's schema profile; does not amend an Accepted record while Proposed. + +**Date:** 2026-10-02 +**Deciders:** Cemil (repository maintainer; approval pending) + +## Decision Drivers + +- JSONB property order cannot define lesson presentation order. +- The two demo domains need authored field labels and deterministic presentation + without production branches on tenant, content-type key or taxonomy key. +- The schema profile must validate an extension rather than accepting an unknown + keyword that a later renderer interprets differently. +- Existing immutable `card` revisions lack presentation metadata. A compatible + extension must not rewrite them or make fresh built-in seeding invalid. +- First public rendering needs a small safe subset, not speculative Markdown, + URL/media or HTML execution and sanitization rules. + +## Considered Options + +1. **Root `x-fields` ordered descriptors** (recommended). One array carries order + and localized labels; direct correspondence with root properties is validated. +2. **Per-property `x-order` and `x-label`** (rejected). Requires tie, missing-order + and fallback rules in addition to label validation. +3. **A new presentation column** (rejected here). Adds a second schema contract and + migration when a bounded extension can preserve the existing exact revision pin. +4. **JSON object order or property-name labels** (rejected). JSONB loses the former; + the latter cannot deliver authored bilingual labels. +5. **A rich role/layout and markup grammar now** (not selected). Phase 05 owns richer + learning content; Phase 06 owns additional renderer coverage before its consumers. + +## Decision + +LearnStack represents ordered text-card fields with an optional root-level +`x-fields` array in a content type's JSON Schema. This is a proposed extension of +[ADR-0043](0043-customization-payload-validation.md), effective only on exact approval. +It adds no renderer key, presentation column, live-key binding or compiled cache. + +```json +"x-fields": [ + { "name": "concept", "label": { "en": "Concept", "tr-TR": "Kavram" } }, + { "name": "example", "label": { "en": "Example", "tr-TR": "Örnek" } } +] +``` + +### Profile and semantic validation + +- `x-fields` is optional and root-only. If present, it is a nonempty array and each + descriptor has exactly `name` and `label`. Names exactly match and cover every + root `properties` key once; duplicate, unknown or omitted properties fail. +- The array's order is the sole field order. Names are ordinal and never normalized + into a different JSON property. Labels are nonempty Pattern-B `LocalizedText` maps, + using its existing canonical-locale, length, count and storage-safety bounds. +- Label resolution uses `LocalizedText` fallback, never the raw property name. + Generic validation does not require every enabled tenant locale: adding a locale + does not invalidate an immutable schema. Both seed types cover their seed locales. +- Presence selects the text-card profile: root type `object`, direct property + schemas of type `string`, `additionalProperties: false`, and no alternative + property shape through references/combinators, enum/const, array or nested object. + Existing validation constraints such as `required`, `minLength` and `maxLength` + still apply. No field carries `format`, another `x-*` rendering extension or markup. +- Only the existing `default-card` composite is admitted for a content type carrying + this profile in P02d-2. Richer profiles require an owning-phase decision before use. +- Keep the four gates in their existing order. Gate 2 recognizes root-only syntax; + Gate 4 remains provider schema compilation. Customization explicitly resolves + descriptor/property/label and composite compatibility after all four gates and + before persistence; the generic validator never reads module registries. + Failures carry `validation_failed`, existing localized messages and JSON Pointers + to the offending descriptor, label, property or misplaced keyword. +- Extension traversal and the reference-graph walker treat `x-fields` descriptors + as metadata, not subschemas. A nested `x-fields` in a real subschema is refused; + metadata keys do not hide references or unsupported declarations in real schemas. + A recognized extension without a resolver cannot report semantic success; + unknown inert schema annotations retain ADR-0043's existing dialect behavior. + +### Compatibility and Education writes + +Schemas without `x-fields` remain governed by ADR-0043 unchanged. In particular, +built-in `card` stays Active at its original version, without enrichment or an +identity-specific exemption. Built-in `plain` is also unchanged. Neither is selected +by the new demo lessons. + +Education validates every submitted body against its exact eligible schema revision, +not against the renderer subset. Other valid schemas can be stored; they do not gain +rendering support merely by passing schema validation. The two seed types deliberately +choose the text-card profile. Missing or unsupported presentation later produces a +bounded placeholder under P02d-6's G41, never raw JSON or inferred field labels. + +### Public text boundary + +The P02d-6 text-card renders present string values and labels as escaped React text +nodes in descriptor order. Absent optional fields are omitted. A mismatched stored +value is a bounded invalid-content state, never coercion into HTML or a raw dump. +There is no linkification, Markdown parsing, `dangerouslySetInnerHTML`, embed, +URL/media attribute or remote fetch. URL-looking text stays inert text. + +This closes G19's P02d-2 scope by admitting no active URL or markup sink in the +seeded profile. It does not choose a URL scheme/origin policy for future sinks. +Before their first producers/consumers, Phase 04's media/CMS and Phase 05's richer +learning content settle a shared URL/markup policy with Security Standards. Phase 06 +adds only coverage backed by that contract. Passing `format: uri` alone never +authorizes navigation, fetching or media loading. + +## Context + +The existing profile recognizes `x-renderer`, `x-taxonomy` and `x-language`. +`x-fields` changes admission and semantic interpretation, so it needs an approved +ADR rather than silently extending a validator. Root array parsing preserves order +through JSONB without depending on object order. + +The existing built-in card is valid but has no authored field order/labels. Making +the extension mandatory for all `default-card` definitions would break immutable +data or require a tenant/type-specific exception. Optional presence provides an +explicit compatible opt-in. Full label coverage is a seed obligation, not a new +cross-module locale-membership invariant. + +## Consequences + +- One descriptor contract drives validated authored order and labels in both domains. +- No migration, new primitive or renderer-registry entry is necessary. +- Text-only rendering avoids an active-content security policy before a real sink. +- Old revisions retain validity but do not automatically acquire rich presentation. +- Additional shapes and markup need decisions in their named owning phases. + +## Implementation Notes + +- P02d-2 Step 1 adds profile parsing/resolution and exact-definition DTOs; Step 4 + publishes two explicit text-card types and verifies their order/labels on reruns. +- P02d-4 resolves public descriptors only after content-access eligibility. +- P02d-6 implements this subset; G41 still decides component placement and fallbacks. +- On approval, link the extension from ADR-0043 through a dated amendment that + preserves its original decision, and close G18/G19's P02d-2 parts in the register. + +## Architecture Tests + +Proposed obligations, not implemented tests: + +- Reject malformed, nested, duplicate, missing and unknown descriptors with pointers. +- Reject invalid labels, unsupported shape/annotations and incompatible composites. +- Preserve all four gates and legacy schemas without `x-fields`. +- Array order survives storage; both seeded definitions cover their enabled locales. +- Unsafe-looking strings remain text; unsupported data never reaches an active sink. +- Changed seed schemas/labels under an existing exact pin fail convergence. + +Register test names only when their implementation is present. + +## References + +- [P02d-2 decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-2-decision-package-2026-10-02) +- [Customization module](../modules/customization/README.md) +- [Tenant customization model](../architecture/32-tenant-customization-model.md) +- [Localization](../standards/08-localization.md) +- [Security](../standards/11-security.md#xss--output-encoding) +- [ADR-0018](0018-tenant-driven-customization-model.md) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index f6a861ee..b2f35678 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -70,18 +70,21 @@ an amendment is not a lifecycle status change. | # | Title | Topic | Target phase / decision point | |---|---|---|---| -| 0049 | [Institution Sites and an Optional Course Marketplace](0049-institution-sites-and-course-marketplace.md) | Proposed 2026-09-15: hybrid product direction; accepts no gate and does not supersede ADR-0048 | P02d-2 decision pass, before its first application writers | +| 0049 | [Institution Sites and an Optional Course Marketplace](0049-institution-sites-and-course-marketplace.md) | Direction endorsed 2026-10-02; architecture still Proposed, no gate accepted | P02d-2 preparation hold released; remaining contracts before proposed Phase 09a's first consumers | +| 0050 | [Publication and Course Content Access](0050-publication-and-course-content-access.md) | Proposed: course policy, restricted backfill and anonymous denial; supersedes ADR-0048 only on acceptance | P02d-2, before its policy migration and first protected writers | +| 0051 | [Ordered Text Card Presentation](0051-ordered-text-card-presentation.md) | Proposed: optional root `x-fields`, localized ordered text cards; compatible extension of ADR-0043 | P02d-2, before profile code and tenant-type seed publication | -The target records the maintainer's request to settle product direction before -P02d-2 implementation planning. This is a planning hold, not a new architecture gate -or automatic ADR acceptance. Retaining the Accepted scope is one possible outcome; -this draft cannot reopen G3. The +The maintainer endorsed the recommended direction and released the preparation hold +on 2026-10-02. Exact ADR-0050/0051 and packet approval remain the pre-code boundary; +ADR-0049's future commerce contracts are not hidden packet dependencies. The [phase record](../roadmap/phase-02d-walking-skeleton.md#pending-course-marketplace-proposal) -tracks that pending choice. On acceptance, move the row to Active ADRs instead of -duplicating it. The draft SLAs below remain unchanged. +tracks the endorsement and prepared package. On acceptance, move each row to +Active ADRs instead of duplicating it. The draft SLAs below remain unchanged. The [scoping companion](../architecture/34-course-marketplace-scoping.md) records the -reviewed delivery alternatives and proposed ownership; it accepts no module or phase. +reviewed delivery alternatives and endorsed planning ownership; it accepts no module +or phase. The Proposed table's decision points are explicit first-consumer gates; +Open ADR Drafts below retain their existing phase-exit SLAs. ## Superseded ADRs diff --git a/docs/glossary.md b/docs/glossary.md index 0693a213..2591e374 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -62,6 +62,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across |------|------------| | **Enrollment** | A learner's grant of access to a specific course (and specific course version). | | **Course Access** | A *learner's* right to open a specific course, derived from an `Enrollment` (or from a tenant-side purchase, cohort membership, or admin grant). Evaluated inside the Enrollment module against tenant data. **Not an Entitlement** — see *Feature Flags & Entitlements*. The two words were used interchangeably in earlier drafts; they are different subjects (a learner versus a tenant), different owners (LearnStack versus Hub), and different lifecycles. | +| **Course Content Access Policy** | Proposed creation-time Course classification `public` or `enrollment_required`, inherited by lessons under [ADR-0050](decisions/0050-publication-and-course-content-access.md). Distinct from publication, tenant entitlements and a particular learner's Course Access. Not implemented or Accepted yet. | | **Cohort** | A group of learners progressing through the same course version on a shared timeline. Cohorts may have scheduled live sessions. | | **Progress** | The learner's recorded advancement against the structure of a course version. | @@ -96,7 +97,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | Term | Definition | |------|------------| -| **Course Marketplace** | The proposed learner-facing channel for discovering and buying institutions' courses through platform checkout, with commission and seller payouts. [ADR-0049](decisions/0049-institution-sites-and-course-marketplace.md) is Proposed; this is not accepted roadmap scope or the Hub Marketplace. | +| **Course Marketplace** | The endorsed learner-facing target for discovering and buying institutions' education through platform checkout, commission and seller payouts. [ADR-0049](decisions/0049-institution-sites-and-course-marketplace.md) and [Phase 09a](roadmap/phase-09a-course-marketplace-pilot.md) remain Proposed; initial pilot candidate is a dated cohort, not the Hub Marketplace or a shipped commerce capability. | | **Hub Marketplace** | The optional, post-MVP exchange of reusable tenant customization bundles owned by [Phase 12](roadmap/phase-12-hub-marketplace.md). Its initial scope is free-only and its ADR-0034 content-boundary decision remains open; it is distinct from the Course Marketplace. | | **Product** | A sellable platform item. | | **Plan (tenant storefront)** | A package or subscription definition referencing one or more products. Lives in the LearnStack core `Billing` module — what a tenant sells to its own learners. Distinct from the Hub-side `Plan` (see *Hub & Licensing*) that governs the tenant's own LearnStack subscription. | @@ -139,6 +140,14 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across ## Extension Model +Proposed P02d-2 contract terms are not implemented interfaces or Accepted extensions: + +| Term | Definition | +|---|---| +| **`IExactCustomizationDefinitionReader`** | Proposed contextual uncached application reader for exact content-type/taxonomy revision values, with NewBinding versus ExistingPin eligibility; [Customization spec](modules/customization/README.md#p02d-2-proposed-exact-write-contract). | +| **`ITenantLocaleEligibilityReader`** | Proposed contextual uncached Tenancy contract for canonical enabled locale membership and valid locale configuration; [Tenancy spec](modules/tenancy/README.md#p02d-2-proposed-locale-and-branding-contract). | +| **`x-fields`** | Proposed optional root JSON Schema array of ordered property names and Pattern-B labels for the bounded text-card profile; [ADR-0051](decisions/0051-ordered-text-card-presentation.md). It is metadata, not a schema or a new renderer primitive. | + | Term | Definition | |------|------------| | **Tenant Customization Aggregate** | One of `TenantContentType`, `TenantPageBlock`, `TenantLessonItemType`, `TenantLevelTaxonomy`, `TenantScoringRule`, `TenantCompletionRule`, `TenantCustomFieldDef`, `TenantTemplateLibrary`. Per [ADR-0018](decisions/0018-tenant-driven-customization-model.md), per-tenant domain shapes live here as data, not code. | @@ -186,6 +195,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | Term | Definition | |------|------------| | **TenantBranding** | The tenant's presentation tokens. Not an aggregate of its own: the values are tenant settings held in `tenant_settings` ([Frontend Architecture Standards § Tenant Branding](standards/07-frontend-architecture.md#tenant-branding)). 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). | +| **`branding.theme`** | Proposed single tenant-wide TenantSetting document with four validated color fields; one root/version protects contrast during concurrent replacement. The [Tenancy spec](modules/tenancy/README.md#whole-theme-setting-and-public-boundary) owns the command-local registry; other generic setting keys remain legal. | | **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 diff --git a/docs/modules/customization/README.md b/docs/modules/customization/README.md index 5f7a3ebe..c29472a7 100644 --- a/docs/modules/customization/README.md +++ b/docs/modules/customization/README.md @@ -181,6 +181,35 @@ once across every pod without enumerating anything — the compiled-validator cache that used to sit beside it. It lands with its first consumer in [Phase 02d](../../roadmap/phase-02d-walking-skeleton.md). +## P02d-2 proposed exact write contract + +**Prepared; approval pending — 2026-10-02.** An application interface in +`Customization.Application.Contracts` resolves an exact content-type or taxonomy +revision for the caller's announced tenant. DTOs contain values only: key, version, +status, JSON Schema/composite and validated presentation, or immutable bands/labels. +The selected interface is `IExactCustomizationDefinitionReader`; it takes an explicit +binding purpose (`NewBinding` or `ExistingPin`), never an inferred live version. + +- New Course taxonomy and new Lesson content-type bindings require the exact Active + revision; a present band must be declared in that revision. +- A translation on an existing Lesson can use its exact Active or Deprecated pin. + No command rewrites a pin or replaces it with the newest revision. +- Absent, deleted, Draft or cross-tenant definitions return the same bounded binding + `validation_failed`; an error cannot expose another tenant's revision or label. +- Read uncached in the caller's ambient transaction/context. Eligibility is observed + at this read, not promised Active-at-commit; immutable schema/bands protect the pin + if it is concurrently deprecated. Strict commit-time eligibility is not selected. +- Body validation uses `IJsonSchemaValidator` against that returned exact schema. + Public response shape, generation-keyed read cache and renderer fallback remain + P02d-3/4/6 gates, not features of this write contract. +- Module-owned contextual verification queries give the seeder exact IDs, revision + data, labels/bands and state. They are audit Off and introduce no setter exception. + +[ADR-0051](../../decisions/0051-ordered-text-card-presentation.md) proposes the optional +root `x-fields` profile and semantic resolver. It preserves the four gates and legacy +schemas; no profile code changes until exact approval. Seed definitions opt into the +profile, whereas built-in `card`/`plain` remain unchanged and Active. + ## Component diagram ```mermaid diff --git a/docs/modules/education/README.md b/docs/modules/education/README.md index 58728061..c0358672 100644 --- a/docs/modules/education/README.md +++ b/docs/modules/education/README.md @@ -7,6 +7,9 @@ records all five required checks on the final PR head and merge commit. The [decision pass](../../roadmap/phase-02d-walking-skeleton.md#p02d-1-decision-pass-2026-09-14) records the accepted scope. Commands, audit catalogue entries and seed writes remain planned for P02d-2; public reads remain planned for P02d-4. +The [P02d-2 package](../../roadmap/phase-02d-walking-skeleton.md#p02d-2-decision-package-2026-10-02) +is prepared on 2026-10-02, with exact approval pending. The diagrams below describe +shipped P02d-1; they do not claim the proposed access column or handlers exist. ## Overview @@ -171,6 +174,68 @@ A command writes one Education root. Cross-module calls are reads through applic contracts, and audit durability is part of the ambient transaction. The seeder has no second write path. +## P02d-2 proposed writer contract + +**Prepared, not Accepted or implemented — 2026-10-02.** Approval is coupled with +[ADR-0050](../../decisions/0050-publication-and-course-content-access.md), +[ADR-0051](../../decisions/0051-ordered-text-card-presentation.md) and the phase package. +This section owns command detail; the phase owns gate disposition and seed inventory. + +| Command | Root / inputs | Validation and outcome | +|---|---|---| +| `CreateCourseCommand` | New Course; explicit id, slug key, access policy, optional complete taxonomy revision/band pin | Tenant/organization from context; exact new taxonomy binding must be Active and contain the band; draft creation, no translation or lesson write | +| `AddCourseTranslationCommand` | Existing Course; id, exact expected version, locale, title, summary, translated slug | Root visible and writable, enabled canonical locale, valid text/slug, draft-only insert; no overwrite | +| `PublishCourseCommand` | Existing Course; id and exact expected version | Draft → published only; empty/incomplete translations allowed; no child publication or implicit grant | +| `CreateLessonCommand` | New Lesson; explicit id, parent course id, sort, exact content-type key/version | Parent must be visible and writable in announced scope; derive its tenant/organization; new exact type binding must be Active; draft, no body yet | +| `AddLessonTranslationCommand` | Existing Lesson; id, exact expected version, locale, title, slug, JSON object body | Enabled canonical locale; validate against its immutable pin, including eligible Deprecated revision; draft-only insert | +| `PublishLessonCommand` | Existing Lesson; id and exact expected version | Draft → published only; no Course mutation, grant or readiness requirement beyond the selected lifecycle | + +Only a trusted contextual caller invokes these unrouted commands. No command is +`PublicSurface`, grants HTTP access or registers an authoring permission. Exact +expected versions protect existing-root writes; omission/invalidity is validation +failure and stale values are concurrency conflicts. Seed queries obtain current +versions for unfinished acts, not permission to retry failed writes blindly. + +Customization is read through its +[exact value contract](../customization/README.md#p02d-2-proposed-exact-write-contract); +locale membership through Tenancy's +[proposed locale contract](../tenancy/README.md#p02d-2-proposed-locale-and-branding-contract). +Both execute uncached inside the caller's ambient frame and announced context. +No cross-chain FK, foreign Domain/Infrastructure reference or independent transaction +is introduced. Revision/locale eligibility is observed at the validation read; +later deprecation/disable does not rewrite stored bodies and is rechecked by readers. + +### Failure and transaction contract + +| Condition | Result | +|---|---| +| Missing, cross-tenant or hidden sibling parent/root | `not_found`; no name/id disclosure | +| Visible parent/root incompatible with write scope | `resource_scope_violation` before mutation | +| Malformed input, disabled/absent locale, invalid pin/band or body | `validation_failed`, field/JSON Pointer details without foreign data | +| Known root-id/key/locale/slug uniqueness or lifecycle refusal | `business_rule_violation`; insertion reserves the localized slug, not publication | +| Stale expected version or EF optimistic concurrency | `concurrency_conflict` | +| Unknown database fault | Existing infrastructure exception handling; never disguise it as a business collision | + +Infrastructure maps only named owned constraints; arbitrary unique/trigger exceptions +are not exposed as caller diagnostics. Root state, scope and validation guards precede +the first mutation/stamp. A failed nested command cannot leave dirty tracked changes +for a successful outer command to flush. Mark the ambient frame rollback-only if a +failed save or already-applied mutation cannot be safely discarded, and prove both +ordinary failure and an outer handler absorbing that failure. + +Each command writes one root and its contained translations. Publishing is MUST +audited; draft creation and translation insertion are proposed SHOULD operations. +Pending audit writes and business changes obey the existing ambient durability rules. +No explicit second transaction or cross-root publication is permitted. + +### Seed verification + +Contextual module-owned `ISender` read requests return bounded verification DTOs, +including exact ownership/content/state and current root version where needed. +They are explicitly audit Off, unrouted, without unresolved/public admission and +never bypass RLS. The phase's convergence rules govern skip/create/verify behavior; +they are not a weaker alternate write path. + ## Components and primary read flow ```mermaid diff --git a/docs/modules/education/audit.md b/docs/modules/education/audit.md index 4f1c7bc3..2f421195 100644 --- a/docs/modules/education/audit.md +++ b/docs/modules/education/audit.md @@ -18,3 +18,22 @@ publication or cross-root publication is declared. Public-read classification belongs to P02d-4 G28. No anonymous request is shipped or classified by this schema packet. This matrix cannot narrow the baseline MUST floor. + +## P02d-2 proposed additions + +Prepared on 2026-10-02; approval pending with the +[writer contract](README.md#p02d-2-proposed-writer-contract). These are classifications +ahead of code, not executable catalogue registrations: + +| Resource | Operation | Class | Why | +|---|---|---|---| +| `Course` | `education.course.create` `(planned)` | SHOULD | Draft authoring, before public exposure | +| `Course` | `education.course.translation_add` `(planned)` | SHOULD | Draft-only contained translation; no independent satellite subject | +| `Lesson` | `education.lesson.create` `(planned)` | SHOULD | Draft authoring, independently scoped root | +| `Lesson` | `education.lesson.translation_add` `(planned)` | SHOULD | Draft-only contained translation and validated body | + +The two publish operations remain MUST. If ADR-0050 is approved, course publication +exposes eligible marketing metadata; lesson publication enables content only under +the parent/access rules. Restriction does not lower the selected MUST classification. +Contextual seed verification queries are explicitly Off when their request types +are introduced; they declare no synthetic write operation. diff --git a/docs/modules/education/permissions.md b/docs/modules/education/permissions.md index 6d6f0100..a3c918bc 100644 --- a/docs/modules/education/permissions.md +++ b/docs/modules/education/permissions.md @@ -28,3 +28,9 @@ The matrix gains permission keys, scopes and default-role grants with the corres command-surface decision, under [Permission Standards](../../standards/19-permissions.md), before any permission is registered. The reachability table grants no capability and introduces no permission key ahead of its decision. + +The [prepared P02d-2 contract](README.md#p02d-2-proposed-writer-contract) names six +unrouted write commands and contextual verification queries. Approval pending; +none admits unresolved context or anonymous/public invocation. The proposed access +policy does not create a permission or a grant evaluator. Authenticated authoring +and protected learner reads retain their Phase 05 and Phase 07 owners. diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index c4d16e46..f0555df5 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -2,7 +2,8 @@ **Status:** Design stable, partially implemented (Phase 02a Packet 6 shipped the schema and its schema-level isolation suite; commands, host resolution and the -request-level isolation suite are Packet 7). +request-level isolation suite shipped in Packet 7). P02d-2 locale/branding writers +are prepared for approval, not implemented. The first module spec in the repository, per [Documentation Standards § Per-Module Specifications](../../standards/13-documentation.md). @@ -70,6 +71,93 @@ Tenancy owns **who a request belongs to** and nothing about what they do with it ([ADR-0018](../../decisions/0018-tenant-driven-customization-model.md)), not columns here. +## P02d-2 proposed locale and branding contract + +**Prepared, not Accepted or implemented — 2026-10-02.** The +[phase package](../../roadmap/phase-02d-walking-skeleton.md#p02d-2-decision-package-2026-10-02) +owns approval and seed inventory. All three commands are unrouted, require resolved +tenant-wide context and write one aggregate; organization context is refused rather +than silently promoted to tenant scope. Tenant ids are not caller authority. + +| Command | Root and contract | +|---|---| +| `AddTenantLocaleCommand` | Tenant; exact expected version, canonicalizable locale, enabled/default flags and nonnegative sort. Duplicate locale is a business-rule refusal; disabled default is refused before mutation | +| `SetDefaultTenantLocaleCommand` | Tenant; exact expected version and existing enabled locale. Missing locale is a bounded validation failure; disabled target is refused before root stamping | +| `SetTenantBrandingCommand` | TenantSetting; fixed setting id for creation, complete theme and nullable expected version. Null means create-only; an exact version means replace-only. No blind upsert/retry, wildcard or partial patch | + +### Locale guarantees and read contract + +Zero locale rows or an all-disabled set is valid. Supported commands leave exactly +one enabled default whenever any enabled locale exists. Adding the first enabled +locale promotes it even if disabled rows already exist. Validate pre-existing sets +before every mutation: multiple defaults, a disabled default or enabled rows without +a default are refused without choosing a winner or changing root/captured state. +Move disabled-target validation ahead of `SetDefaultLocale`'s `MarkUpdated`. + +Reuse the existing two-pass default switch in `TenancyWriteStores`, within the +command's ambient transaction. An injected second-save failure must roll back the +first clear, proven from a fresh scope. A raw store call outside the transaction +does not acquire that guarantee. + +Keep the shipped partial unique index (at most one default), and add +`CHECK (NOT is_default OR is_enabled)` in a new migration. Existing disabled-default +rows fail migration preflight/validation for explicit operator remediation; never +choose a locale automatically. The CHECK cannot detect enabled-without-default; +reader/writer validation does. Arbitrary raw deletes do not gain an exactly-one +database guarantee. + +`ITenantLocaleEligibilityReader` in Tenancy application contracts returns canonical +enabled membership in the announced tenant, uncached in the caller's ambient frame. +It rejects invalid legacy configuration with a bounded configuration failure; it +does not invent `en`. A missing/disabled requested member gives Education bounded +`validation_failed`. Content URL/body lookup has no label fallback. Phase 03 owns +locale removal/disable commands; those retain Education translations and future +public readers recheck enabled membership rather than cascading across modules. + +No platform locale registry is introduced. Admission uses the existing `LocaleTag` +grammar, canonicalization and 35-character application bound; membership is the +tenant's own enabled rows, not a speculative platform language allowlist. + +### Whole-theme setting and public boundary + +The command-local registry admits only tenant-wide `branding.theme`. It does not +constrain every generic `TenantSetting` key; existing raw `tz` and organization +`theme` fixtures remain valid. Its descriptor owns the exact object validator, +tenant-wide scope and public field-to-CSS map: + +| JSON field | CSS variable | Value | +|---|---|---| +| `primary` | `--ls-primary` | Canonical lowercase `#rrggbb` | +| `background` | `--ls-bg` | Canonical lowercase `#rrggbb` | +| `foreground` | `--ls-fg` | Canonical lowercase `#rrggbb` | +| `muted` | `--ls-muted` | Canonical lowercase `#rrggbb` | + +The object has exactly these four string fields, without duplicates or extras. +No CSS name/function, alpha, font, logo, URL or layout value is admitted. Validate +the complete candidate before mutation: foreground/background and muted/background +at least 4.5:1; primary/background at least 3:1 for supported UI usage. Do not use +primary as normal-sized text or white-on-primary without a separately validated pair. +A failing pair returns `validation_failed`, not a warning-only save without a Studio. + +One setting root and exact version protect the whole contrast unit. Concurrent +replacement yields a concurrency conflict; competing creates use the existing +tenant/scope/key uniqueness. No retry merges colors from different candidates. +Invalid existing override falls back as a whole to safe CSS defaults at the later +public projection; never emit raw JSON or partial unsafe colors. G16(f/g) and G42 +still own transport, attribution and injection. Authoring this baseline theme is +not gated by `tenancy.white_label_branding` in P02d-2. + +Before the first writer, mark generic `TenantSetting.Value` `[PiiSensitive]`; logs and +audit redact the whole JSON value under ADR-0044. Public allowlisting is independent +of this conservative annotation and never permits generic settings disclosure. +The setting write is MUST; locale writes are SHOULD over their owning Tenant root, +including contained locale changes. Contextual seed verification queries are Off. + +No settings cache in P02d-2/3: no generation migration, TTL or cross-process stale +entry. P02d-3 implements the typed ambient accessor; it explicitly reads tenant-wide +rows and exact organization rows, then merges in memory. Performance is measured +there, not claimed satisfied by this proposal. + ## Entity-relationship diagram Aggregate roots in the shipped code are `Tenant`, `Organization`, `TenantDomain` and @@ -356,7 +444,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. 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 | +| Settings read for a request | **< 5 ms** p95 target, not measured | Proposed P02d-2/3: no settings cache; P02d-3 measures the ambient indexed read/merge. Phase 02b's event does not exist yet | 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. @@ -390,12 +478,11 @@ request and are the only Tenancy work an anonymous visitor pays for. zero rows whether or not a filter exists. The filter is the layer above it, and it fails closed the same way — an unresolved context narrows to the all-zero tenant, which no row can carry. -- **Two defaults per tenant are possible.** Nothing stops two `tenant_locales` - rows with `is_default = true` for one tenant. - [Packet 7](../../roadmap/phase-02a-kernel-tenancy.md) closes it in both places: - a partial unique index `UNIQUE (tenant_id) WHERE is_default`, because an - aggregate invariant alone does not hold across concurrent transactions, plus an - aggregate-level guard for the error message. +- **At most one default is already enforced.** The shipped partial unique index + `UNIQUE (tenant_id) WHERE is_default` prevents competing defaults. It does not + require a default whenever enabled locales exist. P02d-2's proposed command + contract below closes that supported-write gap and adds a default-enabled CHECK; + arbitrary raw deletes do not gain an exactly-one database guarantee. - **Nothing stops a tenant claiming a hostname it does not own.** `ux_tenant_domains_host` is globally unique — it has to be, or a host would resolve to two tenants — so the *first* tenant to insert a `Requested` row for diff --git a/docs/modules/tenancy/audit.md b/docs/modules/tenancy/audit.md index 54f7de50..49699a5a 100644 --- a/docs/modules/tenancy/audit.md +++ b/docs/modules/tenancy/audit.md @@ -3,6 +3,14 @@ Per [Audit Coverage](../../standards/18-audit-coverage.md), which names this file. Part of the [module spec](README.md). +**P02d-2 preparation — 2026-10-02.** The +[proposed writer contract](README.md#p02d-2-proposed-locale-and-branding-contract) +does not remove `(planned)` markers. Locale commands declare `tenancy.locale.write` +over the owning Tenant root and captured locale navigation; branding declares +`tenancy.setting.write` over TenantSetting. Generic setting values are proposed +whole-value `[PiiSensitive]` redactions before that writer; public branding projection +is a separate allowlist. Verification request types are explicitly Off when added. + Four writes below exist today, between them raising three of the slugs. `Tenant` create and `Organization` create, written together by `ProvisionTenantCommand` ([ADR-0042](../../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md)) diff --git a/docs/modules/tenancy/permissions.md b/docs/modules/tenancy/permissions.md index ed17422b..5b15120b 100644 --- a/docs/modules/tenancy/permissions.md +++ b/docs/modules/tenancy/permissions.md @@ -3,6 +3,12 @@ Per [Permission Standards](../../standards/19-permissions.md), which names this file. Part of the [module spec](README.md). +**P02d-2 preparation — 2026-10-02.** The +[locale and branding commands](README.md#p02d-2-proposed-locale-and-branding-contract) +are proposed unrouted tenant-wide seed operations, with no registered permission or +organization override. Their eventual identity-backed permission admission remains +Phase 03. This note grants no HTTP or Hub-internal reachability for the new commands. + **No permission keys yet.** The matrix below is a forward declaration in the `{module}.{resource}.{action}` form with the closed action set of [Permission Standards](../../standards/19-permissions.md). Registration runs diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index ae5dca42..35e23185 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -44,7 +44,7 @@ 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: Two-Tenant Walking Skeleton](phase-02d-walking-skeleton.md) — **in progress**; P02d-1 complete and merged, next is P02d-2's decision pass (see its Status block) +- [Phase 02d: Two-Tenant Walking Skeleton](phase-02d-walking-skeleton.md) — **in progress**; P02d-1 merged, P02d-2 decision package prepared for exact approval; no P02d-2 implementation yet - [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) @@ -71,6 +71,16 @@ the Hub repository for the rest: > sorts last alphabetically while running **before** them in dependency order. The > dependency map below is authoritative for order; filename order is not. +## Proposed additional track + +[Phase 09a: Course Marketplace Pilot](phase-09a-course-marketplace-pilot.md) records +the endorsed hybrid target from 2026-10-02. It remains Proposed under ADR-0049; +it is not an Accepted extension of the dependency map or MVP exit. It separates +institution-storefront billing (Phase 09), learner marketplace commerce (proposed +09a), Hub software billing (09b) and Hub customization bundles (12). +Its M1–M11 register assigns commercial, capacity, privacy, staff-access and recovery +decisions to their first consumers. Those contracts do not hold P02d-2 preparation. + ## Phase Dependency Map ```mermaid diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 9517b1b1..a0bd5de9 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -17,9 +17,10 @@ > | P02d-6 | Public renderer | not started | > | P02d-7 | Demo, full-stack CI and exit | not started | -**Next: P02d-2's decision pass.** The schema prerequisite is merged. Its remaining -gate parts in [the packet table](#packets-and-decision-gates) still require explicit -acceptance before implementation; this closeout accepts none of them. +**Preparation update — 2026-10-02.** P02d-1 remains merged; P02d-2 implementation +has not started. Its [decision package](#p02d-2-decision-package-2026-10-02) and four +implementation steps are prepared for exact approval. The two new ADRs remain +Proposed; this preparation update accepts no gate and claims no code delivery. ## Goal @@ -298,27 +299,27 @@ premise a row cites is re-verified at that pass rather than trusted. |---|---|---|---|---|---| | 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) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): G1 | | G2 | Which aggregate does `Lesson` belong to, and what is its parent: an entity inside `Course`, its own root referencing `Course`, or a minimal `CourseVersion` and default `Module` now? With it: how the satellites are mapped (base type, markers, `deleted_at`), the `sort` invariant and tie-breaker, and what the shape obliges Phase 05 to preserve — course and lesson ids, published slugs, order, organization scope, the inline body | The reviews split. One brings the version spine forward so Phase 05 enriches rather than re-parents; two keep this phase thin and record the preservation obligations, with Phase 05 designing the move. Between the thin shapes: inside `Course` means `ON DELETE CASCADE` and one audit row, but a lesson edit mutates `Course` structurally, which [Domain Model § Education Catalog](../architecture/02-domain-model.md#education-catalog) says a published course never is; its own root means `RESTRICT`, its own `row_version` and its own audit subject | Contract: a dated phase-doc statement; no Accepted ADR holds the `Course` / `CourseVersion` hierarchy, so a new ADR only if the answer needs a cross-root write ([ADR-0042](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md)). Detail: the Education spec's data model; an interim note in [Domain Model § Learning Content](../architecture/02-domain-model.md#learning-content) where the answer departs from it; the class count in [Database Standards § Foreign keys between tenant-owned tables](../standards/05-database.md#foreign-keys-between-tenant-owned-tables) if `Lesson` cascades | P02d-1 (the lessons foreign-key target, `ON DELETE`, `row_version`, the root mapping and satellite `deleted_at`) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): G2 | -| G3 | Which of `courses` and `lessons` carry a publication state, with which values and transitions, and what does "published" mean to an anonymous reader — publicly readable, or only listed? Which command sets it, which states does the seed write, and may a course with no lessons, or untranslated in an enabled locale, be published? And for any transition or deletion this phase does not ship (unpublishing a course or lesson, deleting either), which phase owns it? | `courses` `draft` / `published`, meaning publicly readable (Phase 05 adds catalog visibility as its own concept); lessons carry a state and show only when both are published; draft → published only; an empty course may be published, since publish validation is Phase 05's. One review leaned "listed in the catalog" | Contract: a new ADR, or a dated phase-doc statement recording why a two-value, one-transition column is not the state machine Decision Timing reserves for a decision record; no Accepted ADR decides publication ([ADR-0018](../decisions/0018-tenant-driven-customization-model.md) reserves the lifecycle to LearnStack). Detail: the `CHECK` ([Database Standards § Constraints](../standards/05-database.md#constraints)), the Education spec's state diagram, the publish row [Audit Coverage Standards](../standards/18-audit-coverage.md) makes MUST | P02d-1 (column presence and value set), P02d-2 (publishing commands and seeded states; transition contract closed in P02d-1) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): values and publication/transition contract; P02d-2 commands and seeded states remain open | +| G3 | Which of `courses` and `lessons` carry a publication state, with which values and transitions, and what does "published" mean to an anonymous reader — publicly readable, or only listed? Which command sets it, which states does the seed write, and may a course with no lessons, or untranslated in an enabled locale, be published? And for any transition or deletion this phase does not ship (unpublishing a course or lesson, deleting either), which phase owns it? | `courses` `draft` / `published`, meaning publicly readable (Phase 05 adds catalog visibility as its own concept); lessons carry a state and show only when both are published; draft → published only; an empty course may be published, since publish validation is Phase 05's. One review leaned "listed in the catalog" | Contract: a new ADR, or a dated phase-doc statement recording why a two-value, one-transition column is not the state machine Decision Timing reserves for a decision record; no Accepted ADR decides publication ([ADR-0018](../decisions/0018-tenant-driven-customization-model.md) reserves the lifecycle to LearnStack). Detail: the `CHECK` ([Database Standards § Constraints](../standards/05-database.md#constraints)), the Education spec's state diagram, the publish row [Audit Coverage Standards](../standards/18-audit-coverage.md) makes MUST | P02d-1 (column presence and value set), P02d-2 (publishing commands and seeded states; transition contract closed in P02d-1) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): values and publication/transition contract; P02d-2 commands and seeded states remain open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | | G4 | Where does a lesson body's binding to the content-type key and `schema_version` it was validated against live — on `lessons` or on each translation row — and where does the body live: its column, type, per-locale placement, and how non-translatable field values are carried? May a constraint cross into the Customization chain? What becomes of Localization Standards' `isLocalized` marker, which nothing implements? | A value pin `(content_type_key, schema_version)` on `lessons`, as Phase 04 plans for `ContentEntry`, with no foreign key; the field document per locale in `lesson_translations`, every locale validated against the one pin, duplicated non-translatable values accepted until Phase 05's lesson items retire them; the marker removed or given its introducing phase | Detail: this document's § Localization schema, the Education spec, [Localization Standards § Pattern A](../standards/08-localization.md#pattern-a--side-translation-table-default-for-content-shaped-entities) in the same diff. Contract: a dated ADR-0043 amendment if a localization keyword enters the schema profile; its own ADR or amendment if a cross-chain foreign key is chosen, as ADR-0044 § 9 did, with Phase 04 and [Database Standards § Migrations](../standards/05-database.md#migrations) in the same diff | P02d-1 (the first `lessons` and `lesson_translations` DDL; a pin added later needs a backfill that guesses between two Active content types) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): G4 | -| G5 | Before Phase 05's `Level` exists, how does a course or lesson carry the level band criterion 1 shows? Does the reference pin a taxonomy revision, how is a band validated on write, and what renders when the resolved revision no longer declares the stored band? | The reviews split: (a) a nullable, non-translatable `(taxonomy_key, band_key)` on `courses`, resolved against the live revision, because the criterion names the catalog; (b) a revision-pinned triple; (c) no column, the band shown through a lesson-page `x-taxonomy` field, with the criterion reworded. No shipped path validates a band value under any of them | Detail: a phase-doc statement, the Education spec, and a Phase 05 inherited row if a reference ships. Contract: a dated ADR-0010 amendment or a new ADR if an Education table takes a foreign key into Customization | P02d-1 (whether and where a column exists), P02d-2 (validation, seeded references), P02d-4 and P02d-6 (the unresolved-band state) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): column; validation, seeded references and unresolved-band behavior remain open | +| G5 | Before Phase 05's `Level` exists, how does a course or lesson carry the level band criterion 1 shows? Does the reference pin a taxonomy revision, how is a band validated on write, and what renders when the resolved revision no longer declares the stored band? | The reviews split: (a) a nullable, non-translatable `(taxonomy_key, band_key)` on `courses`, resolved against the live revision, because the criterion names the catalog; (b) a revision-pinned triple; (c) no column, the band shown through a lesson-page `x-taxonomy` field, with the criterion reworded. No shipped path validates a band value under any of them | Detail: a phase-doc statement, the Education spec, and a Phase 05 inherited row if a reference ships. Contract: a dated ADR-0010 amendment or a new ADR if an Education table takes a foreign key into Customization | P02d-1 (whether and where a column exists), P02d-2 (validation, seeded references), P02d-4 and P02d-6 (the unresolved-band state) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): column; validation, seeded references and unresolved-band behavior remain open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | | G6 | Locale identity on the content path. (a) What spelling and column type do the satellites' `locale` columns store, and which rule replaces Localization Standards' "Lowercase", which the shipped `LocaleTag` does not follow? (b) Is the `locale` parameter canonicalized before lookup, the membership check and every cache or cursor key, or is a non-canonical spelling refused? (c) What does a non-canonical `/{locale}/` segment get? | (a) `LocaleTag`'s canonical case (`tr-TR`, `zh-Hans`) in `varchar(35)`, as `tenant_locales` stores it — [ADR-0018](../decisions/0018-tenant-driven-customization-model.md)'s 2026-09-04 amendment already makes case variants one locale; (b) well-formedness, then canonicalization, then lookup; (c) a redirect to the canonical segment, decided with G36 | Detail: [Localization Standards § Locale Codes](../standards/08-localization.md#locale-codes) and the Database Standards satellite fence in the same diff. No ADR: ADR-0008 states no casing rule | P02d-1 (a: the first stored rows), P02d-4 (b: validators, cursor binding), P02d-5 (c, with G36) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): (a); (b) and (c) remain open | -| G7 | Organization write scope. (1) Does a lesson carry its course's organization scope? (2) What forces a satellite's — and a lesson's — mirrored `organization_id` to equal its parent's at insert: writer derivation alone, or that plus a database backstop, and which? (3) May an organization-scoped session `INSERT` a tenant-wide row through the `organization_id IS NULL` arm of `WITH CHECK`, which [ADR-0003](../decisions/0003-tenant-isolation-defense-in-depth.md)'s Amendment 5 and Database Standards say it cannot and which it can at `HEAD`? | (1) Identical scope for a course, its lessons and every translation. (2) Writers derive the child's organization from the authorised parent; the reviews split on the backstop — a stored generated scope column with an organization-inclusive composite key, which structural sweeps can see, or a `BEFORE INSERT` trigger reading the parent under the caller's policies — and one review requires database enforcement. A nullable three-column key is already excluded, because `MATCH SIMPLE` skips the check. (3) Tighten, after the pass confirms no audit writer composes a null-organization row under an announced organization | Contract: one dated ADR-0003 amendment for (2) and (3), with an ADR-0041 erratum beside any sentence the pass finds false when it entered the record; the template replaced in place in [Database Standards](../standards/05-database.md) with its disclosure; forward migrations for `tenant_settings` and `audit_log` if (3) tightens. Detail: [Database Standards § Translation satellite tables](../standards/05-database.md#translation-satellite-tables); a catalogue row with a planted offender if a database mechanism is chosen | P02d-1 (policy SQL, the generated column or trigger, aggregate factories), P02d-2 (child derivation in the commands) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): database controls and factory derivation; P02d-2 command derivation remains open | +| G7 | Organization write scope. (1) Does a lesson carry its course's organization scope? (2) What forces a satellite's — and a lesson's — mirrored `organization_id` to equal its parent's at insert: writer derivation alone, or that plus a database backstop, and which? (3) May an organization-scoped session `INSERT` a tenant-wide row through the `organization_id IS NULL` arm of `WITH CHECK`, which [ADR-0003](../decisions/0003-tenant-isolation-defense-in-depth.md)'s Amendment 5 and Database Standards say it cannot and which it can at `HEAD`? | (1) Identical scope for a course, its lessons and every translation. (2) Writers derive the child's organization from the authorised parent; the reviews split on the backstop — a stored generated scope column with an organization-inclusive composite key, which structural sweeps can see, or a `BEFORE INSERT` trigger reading the parent under the caller's policies — and one review requires database enforcement. A nullable three-column key is already excluded, because `MATCH SIMPLE` skips the check. (3) Tighten, after the pass confirms no audit writer composes a null-organization row under an announced organization | Contract: one dated ADR-0003 amendment for (2) and (3), with an ADR-0041 erratum beside any sentence the pass finds false when it entered the record; the template replaced in place in [Database Standards](../standards/05-database.md) with its disclosure; forward migrations for `tenant_settings` and `audit_log` if (3) tightens. Detail: [Database Standards § Translation satellite tables](../standards/05-database.md#translation-satellite-tables); a catalogue row with a planted offender if a database mechanism is chosen | P02d-1 (policy SQL, the generated column or trigger, aggregate factories), P02d-2 (child derivation in the commands) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): database controls and factory derivation; P02d-2 command derivation remains open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | | G8 | Which structural guards does the Education chain register, so its tables cannot regress with the suite green: every foreign key between two tables carrying `tenant_id` includes it; every table carrying `organization_id` has the immutability trigger (and how `audit_log`'s append-only guard counts); the Pattern A rule, which would make [ADR-0008](../decisions/0008-localization-schema.md)'s "the migration linter rejects ad-hoc per-locale columns" true? And how does `fn_organization_id_immutable` — which reads `OLD.id` and is declared only in the Tenancy chain — serve satellites that have no `id`? | Three rows, each with a planted-offender companion; the function replaced by a Tenancy-chain migration that reports `OLD.organization_id` or reads the row key through `to_jsonb(OLD)`, which (as in the audit append-only guard's row comparison) never names a column the table may lack, with the cross-chain dependency recorded under Database Standards § Migrations | Detail: Standards 21 rows Registered and Implemented in the packet; the Database Standards immutability fence and § Migrations; `MigrationRollbackTests`. Contract, only if ADR-0008's sentence is left untrue: an ADR-0041 erratum if it was false when entered, otherwise a dated amendment | P02d-1 (a guard shipped with its first new subject is the only point its companion is written against real tables) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): G8 | | G9 | Education schema detail: the content slug's character shape, normalization, width and database backstop — including whether a GUID-shaped slug is refused, which G26's shared-slot path needs; whether an Education table holds a foreign key into `tenants`, `organizations` or `tenant_locales`; and each runtime role's privileges on the four tables | `UrlSlug`'s shape with its own width constant and a `ck__slug_format` backstop, since restrictive now is the reversible choice (ASCII-only slugs exclude native-script URLs, a product choice); no foreign key into Tenancy; `learnstack_app` `SELECT, INSERT` plus exactly what G11's commands need, `learnstack_platform` `SELECT` | Detail: Localization Standards § Pattern A for the shape; the Database Standards satellite fence and [§ GRANT matrix](../standards/05-database.md#grant-matrix); § Migrations only if a cross-chain key is chosen | P02d-1 (the creating migration writes the `CHECK` and the grants; the grants couple with G11) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): G9 | | G10 | What is the catalog's default order and tie-breaker, and what is the cursor it mints: its payload and version; what it binds (tenant, organization, locale, sort, filters, endpoint); its integrity (none, a MAC with a key version, or server-side state); its direction; what happens when a row changes between pages; which list parameters the endpoint binds; where it is decoded; whether the codec is this endpoint's or the kernel's; and which cursor classes answer `400`? | The reviews split between a keyless versioned payload with a binding fingerprint, decoded at binding so a garbage cursor opens no transaction, and an HMAC-authenticated cursor with key rotation. Both keep tenant and organization out of the cursor, and bind `CursorPaginationRequest` rather than `ListRequest`, whose `q` is Phase 04's search | Contract: a phase-doc statement if the codec is endpoint-local and keyless; a new ADR if it becomes a kernel rule later lists follow, or a MAC adds a secret and a rotation posture. Detail: [API Standards § Pagination](../standards/04-api-design.md#pagination), which drops "Nothing validates its *shape* yet"; Standards 21 rows | P02d-1 (the order part: an ordering column, publication timestamp or collation), P02d-4 (the codec part) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): order; P02d-4 codec remains open | -| 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) | Open | -| 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) | Open | -| 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) | Open | -| 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) | Open | -| 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) | Open | -| 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) | Open | -| 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) | Open | -| 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) | Open | -| 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) | Open | -| 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) | Open | -| 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) | Open | +| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | +| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | +| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | +| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | +| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | +| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | +| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | +| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | +| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | +| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | +| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | | 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 | Open | -| 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) | Open | +| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | | 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) | Open | | 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) | Open | | 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) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): slug grammar only; P02d-4 route and response contracts remain open | @@ -387,6 +388,140 @@ requires maintainer approval, a superseding access ADR and a dated G3 supersessi entry before implementation. The original question, accepted answer and delivery record remain intact. Resolving the product direction alone does not reopen G3. +**Maintainer endorsement — 2026-10-02.** The maintainer approved following the +recommendations and completing preparation. The hybrid direction and proposed +[Phase 09a](phase-09a-course-marketplace-pilot.md) are planning targets, not accepted +commerce contracts. The preparation hold is released. Protected authoring is prepared +in ADR-0050, but the exact new ADR and packet decisions must be approved before code, +as the maintainer requested. Future commerce feasibility does not block this packet. + +### P02d-2 decision package (2026-10-02) + +**Prepared; exact approval pending.** This package is the concrete result of the +authorized preparation. It accepts no gate, registers no implemented proof and +changes no historical P02d-1 decision. Approval must cover ADR-0050, ADR-0051 and +the packet statements below. Then update the current gate cells, append the dated G3 +supersession, perform ADR lifecycle bookkeeping and register new catalogue rows +before implementation. Keep the original G3 question, accepted answer and delivery +record unchanged. + +#### P02d-2 proposed answers + +| Gate part | Prepared answer and detail owner | +|---|---| +| G3: access, commands and seed states | [ADR-0050](../decisions/0050-publication-and-course-content-access.md) separates publication/access, backfills restricted policy and denies protected lesson inventory. Lifecycle stays independent draft → published. [Education writer plan](../modules/education/README.md#p02d-2-proposed-writer-contract) names six commands; seed states are explicit below | +| G5: level validation | New course binding requires the exact Active taxonomy revision and declared band; never resolve a live key. The [Customization contract](../modules/customization/README.md#p02d-2-proposed-exact-write-contract) owns eligible revision rules. Unresolved public labels remain G5's P02d-4/6 decision | +| G7: child derivation | Scope comes from trusted context and authorized parent; missing/cross/sibling parent is `not_found`, visible but unwritable parent scope is `resource_scope_violation`. No request tenant/organization authority; database guards remain unchanged | +| G11: writers, failures and convergence | Separate create, translation-add and publish commands per Education root; translation insertion reports known slug uniqueness as `business_rule_violation`. Tenancy locale and whole-theme commands write one root. The specs own matrices; seed never treats a generic lifecycle failure as success | +| G12: contract | Uncached Customization application interface returns immutable value DTOs for exact revisions; new binds Active, existing-pin writes Active/Deprecated. No foreign Domain/Infrastructure reference, FK, public marker or cache. Snapshot eligibility is at validation read, not a claim of Active-at-commit | +| G13: locale | Uncached Tenancy application contract requires canonical enabled membership. No platform locale registry; use existing LocaleTag grammar/canonicalization/35-character bound. No rows means no content locale, not implicit `en`; label fallback does not change URL/body eligibility. Removal/disable retains Education data and later reads recheck membership; the lifecycle commands belong to Phase 03 | +| G14: seed | Inventory and test-owned controls below; all literal identities and expected counts move into `SeedData` with implementation. Tenant-wide branding/content announce null organization. Built-in `card`/`plain` stay unchanged and Active | +| G15: setter fence | Ownership verification becomes contextual `ISender` queries, explicitly audit Off; no direct seeder transaction/context setter. Add a planted-caller source proof; no new ADR-0040 setter admission | +| G16(a–e): branding | One tenant-wide `branding.theme` document, four closed color fields, complete replacement and contrast refusal; exact version for replacement. Command-local registry preserves generic settings. Organization overrides refused; no fonts, logo, URL or layout setting in this packet | +| G17: PII | Mark generic `TenantSetting.Value` `[PiiSensitive]` before its writer, including whole JSON audit redaction. Public branding allowlisting is a separate boundary, not permission to expose generic settings | +| G18: presentation | [ADR-0051](../decisions/0051-ordered-text-card-presentation.md) extends ADR-0043 with optional strict root `x-fields`; seed opts into ordered localized plain-string cards. Legacy schemas remain valid, unchanged; no renderer-key or presentation-column change | +| G19: active content | The seeded profile has no active URL/markup sink; output is escaped text, never linkification or Markdown/HTML. ADR-0051 names first-sink owners; generic schema validation is not navigation/media authorization | +| G20: literal source | `SeedData` owns all demo identities, literals and expected inventory. A separate Registered guard will consume that declaration; production subjects/exemptions and behavioral genericity proof stay with their later packet parts | +| G21: subresources | No new remote asset/font/logo or cross-origin subresource from seed/theme/text cards. Cookie behavior remains P02d-5; public output still needs later transport/render gates | +| G23: freshness bound | No settings cache in P02d-2/3: no seed-process staleness, generation migration or out-of-band loader. P02d-3 owns the ambient typed accessor and scoped merge; latency is a later measurement, not a passed budget | + +The replacement vehicles for G18/G19 are the new ADR-0051 and, on approval, an +append-only ADR-0043 amendment linking it. The original register's Leaning/Vehicle +text remains review history; approval records this selected vehicle explicitly. +No Accepted ADR is edited by this preparation pass. + +#### Seed inventory and ownership + +This is a planned inventory, not currently seeded data. Implementation places exact +IDs, schema/body literals, slugs, labels, palettes and counts in `SeedData`; no +production branch knows `demo-english`, `demo-yoga`, `grammar-topic` or `asana-pose`. +The existing fixed tenant, organization, host and built-in customization IDs stay. + +| Data | English tenant | Yoga tenant | +|---|---|---| +| Host context | Existing tenant host, organization null | Existing Studio One host | +| Locales | `en`, enabled default | `tr-TR`, enabled default; `en`, enabled | +| Tenant content type | `grammar-topic`, revision 1 Active, `default-card`; plain-string `concept`/`example` | `asana-pose`, revision 1 Active, `default-card`; plain-string `pose`/`instruction` | +| Taxonomy | `cefr`, revision 1 Active; six declared bands | `yoga-difficulty`, revision 1 Active; three declared bands | +| Branding | One tenant-wide `branding.theme`, complete valid palette | One tenant-wide `branding.theme`, distinct complete valid palette | +| Courses | Four: tenant-wide published public, tenant-wide draft public, tenant-wide published restricted, Kadıköy-scoped published public | Four: tenant-wide published public, Studio One published public, Studio Two published public, Studio One published restricted | +| Lessons | Five: two under public tenant-wide course (published/draft), one published under draft parent, one published under restricted parent, one published Kadıköy-scoped | Five: one tenant-wide, two Studio One public, one Studio Two, one Studio One restricted; published | +| Translations | One `en` translation per course/lesson | Both `tr-TR` and `en` per course/lesson; genuinely different translated slugs/labels | +| Cross-tenant positive control | Published public tenant-wide course uses `en` slug `foundation` | Published public Studio One course also uses `en` slug `foundation`; no uniqueness across tenants | + +All eight courses and ten lessons have explicit pins and access/state choices; +lesson policy is inherited. The planned translations total 27: nine English and +18 Yoga. No remote media is seeded. Wrong-course proof uses the two visible Yoga +courses; organization controls are hidden/visible against the appropriate host. + +Pagination uses **test-owned** courses and a test-selected bounded page size in +P02d-4, not arbitrary mass demo data or a premature G10 default. A retained +disabled-locale translation is also a P02d-4 historical-state fixture, with an +explicit test setup; `make seed` does not bypass locale admission to fabricate it. +Schema mismatch, absent presentation, missing labels and unresolved pins are negative +test fixtures rather than invalid demo rows. Packet 7's raw `tz`/organization `theme` +fixtures remain legal and are counted separately from the new seed theme. + +Every act derives a fresh composed trusted seed scope, with nullable organization. +Tenant locales, customization definitions, branding and tenant-wide courses run in +tenant-wide context; organization courses/lessons run in their exact organization. +Translations and publication use the root's write scope. No admin role, superuser, +RLS bypass, direct EF mutation or ad hoc seed SQL supplies normal writes. + +Seed order is provisioning/organizations/hosts, enabled locales, built-ins and tenant +types/taxonomies (create then publish), branding, course/lesson drafts and translations, +then each selected root's publication. Translation precedes publication on a new root. +Restricted controls depend on accepted ADR-0050 and its applied migration. + +Before skipping an existing act, the ownership query verifies exact ID, parent, scope, +pins, enabled locale, translated slug/text, semantic JSON, policy and state as relevant. +Absent acts are written; exact completed acts are skipped without mutation/audit. +Creation verification accepts only the declared draft intermediate state or intended +final publication state, with identical immutable data; later acts still verify all +translations and final state. It does not demand draft on a completed published root +or accept arbitrary states. This rule also covers customization create/publish acts. +A typed uniqueness, concurrency or lifecycle race triggers one fresh-scope ownership +check against that act's completed postcondition, never blind retry or +matching by display name. Mismatch fails nonzero without editing, unpublishing, +rebinding or selecting another revision. Existing published translations are checked +and skipped before calling the draft-only translation command. A published mismatch +is not accepted merely because that command returns `translation_requires_draft`. +If a competing runner has not completed the exact postcondition, fail safely for a +later explicit rerun rather than returning false success or spinning indefinitely. +Interrupted partial seeds converge; a second completed run adds no roots or audit rows. + +#### Implementation steps and review loop + +All steps run on **development**. No branch switch, push or PR is part of preparation. +Each implementation step follows the maintainer's requested loop: implement and +commit; first fresh independent multidisciplinary review; verify/fix valid findings +and commit; second fresh review agents; verify/fix and commit; continue automatically. +Agent models/effort follow complexity and available models. Unverified findings are +rejected with evidence; an unresolved material defect blocks that step's completion. + +| Step | Coherent change | Required evidence before step completion | +|---|---|---| +| 1 — policy and contract foundation | Education access value/configuration and additive migration; exact Customization/Tenancy read DTO contracts; ADR-0051 profile parser/resolver; module-scoped seed verification queries | Domain/profile positive and negative cases, exact revision and locale isolation, policy/default/legacy preservation, migration forward/down/reapply and pending-model check; unsupported schemas remain valid but not implicitly renderable | +| 2 — Tenancy writers | Locale commands and pre-mutation guards; default-enabled CHECK migration; whole-theme registry/command, contrast and concurrency; PII capture; matrices/composition | Disabled-first→enabled-second, disabled promotion leaves root/audit unchanged, invalid legacy refusal, injected second-save rollback, scoped writes, palette and concurrent replacement/create conflicts, whole-value audit redaction | +| 3 — Education writers | Six separate commands, validation against exact schema/taxonomy and enabled locale, parent scope, named-constraint failures, audit and ambient transaction | Body/pin/locale/slug failures, one-root lifecycle, publication audit atomicity, RLS mirrors, uniqueness races, stale versions, and swallowed nested failures cannot later flush dirty state | +| 4 — convergent seed and packet closeout | Contextual verification replaces direct setter; complete SeedData acts; source guard and planted companion; seed tests and Packet 7 counts recomputed; documents match delivery | Fresh seed, second-run no-change, interrupted recovery, concurrent seed convergence, mismatches fail nonzero without writes, host/scope positive controls, no caller-fence escape, full required verification and two review rounds | + +Checks scale to each step's change. Final closeout runs Release build/format and all +required unit, architecture, contract, Docker-free and Docker integration suites with +zero skips; applicable EF chains' pending-model/forward/rollback/reapply checks; +seed convergence; Markdown links/anchors and `git diff --check`. Record actual commands, +counts and results in the delivery record, not a speculative passing total here. + +#### Approval boundary and readiness + +Prepared decisions are reviewable; implementation is not authorized by this draft. +The only outstanding **P02d-2 decision** is exact approval of this package and +ADR-0050/0051. After approval, perform the recorded lifecycle/gate/catalogue updates +before Step 1. The marketplace's company/country, provider, selected region, legal +roles, seller corridors and detailed Phase 09a contracts remain its own first-consumer +gates. They neither expand P02d-2 nor hold its independent protected-content work. + + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/docs/roadmap/phase-03-identity-admin.md b/docs/roadmap/phase-03-identity-admin.md index fd3f0dae..23c338f8 100644 --- a/docs/roadmap/phase-03-identity-admin.md +++ b/docs/roadmap/phase-03-identity-admin.md @@ -107,6 +107,13 @@ 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. +**Preparation update — 2026-10-02.** The +[P02d-2 G17 proposal](phase-02d-walking-skeleton.md#p02d-2-proposed-answers) selects +whole-value `[PiiSensitive]` for generic `TenantSetting.Value` before its first +writer, with exact approval pending. This phase still owns identity-backed access, +DSAR and the marketplace purpose/recipient boundary; a public theme allowlist never +permits exporting arbitrary tenant settings. + **Attribute ownership.** Each attribute has exactly one owner, and the owner determines the table it lives in and who may write it. diff --git a/docs/roadmap/phase-04-cms-media-pages.md b/docs/roadmap/phase-04-cms-media-pages.md index cf37afd2..e69463fc 100644 --- a/docs/roadmap/phase-04-cms-media-pages.md +++ b/docs/roadmap/phase-04-cms-media-pages.md @@ -199,6 +199,12 @@ one thing a per-table constraint cannot do. > [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register). > The pass that closes it names both here and in the completion criterion below. +**Prepared G11 answer — 2026-10-02, approval pending.** The Education reporting +commands are `AddCourseTranslationCommand` and `AddLessonTranslationCommand`, mapping +their named localized-slug constraints to `business_rule_violation` at insertion. +Publication does not reserve a slug. This is the P02d-2 proposal, not a shipped CMS +writer or an accepted G11 closeout. + 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 @@ -262,6 +268,12 @@ Storage and asset management: - Public / tenant-scoped / per-user access tiers with signed URL minting, and the public asset URL strategy. + Proposed [ADR-0050](../decisions/0050-publication-and-course-content-access.md) + makes protected Education media an explicit first-producer boundary: public DTOs + emit no protected bearer URL; issuance and retrieval require effective access. + It does not claim an authenticated evaluator exists before Phase 07. The first + active URL/markup sink also needs the shared safety contract named in ADR-0051. + **This phase owns the media processing pipeline.** No phase currently does: [Media Pipeline](../architecture/16-media-pipeline.md) describes `IVideoTranscoder`, ffmpeg workers, HLS renditions and a managed-service scale path in full detail, and the @@ -407,8 +419,10 @@ describes. `Result.Fail(business_rule_violation, …)` with the disclosure rules above. For courses, the selected command and its concrete error mapping remain G11 in [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register); - once G11 is resolved, this criterion must name that command and mapping and verify - the command-level refusal. + its prepared answer names `AddCourseTranslationCommand` (and + `AddLessonTranslationCommand` for lessons), mapping named localized-slug + constraints to `business_rule_violation` on insertion. Exact approval remains + pending; on acceptance verify those command-level refusals. - 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 545424ae..92a4bdc7 100644 --- a/docs/roadmap/phase-05-education-learning-content.md +++ b/docs/roadmap/phase-05-education-learning-content.md @@ -49,6 +49,13 @@ Decisions consumed: ### What Phase 02d supplies +**Preparation impact — 2026-10-02.** +[ADR-0050](../decisions/0050-publication-and-course-content-access.md) is Proposed; +ADR-0048 still governs until approval. If accepted, this phase also preserves Course +content-access policy through version/module migration, decides its versioned owner +and any policy-edit/reparent/preview behavior before those writers, and never infers +public access from a new version or listing. This note claims no shipped column. + Phase 05 does not re-create these. Phase 02d's [delivery status](phase-02d-walking-skeleton.md#delivery-record-p02d-1) distinguishes accepted design from shipped implementation. This phase's decision pass designs the diff --git a/docs/roadmap/phase-07-enrollment-learner-portal.md b/docs/roadmap/phase-07-enrollment-learner-portal.md index 20fd3b46..9bc5e8d5 100644 --- a/docs/roadmap/phase-07-enrollment-learner-portal.md +++ b/docs/roadmap/phase-07-enrollment-learner-portal.md @@ -17,6 +17,14 @@ from Phase 03, and the outbox and background-job infrastructure from ### What Phase 02d did not build +**Preparation impact — 2026-10-02.** Proposed +[ADR-0050](../decisions/0050-publication-and-course-content-access.md) separates +publication from inherited course policy; it is not Accepted or implemented yet. +If approved, restricted courses expose marketing metadata but no anonymous lesson +inventory/body; this phase supplies the first effective learner-access evaluator. +No credentials or absent evaluator may cause public fallback. Check access before +payload/`304` and start protected responses private/no-store before any cache decision. + Worth stating plainly, because a walking skeleton is easy to over-read: [Phase 02d](phase-02d-walking-skeleton.md) is **anonymous and read-only**. It renders a @@ -116,6 +124,13 @@ which the Enrollment module consumes and converts into a `CourseAccess` with `source = billing`. Phase 07 ships the consumer contract; Phase 09 ships the producer. The hand-off seam is the integration event, not a shared table. +**Endorsed marketplace target, contract still open:** proposed +[Phase 09a M7](phase-09a-course-marketplace-pilot.md#decision-register) requires +durable order/fulfillment justification before this phase's first paid-grant consumer, +with matching Phase 09/09a producers. `source = billing` alone cannot distinguish +two purchases. Refund/revocation removes only its own justification; independent +access survives. Course access does not reserve or fulfill a live cohort seat. + **What `CourseAccess` is not.** It is a boolean grant, not a balance. A consumable allowance — a ten-session credit pack, "three make-up classes per term" — is *stateful course access*, and the genericity boundary in diff --git a/docs/roadmap/phase-09a-course-marketplace-pilot.md b/docs/roadmap/phase-09a-course-marketplace-pilot.md new file mode 100644 index 00000000..3f6a1061 --- /dev/null +++ b/docs/roadmap/phase-09a-course-marketplace-pilot.md @@ -0,0 +1,146 @@ +# Phase 09a: Course Marketplace Pilot + +> **Status (2026-10-02): Proposed planning artifact.** The maintainer endorsed the +> hybrid direction and recommended preparation boundaries. The owning `Marketplace` +> module, one-installation pilot and dated-cohort sales unit are endorsed planning +> targets, not implemented or fully accepted contracts. ADR-0049 remains Proposed. +> Platform company/country, seller geography, currency corridors, provider and legal +> roles remain open. This file authorizes no code, onboarding or live sales. +> +> Phase identifiers describe ownership, not execution order. Packet identifiers below +> are `P09a-*`; this phase is distinct from institution billing in Phase 09, software +> billing in Phase 09b and the Hub bundle marketplace in Phase 12. + +## Goal + +Evaluate an opt-in full Course Marketplace alongside independent institution sites: +shared discovery, one-seller platform checkout, commission and institution payout for +one selected live product. Measure incremental learner value and sustainable delivery +economics before broad rollout. + +[ADR-0049](../decisions/0049-institution-sites-and-course-marketplace.md) owns the +direction and acceptance conditions; +[the scoping companion](../architecture/34-course-marketplace-scoping.md) owns the +candidate domain, public-data, operations and recovery boundaries. This phase owns +delivery sequencing and its first-consumer gates, not a second copy of those contracts. + +## Scope + +### Endorsed planning boundaries + +- Institutions keep tenant-owned content and their own sites. Participation and + individual listings are opt-in. Independent individual instructors are not initial + sellers; a later scope expansion requires its own admission decision. +- Initial sources belong to one selected regional SaaS installation. Actual region + and commercial country combinations are undecided. Other SaaS installations, + Dedicated and Self-Hosted sources are outside this pilot. +- The proposed module lives in the modular monolith. Hub contains no course listings, + learner orders or payouts. Reuse of Billing's payment capability needs an approved + contract; an order has one authoritative owner. +- Dated cohort participation is the recommended and endorsed first sales unit. + One cohort/seat authority serves institution-site and marketplace sales; a course + grant alone is not proof of live fulfillment. +- The endorsed target includes participation in all eligible SaaS plans, with + commission on marketplace sales. Entitlement, seller + eligibility, consent, moderation and security suspension remain separate. +- LearnStack coordinates marketplace payments/support; institutions deliver teaching. + Institutions are the preferred contractual education sellers; that business + preference does not select provider merchant of record, invoice issuer or legal + support/refund liabilities. Validate each before commerce. +- No session-consumption ledger, multi-seller cart, cross-installation federation, + editable platform course copy or new infrastructure runtime is supplied here. + +### Inputs and timing + +Full checkout depends on Phase 03 identity/authorization, Phase 05 version identity, +Phase 07 enrollment/grants, Phase 08b capacity/reservations, Phase 08c live delivery +and Phase 09 payment inputs. Before **live sales**, satisfy the applicable Phase 11 +production-readiness proofs or explicitly accept bringing them earlier into this plan. +Demand-gated adapters still require their named triggers; not every adapter is a +blanket pilot dependency. + +Purchase/grant attribution must be designed before Phase 07's **first billing consumer +contract**, with matching Phase 09/09a producers, or a separately versioned marketplace +consumer and migration before its first grant. It cannot wait until a Phase 09a +payment event already depends on it. Other first-consumer dependencies are below. + +### Decision register + +All execution contracts remain open. An endorsement of a product preference is not +provider/legal approval or an accepted storage/authorization design. + +| Gate | First consumer and required decision | Owner / blocking point | +|---|---|---| +| M1 — commercial feasibility | Platform entity/country; initial seller/buyer countries/currencies; seller/KYC eligibility; contractual seller/invoice/collector/provider MoR; tax, applicable funds-handling duties and written provider/legal feasibility | P09a-0; before commerce implementation or commercial commitments | +| M2 — live product | Named cohort/version/branch/schedule/language; one capacity authority across both channels; hold/confirmation/expiry, cancellation, failed delivery and refund obligations | Enrollment / Scheduling; before first offer/checkout writer | +| M3 — public discovery | Approved public fields and projection writer; independent public search contract; host/context matrix, table class, least-privilege roles, audit and index boundaries | Marketplace; before first projection migration, public reader or search consumer | +| M4 — staff operations | Staff population and backoffice owner; realm/audience and permission/resource scope; seller/listing approval, suspension, appeal and exceptional private inspection with reason/audit | Identity / Marketplace / Audit; before first staff reader or cross-repository crossing | +| M5 — privacy and rights | Purpose-based legal roles; consent and recipients; DSAR/export/erasure, retention exceptions and incident/subprocessor ownership; residency/transfers and media rights | Architecture 23 / Phase 03 owners; before first marketplace PII writer or source export | +| M6 — source publication | Consent, stable tenant/source/revision IDs, ordering/replay, deletion/withdrawal/suspension, invalidation and checkout revalidation | Source modules / Marketplace; before first listing producer | +| M7 — attributed access | Durable purchase/fulfillment justification, effective-access calculation and source-scoped revocation; no `source = billing` shortcut | Phase 07 first consumer; Phase 09/09a producer compatibility | +| M8 — recoverable commerce | Purchase-time terms/channel/seller; provider idempotency/status; reordered/lost events; payment/delivery/payable/payout state and reconciliation, reserves/loss allocation, refund/dispute recovery | Marketplace commerce decision; before first central order/money writer | +| M9 — participation | Plan availability and feature/limit/killswitch contract under ADR-0021/0045; admission and consent separate; existing access/outstanding funds survive or terminate under explicit lifecycle rules | P09a-0/2; registry decisions before consumers, no speculative key now | +| M10 — launch readiness | Applicable Phase 11 security, recovery, operations/runbooks and supported deployment proofs; no reliance on foundation wiring alone | Before P09a-4 live sales | +| M11 — pilot evidence | Sellers/offers/commission commitment; sample/window, metric owners, numeric stop/go thresholds and counted acquisition/support/payment/delivery costs | Before live pilot; positive evidence before broad rollout | + +### Proposed packets + +| Packet | Contents | Cannot start until | +|---|---|---| +| P09a-0 | Accepted scope/feasibility and sequencing record; exact first-consumer contracts and any required ADRs | Product direction approved; M1 and affected design decisions resolved; prerequisites verified | +| P09a-1 | Public profiles/listings and bounded public catalog/search projection | M3/M5/M6 accepted; applicable identity/source dependencies | +| P09a-2 | Seller admission, moderation, staff/support access and participation control | M4/M5/M9 accepted; catalog boundary | +| P09a-3 | Single-seller offer/checkout, shared live capacity, attributed fulfillment, refunds/payables/payouts and reconciliation | M1/M2/M7/M8 accepted; relevant Phases 07/08b/08c/09 inputs | +| P09a-4 | Readiness verification, bounded live pilot and evidence/closeout | Prior packets pass; M10/M11 accepted and readiness proved | + +A referral experiment remains a separately selectable alternative, not the selected +full-checkout pilot. No current Phase 02d seed fakes marketplace sales or grants. + +## Deliverables + +- Accepted milestone/ADR package, module specification, permission and audit matrices. +- Opt-in approved public catalog and seller operations with tested privacy boundaries. +- One dated live offer and single-seller checkout with verified shared capacity. +- Recoverable, attributable delivery and provider-backed financial handling. +- Approved operational, privacy and launch proof records. +- A dated pilot result and stop/go decision with costs and limitations disclosed. + +## Completion Criteria + +- Institution sites remain independently usable; listing withdraws without ownership + transfer or exposing private content. +- Public projection/search contains only declared public data; no privileged source + connection, invented tenant context or tenant-search filter removal. +- Staff approval and support enforce resource-scoped permissions and audit; + membership in an institution does not confer platform moderation. +- Competing site/marketplace purchases cannot confirm the same last cohort place. +- Purchase attribution supports independent grants and refund of only the relevant + justification. Restricted content/media stays protected. +- Duplicate, reordered or lost provider events, ambiguous responses, failed delivery, + post-payout refunds and reconciliation have tested recovery/escalation outcomes. +- Company/country/provider/legal roles and actual processing purposes are validated. +- Applicable launch controls pass before real payment, not only a manual-provider test. +- Pilot metrics are evaluated against thresholds recorded before launch. Broad rollout + occurs only through a separate positive evidence decision. + +## Risks + +- Institution software demand does not prove profitable buyer acquisition. +- A successful course grant can conceal failed live delivery or oversold seats. +- International card acceptance does not prove foreign-seller onboarding/payout. +- Global discovery can accidentally become a global private-data or operator surface. +- A region label does not establish the complete transfer or media-rights boundary. +- Provider/local state can diverge even in one SaaS installation; retries alone do not + reconcile money or fulfill the teaching promise. + +## Phase Exit Decision + +Close this **pilot** only with exact delivered scope, first-consumer ADR/contracts, +test and readiness evidence, financial/operational recovery records and a dated +stop/go outcome. A negative pilot result can close a bounded experiment; it cannot +authorize broad rollout. Name the next approved scope explicitly rather than +silently expanding to federation, multi-seller commerce or a session ledger. + +This Proposed record does not change the Accepted roadmap baseline until its decision +package is approved. P02d-2 readiness depends on its own access and writer decisions, +not on closing these future commerce gates. diff --git a/docs/standards/07-frontend-architecture.md b/docs/standards/07-frontend-architecture.md index a65b000e..72fe7275 100644 --- a/docs/standards/07-frontend-architecture.md +++ b/docs/standards/07-frontend-architecture.md @@ -196,6 +196,12 @@ export default async function CourseListPage() { ## Tenant Branding +**Prepared G16(a–e)/G21 proposal — 2026-10-02, approval pending.** The +[Tenancy contract](../modules/tenancy/README.md#whole-theme-setting-and-public-boundary) +selects one whole-theme color document, contrast refusal, no organization override +and no font/logo/URL/layout value. Public transport/attribution and injection remain +G16(f/g)/G42; this proposal does not claim a themed layout or an anonymous API exists. + > **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). diff --git a/docs/standards/08-localization.md b/docs/standards/08-localization.md index b77888b9..c3348cae 100644 --- a/docs/standards/08-localization.md +++ b/docs/standards/08-localization.md @@ -142,9 +142,11 @@ translation in the requested locale has no URL in that locale, and a link to it omitted rather than rendered dead. A slug collision is refused when the translation is inserted; its writing command -returns `Result.Fail(business_rule_violation, …)`. P02d-2 -[G11](../roadmap/phase-02d-walking-skeleton.md#the-decision-register) remains open; -the pass that resolves it names the selected command and its concrete error mapping +returns `Result.Fail(business_rule_violation, …)`. The prepared, approval-pending +[P02d-2 G11 answer](../roadmap/phase-02d-walking-skeleton.md#p02d-2-proposed-answers) +selects `AddCourseTranslationCommand` and `AddLessonTranslationCommand` for Education, +mapping their named localized-slug uniqueness constraints at insertion. Publication +does not reserve or newly collide a slug. On approval, record the accepted mapping here and in [Phase 04's collision criterion](../roadmap/phase-04-cms-media-pages.md#completion-criteria). The refusal names the conflicting entity when the caller may read it — tenant-wide rows and the caller's own organization's rows both qualify under the canonical policy — and @@ -224,6 +226,13 @@ var msg = _stringLocalizer["course.publish.success"]; > remaining parts are in > [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +**Prepared G13 answer — 2026-10-02, approval pending.** The +[Tenancy contract](../modules/tenancy/README.md#locale-guarantees-and-read-contract) +selects no platform registry: use LocaleTag's existing grammar, canonicalization and +35-character bound, then the tenant's enabled membership. No locale rows authorize +no content locale, rather than an implicit `en`. Request G6(b) and display G24 remain +their later packet parts. + ## Right-to-Left - The platform supports RTL languages from the start. diff --git a/docs/standards/16-accessibility.md b/docs/standards/16-accessibility.md index ab6d9fa7..17da071f 100644 --- a/docs/standards/16-accessibility.md +++ b/docs/standards/16-accessibility.md @@ -54,6 +54,12 @@ LearnStack is an education platform; learners with disabilities are a first-clas warning, is G16 (d) in [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). + **Prepared G16(d) proposal — 2026-10-02, approval pending:** the first whole-theme + command refuses a failing pair before saving. Its + [complete palette contract](../modules/tenancy/README.md#whole-theme-setting-and-public-boundary) + defines supported usage and atomic replacement. The future Studio can explain + that refusal; a warning does not authorize saving an invalid palette. + ### Images and Media - `alt` attribute on every `` content image. Decorative images use `alt=""`. diff --git a/docs/standards/README.md b/docs/standards/README.md index 02b55460..faa568e8 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -90,7 +90,7 @@ 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 data tables. P02d-1 Step 2 adds Education: five chains and twenty-one data tables (twenty-six including five migration-history tables), with parent-mirror and Pattern A proofs. 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` — are 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 required as of P02d-1, 2026-09-14 ([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. P02d-1 Step 2 implements Education's Pattern A satellites, canonical locales and flat localized-slug uniqueness; public locale resolution remains P02d-4. 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. | +| 08 | [Localization](08-localization.md) | **Active** | Packet 6 shipped `tenant_locales` and the slug schema. The partial unique default index enforces at most one default, not existence of a default for every enabled set; P02d-2's proposed supported-write and default-enabled guards are not implemented yet. `LocalizedText` and `LocalizedMessage` ship with their own cases, and API errors are keyed. P02d-1 shipped Education Pattern A satellites, canonical locales and flat localized-slug uniqueness; public locale resolution remains P02d-4. The i18n runtime belongs to [Phase 04](../roadmap/phase-04-cms-media-pages.md); Phase 02d's redirect (G36) and UI messages/library (G39) remain open in its register. | | 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. | From 3f849d1001aabf3412cd073915319b1f680de3b2 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 2 Oct 2026 01:20:53 +0300 Subject: [PATCH 05/28] docs(roadmap): record P02d-2 preparation verification Record two independent review rounds and the completed architecture, documentation and hook checks without claiming future implementation proofs or accepting the proposed ADRs. --- docs/roadmap/phase-02d-walking-skeleton.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index a0bd5de9..898aa6d1 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -514,6 +514,25 @@ counts and results in the delivery record, not a speculative passing total here. #### Approval boundary and readiness +**Preparation verification — 2026-10-02.** The reviewed documentation commit is +`7bc8e63`, against `6bbcb1e`; no implementation is included. First-round security +and governance findings were verified and fixed: post-build extension resolution, +explicit lesson seed states, no platform locale registry and endorsed participation +wording. Two fresh second-round agents independently returned Approve, with no +remaining actionable findings. Reviews used `gpt-6-astra` (high) for security and +`gpt-6.1-sol` (xhigh) for governance/seed/corpus consistency. + +- Architecture: 177 passed, zero failures/skips, Release `--no-build --no-restore`; + the TRX execution-count check passed. This verifies existing guards, not future + policy migrations, writers or public readers. +- Documentation: 33 Markdown files, 1,715 local references and 444 fragments checked; + added prose wrapping and `git diff --check` passed. External provider/legal evidence + was not reassessed in this preparation pass. +- Accepted ADRs were unchanged. The entire P02d-1 decision/delivery suffix was + compared byte-for-byte with the baseline and is unchanged. +- Commit hooks, including staged Leakwatch and commit-message validation, passed. + Work remains on development; no push or PR was performed. + Prepared decisions are reviewable; implementation is not authorized by this draft. The only outstanding **P02d-2 decision** is exact approval of this package and ADR-0050/0051. After approval, perform the recorded lifecycle/gate/catalogue updates From 45805c8d78bf8b5a8a22ba6307e8064de148df2e Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 2 Oct 2026 01:42:18 +0300 Subject: [PATCH 06/28] docs: accept P02d-2 decisions and reconcile planning records Record maintainer approval of ADR-0050/0051 and the exact packet plan. Retain prior decision history, disclose ADR-0048 supersession and append ADR-0043's compatible extension. Align current gate and corpus references, register future source proofs, and preserve later open decisions. Validate Markdown references and existing architecture guards; no code is delivered. Implementation waits at the maintainer's explicit request. ADR: 0043, 0048, 0050, 0051 Module: Tenancy, Customization, Education --- .claude/skills/seed-tenant/SKILL.md | 9 +- CLAUDE.md | 10 +- README.md | 5 +- docs/architecture/02-domain-model.md | 6 +- docs/architecture/12-localization.md | 19 ++- docs/architecture/14-frontend-architecture.md | 30 ++-- .../32-tenant-customization-model.md | 45 +++--- .../34-course-marketplace-scoping.md | 36 ++--- .../0043-customization-payload-validation.md | 19 +++ .../0048-walking-skeleton-publication.md | 18 ++- ...nstitution-sites-and-course-marketplace.md | 37 ++--- ...0-publication-and-course-content-access.md | 34 ++-- .../0051-ordered-text-card-presentation.md | 23 +-- docs/decisions/README.md | 22 +-- docs/glossary.md | 14 +- docs/modules/customization/README.md | 12 +- docs/modules/education/README.md | 30 ++-- docs/modules/education/audit.md | 27 ++-- docs/modules/education/permissions.md | 6 +- docs/modules/tenancy/README.md | 27 ++-- docs/modules/tenancy/audit.md | 9 +- docs/modules/tenancy/permissions.md | 6 +- docs/roadmap/README.md | 2 +- docs/roadmap/phase-02d-walking-skeleton.md | 150 +++++++++++------- docs/roadmap/phase-03-identity-admin.md | 10 +- docs/roadmap/phase-04-cms-media-pages.md | 29 ++-- .../phase-05-education-learning-content.md | 6 +- .../phase-07-enrollment-learner-portal.md | 6 +- docs/standards/07-frontend-architecture.md | 8 +- docs/standards/08-localization.md | 24 +-- docs/standards/16-accessibility.md | 10 +- .../21-architecture-tests-catalogue.md | 33 ++++ docs/standards/README.md | 2 +- 33 files changed, 422 insertions(+), 302 deletions(-) diff --git a/.claude/skills/seed-tenant/SKILL.md b/.claude/skills/seed-tenant/SKILL.md index 91e7ab19..0590034c 100644 --- a/.claude/skills/seed-tenant/SKILL.md +++ b/.claude/skills/seed-tenant/SKILL.md @@ -17,13 +17,14 @@ description: > # Seeding a tenant -**Preparation update — 2026-10-02.** +**Decision acceptance — 2026-10-02.** [P02d-2's package](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-2-decision-package-2026-10-02) -is Proposed, not seeded implementation. It plans nullable organization contexts, +is Accepted, not seeded implementation. It plans nullable organization contexts, contextual verification through `ISender`, tenant-specific type/taxonomy definitions, enabled locales, one whole-theme setting and explicit public/restricted content. -Exact ADR-0050/0051 and packet approval precede code. Until implementation, the -current command below still seeds only the shipped provisioning and built-in slice. +ADR-0050/0051 and the package are approved; implementation waits at the maintainer's +request. Until implementation, the current command below still seeds only the shipped +provisioning and built-in slice. ## Purpose diff --git a/CLAUDE.md b/CLAUDE.md index 9f048d9f..7e189450 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,11 +69,11 @@ its decision pass. **P02d-1 is complete and merged** through [PR #22](https://github.com/HodeTech/LearnStack/pull/22) on 2026-09-14. Education's domain, schema and isolation proofs pass; all three steps completed two independent agent review rounds. The [merge closeout](docs/roadmap/phase-02d-walking-skeleton.md#merge-and-closeout-2026-09-14) -records verification of the final PR head and merge commit. **P02d-2 preparation is -complete for review**: its [decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-2-decision-package-2026-10-02) -proposes protected content, exact write contracts and a four-step implementation. -ADR-0050/0051 and the package require exact approval before code. Implementation -has not started; public reads belong to P02d-4. +records verification of the final PR head and merge commit. **P02d-2's decision pass +is Accepted — 2026-10-02**: its [decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-2-decision-package-2026-10-02) +and ADR-0050/0051 establish protected content, exact write contracts and four +implementation steps. Lifecycle/gate/catalogue documentation is updated. Implementation +has not started and waits at the maintainer's request; public reads belong to P02d-4. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` (core + 7 modules × 4 projects + 4 test projects including the diff --git a/README.md b/README.md index 3aed4eef..ed52fd33 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,9 @@ items, rules, custom fields and notification templates; their delivery is tracke **Phase 01 and Phase 02a are complete. Phase 02d is in progress.** [P02d-1](docs/roadmap/phase-02d-walking-skeleton.md#merge-and-closeout-2026-09-14) is **complete and merged**: Education domain, schema and isolation proofs. -**P02d-2's decision package is prepared for approval**, including two Proposed ADRs -and four implementation steps. Command handlers and seed writes have not started. +**P02d-2's decision package and ADR-0050/0051 are Accepted** as of 2026-10-02, +with four implementation steps. Documentation is updated; command handlers and seed +writes have not started. Implementation waits at the maintainer's request. **P02d-4** owns anonymous public API reads. Browser rendering follows in P02d-5–7; none of these later packets has started. diff --git a/docs/architecture/02-domain-model.md b/docs/architecture/02-domain-model.md index 0ff29fc9..f6b91c29 100644 --- a/docs/architecture/02-domain-model.md +++ b/docs/architecture/02-domain-model.md @@ -313,11 +313,11 @@ per ADR-0018, not on `Membership` extension tables. > owns the migration and its verification. P02d-1 accepts this design before its > implementation. -**P02d-2 preparation — 2026-10-02, approval pending.** -[ADR-0050](../decisions/0050-publication-and-course-content-access.md) proposes +**P02d-2 accepted design — 2026-10-02, not implemented.** +[ADR-0050](../decisions/0050-publication-and-course-content-access.md) defines Course-level content-access policy inherited by independent Lessons; it is neither a grant nor a price and is not part of the shipped diagram. Phase 05 preserves and -locates that policy in its versioned model if the proposal is accepted. +locates that policy in its versioned model before its writers. ## Assessment diff --git a/docs/architecture/12-localization.md b/docs/architecture/12-localization.md index e23c31c1..179af065 100644 --- a/docs/architecture/12-localization.md +++ b/docs/architecture/12-localization.md @@ -48,10 +48,11 @@ CREATE TABLE tenant_locales ( ``` No public no-row fallback is implemented. The -[prepared P02d-2 G13 answer](../roadmap/phase-02d-walking-skeleton.md#p02d-2-proposed-answers) -proposes no content locale when rows are absent and refuses disabled membership; -it awaits approval. This replaces the earlier unimplemented platform-`en` proposal -on acceptance. Display-label fallback remains separate from URL/body admission. +[accepted P02d-2 G13 answer](../roadmap/phase-02d-walking-skeleton.md#p02d-2-accepted-answers) +admits no content locale when rows are absent and refuses disabled membership. +This replaces the earlier unimplemented platform-`en` proposal. Writer enforcement +belongs to P02d-2; public reads remain P02d-4. Display-label fallback is separate +from URL/body admission. 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 @@ -74,7 +75,8 @@ the accepted P02d-1 Course shape; the [Education module spec](../modules/education/README.md#data-model-and-invariants) owns its complete model. `Course` and `Lesson` are independent roots with independent `draft` / `published` states under -[ADR-0048](../decisions/0048-walking-skeleton-publication.md). Catalog visibility, +[ADR-0050](../decisions/0050-publication-and-course-content-access.md). Its accepted +access-policy column is not part of this shipped-schema sketch. Catalog visibility, SEO and course versions are Phase 05 additions, not columns this sketch implies have already shipped. @@ -246,15 +248,16 @@ anyone editing a slug. Preferring the tenant-wide row is worse: it lets an organ author create a row no URL can reach. One flat namespace per `(tenant_id, locale)` removes the question. An organization that wants its own variant of a shared course gives it its own slug. The constraint refuses a collision when the translation is inserted; -P02d-2's G11 decision names the writing command and its error contract. Rendering never -chooses between two colliding translations. +P02d-2's accepted G11 answer names translation-add commands and their error mapping. +Rendering never chooses between two colliding translations. For Education, the flat namespace is separate per translation table: all course slugs share one namespace and all lesson slugs another. A lesson's parent course does not narrow the lesson namespace. A draft translation reserves its slug immediately, and a parent's soft deletion does not release it because the satellite has no independent `deleted_at`. Parent publication and deletion still gate public read eligibility under -[ADR-0048](../decisions/0048-walking-skeleton-publication.md); a reserved slug does not +[ADR-0050](../decisions/0050-publication-and-course-content-access.md), with content +policy additionally gating lesson exposure; a reserved slug does not make draft content public. Future slug release belongs to Phase 05 with Phase 04's redirect/slug registry. diff --git a/docs/architecture/14-frontend-architecture.md b/docs/architecture/14-frontend-architecture.md index 61102c47..5965a2e1 100644 --- a/docs/architecture/14-frontend-architecture.md +++ b/docs/architecture/14-frontend-architecture.md @@ -232,7 +232,7 @@ Static export is not used; tenants are resolved at request time and the renderer ## Theming -**P02d-2 proposal — 2026-10-02, approval pending.** The +**P02d-2 accepted design — 2026-10-02, not implemented.** The [whole-theme contract](../modules/tenancy/README.md#whole-theme-setting-and-public-boundary) selects only tenant-wide color values and no remote subresource. Organization merges, logo/font URLs and Studio below are Phase 06 targets, not this packet's behavior. @@ -242,8 +242,9 @@ A tenant's branding flows from the API as design tokens, and the renderer applie 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). +The accepted [Tenancy contract](../modules/tenancy/README.md#whole-theme-setting-and-public-boundary) +owns P02d-2's admitted tokens and values. G16(f/g)'s public projection and entitlement/ +attribution remain in the phase 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 @@ -256,10 +257,10 @@ the merged token set is the source of truth for the SSR'd page. The first paint is themed; there is no FOUC because tokens are injected into the SSR'd HTML. -Logo and font assets are URLs (served from CDN). Custom fonts are validated and -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. +Logo/font assets and uploads are Phase 06 targets, requiring safe media and +subresource contracts before their writers or consumers. P02d-2's accepted color-only +theme admits no asset URL, font or cross-origin subresource. It does not authorize +the future CDN/custom-font behavior. 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 @@ -414,8 +415,9 @@ it asserts, is G44 in - 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). + P02d-2's accepted G16(d) contract refuses failing palettes before saving; the + [Tenancy contract](../modules/tenancy/README.md#whole-theme-setting-and-public-boundary) + owns supported pairs. Phase 06 decides safe override composition before its writer. ## Splitting into Multiple Apps Later @@ -437,12 +439,10 @@ 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. +> **Remaining Phase 02d decision.** Public route caching and its key remain G37 in +> [the phase register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> G16(d) now requires contrast refusal, not a warning-only save. Neither decision +> claims implemented transport or rendering. - **Per-tenant SSR cost** — caching is per `(tenantId, organizationId?, locale, slug)`. Cardinality is bounded; budget memory headroom. diff --git a/docs/architecture/32-tenant-customization-model.md b/docs/architecture/32-tenant-customization-model.md index 1729bc8d..c7f25714 100644 --- a/docs/architecture/32-tenant-customization-model.md +++ b/docs/architecture/32-tenant-customization-model.md @@ -45,22 +45,19 @@ organization-scoped where it makes sense. ## 2. Generic primitive renderers -**P02d-2 presentation proposal — 2026-10-02, approval pending.** +**P02d-2 accepted presentation — 2026-10-02, not implemented.** [ADR-0051](../decisions/0051-ordered-text-card-presentation.md) adds optional root ordered `x-fields` metadata and a plain-string `default-card` profile. The two seed types opt in; legacy schemas remain valid. The wider renderer set below is a target, not implemented Phase 02d coverage. P02d-6 implements only the approved subset. -> **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. +> **Accepted subset; renderer placement remains open.** ADR-0051 selects plain-string +> text cards with authored order and labels; Phase 02d adds no Markdown or active sink. +> ADR-0018's wider closed set below is unchanged and is not implemented coverage. +> G41 still decides unsupported-content fallbacks and component placement. The folder +> in the sketch does not exist; the key registry in +> `frontend/apps/web/src/lib/customization/` does not settle that gate. See +> [Phase 02d's register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). The frontend ships a **fixed, closed set** of primitive renderers: @@ -218,12 +215,11 @@ 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. +> **Broader target example, not the P02d-2 seed.** The +> [accepted inventory](../roadmap/phase-02d-walking-skeleton.md#seed-inventory-and-ownership) +> selects `asana-pose` with plain-string `pose`/`instruction`, `default-card` and +> `yoga-difficulty`. The integer, enum and video example below requires later renderer +> coverage; it does not expand ADR-0051's text-card subset. `tenant_content_types`: @@ -470,7 +466,7 @@ discovering it on a page load. ### 8.1 Validation timing — write time, not read time -ADR-0051's proposed extension preserves the existing four admission gates and adds +ADR-0051's accepted extension preserves the existing four admission gates and adds explicit semantic descriptor validation. Instance validation remains on Education's exact-pin write path; read eligibility precedes public descriptor resolution. The text profile adds no active URL/markup sink. A later schema-valid `uri` is not a @@ -756,13 +752,12 @@ 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. +> **Branding values decided; entitlement remains open.** G16(a–e) selects the +> [whole-theme contract](../modules/tenancy/README.md#whole-theme-setting-and-public-boundary). +> Whether `tenancy.white_label_branding` governs applying tokens or only removing +> LearnStack attribution remains G16(g) in +> [Phase 02d's register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +> The Branding row's "(plan-gated)" is still a target pending that decision. ## 11. Hard architectural invariants diff --git a/docs/architecture/34-course-marketplace-scoping.md b/docs/architecture/34-course-marketplace-scoping.md index 7f89c71b..95f264bc 100644 --- a/docs/architecture/34-course-marketplace-scoping.md +++ b/docs/architecture/34-course-marketplace-scoping.md @@ -10,8 +10,9 @@ approved here. **Review date:** 2026-10-02. The maintainer's target remains institution sites plus a full marketplace, with Turkey and international sales. Initial seller geography is deliberately undecided. The maintainer endorsed the recommendations and authorized -P02d-2 preparation. Exact access, security and commerce contracts still require -approval; legal/provider feasibility does not follow from a product preference. +P02d-2 preparation, then approved ADR-0050/0051 and the exact packet package. +Marketplace security and commerce contracts still require approval; legal/provider +feasibility does not follow from a product preference. ## Proposed delivery ownership @@ -95,22 +96,19 @@ Free enrollment, anonymous previews and restricted content are also separate; pr zero is not an access policy. Public APIs, renderers, caches and protected media enforce the same policy. Failure of a protected-access evaluator denies access. -The current public-only G3 answer and [ADR-0048](../decisions/0048-walking-skeleton-publication.md) -remain binding. If protected authoring is requested in P02d-2, obtain explicit approval -to reopen G3 and write a superseding ADR before code. It selects persisted policy, -defaults, preview behavior, forward migration, command validation and fail-closed reads -until Phase 07 supplies grants. Append a dated G3 supersession and update affected -packet criteria while preserving the accepted question, answer and delivery history. -A hybrid-direction approval alone cannot make that change. - -The alternative is an explicitly public-only skeleton. It supplies no private/paid -authoring promise; a later protected-content owner must land the decision and migration -before its first writer/reader. No prices, channel IDs, orders or federation identifiers -are needed in P02d-2 for either alternative. Public-only work is technically independent -of marketplace commerce. The maintainer released the preparation hold on -2026-10-02. The exact -[ADR-0050](../decisions/0050-publication-and-course-content-access.md) access proposal -and P02d-2 decision package still need approval before protected implementation. +The maintainer approved [ADR-0050](../decisions/0050-publication-and-course-content-access.md) +and the P02d-2 package on 2026-10-02. Its +[dated G3 supersession](../roadmap/phase-02d-walking-skeleton.md#g3-supersession-2026-10-02) +replaces ADR-0048's public-only implication while preserving the original answer and +delivery history. Explicit persisted policy, restricted legacy backfill and fail-closed +anonymous reads are decided; their implementation has not started. Phase 07 supplies +grants; Phase 05 owns preview/policy evolution. + +The public-only alternative was considered and not selected for the first protected +writers. No prices, channel IDs, orders or federation identifiers are needed in +P02d-2. Its access contract is independent of marketplace commerce. The maintainer +requested documentation updates and a wait, so implementation remains paused by +instruction rather than by an unresolved marketplace dependency. ## Public catalog and search @@ -353,7 +351,7 @@ economics stop or re-scope that offer rather than authorize unchecked expansion. | Boundary | Required before the first affected code | |---|---| -| Protected publication | Approved G3 reopening, superseding ADR and migration before protected P02d-2 writers/readers; otherwise public-only baseline | +| Protected publication | ADR-0050 and dated G3 supersession Accepted 2026-10-02; migration and access enforcement still required before protected P02d-2 writers/readers | | Source identity and organization scope | Keep P02d-1 tenant ownership and parent-derived scope; listings are not tenant authority | | Global catalog / commerce | Accept table classes, roles, host/context, audit and export rules before P09a migration or reader/producer; no broader Education filters | | Source publication/export | Consent, revisions, withdrawal and durable delivery before the first listing producer; P02d-2 promises tenant-local publication only | diff --git a/docs/decisions/0043-customization-payload-validation.md b/docs/decisions/0043-customization-payload-validation.md index 993f0bcc..f1c4c30b 100644 --- a/docs/decisions/0043-customization-payload-validation.md +++ b/docs/decisions/0043-customization-payload-validation.md @@ -692,6 +692,25 @@ parsing. `LocalizedText.From` refuses the same characters for a sharper reason: `JsonSerializer` rewrites an unpaired surrogate to `U+FFFD`, so a display name containing one was stored **changed**, with nothing raised anywhere. +### Amendment 5 — optional ordered text-card profile (2026-10-02) + +The maintainer approved [ADR-0051](0051-ordered-text-card-presentation.md) with the +P02d-2 decision package. It defines optional root `x-fields` metadata and a bounded +plain-string text-card profile. This is a compatible extension, not a correction +or a rewrite of the original Decision. Schemas without the extension, including +the immutable built-in `card` and `plain`, retain their existing admission rules. + +The four admission gates keep their order. Gate 2 recognizes the extension's syntax; +Gate 4 compiles the schema. Customization resolves descriptor/property/label and +composite compatibility after Gate 4 and before persistence, without giving the +generic validator module-registry access. Unknown inert dialect annotations retain +their original behavior; recognized metadata cannot silently bypass its resolver. + +ADR-0051 owns the full profile, compatibility and safe-text contract. Its accepted +P02d-2 gate parts are recorded in the phase register. The Customization spec, Tenant +Customization Model, glossary and ADR index link the extension. No profile code or +renderer is delivered by this amendment; P02d-2 and P02d-6 own implementation. + ## References - [ADR-0018](0018-tenant-driven-customization-model.md) — the customization model diff --git a/docs/decisions/0048-walking-skeleton-publication.md b/docs/decisions/0048-walking-skeleton-publication.md index 9d89d16b..07f6a33c 100644 --- a/docs/decisions/0048-walking-skeleton-publication.md +++ b/docs/decisions/0048-walking-skeleton-publication.md @@ -2,7 +2,12 @@ ## Status -Accepted — 2026-09-14. Maintainer approval precedes P02d-1 implementation. +Superseded by [ADR-0050](0050-publication-and-course-content-access.md) — 2026-10-02. +Originally Accepted on 2026-09-14 before P02d-1 implementation. + +ADR-0050 retains independent publication but separates course content-access policy +from anonymous eligibility. The original decision below remains unchanged as the +P02d-1 record; the current contract and migration obligations belong to ADR-0050. ## Decision Drivers @@ -121,6 +126,17 @@ and matrix guards cover the publication commands when P02d-2 introduces them. Behavioral tests, rather than a source-text scan, prove allowed and refused transitions in P02d-1 and combined read eligibility in P02d-4. +## Amendments + +### Amendment 1 — supersession for protected authoring (2026-10-02) + +The maintainer approved ADR-0050 and the P02d-2 decision package. This lifecycle +change supersedes the public-only implication; it is not an erratum. Independent +`draft → published` transitions survive unchanged. The Status banner and ADR index +now link forward, and the phase register carries a dated G3 supersession. Current +Education, localization, audit and Phase 05/07 planning references use ADR-0050; +the original P02d-1 accepted answer and delivery history remain intact. + ## References - [ADR-0008: Localization Schema](0008-localization-schema.md) diff --git a/docs/decisions/0049-institution-sites-and-course-marketplace.md b/docs/decisions/0049-institution-sites-and-course-marketplace.md index 09b796f0..3b6fb28d 100644 --- a/docs/decisions/0049-institution-sites-and-course-marketplace.md +++ b/docs/decisions/0049-institution-sites-and-course-marketplace.md @@ -29,8 +29,8 @@ not approval of unseen access or commerce contracts. This ADR remains Proposed. place, a session reservation or a consumable pack creates different obligations. - [P02d-1](../roadmap/phase-02d-walking-skeleton.md#merge-and-closeout-2026-09-14) shipped tenant-owned courses and lessons with isolation. Their first application - writers are next; [ADR-0048](0048-walking-skeleton-publication.md) still governs - public-only publication. + writers are next; [ADR-0050](0050-publication-and-course-content-access.md) + separates publication/access under the accepted P02d-2 package, not commerce. - Shared discovery must not expose private lessons, learners, finances or operations across institutions. Marketplace economics and operational readiness are unproven. @@ -105,24 +105,19 @@ keeps those choices separate. ### Publication, discovery and access [G3's publication answer](../roadmap/phase-02d-walking-skeleton.md#p02d-1-accepted-answers) -closed on 2026-09-14 through ADR-0048. P02d-2 inherits public-only publication; only -G3's command names and seeded states remain open. This proposal does not reopen it. - -Protected authoring in P02d-2 requires explicit maintainer approval to reopen that -part of G3, then an approved **superseding access ADR**, migration/default/denial -contract and dated G3 supersession before writers. Preserve the original question, -accepted answer and delivery record. P02d-4 must deny restricted bodies until an -authenticated access evaluator exists. A free price or a public listing grants no -protected access. The [access process](../architecture/34-course-marketplace-scoping.md#publication-discovery-and-access) -describes the alternatives; accepting hybrid direction alone chooses neither. - -Public-only P02d-2 has no technical dependency on marketplace commerce. The -[planning hold](../roadmap/phase-02d-walking-skeleton.md#pending-course-marketplace-proposal) -reflected the maintainer's request to settle direction first. Their 2026-10-02 -endorsement releases it for preparation. The exact -[access proposal](0050-publication-and-course-content-access.md) and P02d-2 decision -package require approval before protected implementation; commerce feasibility is -not a hidden dependency of this packet. +closed on 2026-09-14 through ADR-0048. Hybrid direction endorsement alone did not +reopen that public-only implication. The maintainer subsequently approved +[ADR-0050](0050-publication-and-course-content-access.md) and the exact P02d-2 package +on 2026-10-02; the +[dated G3 supersession](../roadmap/phase-02d-walking-skeleton.md#g3-supersession-2026-10-02) +preserves the original question, accepted answer and delivery history. ADR-0050 +owns independent publication, persisted policy, restricted backfill and denial +before anonymous lesson exposure. This marketplace proposal itself supersedes no ADR. + +P02d-2 has no technical dependency on marketplace commerce. Its decision pass is +Accepted; implementation waits at the maintainer's explicit request after the +documentation update. Commerce feasibility does not reopen its access decision or +silently block the independent packet. ### Commerce and the Hub boundary @@ -197,7 +192,7 @@ cannot approve an unspecified schema, role or payment arrangement. | Operations and support | Backoffice owner and staff population; approval, suspension, refund/dispute and delivery support responsibilities. Assign realm/audience, permission/resource scope, exceptional private review and reasoned audit contracts before the first staff reader or crossing | | Privacy and distribution | Purpose-based controller/processor assessment, platform versus institution permissions/consent, DSAR/export/erasure and retention ownership; data residency/transfers and media rights. Include Architecture 23 and Phase 03 in the approval impact set | | Public-read and commerce security | Owner of the request/host matrix, table classes, reader/writer roles, audit classification and ordered/recoverable fulfillment/payable/payout contract before their first migrations, readers or producers | -| P02d-2 access and planning | Preparation hold released on 2026-10-02; ADR-0050 proposes protected access and must receive exact approval with the P02d-2 package before protected writers | +| P02d-2 access and planning | ADR-0050/0051 and the exact package Accepted 2026-10-02, with dated G3 supersession; implementation waits at maintainer request. This does not accept marketplace commerce | | Pilot evidence | Approve the entry conditions, metric owners and a dated stop/go threshold record before a live pilot; positive evidence is a broad-rollout gate | Approval of this draft requires the positioning and named roadmap changes together. diff --git a/docs/decisions/0050-publication-and-course-content-access.md b/docs/decisions/0050-publication-and-course-content-access.md index 58a4154f..74bb2a72 100644 --- a/docs/decisions/0050-publication-and-course-content-access.md +++ b/docs/decisions/0050-publication-and-course-content-access.md @@ -2,13 +2,14 @@ ## Status -Proposed — 2026-10-02. Prepared after the maintainer endorsed protected-content -preparation before P02d-2. Exact policy and migration approval are still pending. +Accepted — 2026-10-02. The maintainer approved the exact access policy, migration +and P02d-2 decision package. Implementation has not started. **Date:** 2026-10-02 -**Deciders:** Cemil (repository maintainer; approval pending) -**Relationship:** Supersedes ADR-0048 on acceptance; it does not supersede it while -this record is Proposed. The existing G3 answer remains binding until that approval. +**Deciders:** Cemil (repository maintainer) +**Relationship:** Supersedes ADR-0048. The dated +[G3 supersession](../roadmap/phase-02d-walking-skeleton.md#g3-supersession-2026-10-02) +preserves the original answer and delivery history. ## Decision Drivers @@ -25,7 +26,7 @@ this record is Proposed. The existing G3 answer remains binding until that appro ## Considered Options -1. **Course-level policy inherited by lessons** (recommended). Adds one explicit +1. **Course-level policy inherited by lessons** (chosen). Adds one explicit creation-time policy; separates publication from access without a grant subsystem. 2. **Retain published = anonymously readable** (rejected for protected authoring). Valid public-only baseline, but cannot safely represent the approved paid direction. @@ -38,9 +39,9 @@ this record is Proposed. The existing G3 answer remains binding until that appro ## Decision LearnStack separates independent publication from a course's content-access policy. -The proposed `Course.ContentAccess` is `public` or `enrollment_required`; lessons +`Course.ContentAccess` is `public` or `enrollment_required`; lessons inherit their parent's policy. Publication never creates a learner grant or selects -a sales channel. This Decision takes effect only after exact maintainer approval. +a sales channel. ### Storage and first writers @@ -69,7 +70,7 @@ publication does not claim a lesson mutation or access grant in audit. ### Anonymous exposure -| Surface | Proposed eligibility and data | +| Surface | Eligibility and data | |---|---| | Course catalog / course detail | Published, live course in the admitted tenant/organization and enabled requested locale; declared marketing fields (title, summary, slug, level label) and policy can be public under either policy | | Lesson list / detail under `public` | Existing parent/child publication, translation, deletion, organization and course-membership checks all pass; body access additionally requires the parent's policy to be `public` | @@ -148,11 +149,11 @@ skeleton in P02d-1. Its lifecycle remains useful, but the anonymously readable implication changes when protected authoring is selected. This is a new decision, not a correction of a false historical statement. -On exact approval, append a dated G3 supersession in -[Phase 02d](../roadmap/phase-02d-walking-skeleton.md#the-decision-register), update G3's -current status and affected packet criteria, and mark ADR-0048 superseded according -to repository governance. Preserve the original accepted answer and delivery history. -Until that approval, this text is a proposal and P02d-2 code cannot rely on it. +The maintainer approved this contract on 2026-10-02. The +[dated G3 supersession](../roadmap/phase-02d-walking-skeleton.md#g3-supersession-2026-10-02) +records the current policy and packet obligations; ADR-0048 retains its original +decision as history. Acceptance establishes the contract, not a shipped migration, +writer or reader. ## Consequences @@ -176,7 +177,7 @@ Until that approval, this text is a proposal and P02d-2 code cannot rely on it. ## Architecture Tests -Proposed obligations, not implemented tests: +Accepted obligations, not implemented tests: - New policy requires explicit valid input; storage rejects every other value. - Migration restricts existing rows and preserves all other data; disposable Down/Up @@ -187,7 +188,8 @@ Proposed obligations, not implemented tests: - Caches, direct lookup, credentials and media projection cannot bypass access. - Seed convergence verifies policy exactly; conflicting existing policy fails nonzero. -Register concrete tests only with their implementation. Existing tenant/organization, +Reserve agreed rule names as Registered in the architecture catalogue before code; +mark them Implemented only when their tests exist and run. Existing tenant/organization, one-root publication, concurrency and audit guards remain required. ## References diff --git a/docs/decisions/0051-ordered-text-card-presentation.md b/docs/decisions/0051-ordered-text-card-presentation.md index 3967b69b..2e120116 100644 --- a/docs/decisions/0051-ordered-text-card-presentation.md +++ b/docs/decisions/0051-ordered-text-card-presentation.md @@ -2,11 +2,12 @@ ## Status -Proposed — 2026-10-02. Exact approval is required before P02d-2 presentation code. -Extends ADR-0043's schema profile; does not amend an Accepted record while Proposed. +Accepted — 2026-10-02. The maintainer approved the exact presentation contract +and P02d-2 decision package. Extends ADR-0043's schema profile through its dated +Amendment 5; profile and renderer implementation have not started. **Date:** 2026-10-02 -**Deciders:** Cemil (repository maintainer; approval pending) +**Deciders:** Cemil (repository maintainer) ## Decision Drivers @@ -22,7 +23,7 @@ Extends ADR-0043's schema profile; does not amend an Accepted record while Propo ## Considered Options -1. **Root `x-fields` ordered descriptors** (recommended). One array carries order +1. **Root `x-fields` ordered descriptors** (chosen). One array carries order and localized labels; direct correspondence with root properties is validated. 2. **Per-property `x-order` and `x-label`** (rejected). Requires tie, missing-order and fallback rules in addition to label validation. @@ -36,8 +37,8 @@ Extends ADR-0043's schema profile; does not amend an Accepted record while Propo ## Decision LearnStack represents ordered text-card fields with an optional root-level -`x-fields` array in a content type's JSON Schema. This is a proposed extension of -[ADR-0043](0043-customization-payload-validation.md), effective only on exact approval. +`x-fields` array in a content type's JSON Schema. This extends +[ADR-0043](0043-customization-payload-validation.md) without changing its four gates. It adds no renderer key, presentation column, live-key binding or compiled cache. ```json @@ -132,12 +133,13 @@ cross-module locale-membership invariant. publishes two explicit text-card types and verifies their order/labels on reruns. - P02d-4 resolves public descriptors only after content-access eligibility. - P02d-6 implements this subset; G41 still decides component placement and fallbacks. -- On approval, link the extension from ADR-0043 through a dated amendment that - preserves its original decision, and close G18/G19's P02d-2 parts in the register. +- ADR-0043 Amendment 5 links this extension without rewriting its original decision. + The phase register records G18/G19's accepted P02d-2 parts; later sink and renderer + decisions remain with their named owners. ## Architecture Tests -Proposed obligations, not implemented tests: +Accepted obligations, not implemented tests: - Reject malformed, nested, duplicate, missing and unknown descriptors with pointers. - Reject invalid labels, unsupported shape/annotations and incompatible composites. @@ -146,7 +148,8 @@ Proposed obligations, not implemented tests: - Unsafe-looking strings remain text; unsupported data never reaches an active sink. - Changed seed schemas/labels under an existing exact pin fail convergence. -Register test names only when their implementation is present. +Reserve agreed rule names as Registered before code; mark them Implemented only +when their tests exist and run. ## References diff --git a/docs/decisions/README.md b/docs/decisions/README.md index b2f35678..76e0cc1d 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -64,22 +64,21 @@ an amendment is not a lifecycle status change. | 0043 | [What LearnStack Does With a Tenant-Authored Payload](0043-customization-payload-validation.md) | JSON Schema draft 2020-12, evaluated by `JsonSchema.Net` **pinned at 8.0.5** (the last MIT-expression release) behind an `IJsonSchemaValidator` port. A tenant document passes **four ordered gates** — JSON, the LearnStack profile, the 2020-12 meta-schema, then the build — because the library's own defaults keep none of the rules the corpus states: its default dialect is not 2020-12, a tenant's `$schema` line overrides the pinned one, a tenant's `$id` becomes process-global state (first-writer-wins, a cross-tenant denial of service), and `true` / `false` / `{}` are all valid schemas that switch validation off. `pattern` is refused — the built `Regex` carries an infinite `MatchTimeout` and `maxLength` does not short-circuit it (measured, 30.2 s on one property). **No compiled-validator cache**: compiling is measured *cheaper* than evaluating (0.5× at the 100-property ceiling), so § 8.2's cache is deleted rather than re-keyed. Rule bodies are opaque `text` with a `dialect` discriminator naming the language of their `condition` expressions. Amendments 1–2 (2026-09-04/05) refuse **every** `$ref` cycle and bound the reference graph; Amendment 3 (2026-09-06) seals the port's exception surface and makes `ValidateInstance` enforce § 8.4's entry cap; **Amendment 4 (2026-09-07) refuses what the `jsonb` column refuses** — `U+0000`, an unpaired surrogate, a number outside `numeric` | | 0044 | [The Audit Write Path](0044-audit-write-path.md) | **Amendments 1–5 (2026-09-08), 6–7 (2026-09-11).** The eleven questions ADR-0033 left open, decided before the first audit file: the reserved **non-nil** platform sentinel tenant id (the nil uuid is refused by three shipped guards that read all-zero as *no tenant*), and a `tenants` CHECK that keeps it unprovisionable; the row's `tenant_id` is the tenant the **transaction announced**, so a provisioning command audits under the tenant it creates and `make seed` still commits; **N intents per request**, one per audited resource, because two shipped commands already write two — and only the **owning** unit-of-work frame writes them or reports the commit boundary, which is what stops a nested `Send` from claiming durability nothing committed; `outcome` closes at four values (`indeterminate` included) and the writer always supplies `timestamp`, so ADR-0033's deliberate duplicate-id pair is legal under `(id, timestamp)` rather than a `23505`; the catalogue is **code** keyed by request type and the matrix is prose, with a test asserting they agree, and every request must be classified — there is no `RequestKind.Other`; the interceptor captures **every** tracked entity minus a named exclusion list, because the `AuditableEntity<>` predicate was blind to `platform_host_to_tenant`; `[PiiSensitive]` and an elision record that preserves the column's JSON type; `audit_log` is org-scoped, carries **no** FK to `tenants`, and both standalone writers announce **two** GUCs — measured: an org-scoped `denied` row is refused without the second. Amendment 1 makes `audit_config` tenant-**wide**; Amendment 2 corrects the redaction sentinel to the shipped constant and the reason `FORCE` needs a trigger (the policy binds the owner by tenant, not by immutability — measured); Amendment 3 gives the matrix↔catalogue join two directions with two domains, because 23 of 30 shipped matrix slugs have no request type and the rule as written was red on arrival, and moves the audit value types into `SharedKernel.Audit` to break the same project cycle `AuditEntryId` had; Amendment 4 makes `[PiiSensitive]` property-granular, `jsonb` included, ships `audit_config` unwritten and binds the matrix rule to modules with code; Amendment 5 guards the sentinel where it is announced and fills `entity_type` / `entity_id` from the declared aggregate; Amendment 6 — from an external review that reproduced four defects against real PostgreSQL — has a handler designate the subject when it writes two instances of its aggregate, carries the actor and correlation id on the intent, keeps each intent's own request's result, captures a contained entity with its aggregate, and instance-qualifies `changes` pointers; Amendment 7 corrects § 7's count of plain entities — seven today, five of them MUST — and names the Tenancy matrix alone as the one that singles out the host mapping | | 0045 | [The Entitlement and Feature-Flag Socket](0045-entitlement-and-feature-flag-socket.md) | **Amendment 1 (2026-09-08)** reads it against the Hub's merged code: the limit vocabulary is the Hub's, `expires_at`/`valid_until` are nullable, the generation guard admits the equal case, and `platform_killswitches` ships **unwritten** because `DenyAllPlatformAdminGate` makes a writer unreachable until Phase 03. **Amendment 2 (2026-09-11):** a registry's membership is the vocabulary the contract names — fourteen Hub features, nine `limits.*`, two tenant flags, three killswitches — and enforcement, not membership, waits for a consumer. **Amendment 3 (2026-09-11):** a key's fail-open/fail-closed class decides only when no projection exists; one past its grace window is read-only, as ADR-0021 decides. **Amendment 4 (2026-09-11):** of the projection's names, `PlanCode` differs from the wire (`tier`) and `ExpiresAt` from the column (`valid_until`) — § 1 had called both wire differences. Declares the port twenty documents name and none define. `IEntitlementProvider.GetAsync(TenantId)` + `RefreshAsync(projection)`, the projection carrying every field `entitlement-v1.schema.json` requires — **`compliance` and `generation` included**, both already `NOT NULL` columns — and the refresh **generation-guarded inside the write statement**, so a stale push cannot resurrect a revoked plan. `IFeatureFlags` is the only module-facing read and **composes over the provider** rather than querying `platform_entitlement_cache`, which is the only reading under which the Phase 02a criterion — swapping the provider changes the answer — can be true. Limits: **`-1` unlimited, `0` denied**, `long` not `long?`; the inverse reading would have made the degraded read-only mode grant unlimited. `NullEntitlementProvider` is registered in **every** deployment mode, per ADR-0035's default-implementation gate rather than ADR-0020's Development-only switch. Killswitches leave `tenant_feature_flags` for a platform-scoped `platform_killswitches`: a foreign key to `tenants` is a constraint no role or `BYPASSRLS` moves, so the sentinel could never have satisfied it. Push-primary, **not** push-only — ADR-0034's `license/verify` fallback stands; the no-Hub absolute belongs to host resolution | -| 0048 | [Publication Before Course Versioning](0048-walking-skeleton-publication.md) | Accepted 2026-09-14: independent course/lesson publication, combined anonymous-read eligibility and Phase 05 preservation obligations | +| 0050 | [Publication and Course Content Access](0050-publication-and-course-content-access.md) | Accepted 2026-10-02: publication/access separation, restricted backfill and anonymous denial; supersedes ADR-0048; implementation pending | +| 0051 | [Ordered Text Card Presentation](0051-ordered-text-card-presentation.md) | Accepted 2026-10-02: optional root `x-fields`, localized ordered text cards; compatible extension linked by ADR-0043 Amendment 5; implementation pending | ## Proposed ADRs | # | Title | Topic | Target phase / decision point | |---|---|---|---| | 0049 | [Institution Sites and an Optional Course Marketplace](0049-institution-sites-and-course-marketplace.md) | Direction endorsed 2026-10-02; architecture still Proposed, no gate accepted | P02d-2 preparation hold released; remaining contracts before proposed Phase 09a's first consumers | -| 0050 | [Publication and Course Content Access](0050-publication-and-course-content-access.md) | Proposed: course policy, restricted backfill and anonymous denial; supersedes ADR-0048 only on acceptance | P02d-2, before its policy migration and first protected writers | -| 0051 | [Ordered Text Card Presentation](0051-ordered-text-card-presentation.md) | Proposed: optional root `x-fields`, localized ordered text cards; compatible extension of ADR-0043 | P02d-2, before profile code and tenant-type seed publication | -The maintainer endorsed the recommended direction and released the preparation hold -on 2026-10-02. Exact ADR-0050/0051 and packet approval remain the pre-code boundary; -ADR-0049's future commerce contracts are not hidden packet dependencies. The -[phase record](../roadmap/phase-02d-walking-skeleton.md#pending-course-marketplace-proposal) -tracks the endorsement and prepared package. On acceptance, move each row to -Active ADRs instead of duplicating it. The draft SLAs below remain unchanged. +The maintainer endorsed the marketplace direction and accepted ADR-0050/0051 and +the P02d-2 package on 2026-10-02. Their rows are now in Active ADRs; ADR-0049 remains +Proposed and its commerce contracts are not hidden P02d-2 dependencies. The +[phase record](../roadmap/phase-02d-walking-skeleton.md#approval-boundary-and-readiness) +records acceptance and the request to wait before implementation. On future acceptance, +move a Proposed row to Active ADRs instead of duplicating it. Draft SLAs are unchanged. The [scoping companion](../architecture/34-course-marketplace-scoping.md) records the reviewed delivery alternatives and endorsed planning ownership; it accepts no module @@ -88,6 +87,11 @@ Open ADR Drafts below retain their existing phase-exit SLAs. ## Superseded ADRs +- **[ADR-0048 — Publication Before Course Versioning](0048-walking-skeleton-publication.md)** + — superseded by [ADR-0050](0050-publication-and-course-content-access.md) on + 2026-10-02. Independent lifecycle survives; content-access policy replaces the + public-only implication. Original decision and P02d-1 delivery history are retained. + - **ADR-0014 — Adopt Dapr for Cross-Cutting Infrastructure** — superseded by [ADR-0038: Cross-Cutting Port and Event Contracts](0038-cross-cutting-port-and-event-contracts.md) on 2026-08-26. The Dapr choice and ADR-0035 demand gate survive; ADR-0038 restates diff --git a/docs/glossary.md b/docs/glossary.md index 2591e374..9e5fbb2a 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -47,7 +47,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across |------|------------| | **Program** | A higher-level grouping of related courses or learning paths. | | **Course** | A tenant-owned learning product with a stable id. P02d-1 implements its domain, schema and isolation; command and seed writes belong to P02d-2, public reads to P02d-4. [Phase 05](roadmap/phase-05-education-learning-content.md#what-phase-02d-supplies) adds versioning and catalog visibility. | -| **Publication** | The independent `draft` / `published` lifecycle of a walking-skeleton Course or Lesson. A published state is one condition of anonymous-read eligibility; tenant, organization, translation, deletion and parent eligibility still apply. It does not create a Course Version or grant Course Access. [ADR-0048](decisions/0048-walking-skeleton-publication.md) owns the lifecycle. | +| **Publication** | The independent `draft` / `published` lifecycle of a walking-skeleton Course or Lesson. A published state is one condition of anonymous-read eligibility; tenant, organization, translation, deletion and parent eligibility still apply. It does not create a Course Version or grant Course Access. [ADR-0050](decisions/0050-publication-and-course-content-access.md) retains the lifecycle and separates content-access policy. | | **Revision pin** | A stored binding to a particular customization definition's key and schema version, rather than whichever revision is active when content is read. Education uses pins for lesson content types and optional course taxonomy/band references; a successor does not implicitly rebind existing content. [Education data model](modules/education/README.md#data-model-and-invariants) owns the binding contract. | | **Course Version** | A versioned, publishable structure of modules and lessons attached to a Course. Enrollments target a specific version. | | **Module (course aggregate)** | An ordered grouping of lessons inside a course version. Distinct from the backend module-loading concept *`IModule`* (see *Module-Loading Contracts* below). When the term "module" appears unqualified in code or docs, prefer this domain meaning unless the surrounding text is clearly about the backend loader. | @@ -62,7 +62,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across |------|------------| | **Enrollment** | A learner's grant of access to a specific course (and specific course version). | | **Course Access** | A *learner's* right to open a specific course, derived from an `Enrollment` (or from a tenant-side purchase, cohort membership, or admin grant). Evaluated inside the Enrollment module against tenant data. **Not an Entitlement** — see *Feature Flags & Entitlements*. The two words were used interchangeably in earlier drafts; they are different subjects (a learner versus a tenant), different owners (LearnStack versus Hub), and different lifecycles. | -| **Course Content Access Policy** | Proposed creation-time Course classification `public` or `enrollment_required`, inherited by lessons under [ADR-0050](decisions/0050-publication-and-course-content-access.md). Distinct from publication, tenant entitlements and a particular learner's Course Access. Not implemented or Accepted yet. | +| **Course Content Access Policy** | Accepted creation-time Course classification `public` or `enrollment_required`, inherited by lessons under [ADR-0050](decisions/0050-publication-and-course-content-access.md). Distinct from publication, tenant entitlements and a particular learner's Course Access. Accepted 2026-10-02; not implemented yet. | | **Cohort** | A group of learners progressing through the same course version on a shared timeline. Cohorts may have scheduled live sessions. | | **Progress** | The learner's recorded advancement against the structure of a course version. | @@ -140,13 +140,13 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across ## Extension Model -Proposed P02d-2 contract terms are not implemented interfaces or Accepted extensions: +Accepted P02d-2 contract terms are not implemented interfaces or extensions: | Term | Definition | |---|---| -| **`IExactCustomizationDefinitionReader`** | Proposed contextual uncached application reader for exact content-type/taxonomy revision values, with NewBinding versus ExistingPin eligibility; [Customization spec](modules/customization/README.md#p02d-2-proposed-exact-write-contract). | -| **`ITenantLocaleEligibilityReader`** | Proposed contextual uncached Tenancy contract for canonical enabled locale membership and valid locale configuration; [Tenancy spec](modules/tenancy/README.md#p02d-2-proposed-locale-and-branding-contract). | -| **`x-fields`** | Proposed optional root JSON Schema array of ordered property names and Pattern-B labels for the bounded text-card profile; [ADR-0051](decisions/0051-ordered-text-card-presentation.md). It is metadata, not a schema or a new renderer primitive. | +| **`IExactCustomizationDefinitionReader`** | Accepted, not implemented, contextual uncached application reader for exact content-type/taxonomy revision values, with NewBinding versus ExistingPin eligibility; [Customization spec](modules/customization/README.md#p02d-2-accepted-exact-write-contract). | +| **`ITenantLocaleEligibilityReader`** | Accepted, not implemented, contextual uncached Tenancy contract for canonical enabled locale membership and valid locale configuration; [Tenancy spec](modules/tenancy/README.md#p02d-2-accepted-locale-and-branding-contract). | +| **`x-fields`** | Accepted, not implemented, optional root JSON Schema array of ordered property names and Pattern-B labels for the bounded text-card profile; [ADR-0051](decisions/0051-ordered-text-card-presentation.md). It is metadata, not a schema or a new renderer primitive. | | Term | Definition | |------|------------| @@ -195,7 +195,7 @@ Proposed P02d-2 contract terms are not implemented interfaces or Accepted extens | Term | Definition | |------|------------| | **TenantBranding** | The tenant's presentation tokens. Not an aggregate of its own: the values are tenant settings held in `tenant_settings` ([Frontend Architecture Standards § Tenant Branding](standards/07-frontend-architecture.md#tenant-branding)). 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). | -| **`branding.theme`** | Proposed single tenant-wide TenantSetting document with four validated color fields; one root/version protects contrast during concurrent replacement. The [Tenancy spec](modules/tenancy/README.md#whole-theme-setting-and-public-boundary) owns the command-local registry; other generic setting keys remain legal. | +| **`branding.theme`** | Accepted, not implemented, single tenant-wide TenantSetting document with four validated color fields; one root/version protects contrast during concurrent replacement. The [Tenancy spec](modules/tenancy/README.md#whole-theme-setting-and-public-boundary) owns the command-local registry; other generic setting keys remain legal. | | **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 diff --git a/docs/modules/customization/README.md b/docs/modules/customization/README.md index c29472a7..b1d538bf 100644 --- a/docs/modules/customization/README.md +++ b/docs/modules/customization/README.md @@ -181,9 +181,11 @@ once across every pod without enumerating anything — the compiled-validator cache that used to sit beside it. It lands with its first consumer in [Phase 02d](../../roadmap/phase-02d-walking-skeleton.md). -## P02d-2 proposed exact write contract + -**Prepared; approval pending — 2026-10-02.** An application interface in +## P02d-2 accepted exact write contract + +**Accepted design, not implemented — 2026-10-02.** An application interface in `Customization.Application.Contracts` resolves an exact content-type or taxonomy revision for the caller's announced tenant. DTOs contain values only: key, version, status, JSON Schema/composite and validated presentation, or immutable bands/labels. @@ -205,10 +207,10 @@ binding purpose (`NewBinding` or `ExistingPin`), never an inferred live version. - Module-owned contextual verification queries give the seeder exact IDs, revision data, labels/bands and state. They are audit Off and introduce no setter exception. -[ADR-0051](../../decisions/0051-ordered-text-card-presentation.md) proposes the optional +[ADR-0051](../../decisions/0051-ordered-text-card-presentation.md) defines the optional root `x-fields` profile and semantic resolver. It preserves the four gates and legacy -schemas; no profile code changes until exact approval. Seed definitions opt into the -profile, whereas built-in `card`/`plain` remain unchanged and Active. +schemas; profile implementation belongs to P02d-2 Step 1. Planned seed definitions opt +into the profile, whereas built-in `card`/`plain` remain unchanged and Active. ## Component diagram diff --git a/docs/modules/education/README.md b/docs/modules/education/README.md index c0358672..0b6a3907 100644 --- a/docs/modules/education/README.md +++ b/docs/modules/education/README.md @@ -8,8 +8,8 @@ The [decision pass](../../roadmap/phase-02d-walking-skeleton.md#p02d-1-decision- records the accepted scope. Commands, audit catalogue entries and seed writes remain planned for P02d-2; public reads remain planned for P02d-4. The [P02d-2 package](../../roadmap/phase-02d-walking-skeleton.md#p02d-2-decision-package-2026-10-02) -is prepared on 2026-10-02, with exact approval pending. The diagrams below describe -shipped P02d-1; they do not claim the proposed access column or handlers exist. +is Accepted on 2026-10-02; implementation waits at the maintainer's request. The +diagrams below describe shipped P02d-1; the access column and handlers do not exist yet. ## Overview @@ -145,14 +145,15 @@ owns their storage conventions. ## State diagrams -[ADR-0048](../../decisions/0048-walking-skeleton-publication.md#lifecycle) owns the -[publication](../../glossary.md#education--learning) diagram and its semantics. The two -roots use it independently. Neither satellite has a publication state separate from its parent. +[ADR-0050](../../decisions/0050-publication-and-course-content-access.md#publication-lifecycle) +retains the independent [publication](../../glossary.md#education--learning) lifecycle +shipped under ADR-0048. Publication is separate from content-access policy. Neither +satellite has a publication state separate from its parent. ## Primary write sequence -No command exists in P02d-1. The following is the planned P02d-2 path; its decision -pass names the commands and contracts before their implementation. +No Education command exists yet. The following is P02d-2's accepted write plan; +the contracts below are decided, with implementation still to follow. ```mermaid sequenceDiagram @@ -174,10 +175,12 @@ A command writes one Education root. Cross-module calls are reads through applic contracts, and audit durability is part of the ambient transaction. The seeder has no second write path. -## P02d-2 proposed writer contract + -**Prepared, not Accepted or implemented — 2026-10-02.** Approval is coupled with -[ADR-0050](../../decisions/0050-publication-and-course-content-access.md), +## P02d-2 accepted writer contract + +**Accepted design, not implemented — 2026-10-02.** The maintainer approved this +contract with [ADR-0050](../../decisions/0050-publication-and-course-content-access.md), [ADR-0051](../../decisions/0051-ordered-text-card-presentation.md) and the phase package. This section owns command detail; the phase owns gate disposition and seed inventory. @@ -197,9 +200,9 @@ failure and stale values are concurrency conflicts. Seed queries obtain current versions for unfinished acts, not permission to retry failed writes blindly. Customization is read through its -[exact value contract](../customization/README.md#p02d-2-proposed-exact-write-contract); +[exact value contract](../customization/README.md#p02d-2-accepted-exact-write-contract); locale membership through Tenancy's -[proposed locale contract](../tenancy/README.md#p02d-2-proposed-locale-and-branding-contract). +[accepted locale contract](../tenancy/README.md#p02d-2-accepted-locale-and-branding-contract). Both execute uncached inside the caller's ambient frame and announced context. No cross-chain FK, foreign Domain/Infrastructure reference or independent transaction is introduced. Revision/locale eligibility is observed at the validation read; @@ -224,7 +227,8 @@ failed save or already-applied mutation cannot be safely discarded, and prove bo ordinary failure and an outer handler absorbing that failure. Each command writes one root and its contained translations. Publishing is MUST -audited; draft creation and translation insertion are proposed SHOULD operations. +audited; draft creation and translation insertion are accepted SHOULD classifications, +not implemented operations. Pending audit writes and business changes obey the existing ambient durability rules. No explicit second transaction or cross-root publication is permitted. diff --git a/docs/modules/education/audit.md b/docs/modules/education/audit.md index 2f421195..2f2553bc 100644 --- a/docs/modules/education/audit.md +++ b/docs/modules/education/audit.md @@ -1,28 +1,29 @@ # Education Audit Coverage Matrix -**Status:** Accepted design — 2026-09-14, with the [module spec](README.md). No -Education request or catalogue source exists yet. Every row below is a forward +**Status:** Accepted design — 2026-09-14; updated 2026-10-02 with ADR-0050 and the +[module spec](README.md). No Education request or catalogue source exists yet. Every row below is a forward declaration under [Audit Coverage Standards](../../standards/18-audit-coverage.md). | Resource | Operation | Class | Why | |---|---|---|---| -| `Course` | `education.course.publish` `(planned)` | **MUST** | Makes course content eligible for anonymous reading; the baseline requires course-publication audit | -| `Lesson` | `education.lesson.publish` `(planned)` | **MUST** | Makes the lesson body eligible when its parent is published, under [ADR-0048](../../decisions/0048-walking-skeleton-publication.md) | - -P02d-2 decides the full command set and adds its create/translation rows before the -handlers. Each implemented operation then loses `(planned)` and gains its executable -catalogue entry in the same commit. A translation belongs to its root's captured -navigation, with the owning course or lesson as audit subject. No standalone satellite +| `Course` | `education.course.publish` `(planned)` | **MUST** | Makes eligible course marketing metadata publishable under ADR-0050; course publication remains MUST | +| `Lesson` | `education.lesson.publish` `(planned)` | **MUST** | Adds one publication prerequisite; anonymous body access also requires eligible parent and public policy under [ADR-0050](../../decisions/0050-publication-and-course-content-access.md) | + +P02d-2's accepted contract names the full command set; its create/translation rows +below precede the handlers. Each implemented operation then loses `(planned)` and +gains its executable catalogue entry in the same commit. A translation belongs to +its root's captured navigation, with the owning course or lesson as audit subject. +No standalone satellite publication or cross-root publication is declared. Public-read classification belongs to P02d-4 G28. No anonymous request is shipped or classified by this schema packet. This matrix cannot narrow the baseline MUST floor. -## P02d-2 proposed additions +## P02d-2 accepted additions -Prepared on 2026-10-02; approval pending with the -[writer contract](README.md#p02d-2-proposed-writer-contract). These are classifications +Accepted on 2026-10-02 with the +[writer contract](README.md#p02d-2-accepted-writer-contract). These are classifications ahead of code, not executable catalogue registrations: | Resource | Operation | Class | Why | @@ -32,7 +33,7 @@ ahead of code, not executable catalogue registrations: | `Lesson` | `education.lesson.create` `(planned)` | SHOULD | Draft authoring, independently scoped root | | `Lesson` | `education.lesson.translation_add` `(planned)` | SHOULD | Draft-only contained translation and validated body | -The two publish operations remain MUST. If ADR-0050 is approved, course publication +The two publish operations remain MUST. Under accepted ADR-0050, course publication exposes eligible marketing metadata; lesson publication enables content only under the parent/access rules. Restriction does not lower the selected MUST classification. Contextual seed verification queries are explicitly Off when their request types diff --git a/docs/modules/education/permissions.md b/docs/modules/education/permissions.md index a3c918bc..d9c09e56 100644 --- a/docs/modules/education/permissions.md +++ b/docs/modules/education/permissions.md @@ -29,8 +29,8 @@ command-surface decision, under [Permission Standards](../../standards/19-permis before any permission is registered. The reachability table grants no capability and introduces no permission key ahead of its decision. -The [prepared P02d-2 contract](README.md#p02d-2-proposed-writer-contract) names six -unrouted write commands and contextual verification queries. Approval pending; -none admits unresolved context or anonymous/public invocation. The proposed access +The [accepted P02d-2 contract](README.md#p02d-2-accepted-writer-contract) names six +unrouted write commands and contextual verification queries, not implemented yet; +none admits unresolved context or anonymous/public invocation. The accepted access policy does not create a permission or a grant evaluator. Authenticated authoring and protected learner reads retain their Phase 05 and Phase 07 owners. diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index f0555df5..3047a06a 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -3,7 +3,7 @@ **Status:** Design stable, partially implemented (Phase 02a Packet 6 shipped the schema and its schema-level isolation suite; commands, host resolution and the request-level isolation suite shipped in Packet 7). P02d-2 locale/branding writers -are prepared for approval, not implemented. +have Accepted contracts as of 2026-10-02, not implemented writers. The first module spec in the repository, per [Documentation Standards § Per-Module Specifications](../../standards/13-documentation.md). @@ -71,11 +71,13 @@ Tenancy owns **who a request belongs to** and nothing about what they do with it ([ADR-0018](../../decisions/0018-tenant-driven-customization-model.md)), not columns here. -## P02d-2 proposed locale and branding contract + -**Prepared, not Accepted or implemented — 2026-10-02.** The +## P02d-2 accepted locale and branding contract + +**Accepted design, not implemented — 2026-10-02.** The [phase package](../../roadmap/phase-02d-walking-skeleton.md#p02d-2-decision-package-2026-10-02) -owns approval and seed inventory. All three commands are unrouted, require resolved +records approval and seed inventory. All three commands are unrouted, require resolved tenant-wide context and write one aggregate; organization context is refused rather than silently promoted to tenant scope. Tenant ids are not caller authority. @@ -156,7 +158,7 @@ including contained locale changes. Contextual seed verification queries are Off No settings cache in P02d-2/3: no generation migration, TTL or cross-process stale entry. P02d-3 implements the typed ambient accessor; it explicitly reads tenant-wide rows and exact organization rows, then merges in memory. Performance is measured -there, not claimed satisfied by this proposal. +there, not claimed satisfied by this decision. ## Entity-relationship diagram @@ -178,10 +180,10 @@ 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 -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 +raising `tenancy.locale.write` and `tenancy.setting.write` — belong to P02d-2. +The [accepted contract](#p02d-2-accepted-locale-and-branding-contract) names +`AddTenantLocaleCommand`, `SetDefaultTenantLocaleCommand` and +`SetTenantBrandingCommand`; their handlers are not implemented yet. 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). @@ -444,7 +446,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 target, not measured | Proposed P02d-2/3: no settings cache; P02d-3 measures the ambient indexed read/merge. Phase 02b's event does not exist yet | +| Settings read for a request | **< 5 ms** p95 target, not measured | Accepted P02d-2/3: no settings cache; P02d-3 measures the ambient indexed read/merge. Phase 02b's event does not exist yet | 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. @@ -480,8 +482,9 @@ request and are the only Tenancy work an anonymous visitor pays for. tenant, which no row can carry. - **At most one default is already enforced.** The shipped partial unique index `UNIQUE (tenant_id) WHERE is_default` prevents competing defaults. It does not - require a default whenever enabled locales exist. P02d-2's proposed command - contract below closes that supported-write gap and adds a default-enabled CHECK; + require a default whenever enabled locales exist. P02d-2's accepted command + contract above closes that supported-write gap when implemented and adds a + default-enabled CHECK; arbitrary raw deletes do not gain an exactly-one database guarantee. - **Nothing stops a tenant claiming a hostname it does not own.** `ux_tenant_domains_host` is globally unique — it has to be, or a host would diff --git a/docs/modules/tenancy/audit.md b/docs/modules/tenancy/audit.md index 49699a5a..2450303a 100644 --- a/docs/modules/tenancy/audit.md +++ b/docs/modules/tenancy/audit.md @@ -3,12 +3,13 @@ Per [Audit Coverage](../../standards/18-audit-coverage.md), which names this file. Part of the [module spec](README.md). -**P02d-2 preparation — 2026-10-02.** The -[proposed writer contract](README.md#p02d-2-proposed-locale-and-branding-contract) +**P02d-2 accepted design — 2026-10-02.** The +[accepted writer contract](README.md#p02d-2-accepted-locale-and-branding-contract) does not remove `(planned)` markers. Locale commands declare `tenancy.locale.write` over the owning Tenant root and captured locale navigation; branding declares -`tenancy.setting.write` over TenantSetting. Generic setting values are proposed -whole-value `[PiiSensitive]` redactions before that writer; public branding projection +`tenancy.setting.write` over TenantSetting. Generic setting values require +whole-value `[PiiSensitive]` redaction before that writer; the marker is not added yet. +Public branding projection is a separate allowlist. Verification request types are explicitly Off when added. Four writes below exist today, between them raising three of the slugs. `Tenant` diff --git a/docs/modules/tenancy/permissions.md b/docs/modules/tenancy/permissions.md index 5b15120b..c4905530 100644 --- a/docs/modules/tenancy/permissions.md +++ b/docs/modules/tenancy/permissions.md @@ -3,9 +3,9 @@ Per [Permission Standards](../../standards/19-permissions.md), which names this file. Part of the [module spec](README.md). -**P02d-2 preparation — 2026-10-02.** The -[locale and branding commands](README.md#p02d-2-proposed-locale-and-branding-contract) -are proposed unrouted tenant-wide seed operations, with no registered permission or +**P02d-2 accepted design — 2026-10-02.** The +[locale and branding commands](README.md#p02d-2-accepted-locale-and-branding-contract) +are planned unrouted tenant-wide seed operations, with no registered permission or organization override. Their eventual identity-backed permission admission remains Phase 03. This note grants no HTTP or Hub-internal reachability for the new commands. diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 35e23185..541ea9d4 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -44,7 +44,7 @@ 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: Two-Tenant Walking Skeleton](phase-02d-walking-skeleton.md) — **in progress**; P02d-1 merged, P02d-2 decision package prepared for exact approval; no P02d-2 implementation yet +- [Phase 02d: Two-Tenant Walking Skeleton](phase-02d-walking-skeleton.md) — **in progress**; P02d-1 merged, P02d-2 decision pass Accepted 2026-10-02; implementation waits at maintainer request - [Phase 02b: Events, Background Jobs, Identity, and Session](phase-02b-events-auth.md) - [Phase 03: Identity Domain, Authorization, and Admin Foundation](phase-03-identity-admin.md) - [Phase 04: Headless CMS, Page Builder, and Media Library](phase-04-cms-media-pages.md) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 898aa6d1..3e3f57b5 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -1,6 +1,6 @@ # Phase 02d: Two-Tenant Walking Skeleton -> **Status (2026-09-14).** Phase 02d **in progress**. The kickoff, `P02d-0`, ships this +> **Status (2026-10-02).** 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 @@ -10,17 +10,18 @@ > |---|---|---| > | P02d-0 | Kickoff | ✅ this plan | > | P02d-1 | Education schema and database-level isolation | ✅ complete and merged — 2026-09-14; [merge closeout](#merge-and-closeout-2026-09-14) | -> | P02d-2 | Writers and seed | not started | +> | P02d-2 | Writers and seed | decision pass Accepted — 2026-10-02; implementation not started, waiting at maintainer request | > | 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 | -**Preparation update — 2026-10-02.** P02d-1 remains merged; P02d-2 implementation -has not started. Its [decision package](#p02d-2-decision-package-2026-10-02) and four -implementation steps are prepared for exact approval. The two new ADRs remain -Proposed; this preparation update accepts no gate and claims no code delivery. +**Acceptance update — 2026-10-02.** P02d-1 remains merged. The maintainer accepted +ADR-0050/0051 and the [P02d-2 package](#p02d-2-decision-package-2026-10-02), including +its four implementation steps. Required decision bookkeeping is complete; this +update delivers no code. Implementation has not started and waits at the maintainer's +explicit request. ADR-0049 and Phase 09a remain Proposed. ## Goal @@ -299,27 +300,27 @@ premise a row cites is re-verified at that pass rather than trusted. |---|---|---|---|---|---| | 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) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): G1 | | G2 | Which aggregate does `Lesson` belong to, and what is its parent: an entity inside `Course`, its own root referencing `Course`, or a minimal `CourseVersion` and default `Module` now? With it: how the satellites are mapped (base type, markers, `deleted_at`), the `sort` invariant and tie-breaker, and what the shape obliges Phase 05 to preserve — course and lesson ids, published slugs, order, organization scope, the inline body | The reviews split. One brings the version spine forward so Phase 05 enriches rather than re-parents; two keep this phase thin and record the preservation obligations, with Phase 05 designing the move. Between the thin shapes: inside `Course` means `ON DELETE CASCADE` and one audit row, but a lesson edit mutates `Course` structurally, which [Domain Model § Education Catalog](../architecture/02-domain-model.md#education-catalog) says a published course never is; its own root means `RESTRICT`, its own `row_version` and its own audit subject | Contract: a dated phase-doc statement; no Accepted ADR holds the `Course` / `CourseVersion` hierarchy, so a new ADR only if the answer needs a cross-root write ([ADR-0042](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md)). Detail: the Education spec's data model; an interim note in [Domain Model § Learning Content](../architecture/02-domain-model.md#learning-content) where the answer departs from it; the class count in [Database Standards § Foreign keys between tenant-owned tables](../standards/05-database.md#foreign-keys-between-tenant-owned-tables) if `Lesson` cascades | P02d-1 (the lessons foreign-key target, `ON DELETE`, `row_version`, the root mapping and satellite `deleted_at`) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): G2 | -| G3 | Which of `courses` and `lessons` carry a publication state, with which values and transitions, and what does "published" mean to an anonymous reader — publicly readable, or only listed? Which command sets it, which states does the seed write, and may a course with no lessons, or untranslated in an enabled locale, be published? And for any transition or deletion this phase does not ship (unpublishing a course or lesson, deleting either), which phase owns it? | `courses` `draft` / `published`, meaning publicly readable (Phase 05 adds catalog visibility as its own concept); lessons carry a state and show only when both are published; draft → published only; an empty course may be published, since publish validation is Phase 05's. One review leaned "listed in the catalog" | Contract: a new ADR, or a dated phase-doc statement recording why a two-value, one-transition column is not the state machine Decision Timing reserves for a decision record; no Accepted ADR decides publication ([ADR-0018](../decisions/0018-tenant-driven-customization-model.md) reserves the lifecycle to LearnStack). Detail: the `CHECK` ([Database Standards § Constraints](../standards/05-database.md#constraints)), the Education spec's state diagram, the publish row [Audit Coverage Standards](../standards/18-audit-coverage.md) makes MUST | P02d-1 (column presence and value set), P02d-2 (publishing commands and seeded states; transition contract closed in P02d-1) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): values and publication/transition contract; P02d-2 commands and seeded states remain open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | +| G3 | Which of `courses` and `lessons` carry a publication state, with which values and transitions, and what does "published" mean to an anonymous reader — publicly readable, or only listed? Which command sets it, which states does the seed write, and may a course with no lessons, or untranslated in an enabled locale, be published? And for any transition or deletion this phase does not ship (unpublishing a course or lesson, deleting either), which phase owns it? | `courses` `draft` / `published`, meaning publicly readable (Phase 05 adds catalog visibility as its own concept); lessons carry a state and show only when both are published; draft → published only; an empty course may be published, since publish validation is Phase 05's. One review leaned "listed in the catalog" | Contract: a new ADR, or a dated phase-doc statement recording why a two-value, one-transition column is not the state machine Decision Timing reserves for a decision record; no Accepted ADR decides publication ([ADR-0018](../decisions/0018-tenant-driven-customization-model.md) reserves the lifecycle to LearnStack). Detail: the `CHECK` ([Database Standards § Constraints](../standards/05-database.md#constraints)), the Education spec's state diagram, the publish row [Audit Coverage Standards](../standards/18-audit-coverage.md) makes MUST | P02d-1 (column presence and value set), P02d-2 (publishing commands and seeded states; transition contract closed in P02d-1) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): commands and seed states; independent lifecycle retained. Anonymous access superseded by [ADR-0050 / dated G3 record](#g3-supersession-2026-10-02); original P02d-1 answer retained as history | | G4 | Where does a lesson body's binding to the content-type key and `schema_version` it was validated against live — on `lessons` or on each translation row — and where does the body live: its column, type, per-locale placement, and how non-translatable field values are carried? May a constraint cross into the Customization chain? What becomes of Localization Standards' `isLocalized` marker, which nothing implements? | A value pin `(content_type_key, schema_version)` on `lessons`, as Phase 04 plans for `ContentEntry`, with no foreign key; the field document per locale in `lesson_translations`, every locale validated against the one pin, duplicated non-translatable values accepted until Phase 05's lesson items retire them; the marker removed or given its introducing phase | Detail: this document's § Localization schema, the Education spec, [Localization Standards § Pattern A](../standards/08-localization.md#pattern-a--side-translation-table-default-for-content-shaped-entities) in the same diff. Contract: a dated ADR-0043 amendment if a localization keyword enters the schema profile; its own ADR or amendment if a cross-chain foreign key is chosen, as ADR-0044 § 9 did, with Phase 04 and [Database Standards § Migrations](../standards/05-database.md#migrations) in the same diff | P02d-1 (the first `lessons` and `lesson_translations` DDL; a pin added later needs a backfill that guesses between two Active content types) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): G4 | -| G5 | Before Phase 05's `Level` exists, how does a course or lesson carry the level band criterion 1 shows? Does the reference pin a taxonomy revision, how is a band validated on write, and what renders when the resolved revision no longer declares the stored band? | The reviews split: (a) a nullable, non-translatable `(taxonomy_key, band_key)` on `courses`, resolved against the live revision, because the criterion names the catalog; (b) a revision-pinned triple; (c) no column, the band shown through a lesson-page `x-taxonomy` field, with the criterion reworded. No shipped path validates a band value under any of them | Detail: a phase-doc statement, the Education spec, and a Phase 05 inherited row if a reference ships. Contract: a dated ADR-0010 amendment or a new ADR if an Education table takes a foreign key into Customization | P02d-1 (whether and where a column exists), P02d-2 (validation, seeded references), P02d-4 and P02d-6 (the unresolved-band state) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): column; validation, seeded references and unresolved-band behavior remain open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | +| G5 | Before Phase 05's `Level` exists, how does a course or lesson carry the level band criterion 1 shows? Does the reference pin a taxonomy revision, how is a band validated on write, and what renders when the resolved revision no longer declares the stored band? | The reviews split: (a) a nullable, non-translatable `(taxonomy_key, band_key)` on `courses`, resolved against the live revision, because the criterion names the catalog; (b) a revision-pinned triple; (c) no column, the band shown through a lesson-page `x-taxonomy` field, with the criterion reworded. No shipped path validates a band value under any of them | Detail: a phase-doc statement, the Education spec, and a Phase 05 inherited row if a reference ships. Contract: a dated ADR-0010 amendment or a new ADR if an Education table takes a foreign key into Customization | P02d-1 (whether and where a column exists), P02d-2 (validation, seeded references), P02d-4 and P02d-6 (the unresolved-band state) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): column; [Accepted — 2026-10-02](#p02d-2-accepted-answers): validation and seeded references. Unresolved public-band behavior remains open for P02d-4/6 | | G6 | Locale identity on the content path. (a) What spelling and column type do the satellites' `locale` columns store, and which rule replaces Localization Standards' "Lowercase", which the shipped `LocaleTag` does not follow? (b) Is the `locale` parameter canonicalized before lookup, the membership check and every cache or cursor key, or is a non-canonical spelling refused? (c) What does a non-canonical `/{locale}/` segment get? | (a) `LocaleTag`'s canonical case (`tr-TR`, `zh-Hans`) in `varchar(35)`, as `tenant_locales` stores it — [ADR-0018](../decisions/0018-tenant-driven-customization-model.md)'s 2026-09-04 amendment already makes case variants one locale; (b) well-formedness, then canonicalization, then lookup; (c) a redirect to the canonical segment, decided with G36 | Detail: [Localization Standards § Locale Codes](../standards/08-localization.md#locale-codes) and the Database Standards satellite fence in the same diff. No ADR: ADR-0008 states no casing rule | P02d-1 (a: the first stored rows), P02d-4 (b: validators, cursor binding), P02d-5 (c, with G36) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): (a); (b) and (c) remain open | -| G7 | Organization write scope. (1) Does a lesson carry its course's organization scope? (2) What forces a satellite's — and a lesson's — mirrored `organization_id` to equal its parent's at insert: writer derivation alone, or that plus a database backstop, and which? (3) May an organization-scoped session `INSERT` a tenant-wide row through the `organization_id IS NULL` arm of `WITH CHECK`, which [ADR-0003](../decisions/0003-tenant-isolation-defense-in-depth.md)'s Amendment 5 and Database Standards say it cannot and which it can at `HEAD`? | (1) Identical scope for a course, its lessons and every translation. (2) Writers derive the child's organization from the authorised parent; the reviews split on the backstop — a stored generated scope column with an organization-inclusive composite key, which structural sweeps can see, or a `BEFORE INSERT` trigger reading the parent under the caller's policies — and one review requires database enforcement. A nullable three-column key is already excluded, because `MATCH SIMPLE` skips the check. (3) Tighten, after the pass confirms no audit writer composes a null-organization row under an announced organization | Contract: one dated ADR-0003 amendment for (2) and (3), with an ADR-0041 erratum beside any sentence the pass finds false when it entered the record; the template replaced in place in [Database Standards](../standards/05-database.md) with its disclosure; forward migrations for `tenant_settings` and `audit_log` if (3) tightens. Detail: [Database Standards § Translation satellite tables](../standards/05-database.md#translation-satellite-tables); a catalogue row with a planted offender if a database mechanism is chosen | P02d-1 (policy SQL, the generated column or trigger, aggregate factories), P02d-2 (child derivation in the commands) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): database controls and factory derivation; P02d-2 command derivation remains open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | +| G7 | Organization write scope. (1) Does a lesson carry its course's organization scope? (2) What forces a satellite's — and a lesson's — mirrored `organization_id` to equal its parent's at insert: writer derivation alone, or that plus a database backstop, and which? (3) May an organization-scoped session `INSERT` a tenant-wide row through the `organization_id IS NULL` arm of `WITH CHECK`, which [ADR-0003](../decisions/0003-tenant-isolation-defense-in-depth.md)'s Amendment 5 and Database Standards say it cannot and which it can at `HEAD`? | (1) Identical scope for a course, its lessons and every translation. (2) Writers derive the child's organization from the authorised parent; the reviews split on the backstop — a stored generated scope column with an organization-inclusive composite key, which structural sweeps can see, or a `BEFORE INSERT` trigger reading the parent under the caller's policies — and one review requires database enforcement. A nullable three-column key is already excluded, because `MATCH SIMPLE` skips the check. (3) Tighten, after the pass confirms no audit writer composes a null-organization row under an announced organization | Contract: one dated ADR-0003 amendment for (2) and (3), with an ADR-0041 erratum beside any sentence the pass finds false when it entered the record; the template replaced in place in [Database Standards](../standards/05-database.md) with its disclosure; forward migrations for `tenant_settings` and `audit_log` if (3) tightens. Detail: [Database Standards § Translation satellite tables](../standards/05-database.md#translation-satellite-tables); a catalogue row with a planted offender if a database mechanism is chosen | P02d-1 (policy SQL, the generated column or trigger, aggregate factories), P02d-2 (child derivation in the commands) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): database controls and factory derivation; [Accepted — 2026-10-02](#p02d-2-accepted-answers): command derivation | | G8 | Which structural guards does the Education chain register, so its tables cannot regress with the suite green: every foreign key between two tables carrying `tenant_id` includes it; every table carrying `organization_id` has the immutability trigger (and how `audit_log`'s append-only guard counts); the Pattern A rule, which would make [ADR-0008](../decisions/0008-localization-schema.md)'s "the migration linter rejects ad-hoc per-locale columns" true? And how does `fn_organization_id_immutable` — which reads `OLD.id` and is declared only in the Tenancy chain — serve satellites that have no `id`? | Three rows, each with a planted-offender companion; the function replaced by a Tenancy-chain migration that reports `OLD.organization_id` or reads the row key through `to_jsonb(OLD)`, which (as in the audit append-only guard's row comparison) never names a column the table may lack, with the cross-chain dependency recorded under Database Standards § Migrations | Detail: Standards 21 rows Registered and Implemented in the packet; the Database Standards immutability fence and § Migrations; `MigrationRollbackTests`. Contract, only if ADR-0008's sentence is left untrue: an ADR-0041 erratum if it was false when entered, otherwise a dated amendment | P02d-1 (a guard shipped with its first new subject is the only point its companion is written against real tables) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): G8 | | G9 | Education schema detail: the content slug's character shape, normalization, width and database backstop — including whether a GUID-shaped slug is refused, which G26's shared-slot path needs; whether an Education table holds a foreign key into `tenants`, `organizations` or `tenant_locales`; and each runtime role's privileges on the four tables | `UrlSlug`'s shape with its own width constant and a `ck__slug_format` backstop, since restrictive now is the reversible choice (ASCII-only slugs exclude native-script URLs, a product choice); no foreign key into Tenancy; `learnstack_app` `SELECT, INSERT` plus exactly what G11's commands need, `learnstack_platform` `SELECT` | Detail: Localization Standards § Pattern A for the shape; the Database Standards satellite fence and [§ GRANT matrix](../standards/05-database.md#grant-matrix); § Migrations only if a cross-chain key is chosen | P02d-1 (the creating migration writes the `CHECK` and the grants; the grants couple with G11) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): G9 | | G10 | What is the catalog's default order and tie-breaker, and what is the cursor it mints: its payload and version; what it binds (tenant, organization, locale, sort, filters, endpoint); its integrity (none, a MAC with a key version, or server-side state); its direction; what happens when a row changes between pages; which list parameters the endpoint binds; where it is decoded; whether the codec is this endpoint's or the kernel's; and which cursor classes answer `400`? | The reviews split between a keyless versioned payload with a binding fingerprint, decoded at binding so a garbage cursor opens no transaction, and an HMAC-authenticated cursor with key rotation. Both keep tenant and organization out of the cursor, and bind `CursorPaginationRequest` rather than `ListRequest`, whose `q` is Phase 04's search | Contract: a phase-doc statement if the codec is endpoint-local and keyless; a new ADR if it becomes a kernel rule later lists follow, or a MAC adds a secret and a rotation posture. Detail: [API Standards § Pagination](../standards/04-api-design.md#pagination), which drops "Nothing validates its *shape* yet"; Standards 21 rows | P02d-1 (the order part: an ordering column, publication timestamp or collation), P02d-4 (the codec part) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): order; P02d-4 codec remains open | -| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | -| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | -| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | -| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | -| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | -| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | -| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | -| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | -| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | -| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | -| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | +| G11 | The write surface the seed needs. Which Education commands write courses, lessons and their translations; is a translation written separately from create; is publishing its own command; which command reports a slug collision as `business_rule_violation` rather than a raw unique violation, and does Localization Standards' "from the publish command" still hold? What shape do the Tenancy commands raising `tenancy.locale.write` and `tenancy.setting.write` take? How are the non-baseline writes classified, and how does a re-run converge? | Create course, write course translation, add lesson, write lesson translation, publish course (MUST); one locale command over `Tenant.AddLocale` and `SetDefaultLocale`; a create-or-update setting command keyed on context scope and key; ordering taxonomy → content type → course → lessons; idempotent by conflict, with an ownership check per act and a second-run test. None has a route | Contract: a phase-doc statement plus the Education spec (README write sequence, `audit.md`, `permissions.md` as a forward declaration on [the Tenancy precedent](../modules/tenancy/permissions.md)). Detail: catalogue sources, the Tenancy `audit.md` and `permissions.md`, Localization Standards § Pattern A if the collision sentence changes. An ADR only if a handler must write two roots | P02d-2 (commands, handlers, catalogue sources, seeder acts) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): commands, failures, audit classifications and convergence | +| G12 | Through which `Customization.Application.Contracts` surface does an Education write obtain the schema a body is validated against — exact `(key, schema_version)` including Deprecated revisions, or a key that binds the Active one — and is it an interface or a MediatR query, classified how? Which revisions may a writer bind, and what refusal answers an absent, cross-tenant or ineligible one? On the read side: what the cache keys on, whether the lesson response carries the binding or resolved field descriptors, and what the API and the page show when a binding cannot be resolved | One exact-revision query, Deprecated included, never falling back to Active; only Active revisions bindable for new writes, since a Draft's body can still change; absent and cross-tenant refused indistinguishably as `validation_failed` naming the binding; resolved descriptors in the response; an unresolvable binding shows a bounded placeholder with a warning log, never a `500` and never another revision's fields ([ADR-0013](../decisions/0013-page-block-schema-versioning.md)'s placeholder rule) | Detail: the Customization spec's contract and § Primary read flow, the Education spec's invariants, a phase-doc statement. No ADR: ADR-0010 settles the mechanism. A dated ADR-0013 amendment only if the unresolvable outcome departs from the placeholder rule | P02d-2 (the contract and write eligibility: the lesson writer is its first caller), P02d-3 (the cache key), P02d-4 (descriptors, the unresolvable outcome), P02d-6 (the page state) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): contract and write eligibility. Cache, public response and page behavior remain open for P02d-3/4/6 | +| G13 | May an Education translation be written for a locale absent from, or disabled in, `tenant_locales`, and how is membership checked across the module boundary? Does a read resolve under a disabled locale? What does a tenant with no locale rows serve — [Localization § Tenant Locale Configuration](../architecture/12-localization.md#tenant-locale-configuration) promises platform `en`, and nothing implements it? Does a platform registry bound the enabled set, as Localization Standards names one in a namespace that does not exist? What happens to translations when `RemoveLocale` runs? | A Tenancy application contract checks membership on write; a read resolves only an enabled locale, checked once per request; no cross-chain foreign key; no platform registry in this phase; a tenant with no locale rows serves nothing until it has one | Contract: a phase-doc statement over ADR-0010's application-contract mechanism. Detail: the Tenancy and Education specs; Localization architecture and Localization Standards § Locale Model reconciled in the same diff; Database Standards § Migrations only if a key is chosen | P02d-2 (the translation command's check and the locale command the seed uses; the read half is written to the same answer in P02d-4) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): write eligibility, no platform registry and no implicit no-row locale; public reader implementation remains P02d-4 | +| G14 | Seed inventory. At what scope is each seeded row class written — courses, lessons, translations, branding settings — and from what seeder context, given that `SeedTenantContext` requires an organization? Where do the rows the criteria need live — a sibling-organization course, an organization-scoped course on the tenant host, a `(locale, slug)` held in both tenants, draft and wrong-course rows, more courses than one catalog page, a disabled locale holding translations — `make seed` or test-owned data? Which key the yoga taxonomy uses, which tenant is bilingual, what state do the built-in `card` / `plain` keep, which record holds it all, and how do the Packet 7 fixture's raw settings rows coexist with seeded ones? | English content tenant-wide; the yoga studio gets a tenant-wide, a Studio One and a Studio Two course; a seed context that announces no organization; branding tenant-wide; rows in the seed with `SeedData` as the record; built-ins stay Active and are never selected implicitly; expectations recomputed as enumerated sets. An English organization-scoped row is still needed for the tenant-host criterion, seeded or test-owned — the demo database's contents are the owner's preference | Detail: a phase-doc statement, the `SeedData` remarks, the `seed-tenant` skill, the writers delivery record. No ADR: [Security Standards § Forbidden](../standards/11-security.md#forbidden) already makes scope come from context | P02d-2 (seeder steps, the seed-context constructor, `SeedData`, `SeederTests`; moving placement later rewrites the seed and every request-level case) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): inventory, ownership and test-owned controls | +| G15 | `SeedRunner` calls `IUnitOfWork.SetTenantContextAsync` on its own transaction, and neither [ADR-0040](../decisions/0040-ambient-unit-of-work.md)'s closed setter set nor [Security Standards § The out-of-band setters](../standards/11-security.md#the-out-of-band-setters) lists it. Is that method's caller set mechanically closed, and is the seeder's call reconciled by routing its ownership check through `ISender`, or by admitting the seeder? | Route the ownership check through `ISender`, and add a source scan that admits `TransactionBehavior` (and Phase 02b's transport) with a planted offender | Contract: a dated ADR-0040 amendment plus a setters-table row only if the seeder is admitted. Detail: a Standards 21 source-scan row with its companion | P02d-2 (the Education seed acts reach the ownership check's refusal arm today) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): contextual verification and Registered caller fence | +| G16 | The branding token contract. (a) Where does the settings key registry live, what does a descriptor carry, and does `tenancy.setting.write` refuse keys outside it? (b) Which branding keys exist — per-token keys or one theme document — and is a layout option among them? (c) What value does each accept, fonts and logos included, and what happens to a stored value that fails it? (d) Does a failed contrast check refuse the write or record a warning — [Accessibility Standards § Color and Contrast](../standards/16-accessibility.md#color-and-contrast) says a Studio warning? (e) What does an organization-scoped branding row do here — refused, ignored or applied? (f) Which tokens may leave an anonymous response? (g) Does `tenancy.white_label_branding` — which reads true under `NullEntitlementProvider`, whose projection grants every registered feature, falls back to its catalog default `false` from a projection that omits it, and which the Hub's Starter plan sets false — govern applying theme tokens or only removing LearnStack attribution? | (a) a registry beside `FeatureKeys` and `LimitKeys`, as `Tenant.SetFeatureFlag` already refuses unregistered keys; (b) per-token keys, at most one enumerated layout option or none; (c) `#rrggbb` colours, one font key from a closed self-hosted set, no remote logo; (d) refuse; (e) tenant-wide only, keeping Phase 06's override and ADR-0017's `OrganizationBranding` true; (f) a closed projection of publicly readable keys; (g) not gated — tokens are baseline presentation, and the key's meaning is agreed with the Hub. That token values are tenant settings is settled by [Frontend Architecture Standards § Tenant Branding](../standards/07-frontend-architecture.md#tenant-branding) | Contract: a phase-doc statement plus Frontend Architecture Standards § Tenant Branding; a new ADR if the registry becomes an admission rule for every `tenant_settings` key; a dated ADR-0017 amendment if (e) applies overrides; Accessibility Standards if (d) replaces the warning. Detail: the Tenancy spec and permission matrix, [Frontend Architecture § Theming](../architecture/14-frontend-architecture.md#theming), the `FeatureKeys` descriptor with a matching note in the Hub repository for (g) | P02d-2 (a–e: validation and the seeded keys, which Phase 06's editor later edits), P02d-4 (f, g: the anonymous projection the OpenAPI baseline freezes), P02d-6 (g: whether rendering consults the flag) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): (a–e). Anonymous projection and entitlement/attribution (f/g) remain open for P02d-4/6 | +| G17 | Does `TenantSetting.Value` carry `[PiiSensitive]`? [Phase 03](phase-03-identity-admin.md) sequences the decision before the first command writing `tenant_settings`, and this phase ships that command | Not marked, provided `tenancy.setting.write` admits only G16's closed key set, so the answer cannot stretch to keys a tenant invents; modelling a sensitive part as its own property stays open to Phase 03 | Contract: a dated phase-doc statement, reflected in `TenantSetting.cs`, the Tenancy spec and `audit.md`. Whole-value redaction of `jsonb` is settled by [ADR-0044](../decisions/0044-audit-write-path.md) Amendment 4 § 1 | P02d-2 (the first MUST-class settings audit row is written by the seed, and rows cannot be redacted retroactively); closes with G16 (a) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): generic whole-value PII redaction before the writer | +| G18 | How is a tenant content type presented? `json_schema` is `jsonb`, which keeps no key order, and the schema profile collects only `x-renderer`, `x-taxonomy` and `x-language`. How are field order, a label per enabled locale and a composite's field roles carried; which registered composite draws a lesson for each seeded type; which primitives does this phase implement, and does `markdown` render; how do types with no primitive row (`integer`, `number`, `boolean`, enums) map; may a rendered type declare a field outside the subset; and is a presentation entry naming a missing property refused at save? | A LearnStack extension — `x-order` and `x-label`, or one ordered `x-fields` list — carrying Pattern B labels, resolved at write like `x-taxonomy`; one composite already in both registries; the reviews split on the subset — `text`, `list` and `link`, with `markdown` without raw HTML, or a placeholder until Phase 05's sanitiser; the seed uses only the subset | Contract: a dated ADR-0043 amendment for a keyword or a save-time refusal; a dated ADR-0018 amendment for a presentation column; a phase-doc statement for `title` plus `required`, which cannot carry two locales. Detail: [Tenant Customization Model § 2](../architecture/32-tenant-customization-model.md) and § 8.1, the Customization spec, the profile's extension and reference-graph skip lists, `composites.ts` | P02d-2 (the seed publishes both content types as `schema_version` 1 with their renderer keys and field kinds; a later answer needs successor revisions) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): ADR-0051 profile and first-render subset. Component placement/fallback remains G41 | +| G19 | URL and markup policy for tenant-authored values on an anonymous page: which schemes (`https` only, or `http` too), credentials and `target`, which media origins, whether the rule is enforced on write — in the Education command, or as a validation gate Phase 04's entries share — whether the public API filters too, and whether URLs inside markdown fall under it. The write-time check constrains structure, not schemes: `format: uri` admits `javascript:` and `data:` | The reviews split on `http`; all refuse `javascript:`, dangerous `data:` and credentials; checked on write by a LearnStack rule and again on render; no third-party media in the seed | Detail: one home for the scheme list — [Security Standards § XSS & Output Encoding](../standards/11-security.md#xss--output-encoding) or [Frontend Architecture Standards § Security](../standards/07-frontend-architecture.md#security), not both; the Education spec's write rules; Tenant Customization Model § 8.1 if checked on write. Contract: a dated ADR-0043 amendment if it becomes a shared validation gate | P02d-2 (the lesson command's validation and the seed values; the render-time check reuses the answer) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): no active sink in the seeded text profile; future URL/markup contracts precede Phase 04/05 sinks | +| G20 | What mechanically backs "no production code branches on which tenant it serves"? The shipped domain-term scan strips literals and exempts seed data. (a) The mechanism and its literal source; (b) its subjects, matching and the platform built-ins; (c) its exemptions, including development hosts in frontend or infrastructure configuration; (d) whether a ban on production references to `LearnStack.Tools.Seeder` and a behavioural same-code, different-data test accompany it | A Standards 21 sibling row scanning production backend and `frontend/` sources, comments stripped, for exact identity literals read from `SeedData` (slugs, ids, hosts, display names, customization keys), built-ins excluded, with planted offenders; plus the behavioural test. The exemption policy is the owner's judgement | Detail: a Standards 21 row Registered in the first pass that uses it and Implemented before exit; a phase-doc statement in § Genericity proof. No ADR | P02d-2 (a: every seed literal lives where the source reads it), P02d-5 (c: the first host outside `SeedData`), P02d-6 (b: frontend subjects), P02d-7 (Implemented and required) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): (a) SeedData literal source and Registered guard. Subjects, exemptions and behavioral proof remain open for P02d-5/6/7 | +| G21 | Does the anonymous public path set any cookie — the [Frontend Architecture Standards § Tenant Resolution](../standards/07-frontend-architecture.md#tenant-resolution) flowchart sets them — and may a public page load any cross-origin subresource, such as the CDN-hosted logo and font assets Frontend Architecture describes? | No cookies, since the locale is already in the path and a locale-less request redirects ([Localization Standards § URL Strategy](../standards/08-localization.md#url-strategy)); same-origin subresources only; both asserted by a check. Whether tenant branding may point visitors' browsers at third-party hosts is a data-protection choice for the owner | Detail: a phase-doc statement; the Standards 07 flowchart and Frontend Architecture § Theming reconciled in the deciding pass | P02d-2 (subresources, if G16 admits a URL-valued token), P02d-5 (cookies: the middleware replacement is the first code that could set one) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): subresources. Cookies remain open for P02d-5 | | 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 | Open | -| 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) | Open; [P02d-2 part prepared — approval pending](#p02d-2-proposed-answers) | +| G23 | The typed settings accessor and its freshness. With no `learnstack.tenancy.settings` event until Phase 02b and the seed writing from its own process, what bounds staleness: a TTL with a stated bound, a writer-coupled Tenancy settings generation counter, or no settings cache here? What are the accessor's name and glossary headword; how is a cached read keyed so tenant-wide and organization rows never cross organizations — a settings read depends on `app.organization_id` today, and the policy's tenant-scope read gains a carrier in Phase 03; and does its loader run in the ambient transaction? | The reviews split on freshness — a TTL bound until 02b, a counter, or no cache. For keys: tenant-wide rows loaded with an explicit `organization_id IS NULL` predicate under `CacheKey.ForTenant`, each organization's overrides under `CacheKey.ForOrganization`, merged in memory; an ambient loader. The documented tenant-only key is rejected, because it would serve one organization's overrides to another | Detail: if settings are cached, the Infrastructure Stack Standards cheat-sheet rows and `cache.name` mapping; the Tenancy spec's event row and budget; a glossary headword. Contract only for a counter (the Tenancy spec, Database Standards § Table classes and § GRANT matrix) or an out-of-band loader (an ADR-0040 amendment) | P02d-2 (a counter is bumped inside the setting command's transaction), P02d-3 (name, keys, loader) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): no settings cache in P02d-2/3. Typed ambient accessor/scoped merge remains P02d-3 | | 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) | Open | | 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) | Open | | 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) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): slug grammar only; P02d-4 route and response contracts remain open | @@ -380,55 +381,78 @@ from this packet. Public-only P02d-2 has no technical dependency on those capabi the maintainer's planning hold remains until they resolve or release it. No review recommendation selects protected authoring or accepts marketplace delivery scope. -G3's publication/transition answer accepted on 2026-09-14 remains binding under -ADR-0048. Only G3's command names and seeded states remain for P02d-2. If protected -authoring is explicitly selected, the -[proposed reopening process](../decisions/0049-institution-sites-and-course-marketplace.md#publication-discovery-and-access) -requires maintainer approval, a superseding access ADR and a dated G3 supersession -entry before implementation. The original question, accepted answer and delivery -record remain intact. Resolving the product direction alone does not reopen G3. +**Historical boundary before exact acceptance.** G3's publication answer was accepted +on 2026-09-14 under ADR-0048; direction endorsement alone did not reopen it. The +maintainer's subsequent exact approval of ADR-0050 supplies the superseding contract +and [dated G3 supersession](#g3-supersession-2026-10-02) below. The original question, +accepted answer and delivery record remain intact. **Maintainer endorsement — 2026-10-02.** The maintainer approved following the recommendations and completing preparation. The hybrid direction and proposed [Phase 09a](phase-09a-course-marketplace-pilot.md) are planning targets, not accepted commerce contracts. The preparation hold is released. Protected authoring is prepared -in ADR-0050, but the exact new ADR and packet decisions must be approved before code, -as the maintainer requested. Future commerce feasibility does not block this packet. +in ADR-0050. The subsequent exact approval is recorded below; it does not accept +marketplace commerce. Future commerce feasibility does not block this packet. ### P02d-2 decision package (2026-10-02) -**Prepared; exact approval pending.** This package is the concrete result of the -authorized preparation. It accepts no gate, registers no implemented proof and -changes no historical P02d-1 decision. Approval must cover ADR-0050, ADR-0051 and -the packet statements below. Then update the current gate cells, append the dated G3 -supersession, perform ADR lifecycle bookkeeping and register new catalogue rows -before implementation. Keep the original G3 question, accepted answer and delivery -record unchanged. +**Accepted — 2026-10-02.** The maintainer approved ADR-0050, ADR-0051 and the exact +packet statements, inventory and four-step plan below, then requested documentation +updates and a wait. Current gate cells and ADR lifecycle/index records are updated; +new source proofs are Registered, not Implemented. The original G3 question, +P02d-1 accepted answer and delivery record are preserved. No implementation started. -#### P02d-2 proposed answers +#### G3 supersession (2026-10-02) -| Gate part | Prepared answer and detail owner | +ADR-0050 supersedes ADR-0048's public-only implication. Independent `draft → published` +states, one-root publication and no version snapshot remain unchanged. Explicit +Course content policy is inherited by lessons: `public` or `enrollment_required`. +Legacy rows backfill restricted; migration is still to be implemented in P02d-2. + +This dated entry governs the current P02d-2/4/6 criteria wherever the inherited +packet text below describes publication as sufficient for anonymous body access: + +- P02d-2 requires explicit policy, restricted legacy backfill, six separate Education + commands and exact seed convergence; publication does not grant access. +- P02d-4 admits published marketing fields under either policy but exposes no + restricted lesson inventory, count, descriptor, body or media URL. Direct hidden + lesson lookup remains indistinguishable `not_found`; eligibility precedes public + serialization, caching and conditional responses. +- P02d-5/6 preserve that public DTO boundary and render a bounded locked state with + no invented payment or enrollment action. Credentials never unlock anonymous reads. +- Phases 04/05/07 own protected media, version/policy evolution and grant evaluation; + unsafe live rollback is prohibited under ADR-0050's containment contract. + +The accepted inventory and text-card subset below govern seed and rendering scope +where older packet planning differs. Remaining transport/cache/OpenAPI/renderer +gates stay open for their named packets; acceptance claims no implementation. + + + +#### P02d-2 accepted answers + +| Gate part | Accepted answer and detail owner | |---|---| -| G3: access, commands and seed states | [ADR-0050](../decisions/0050-publication-and-course-content-access.md) separates publication/access, backfills restricted policy and denies protected lesson inventory. Lifecycle stays independent draft → published. [Education writer plan](../modules/education/README.md#p02d-2-proposed-writer-contract) names six commands; seed states are explicit below | -| G5: level validation | New course binding requires the exact Active taxonomy revision and declared band; never resolve a live key. The [Customization contract](../modules/customization/README.md#p02d-2-proposed-exact-write-contract) owns eligible revision rules. Unresolved public labels remain G5's P02d-4/6 decision | +| G3: access, commands and seed states | [ADR-0050](../decisions/0050-publication-and-course-content-access.md) separates publication/access, backfills restricted policy and denies protected lesson inventory. Lifecycle stays independent draft → published. [Education writer plan](../modules/education/README.md#p02d-2-accepted-writer-contract) names six commands; seed states are explicit below | +| G5: level validation | New course binding requires the exact Active taxonomy revision and declared band; never resolve a live key. The [Customization contract](../modules/customization/README.md#p02d-2-accepted-exact-write-contract) owns eligible revision rules. Unresolved public labels remain G5's P02d-4/6 decision | | G7: child derivation | Scope comes from trusted context and authorized parent; missing/cross/sibling parent is `not_found`, visible but unwritable parent scope is `resource_scope_violation`. No request tenant/organization authority; database guards remain unchanged | | G11: writers, failures and convergence | Separate create, translation-add and publish commands per Education root; translation insertion reports known slug uniqueness as `business_rule_violation`. Tenancy locale and whole-theme commands write one root. The specs own matrices; seed never treats a generic lifecycle failure as success | | G12: contract | Uncached Customization application interface returns immutable value DTOs for exact revisions; new binds Active, existing-pin writes Active/Deprecated. No foreign Domain/Infrastructure reference, FK, public marker or cache. Snapshot eligibility is at validation read, not a claim of Active-at-commit | | G13: locale | Uncached Tenancy application contract requires canonical enabled membership. No platform locale registry; use existing LocaleTag grammar/canonicalization/35-character bound. No rows means no content locale, not implicit `en`; label fallback does not change URL/body eligibility. Removal/disable retains Education data and later reads recheck membership; the lifecycle commands belong to Phase 03 | | G14: seed | Inventory and test-owned controls below; all literal identities and expected counts move into `SeedData` with implementation. Tenant-wide branding/content announce null organization. Built-in `card`/`plain` stay unchanged and Active | -| G15: setter fence | Ownership verification becomes contextual `ISender` queries, explicitly audit Off; no direct seeder transaction/context setter. Add a planted-caller source proof; no new ADR-0040 setter admission | +| G15: setter fence | Ownership verification becomes contextual `ISender` queries, explicitly audit Off; no direct seeder transaction/context setter. [Registered caller fence](../standards/21-architecture-tests-catalogue.md#seeder_does_not_call_tenant_context_setters) has a planted-caller companion; no new ADR-0040 setter admission | | G16(a–e): branding | One tenant-wide `branding.theme` document, four closed color fields, complete replacement and contrast refusal; exact version for replacement. Command-local registry preserves generic settings. Organization overrides refused; no fonts, logo, URL or layout setting in this packet | | G17: PII | Mark generic `TenantSetting.Value` `[PiiSensitive]` before its writer, including whole JSON audit redaction. Public branding allowlisting is a separate boundary, not permission to expose generic settings | | G18: presentation | [ADR-0051](../decisions/0051-ordered-text-card-presentation.md) extends ADR-0043 with optional strict root `x-fields`; seed opts into ordered localized plain-string cards. Legacy schemas remain valid, unchanged; no renderer-key or presentation-column change | | G19: active content | The seeded profile has no active URL/markup sink; output is escaped text, never linkification or Markdown/HTML. ADR-0051 names first-sink owners; generic schema validation is not navigation/media authorization | -| G20: literal source | `SeedData` owns all demo identities, literals and expected inventory. A separate Registered guard will consume that declaration; production subjects/exemptions and behavioral genericity proof stay with their later packet parts | +| G20: literal source | `SeedData` owns all demo identities, literals and expected inventory. The [Registered literal guard](../standards/21-architecture-tests-catalogue.md#production_code_does_not_branch_on_demo_tenant_literals) consumes that declaration when implemented; production subjects/exemptions and behavioral genericity proof stay with their later packet parts | | G21: subresources | No new remote asset/font/logo or cross-origin subresource from seed/theme/text cards. Cookie behavior remains P02d-5; public output still needs later transport/render gates | | G23: freshness bound | No settings cache in P02d-2/3: no seed-process staleness, generation migration or out-of-band loader. P02d-3 owns the ambient typed accessor and scoped merge; latency is a later measurement, not a passed budget | -The replacement vehicles for G18/G19 are the new ADR-0051 and, on approval, an -append-only ADR-0043 amendment linking it. The original register's Leaning/Vehicle -text remains review history; approval records this selected vehicle explicitly. -No Accepted ADR is edited by this preparation pass. +G18/G19's selected vehicles are accepted ADR-0051 and append-only ADR-0043 +Amendment 5. The original register's Leaning/Vehicle text remains review history; +this acceptance records the selected vehicle explicitly. Existing ADR decisions +are preserved; ADR-0048's Status and dated supersession are lifecycle bookkeeping. #### Seed inventory and ownership @@ -533,12 +557,32 @@ remaining actionable findings. Reviews used `gpt-6-astra` (high) for security an - Commit hooks, including staged Leakwatch and commit-message validation, passed. Work remains on development; no push or PR was performed. -Prepared decisions are reviewable; implementation is not authorized by this draft. -The only outstanding **P02d-2 decision** is exact approval of this package and -ADR-0050/0051. After approval, perform the recorded lifecycle/gate/catalogue updates -before Step 1. The marketplace's company/country, provider, selected region, legal -roles, seller corridors and detailed Phase 09a contracts remain its own first-consumer -gates. They neither expand P02d-2 nor hold its independent protected-content work. +**Exact acceptance and wait — 2026-10-02.** The maintainer approved ADR-0050/0051 +and this package, with the explicit instruction to complete documentation and wait. +Lifecycle, dated G3 supersession, current gate statuses, corpus references and new +Registered source-proof rows are updated. P02d-2's decision pass is complete and +the document is ready for Step 1 when implementation is resumed. No code, migration, +handler or new seed data has been delivered; the packet is not marked complete. + +The marketplace's company/country, provider, selected region, legal roles, seller +corridors and detailed Phase 09a contracts remain its own first-consumer gates. +ADR-0049 and Phase 09a remain Proposed. They neither expand P02d-2 nor hold its +independent protected-content work. No implementation, push or PR follows this update. + +**Acceptance verification — 2026-10-02.** Two independent reviewers returned Approve +after verified bookkeeping findings were fixed: `gpt-6.1-sol` (xhigh) for governance +and `gpt-6-astra` (high) for security/corpus consistency. Both checked the final scope, +partial gate closure and absence of implementation claims. + +- Existing Release architecture suite: 177 passed, zero failures/skips; the TRX + execution-count guard passed. Future Registered proofs are not included in that count. +- Documentation: 33 changed Markdown files, 2,117 local links and 486 fragments checked; + added prose wrapping and `git diff --check` passed. +- ADR-0043's pre-existing text and ADR-0048's original decision body are unchanged; + additions are dated lifecycle/extension disclosures. The complete P02d-1 suffix + remains byte-for-byte unchanged against `3f849d1`. +- The two new catalogue rows remain Registered; all 144 Implemented entries are + unchanged. No backend/frontend implementation or deployment operation is included. ### P02d-1 decision pass (2026-09-14) diff --git a/docs/roadmap/phase-03-identity-admin.md b/docs/roadmap/phase-03-identity-admin.md index 23c338f8..0f179c04 100644 --- a/docs/roadmap/phase-03-identity-admin.md +++ b/docs/roadmap/phase-03-identity-admin.md @@ -107,12 +107,12 @@ 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. -**Preparation update — 2026-10-02.** The -[P02d-2 G17 proposal](phase-02d-walking-skeleton.md#p02d-2-proposed-answers) selects +**Decision update — 2026-10-02.** The +[accepted P02d-2 G17 answer](phase-02d-walking-skeleton.md#p02d-2-accepted-answers) selects whole-value `[PiiSensitive]` for generic `TenantSetting.Value` before its first -writer, with exact approval pending. This phase still owns identity-backed access, -DSAR and the marketplace purpose/recipient boundary; a public theme allowlist never -permits exporting arbitrary tenant settings. +writer; the marker/writer are not implemented yet. This phase still owns identity-backed +access, DSAR and the marketplace purpose/recipient boundary; a public theme allowlist +never permits exporting arbitrary tenant settings. **Attribute ownership.** Each attribute has exactly one owner, and the owner determines the table it lives in and who may write it. diff --git a/docs/roadmap/phase-04-cms-media-pages.md b/docs/roadmap/phase-04-cms-media-pages.md index e69463fc..8f913853 100644 --- a/docs/roadmap/phase-04-cms-media-pages.md +++ b/docs/roadmap/phase-04-cms-media-pages.md @@ -193,17 +193,17 @@ 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, the selected -> reporting command and its concrete error mapping are G11 in -> [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register). -> The pass that closes it names both here and in the completion criterion below. +> **Education mapping decided.** Course and Lesson translation rows reserve their +> slugs at insertion. [G11](phase-02d-walking-skeleton.md#p02d-2-accepted-answers) +> names the reporting commands and error mapping below; this phase names its CMS +> writer before implementation. -**Prepared G11 answer — 2026-10-02, approval pending.** The Education reporting +**Accepted G11 answer — 2026-10-02, not implemented.** The Education reporting commands are `AddCourseTranslationCommand` and `AddLessonTranslationCommand`, mapping their named localized-slug constraints to `business_rule_violation` at insertion. -Publication does not reserve a slug. This is the P02d-2 proposal, not a shipped CMS -writer or an accepted G11 closeout. +Publication does not reserve a slug. G11's Education mapping is decided; this +does not claim a shipped Education or CMS writer. CMS still names its own writer +before implementation. Also in scope: locale fallback chain per tenant, the `/{locale}/{slug}` routing shape, per-locale publish readiness, and locale negotiation from `Accept-Language` for @@ -268,7 +268,7 @@ Storage and asset management: - Public / tenant-scoped / per-user access tiers with signed URL minting, and the public asset URL strategy. - Proposed [ADR-0050](../decisions/0050-publication-and-course-content-access.md) + Accepted [ADR-0050](../decisions/0050-publication-and-course-content-access.md) makes protected Education media an explicit first-producer boundary: public DTOs emit no protected bearer URL; issuance and retrieval require effective access. It does not claim an authenticated evaluator exists before Phase 07. The first @@ -417,12 +417,11 @@ describes. attempts all three and the database rejects each, connected as `learnstack_app`. The reporting command returns `Result.Fail(business_rule_violation, …)` with the disclosure rules above. For courses, - the selected command and its concrete error mapping remain G11 in - [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register); - its prepared answer names `AddCourseTranslationCommand` (and - `AddLessonTranslationCommand` for lessons), mapping named localized-slug - constraints to `business_rule_violation` on insertion. Exact approval remains - pending; on acceptance verify those command-level refusals. + the accepted [G11 answer](phase-02d-walking-skeleton.md#p02d-2-accepted-answers) + selects `AddCourseTranslationCommand` (and `AddLessonTranslationCommand` for + lessons), mapping named localized-slug constraints to `business_rule_violation` + on insertion. Verify those command-level refusals after P02d-2 implementation; + CMS names and verifies its own insertion writer in this phase. - 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 92a4bdc7..770953b9 100644 --- a/docs/roadmap/phase-05-education-learning-content.md +++ b/docs/roadmap/phase-05-education-learning-content.md @@ -49,9 +49,9 @@ Decisions consumed: ### What Phase 02d supplies -**Preparation impact — 2026-10-02.** -[ADR-0050](../decisions/0050-publication-and-course-content-access.md) is Proposed; -ADR-0048 still governs until approval. If accepted, this phase also preserves Course +**Accepted contract impact — 2026-10-02.** +[ADR-0050](../decisions/0050-publication-and-course-content-access.md) supersedes +ADR-0048. This phase also preserves Course content-access policy through version/module migration, decides its versioned owner and any policy-edit/reparent/preview behavior before those writers, and never infers public access from a new version or listing. This note claims no shipped column. diff --git a/docs/roadmap/phase-07-enrollment-learner-portal.md b/docs/roadmap/phase-07-enrollment-learner-portal.md index 9bc5e8d5..bee08fcd 100644 --- a/docs/roadmap/phase-07-enrollment-learner-portal.md +++ b/docs/roadmap/phase-07-enrollment-learner-portal.md @@ -17,10 +17,10 @@ from Phase 03, and the outbox and background-job infrastructure from ### What Phase 02d did not build -**Preparation impact — 2026-10-02.** Proposed +**Accepted contract impact — 2026-10-02.** [ADR-0050](../decisions/0050-publication-and-course-content-access.md) separates -publication from inherited course policy; it is not Accepted or implemented yet. -If approved, restricted courses expose marketing metadata but no anonymous lesson +publication from inherited course policy; implementation has not started yet. +Restricted courses expose marketing metadata but no anonymous lesson inventory/body; this phase supplies the first effective learner-access evaluator. No credentials or absent evaluator may cause public fallback. Check access before payload/`304` and start protected responses private/no-store before any cache decision. diff --git a/docs/standards/07-frontend-architecture.md b/docs/standards/07-frontend-architecture.md index 72fe7275..2db77d56 100644 --- a/docs/standards/07-frontend-architecture.md +++ b/docs/standards/07-frontend-architecture.md @@ -196,14 +196,14 @@ export default async function CourseListPage() { ## Tenant Branding -**Prepared G16(a–e)/G21 proposal — 2026-10-02, approval pending.** The +**Accepted G16(a–e)/G21 contract — 2026-10-02, not implemented.** The [Tenancy contract](../modules/tenancy/README.md#whole-theme-setting-and-public-boundary) selects one whole-theme color document, contrast refusal, no organization override and no font/logo/URL/layout value. Public transport/attribution and injection remain -G16(f/g)/G42; this proposal does not claim a themed layout or an anonymous API exists. +G16(f/g)/G42; this decision does not claim a themed layout or an anonymous API exists. -> **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 +> **Remaining Phase 02d decisions.** Anonymous branding projection and entitlement/ +> attribution (G16 f/g), and document injection (G42), remain 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. diff --git a/docs/standards/08-localization.md b/docs/standards/08-localization.md index c3348cae..e126b6cf 100644 --- a/docs/standards/08-localization.md +++ b/docs/standards/08-localization.md @@ -31,10 +31,10 @@ 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 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 +> **Remaining Phase 02d decision.** G13 denies disabled or absent locale membership; +> enforcement belongs to P02d-2/4. Display fallback remains G24: this list and > [Localization § Fallback Rules](../architecture/12-localization.md#fallback-rules) -> state different ones (G24) — are open in +> still need one reconciled owner in > [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). ## URL Strategy @@ -142,12 +142,13 @@ translation in the requested locale has no URL in that locale, and a link to it omitted rather than rendered dead. A slug collision is refused when the translation is inserted; its writing command -returns `Result.Fail(business_rule_violation, …)`. The prepared, approval-pending -[P02d-2 G11 answer](../roadmap/phase-02d-walking-skeleton.md#p02d-2-proposed-answers) +returns `Result.Fail(business_rule_violation, …)`. The accepted 2026-10-02 +[P02d-2 G11 answer](../roadmap/phase-02d-walking-skeleton.md#p02d-2-accepted-answers) selects `AddCourseTranslationCommand` and `AddLessonTranslationCommand` for Education, mapping their named localized-slug uniqueness constraints at insertion. Publication -does not reserve or newly collide a slug. On approval, record the accepted mapping -here and in [Phase 04's collision criterion](../roadmap/phase-04-cms-media-pages.md#completion-criteria). +does not reserve or newly collide a slug. The command-level mapping is decided, +not implemented yet; [Phase 04's criterion](../roadmap/phase-04-cms-media-pages.md#completion-criteria) +requires verification of these insertion-time refusals. The refusal names the conflicting entity when the caller may read it — tenant-wide rows and the caller's own organization's rows both qualify under the canonical policy — and otherwise names only the slug and the locale, because naming a row in another @@ -220,13 +221,12 @@ var msg = _stringLocalizer["course.publish.success"]; - Tenant locale membership is validated through a Tenancy application contract when the P02d-2 writer lands, not through a cross-chain Education foreign key. -> **Open in Phase 02d.** Request-parameter canonicalization remains G6 (b), and -> whether a platform registry bounds a tenant's enabled set remains G13. No -> `LearnStack.SharedKernel.Locales` namespace or platform registry exists today. These -> remaining parts are in +> **Open in Phase 02d.** Request-parameter canonicalization remains G6 (b). +> G13 selects no platform registry; no `LearnStack.SharedKernel.Locales` namespace +> exists today. The remaining request handling is in > [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). -**Prepared G13 answer — 2026-10-02, approval pending.** The +**Accepted G13 answer — 2026-10-02, not implemented.** The [Tenancy contract](../modules/tenancy/README.md#locale-guarantees-and-read-contract) selects no platform registry: use LocaleTag's existing grammar, canonicalization and 35-character bound, then the tenant's enabled membership. No locale rows authorize diff --git a/docs/standards/16-accessibility.md b/docs/standards/16-accessibility.md index 17da071f..b0aa110c 100644 --- a/docs/standards/16-accessibility.md +++ b/docs/standards/16-accessibility.md @@ -49,13 +49,9 @@ LearnStack is an education platform; learners with disabilities are a first-clas - Large text ≥ 3:1. - 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). - - **Prepared G16(d) proposal — 2026-10-02, approval pending:** the first whole-theme - command refuses a failing pair before saving. Its +- Tenant theme tokens require contrast validation before saving. G16(d), Accepted + on 2026-10-02, requires the first whole-theme command to refuse a failing pair + before saving; implementation belongs to P02d-2. Its [complete palette contract](../modules/tenancy/README.md#whole-theme-setting-and-public-boundary) defines supported usage and atomic replacement. The future Studio can explain that refusal; a warning does not authorize saving an invalid palette. diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 86f66522..31c9c0c5 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -592,6 +592,23 @@ otherwise). being dropped. Mutation-checked: a `CefrLevel` property on `Tenant` fails it. - **Phase:** 02a (Packet 10). +#### `Production_Code_Does_Not_Branch_On_Demo_Tenant_Literals` + +- **Asserts:** production code does not specialize behavior by demo tenant identity. + The guard reads identity literals from the authoritative `SeedData` declaration, + not a second hard-coded list. Its source reader must observe the declared values + and fail on an incomplete or unreadable declaration rather than report clean. +- **Source:** [Phase 02d G20](../roadmap/phase-02d-walking-skeleton.md#p02d-2-accepted-answers). +- **Scope:** G20(a)'s source is decided in P02d-2. Production subjects, matching, + built-in exclusions and permitted exemptions remain G20(b/c)'s later decisions; + no exemption or exhaustive consumer scope is accepted by this registration. +- **Type:** xUnit + source scan, with planted offenders and allowed-data controls. + **Kind:** structural. +- **Status:** **Registered** — P02d-2 establishes and verifies the literal source; + P02d-5/6 settle remaining scope before their subjects ship. Implement the complete + guard and its planted companion by P02d-7 exit, with no vacuous pass. +- **Phase:** 02d (P02d-2 source; P02d-5/6 scope; P02d-7 implementation exit). + #### `Frontend_Has_Only_The_Web_App` - **Asserts:** `frontend/apps` contains exactly one Next.js application (`web`). @@ -2014,6 +2031,22 @@ because the filters hold, and removing both turns all five red. `AggregateWriteTests`). - **Phase:** 02a (Packet 7). +#### `Seeder_Does_Not_Call_Tenant_Context_Setters` + +- **Asserts:** seed orchestration and ownership verification do not directly invoke + tenant/session context setters or open their own database announcement transaction. + Verification runs through contextual `ISender` requests and the admitted pipeline; + the existing trusted seed context construction remains permitted. A source scan + covers production seeder callers and cannot pass by collecting no subjects. +- **Source:** [Phase 02d G15](../roadmap/phase-02d-walking-skeleton.md#p02d-2-accepted-answers) + and [Security Standards § The out-of-band setters](11-security.md#the-out-of-band-setters). +- **Type:** xUnit + source scan with a companion that plants direct caller violations + and verifies permitted request dispatch/context composition. **Kind:** structural. +- **Status:** **Registered** — implement the guard and planted companion with the + contextual verification replacement in P02d-2 Step 4, before packet completion. + This adds no ADR-0040 setter exception or new database announcer. +- **Phase:** 02d (P02d-2). + #### `Out_Of_Band_Setters_Open_Read_Only_Transactions` - **Asserts:** every file under `backend/src` that announces a session variable — `set_config(` diff --git a/docs/standards/README.md b/docs/standards/README.md index faa568e8..043f6bb9 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -90,7 +90,7 @@ 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 data tables. P02d-1 Step 2 adds Education: five chains and twenty-one data tables (twenty-six including five migration-history tables), with parent-mirror and Pattern A proofs. 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` — are 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 required as of P02d-1, 2026-09-14 ([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. The partial unique default index enforces at most one default, not existence of a default for every enabled set; P02d-2's proposed supported-write and default-enabled guards are not implemented yet. `LocalizedText` and `LocalizedMessage` ship with their own cases, and API errors are keyed. P02d-1 shipped Education Pattern A satellites, canonical locales and flat localized-slug uniqueness; public locale resolution remains P02d-4. The i18n runtime belongs to [Phase 04](../roadmap/phase-04-cms-media-pages.md); Phase 02d's redirect (G36) and UI messages/library (G39) remain open in its register. | +| 08 | [Localization](08-localization.md) | **Active** | Packet 6 shipped `tenant_locales` and the slug schema. The partial unique default index enforces at most one default, not existence of a default for every enabled set; P02d-2's accepted supported-write and default-enabled guards are not implemented yet. `LocalizedText` and `LocalizedMessage` ship with their own cases, and API errors are keyed. P02d-1 shipped Education Pattern A satellites, canonical locales and flat localized-slug uniqueness; public locale resolution remains P02d-4. The i18n runtime belongs to [Phase 04](../roadmap/phase-04-cms-media-pages.md); Phase 02d's redirect (G36) and UI messages/library (G39) remain open in its register. | | 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. | From 682f858250612b8bcad3228723d128c32450477b Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 2 Oct 2026 02:54:11 +0300 Subject: [PATCH 07/28] feat(education): add P02d-2 policy and contract foundation Separate course publication from explicit inherited content access, with restricted defaults and preserved legacy data. Add exact revision and enabled-locale reads, ordered text-card resolution after schema gates, and contextual seed verification through the ambient request pipeline. Register the same readers, handlers and audit classifications in both composition roots. Verify migration reversal only in disposable data. ADR: 0050, 0051, 0043, 0040, 0044 Module: Education, Customization, Tenancy I18n: lockey_locale_invalid --- CLAUDE.md | 6 +- .../PersistenceCompositionExtensions.cs | 9 + backend/src/LearnStack.Api/Program.cs | 6 +- .../JsonSchemaProfile.cs | 9 +- .../SeedComposition.cs | 12 +- .../IExactCustomizationDefinitionReader.cs | 43 ++ .../Seeding/SeedStateQueries.cs | 18 + .../Abstractions/ISeedStateReader.cs | 11 + .../Audit/CustomizationAuditCatalogSource.cs | 4 + ...RegisterTenantContentTypeCommandHandler.cs | 8 +- .../SchemaExtensionResolution.cs | 15 +- .../Customization/TextCardPresentation.cs | 162 ++++++++ .../Seeding/SeedStateQueryHandlers.cs | 42 ++ .../Seeding/SeedStateQueryValidators.cs | 21 + .../CustomizationSeedStateReader.cs | 30 ++ .../ExactCustomizationDefinitionReader.cs | 83 ++++ .../Seeding/SeedStateQueries.cs | 22 + .../Abstractions/ISeedStateReader.cs | 11 + .../Audit/EducationAuditCatalogSource.cs | 17 + .../Seeding/SeedStateQueryHandlers.cs | 42 ++ .../Seeding/SeedStateQueryValidators.cs | 21 + .../Course.cs | 8 + .../CourseContentAccess.cs | 8 + .../PublicationStatus.cs | 2 +- .../Persistence/Configurations.cs | 8 + .../Persistence/EducationSeedStateReader.cs | 35 ++ ...3219_add_course_content_access.Designer.cs | 379 ++++++++++++++++++ ...0261001233219_add_course_content_access.cs | 43 ++ .../EducationDbContextModelSnapshot.cs | 9 + .../Locales/ITenantLocaleEligibilityReader.cs | 13 + .../Seeding/SeedStateQueries.cs | 24 ++ .../Abstractions/ISeedStateReader.cs | 13 + .../Audit/TenancyAuditCatalogSource.cs | 6 + .../Seeding/SeedStateQueryHandlers.cs | 76 ++++ .../Seeding/SeedStateQueryValidators.cs | 29 ++ .../Persistence/TenancySeedStateReader.cs | 51 +++ .../TenantLocaleEligibilityReader.cs | 48 +++ .../CourseContentAccessMigrationTests.cs | 134 +++++++ .../Database/EducationPersistenceTests.cs | 6 +- .../Database/P02d2FoundationTests.cs | 183 +++++++++ .../Education/EducationAggregateTests.cs | 27 +- .../Education/EducationInputTests.cs | 18 +- .../CustomizationCommandTests.cs | 24 ++ .../TextCardPresentationTests.cs | 109 +++++ docs/architecture/02-domain-model.md | 7 +- docs/modules/customization/README.md | 19 + docs/modules/customization/audit.md | 6 + docs/modules/education/README.md | 14 +- docs/modules/education/audit.md | 9 +- docs/modules/tenancy/README.md | 13 + docs/modules/tenancy/audit.md | 7 + docs/roadmap/phase-02d-walking-skeleton.md | 27 ++ 52 files changed, 1908 insertions(+), 39 deletions(-) create mode 100644 backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Definitions/IExactCustomizationDefinitionReader.cs create mode 100644 backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Seeding/SeedStateQueries.cs create mode 100644 backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Abstractions/ISeedStateReader.cs create mode 100644 backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/TextCardPresentation.cs create mode 100644 backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Seeding/SeedStateQueryHandlers.cs create mode 100644 backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Seeding/SeedStateQueryValidators.cs create mode 100644 backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/CustomizationSeedStateReader.cs create mode 100644 backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/ExactCustomizationDefinitionReader.cs create mode 100644 backend/src/Modules/Education/LearnStack.Modules.Education.Application.Contracts/Seeding/SeedStateQueries.cs create mode 100644 backend/src/Modules/Education/LearnStack.Modules.Education.Application/Abstractions/ISeedStateReader.cs create mode 100644 backend/src/Modules/Education/LearnStack.Modules.Education.Application/Audit/EducationAuditCatalogSource.cs create mode 100644 backend/src/Modules/Education/LearnStack.Modules.Education.Application/Seeding/SeedStateQueryHandlers.cs create mode 100644 backend/src/Modules/Education/LearnStack.Modules.Education.Application/Seeding/SeedStateQueryValidators.cs create mode 100644 backend/src/Modules/Education/LearnStack.Modules.Education.Domain/CourseContentAccess.cs create mode 100644 backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/EducationSeedStateReader.cs create mode 100644 backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Migrations/20261001233219_add_course_content_access.Designer.cs create mode 100644 backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Migrations/20261001233219_add_course_content_access.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Locales/ITenantLocaleEligibilityReader.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Seeding/SeedStateQueries.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Abstractions/ISeedStateReader.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Seeding/SeedStateQueryHandlers.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Seeding/SeedStateQueryValidators.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancySeedStateReader.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenantLocaleEligibilityReader.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/CourseContentAccessMigrationTests.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/P02d2FoundationTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Modules/Customization/TextCardPresentationTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 7e189450..e68603f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,8 +72,10 @@ agent review rounds. The [merge closeout](docs/roadmap/phase-02d-walking-skeleto records verification of the final PR head and merge commit. **P02d-2's decision pass is Accepted — 2026-10-02**: its [decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-2-decision-package-2026-10-02) and ADR-0050/0051 establish protected content, exact write contracts and four -implementation steps. Lifecycle/gate/catalogue documentation is updated. Implementation -has not started and waits at the maintainer's request; public reads belong to P02d-4. +implementation steps. Implementation resumed on development: Step 1 supplies the +access-policy migration, exact-definition/locale contracts, presentation validation +and contextual seed verification queries. Writers and seed execution follow in Steps +2–4; public reads belong to P02d-4. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` (core + 7 modules × 4 projects + 4 test projects including the diff --git a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs index 34d3de33..825f87b4 100644 --- a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs @@ -1,3 +1,6 @@ +using LearnStack.Modules.Customization.Application.Contracts.Definitions; +using LearnStack.Modules.Tenancy.Application.Contracts.Locales; +using LearnStack.Modules.Education.Application.Audit; using LearnStack.Infrastructure.Audit; using LearnStack.Modules.Customization.Application.Audit; using LearnStack.Modules.Tenancy.Application.Audit; @@ -263,6 +266,7 @@ public static IServiceCollection AddLearnStackPersistence( services.TryAddEnumerable([ ServiceDescriptor.Singleton(), ServiceDescriptor.Singleton(), + ServiceDescriptor.Singleton(), ]); services.TryAddSingleton(provider => @@ -293,6 +297,11 @@ public static IServiceCollection AddLearnStackPersistence( // therefore not the write store: a content-type handler holding that store // would be a handler the cross-aggregate census counts as writing two roots. services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); return services; } diff --git a/backend/src/LearnStack.Api/Program.cs b/backend/src/LearnStack.Api/Program.cs index 00f752a0..0472ef57 100644 --- a/backend/src/LearnStack.Api/Program.cs +++ b/backend/src/LearnStack.Api/Program.cs @@ -31,13 +31,15 @@ // The module assemblies MediatR scans for handlers. Tenancy's is here as of Packet 7, // which shipped the first production request types — and the parameter existed all along, // so the change was one argument rather than a new seam. Customization's joined it in -// Packet 8. A module whose assembly is missing here has handlers nothing dispatches, and +// Packet 8; Education joins for P02d-2's contextual verification queries. +// A module whose assembly is missing here has handlers nothing dispatches, and // FluentValidation validators nothing runs — which fails as "no handler for request" at // the call site rather than at startup, and as a command that skipped its guards. builder.AddLearnStackCrossCuttingFoundation( deploymentMode, typeof(LearnStack.Modules.Tenancy.Application.AssemblyMarker).Assembly, - typeof(LearnStack.Modules.Customization.Application.AssemblyMarker).Assembly); + typeof(LearnStack.Modules.Customization.Application.AssemblyMarker).Assembly, + typeof(LearnStack.Modules.Education.Application.AssemblyMarker).Assembly); builder.Services.AddLearnStackTenancyEdge(builder.Configuration); builder.Services.AddLearnStackPersistence(builder.Configuration); builder.Services.AddLearnStackRateLimiting(); diff --git a/backend/src/LearnStack.Infrastructure.Validation/JsonSchemaProfile.cs b/backend/src/LearnStack.Infrastructure.Validation/JsonSchemaProfile.cs index 2ff8cc52..3b3480ec 100644 --- a/backend/src/LearnStack.Infrastructure.Validation/JsonSchemaProfile.cs +++ b/backend/src/LearnStack.Infrastructure.Validation/JsonSchemaProfile.cs @@ -130,7 +130,7 @@ internal static class JsonSchemaProfile /// spend a level of the depth budget § 8.4 measures on the schema tree. /// /// - private static readonly string[] ExtensionKeywords = ["x-renderer", "x-taxonomy", "x-language"]; + private static readonly string[] ExtensionKeywords = ["x-renderer", "x-taxonomy", "x-language", "x-fields"]; /// /// Keywords that run a tenant-authored regular expression. Refused until the @@ -368,6 +368,13 @@ private static void Walk( // its own keys are. if (!namesAreAuthored) { + if (property.Name == "x-fields" + && (pointer.Length != 0 || property.Value.ValueKind != JsonValueKind.Array + || property.Value.GetArrayLength() == 0)) + { + failures.Add(child, "lockey_schema_extension_unresolved"); + } + CheckKeyword(property, child, failures, references); // The keyword is checked; its VALUE is data, so the walk diff --git a/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs b/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs index 7840e6d3..d584d9eb 100644 --- a/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs +++ b/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs @@ -1,3 +1,6 @@ +using LearnStack.Modules.Customization.Application.Contracts.Definitions; +using LearnStack.Modules.Tenancy.Application.Contracts.Locales; +using LearnStack.Modules.Education.Application.Audit; using LearnStack.Application.Pipeline; using LearnStack.Infrastructure.MultiTenancy; using LearnStack.Infrastructure.Persistence; @@ -110,6 +113,11 @@ public static ServiceProvider Build( services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); // The Audit module's context, on the same helper and for the same reason as the @@ -196,6 +204,7 @@ public static ServiceProvider Build( services.TryAddEnumerable([ ServiceDescriptor.Singleton(), ServiceDescriptor.Singleton(), + ServiceDescriptor.Singleton(), ]); services.TryAddSingleton(provider => @@ -218,7 +227,8 @@ public static ServiceProvider Build( services.AddSingleton(NullHostResolutionInvalidator.Instance); services.AddLearnStackMediatRPipeline( typeof(ITenantWriteStore).Assembly, - typeof(ITenantContentTypeStore).Assembly); + typeof(ITenantContentTypeStore).Assembly, + typeof(LearnStack.Modules.Education.Application.AssemblyMarker).Assembly); return services.BuildServiceProvider(); } diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Definitions/IExactCustomizationDefinitionReader.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Definitions/IExactCustomizationDefinitionReader.cs new file mode 100644 index 00000000..fddf4380 --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Definitions/IExactCustomizationDefinitionReader.cs @@ -0,0 +1,43 @@ +using System.Collections.Immutable; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; + +namespace LearnStack.Modules.Customization.Application.Contracts.Definitions; + +/// Eligibility is evaluated on the exact revision, never on the current live key. +public enum DefinitionReadPurpose +{ + NewBinding = 0, + ExistingPin = 1, +} + +public enum DefinitionStatus +{ + Active = 1, + Deprecated = 2, +} + +public sealed record TextCardFieldDto(string Name, LocalizedText Label); + +public sealed record ContentTypeDefinitionDto( + Guid Id, string Key, int SchemaVersion, DefinitionStatus Status, + string JsonSchema, string RendererKey, ImmutableArray Fields); + +public sealed record TaxonomyBandDto(string Key, LocalizedText DisplayName, short Sort, string? Metadata); + +public sealed record TaxonomyDefinitionDto( + Guid Id, string Key, int SchemaVersion, DefinitionStatus Status, ImmutableArray Bands); + +/// +/// Uncached, tenant-filtered reads on the caller's ambient transaction. A miss or +/// ineligible revision returns the same bounded validation refusal. Validity is +/// at read time; immutable pins survive a concurrent deprecation (P02d-2). +/// +public interface IExactCustomizationDefinitionReader +{ + Task> ReadContentTypeAsync( + string key, int schemaVersion, DefinitionReadPurpose purpose, CancellationToken cancellationToken); + + Task> ReadTaxonomyAsync( + string key, int schemaVersion, DefinitionReadPurpose purpose, CancellationToken cancellationToken); +} diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Seeding/SeedStateQueries.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Seeding/SeedStateQueries.cs new file mode 100644 index 00000000..82d383e6 --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Seeding/SeedStateQueries.cs @@ -0,0 +1,18 @@ +using System.Collections.Immutable; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Modules.Customization.Application.Contracts.Seeding; + +// Trusted contextual verification only: no public marker, endpoint or tenant input. +public sealed record SeedLookup(T? State) where T : class; + +public sealed record ContentTypeSeedDto(Guid Id, TenantId TenantId, string Key, int SchemaVersion, + string Status, string DisplayNameJson, string JsonSchema, string RendererKey); +public sealed record TaxonomySeedItemDto(string Key, string DisplayNameJson, short Sort, string? Metadata); +public sealed record TaxonomySeedDto(Guid Id, TenantId TenantId, string Key, int SchemaVersion, + string Status, string DisplayNameJson, ImmutableArray Items); + +public sealed record GetContentTypeSeedStateQuery(Guid ContentTypeId) : IRequest>>; +public sealed record GetTaxonomySeedStateQuery(Guid TaxonomyId) : IRequest>>; diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Abstractions/ISeedStateReader.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Abstractions/ISeedStateReader.cs new file mode 100644 index 00000000..8a406f8d --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Abstractions/ISeedStateReader.cs @@ -0,0 +1,11 @@ +using LearnStack.Modules.Customization.Application.Contracts.Seeding; +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.Modules.Customization.Application.Abstractions; + +/// Filtered, uncached verification on the caller's announced transaction. +public interface ISeedStateReader +{ + Task ReadContentTypeAsync(Guid contentTypeId, CancellationToken cancellationToken); + Task ReadTaxonomyAsync(Guid taxonomyId, CancellationToken cancellationToken); +} diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Audit/CustomizationAuditCatalogSource.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Audit/CustomizationAuditCatalogSource.cs index 73d26e78..2a8ce64d 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Audit/CustomizationAuditCatalogSource.cs +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Audit/CustomizationAuditCatalogSource.cs @@ -1,3 +1,4 @@ +using LearnStack.Modules.Customization.Application.Contracts.Seeding; using LearnStack.Modules.Customization.Application.Contracts.Customization; using LearnStack.Modules.Customization.Domain; using LearnStack.SharedKernel.Audit; @@ -24,6 +25,9 @@ public void Describe(IAuditCatalogBuilder builder) { ArgumentNullException.ThrowIfNull(builder); + builder.Off(); + builder.Off(); + builder .MustAudit( "customization.content_type.register", diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/RegisterTenantContentTypeCommandHandler.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/RegisterTenantContentTypeCommandHandler.cs index 62ea5c53..64e0dbb3 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/RegisterTenantContentTypeCommandHandler.cs +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/RegisterTenantContentTypeCommandHandler.cs @@ -58,13 +58,19 @@ public async Task> Handle( return CustomizationFailures.SchemaRefused(admitted.Error!); } + var presentation = TextCardPresentation.Resolve(request.JsonSchema, request.RendererKey); + if (presentation.IsFailure) + { + return CustomizationFailures.SchemaRefused(presentation.Error); + } + // The gates admit LearnStack's own keywords without resolving them — // ADR-0043 § 4 — so the half that needs the registries happens here, before // anything is written. A schema naming a renderer or a taxonomy that does // not exist would otherwise be stored, published, and then trusted by a // read path that never validates. var unresolved = await SchemaExtensionResolution.UnresolvedAsync( - admitted.Value!, taxonomies, cancellationToken); + admitted.Value!, taxonomies, cancellationToken, textCardResolved: true); if (unresolved.Count > 0) { diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/SchemaExtensionResolution.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/SchemaExtensionResolution.cs index d00c6533..a88bf823 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/SchemaExtensionResolution.cs +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/SchemaExtensionResolution.cs @@ -50,7 +50,8 @@ internal static class SchemaExtensionResolution internal static async Task> UnresolvedAsync( IReadOnlyList extensions, ITenantLevelTaxonomyCatalog taxonomies, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool textCardResolved = false) { var missingTaxonomies = await MissingTaxonomiesAsync(extensions, taxonomies, cancellationToken); var unresolved = new List(); @@ -61,12 +62,12 @@ internal static async Task> UnresolvedAs { RendererKeyword => PrimitiveRendererKey.IsKnown(extension.Value), TaxonomyKeyword => !missingTaxonomies.Contains(extension.Value), - - // x-language, and any extension a later release adds to the - // validator's list before this one learns to resolve it. Accepting - // is the direction that fails safe: the alternative refuses a - // document for a keyword nobody has decided about yet. - _ => true, + // ADR-0051 is resolved separately after all four schema gates. + "x-fields" => textCardResolved, + // Existing explicit exception: Phase 04 owns the language registry. + "x-language" => true, + // A newly recognized extension owes a resolver before it can pass. + _ => false, }; if (!resolves) diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/TextCardPresentation.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/TextCardPresentation.cs new file mode 100644 index 00000000..e6937261 --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Customization/TextCardPresentation.cs @@ -0,0 +1,162 @@ +using System.Collections.Immutable; +using System.Text.Json; +using LearnStack.Modules.Customization.Application.Contracts.Definitions; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; + +namespace LearnStack.Modules.Customization.Application.Customization; + +/// ADR-0051 semantic resolution, after schema admission; no registry or locale lookup. +public static class TextCardPresentation +{ + private const int MaxFailures = 25; + + public static Result> Resolve(string admittedSchema, string rendererKey) + { + ArgumentNullException.ThrowIfNull(admittedSchema); + using var document = JsonDocument.Parse(admittedSchema); + var root = document.RootElement; + if (!root.TryGetProperty("x-fields", out var descriptors)) + { + return Result.Ok(ImmutableArray.Empty); + } + + var failures = new Dictionary>(StringComparer.Ordinal); + void Refuse(string location) + { + if (failures.Count < MaxFailures) + { + failures.TryAdd(location, [new LocalizedMessage("lockey_schema_extension_unresolved")]); + } + } + + if (rendererKey != "default-card") + { + Refuse("/x-fields"); + } + + CheckShape(root, "", "object", Refuse); + if (!root.TryGetProperty("additionalProperties", out var additional) + || additional.ValueKind != JsonValueKind.False) + { + Refuse("/additionalProperties"); + } + + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in root.GetProperty("properties").EnumerateObject()) + { + if (!names.Add(property.Name)) + { + Refuse("/properties/" + Escape(property.Name)); + } + + CheckShape(property.Value, "/properties/" + Escape(property.Name), "string", Refuse); + } + + var fields = ImmutableArray.CreateBuilder(); + var covered = new HashSet(StringComparer.Ordinal); + if (descriptors.ValueKind != JsonValueKind.Array || descriptors.GetArrayLength() == 0) + { + Refuse("/x-fields"); + } + else + { + var index = 0; + foreach (var descriptor in descriptors.EnumerateArray()) + { + var location = "/x-fields/" + index++; + if (descriptor.ValueKind != JsonValueKind.Object) + { + Refuse(location); + continue; + } + + var members = new HashSet(StringComparer.Ordinal); + foreach (var member in descriptor.EnumerateObject()) + { + if (!members.Add(member.Name) || member.Name is not ("name" or "label")) + { + Refuse(location + "/" + Escape(member.Name)); + } + } + + if (!descriptor.TryGetProperty("name", out var nameValue) + || nameValue.ValueKind != JsonValueKind.String) + { + Refuse(location + "/name"); + continue; + } + + var name = nameValue.GetString()!; + if (!names.Contains(name) || !covered.Add(name)) + { + Refuse(location + "/name"); + } + + if (!descriptor.TryGetProperty("label", out var label)) + { + Refuse(location + "/label"); + continue; + } + + try + { + fields.Add(new TextCardFieldDto(name, LocalizedText.FromJson(label.GetRawText()))); + } + catch (ArgumentException) + { + Refuse(location + "/label"); + } + } + } + + foreach (var missing in names.Except(covered)) + { + Refuse("/properties/" + Escape(missing)); + } + + return failures.Count == 0 + ? Result.Ok(fields.ToImmutable()) + : Result>.Fail( + new Error(new LocalizedMessage("lockey_validation_failed"), failures)); + } + + private static void CheckShape(JsonElement schema, string location, string expectedType, Action refuse) + { + if (schema.ValueKind != JsonValueKind.Object) + { + refuse(location); + return; + } + + if (!schema.TryGetProperty("type", out var type) + || type.ValueKind != JsonValueKind.String || type.GetString() != expectedType) + { + refuse(location + "/type"); + } + + var members = new HashSet(StringComparer.Ordinal); + foreach (var property in schema.EnumerateObject()) + { + if (!members.Add(property.Name)) + { + refuse(location + "/" + Escape(property.Name)); + } + + // Unknown inert annotations stay legal. Every shape-changing applicator, + // alternate value set, field format and rendering extension is refused. + if (property.Name is "$ref" or "$dynamicRef" or "allOf" or "anyOf" or "oneOf" + or "not" or "if" or "then" or "else" or "enum" or "const" + or "items" or "prefixItems" or "contains" or "unevaluatedItems" + or "dependentSchemas" or "patternProperties" or "unevaluatedProperties" + || (expectedType == "string" && (property.Name is "format" or "properties" + or "additionalProperties" || property.Name.StartsWith("x-", StringComparison.Ordinal)))) + { + refuse(location + "/" + Escape(property.Name)); + } + } + } + + private static string Escape(string name) => name.Replace("~", "~0", StringComparison.Ordinal) + .Replace("/", "~1", StringComparison.Ordinal); +} diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Seeding/SeedStateQueryHandlers.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Seeding/SeedStateQueryHandlers.cs new file mode 100644 index 00000000..06885d27 --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Seeding/SeedStateQueryHandlers.cs @@ -0,0 +1,42 @@ +using LearnStack.Modules.Customization.Application.Abstractions; +using LearnStack.Modules.Customization.Application.Contracts.Seeding; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using MediatR; + +namespace LearnStack.Modules.Customization.Application.Seeding; + +internal sealed class GetContentTypeSeedStateQueryHandler(ISeedStateReader reader, ITenantContext context) + : IRequestHandler>> +{ + public async Task>> Handle( + GetContentTypeSeedStateQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (!context.IsResolved) + { + return Result>.Fail(new Error(new LocalizedMessage("lockey_tenant_mismatch"))); + } + + return Result.Ok(new SeedLookup( + await reader.ReadContentTypeAsync(request.ContentTypeId, cancellationToken))); + } +} + +internal sealed class GetTaxonomySeedStateQueryHandler(ISeedStateReader reader, ITenantContext context) + : IRequestHandler>> +{ + public async Task>> Handle( + GetTaxonomySeedStateQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (!context.IsResolved) + { + return Result>.Fail(new Error(new LocalizedMessage("lockey_tenant_mismatch"))); + } + + return Result.Ok(new SeedLookup( + await reader.ReadTaxonomyAsync(request.TaxonomyId, cancellationToken))); + } +} diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Seeding/SeedStateQueryValidators.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Seeding/SeedStateQueryValidators.cs new file mode 100644 index 00000000..ac163589 --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/Seeding/SeedStateQueryValidators.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using LearnStack.Modules.Customization.Application.Contracts.Seeding; +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.Modules.Customization.Application.Seeding; + +internal sealed class GetContentTypeSeedStateQueryValidator : AbstractValidator +{ + public GetContentTypeSeedStateQueryValidator() + { + RuleFor(request => request.ContentTypeId).NotEmpty().WithErrorCode("lockey_identifier_required"); + } +} + +internal sealed class GetTaxonomySeedStateQueryValidator : AbstractValidator +{ + public GetTaxonomySeedStateQueryValidator() + { + RuleFor(request => request.TaxonomyId).NotEmpty().WithErrorCode("lockey_identifier_required"); + } +} diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/CustomizationSeedStateReader.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/CustomizationSeedStateReader.cs new file mode 100644 index 00000000..74956e8a --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/CustomizationSeedStateReader.cs @@ -0,0 +1,30 @@ +using System.Collections.Immutable; +using LearnStack.Modules.Customization.Application.Abstractions; +using LearnStack.Modules.Customization.Application.Contracts.Seeding; +using LearnStack.Modules.Customization.Domain; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Modules.Customization.Infrastructure.Persistence; + +public sealed class CustomizationSeedStateReader(CustomizationDbContext context) : ISeedStateReader +{ + public async Task ReadContentTypeAsync(Guid contentTypeId, CancellationToken cancellationToken) + { + var id = TenantContentTypeId.From(contentTypeId); + var row = await context.TenantContentTypes.AsNoTracking().SingleOrDefaultAsync( + definition => definition.Id == id && definition.DeletedAt == null, cancellationToken); + return row is null ? null : new ContentTypeSeedDto(row.Id.Value, row.TenantId, row.Key, row.SchemaVersion, + row.Status.ToString(), row.DisplayName.ToJson(), row.JsonSchema, row.RendererKey); + } + + public async Task ReadTaxonomyAsync(Guid taxonomyId, CancellationToken cancellationToken) + { + var id = TenantLevelTaxonomyId.From(taxonomyId); + var row = await context.TenantLevelTaxonomies.AsNoTracking().Include(definition => definition.Items) + .SingleOrDefaultAsync(definition => definition.Id == id && definition.DeletedAt == null, cancellationToken); + return row is null ? null : new TaxonomySeedDto(row.Id.Value, row.TenantId, row.Key, row.SchemaVersion, + row.Status.ToString(), row.DisplayName.ToJson(), row.Items.OrderBy(item => item.Sort) + .Select(item => new TaxonomySeedItemDto(item.Key, item.DisplayName.ToJson(), item.Sort, item.Metadata)) + .ToImmutableArray()); + } +} diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/ExactCustomizationDefinitionReader.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/ExactCustomizationDefinitionReader.cs new file mode 100644 index 00000000..55353f3d --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/ExactCustomizationDefinitionReader.cs @@ -0,0 +1,83 @@ +using System.Collections.Immutable; +using LearnStack.Modules.Customization.Application.Contracts.Definitions; +using LearnStack.Modules.Customization.Application.Customization; +using LearnStack.Modules.Customization.Domain; +using LearnStack.SharedKernel.Domain; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Modules.Customization.Infrastructure.Persistence; + +public sealed class ExactCustomizationDefinitionReader(CustomizationDbContext context, ITenantContext tenantContext) + : IExactCustomizationDefinitionReader +{ + public async Task> ReadContentTypeAsync( + string key, int schemaVersion, DefinitionReadPurpose purpose, CancellationToken cancellationToken) + { + if (!EligibleInput(key, schemaVersion, purpose)) + { + return Refused(); + } + + var definition = await context.TenantContentTypes.AsNoTracking().SingleOrDefaultAsync( + row => row.Key == key && row.SchemaVersion == schemaVersion && row.DeletedAt == null, + cancellationToken); + if (definition is null || !EligibleStatus(definition.Status, purpose)) + { + return Refused(); + } + + var presentation = TextCardPresentation.Resolve(definition.JsonSchema, definition.RendererKey); + if (presentation.IsFailure) + { + // A malformed stored definition must not leak its private schema details. + return Refused(); + } + + return Result.Ok(new ContentTypeDefinitionDto(definition.Id.Value, definition.Key, + definition.SchemaVersion, Status(definition.Status), definition.JsonSchema, + definition.RendererKey, presentation.Value)); + } + + public async Task> ReadTaxonomyAsync( + string key, int schemaVersion, DefinitionReadPurpose purpose, CancellationToken cancellationToken) + { + if (!EligibleInput(key, schemaVersion, purpose)) + { + return Refused(); + } + + var definition = await context.TenantLevelTaxonomies.AsNoTracking().Include(row => row.Items) + .SingleOrDefaultAsync(row => row.Key == key && row.SchemaVersion == schemaVersion && row.DeletedAt == null, + cancellationToken); + if (definition is null || !EligibleStatus(definition.Status, purpose)) + { + return Refused(); + } + + return Result.Ok(new TaxonomyDefinitionDto(definition.Id.Value, definition.Key, + definition.SchemaVersion, Status(definition.Status), definition.Items.OrderBy(item => item.Sort) + .Select(item => new TaxonomyBandDto(item.Key, item.DisplayName, item.Sort, item.Metadata)) + .ToImmutableArray())); + } + + private bool EligibleInput(string key, int version, DefinitionReadPurpose purpose) => + tenantContext.IsResolved && !string.IsNullOrEmpty(key) && key.Length <= CustomizationKey.MaxLength + && UrlSlug.IsUrlSafe(key) && version > 0 && Enum.IsDefined(purpose); + + private static bool EligibleStatus(CustomizationStatus status, DefinitionReadPurpose purpose) => + status == CustomizationStatus.Active + || (purpose == DefinitionReadPurpose.ExistingPin && status == CustomizationStatus.Deprecated); + + private static DefinitionStatus Status(CustomizationStatus status) => status == CustomizationStatus.Active + ? DefinitionStatus.Active : DefinitionStatus.Deprecated; + + private static Result Refused() => Result.Fail(new Error( + new LocalizedMessage("lockey_validation_failed"), + new Dictionary>(StringComparer.Ordinal) + { + ["Definition"] = [new LocalizedMessage("lockey_schema_extension_unresolved")], + })); +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application.Contracts/Seeding/SeedStateQueries.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Application.Contracts/Seeding/SeedStateQueries.cs new file mode 100644 index 00000000..ad92ade9 --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application.Contracts/Seeding/SeedStateQueries.cs @@ -0,0 +1,22 @@ +using System.Collections.Immutable; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Modules.Education.Application.Contracts.Seeding; + +// Trusted contextual verification only: no public marker, endpoint or tenant input. +public sealed record SeedLookup(T? State) where T : class; + +public sealed record CourseTranslationSeedDto(string Locale, string Title, string? Summary, string Slug); +public sealed record LessonTranslationSeedDto(string Locale, string Title, string Slug, string Body); +public sealed record CourseSeedDto(Guid Id, TenantId TenantId, OrganizationId? OrganizationId, + string SlugKey, string Status, string ContentAccess, string? LevelTaxonomyKey, + int? LevelTaxonomySchemaVersion, string? LevelBandKey, long Version, + ImmutableArray Translations); +public sealed record LessonSeedDto(Guid Id, TenantId TenantId, OrganizationId? OrganizationId, + Guid CourseId, int Sort, string Status, string ContentTypeKey, int ContentTypeSchemaVersion, + long Version, ImmutableArray Translations); + +public sealed record GetCourseSeedStateQuery(Guid CourseId) : IRequest>>; +public sealed record GetLessonSeedStateQuery(Guid LessonId) : IRequest>>; diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Abstractions/ISeedStateReader.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Abstractions/ISeedStateReader.cs new file mode 100644 index 00000000..bb7b8734 --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Abstractions/ISeedStateReader.cs @@ -0,0 +1,11 @@ +using LearnStack.Modules.Education.Application.Contracts.Seeding; +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.Modules.Education.Application.Abstractions; + +/// Filtered, uncached verification on the caller's announced transaction. +public interface ISeedStateReader +{ + Task ReadCourseAsync(Guid courseId, CancellationToken cancellationToken); + Task ReadLessonAsync(Guid lessonId, CancellationToken cancellationToken); +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Audit/EducationAuditCatalogSource.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Audit/EducationAuditCatalogSource.cs new file mode 100644 index 00000000..decec7a7 --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Audit/EducationAuditCatalogSource.cs @@ -0,0 +1,17 @@ +using LearnStack.Modules.Education.Application.Contracts.Seeding; +using LearnStack.SharedKernel.Audit; + +namespace LearnStack.Modules.Education.Application.Audit; + +/// Trusted verification reads only; writer classifications land with Step 3. +public sealed class EducationAuditCatalogSource : IAuditCatalogSource +{ + public string ModuleName => "education"; + + public void Describe(IAuditCatalogBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.Off(); + builder.Off(); + } +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Seeding/SeedStateQueryHandlers.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Seeding/SeedStateQueryHandlers.cs new file mode 100644 index 00000000..18b8ca54 --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Seeding/SeedStateQueryHandlers.cs @@ -0,0 +1,42 @@ +using LearnStack.Modules.Education.Application.Abstractions; +using LearnStack.Modules.Education.Application.Contracts.Seeding; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using MediatR; + +namespace LearnStack.Modules.Education.Application.Seeding; + +internal sealed class GetCourseSeedStateQueryHandler(ISeedStateReader reader, ITenantContext context) + : IRequestHandler>> +{ + public async Task>> Handle( + GetCourseSeedStateQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (!context.IsResolved) + { + return Result>.Fail(new Error(new LocalizedMessage("lockey_tenant_mismatch"))); + } + + return Result.Ok(new SeedLookup( + await reader.ReadCourseAsync(request.CourseId, cancellationToken))); + } +} + +internal sealed class GetLessonSeedStateQueryHandler(ISeedStateReader reader, ITenantContext context) + : IRequestHandler>> +{ + public async Task>> Handle( + GetLessonSeedStateQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (!context.IsResolved) + { + return Result>.Fail(new Error(new LocalizedMessage("lockey_tenant_mismatch"))); + } + + return Result.Ok(new SeedLookup( + await reader.ReadLessonAsync(request.LessonId, cancellationToken))); + } +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Seeding/SeedStateQueryValidators.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Seeding/SeedStateQueryValidators.cs new file mode 100644 index 00000000..79fb3432 --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/Seeding/SeedStateQueryValidators.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using LearnStack.Modules.Education.Application.Contracts.Seeding; +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.Modules.Education.Application.Seeding; + +internal sealed class GetCourseSeedStateQueryValidator : AbstractValidator +{ + public GetCourseSeedStateQueryValidator() + { + RuleFor(request => request.CourseId).NotEmpty().WithErrorCode("lockey_identifier_required"); + } +} + +internal sealed class GetLessonSeedStateQueryValidator : AbstractValidator +{ + public GetLessonSeedStateQueryValidator() + { + RuleFor(request => request.LessonId).NotEmpty().WithErrorCode("lockey_identifier_required"); + } +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/Course.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/Course.cs index 0b5451f5..99482c86 100644 --- a/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/Course.cs +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/Course.cs @@ -26,6 +26,7 @@ private Course(CourseId id, string slugKey) public int? LevelTaxonomySchemaVersion { get; private set; } public string? LevelBandKey { get; private set; } public PublicationStatus Status { get; private set; } + public CourseContentAccess ContentAccess { get; private set; } public IReadOnlyCollection Translations => _translations.AsReadOnly(); /// Builds a draft from validated application input, with no definition lookup. @@ -34,6 +35,7 @@ public static Course Create( TenantId tenantId, OrganizationId? organizationId, string slugKey, + CourseContentAccess contentAccess, IClock clock, UserId createdBy, string? levelTaxonomyKey = null, @@ -54,6 +56,11 @@ public static Course Create( } EducationSlug.EnsureValid(slugKey, nameof(slugKey)); + if (!Enum.IsDefined(contentAccess)) + { + throw new ArgumentOutOfRangeException(nameof(contentAccess)); + } + EnsureLevelPin(levelTaxonomyKey, levelTaxonomySchemaVersion, levelBandKey); var course = new Course(id, slugKey) @@ -64,6 +71,7 @@ public static Course Create( LevelTaxonomySchemaVersion = levelTaxonomySchemaVersion, LevelBandKey = levelBandKey, Status = PublicationStatus.Draft, + ContentAccess = contentAccess, }; course.MarkCreated(clock.UtcNow, createdBy); return course; diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/CourseContentAccess.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/CourseContentAccess.cs new file mode 100644 index 00000000..8327b252 --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/CourseContentAccess.cs @@ -0,0 +1,8 @@ +namespace LearnStack.Modules.Education.Domain; + +/// Course-level content policy inherited by lessons (ADR-0050). +public enum CourseContentAccess +{ + EnrollmentRequired = 0, + Public = 1, +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/PublicationStatus.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/PublicationStatus.cs index e0adce40..705a8c74 100644 --- a/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/PublicationStatus.cs +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/PublicationStatus.cs @@ -1,6 +1,6 @@ namespace LearnStack.Modules.Education.Domain; -/// Independent publication state of each Education root (ADR-0048). +/// Independent publication state, separate from content access (ADR-0050). public enum PublicationStatus { Draft = 0, diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Configurations.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Configurations.cs index 052d3cfb..41553d97 100644 --- a/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Configurations.cs +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Configurations.cs @@ -44,6 +44,7 @@ public void Configure(EntityTypeBuilder builder) { table.HasCheckConstraint("ck_courses_slug_key_format", EducationMapping.SlugCheck("slug_key")); table.HasCheckConstraint("ck_courses_status", "status IN ('draft', 'published')"); + table.HasCheckConstraint("ck_courses_content_access", "content_access IN ('public', 'enrollment_required')"); table.HasCheckConstraint("ck_courses_level_reference", """ (level_taxonomy_key IS NULL AND level_taxonomy_schema_version IS NULL AND level_band_key IS NULL) OR (level_taxonomy_key IS NOT NULL AND level_taxonomy_schema_version IS NOT NULL @@ -58,6 +59,13 @@ public void Configure(EntityTypeBuilder builder) builder.HasAlternateKey(x => new { x.TenantId, x.Id }).HasName("ux_courses_tenant_id_id"); builder.Property(x => x.SlugKey).HasMaxLength(EducationSlug.MaxLength).IsRequired(); builder.Property(x => x.Status).MapStatus(); + builder.Property(x => x.ContentAccess) + .HasConversion( + value => value == CourseContentAccess.Public ? "public" : "enrollment_required", + value => value == "public" ? CourseContentAccess.Public : CourseContentAccess.EnrollmentRequired) + .HasColumnType("text") + .HasDefaultValue(CourseContentAccess.EnrollmentRequired) + .IsRequired(); builder.Property(x => x.LevelTaxonomyKey).HasMaxLength(EducationPinKey.MaxLength); builder.Property(x => x.LevelTaxonomySchemaVersion); builder.Property(x => x.LevelBandKey).HasMaxLength(EducationPinKey.MaxLength); diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/EducationSeedStateReader.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/EducationSeedStateReader.cs new file mode 100644 index 00000000..2550f617 --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/EducationSeedStateReader.cs @@ -0,0 +1,35 @@ +using System.Collections.Immutable; +using LearnStack.Modules.Education.Application.Abstractions; +using LearnStack.Modules.Education.Application.Contracts.Seeding; +using LearnStack.Modules.Education.Domain; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Modules.Education.Infrastructure.Persistence; + +public sealed class EducationSeedStateReader(EducationDbContext context) : ISeedStateReader +{ + public async Task ReadCourseAsync(Guid courseId, CancellationToken cancellationToken) + { + var id = CourseId.From(courseId); + var row = await context.Courses.AsNoTracking().Include(course => course.Translations) + .SingleOrDefaultAsync(course => course.Id == id && course.DeletedAt == null, cancellationToken); + return row is null ? null : new CourseSeedDto(row.Id.Value, row.TenantId, row.OrganizationId, + row.SlugKey, row.Status.ToString(), row.ContentAccess == CourseContentAccess.Public ? "public" : "enrollment_required", + row.LevelTaxonomyKey, row.LevelTaxonomySchemaVersion, row.LevelBandKey, row.Version, + row.Translations.OrderBy(translation => translation.Locale, StringComparer.Ordinal) + .Select(translation => new CourseTranslationSeedDto(translation.Locale, translation.Title, + translation.Summary, translation.Slug)).ToImmutableArray()); + } + + public async Task ReadLessonAsync(Guid lessonId, CancellationToken cancellationToken) + { + var id = LessonId.From(lessonId); + var row = await context.Lessons.AsNoTracking().Include(lesson => lesson.Translations) + .SingleOrDefaultAsync(lesson => lesson.Id == id && lesson.DeletedAt == null, cancellationToken); + return row is null ? null : new LessonSeedDto(row.Id.Value, row.TenantId, row.OrganizationId, + row.CourseId.Value, row.Sort, row.Status.ToString(), row.ContentTypeKey, row.ContentTypeSchemaVersion, + row.Version, row.Translations.OrderBy(translation => translation.Locale, StringComparer.Ordinal) + .Select(translation => new LessonTranslationSeedDto(translation.Locale, translation.Title, + translation.Slug, translation.Body)).ToImmutableArray()); + } +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Migrations/20261001233219_add_course_content_access.Designer.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Migrations/20261001233219_add_course_content_access.Designer.cs new file mode 100644 index 00000000..64546896 --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Migrations/20261001233219_add_course_content_access.Designer.cs @@ -0,0 +1,379 @@ +// +using System; +using LearnStack.Modules.Education.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LearnStack.Modules.Education.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(EducationDbContext))] + [Migration("20261001233219_add_course_content_access")] + partial class add_course_content_access + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LearnStack.Modules.Education.Domain.Course", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ContentAccess") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("enrollment_required") + .HasColumnName("content_access"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("LevelBandKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("level_band_key"); + + b.Property("LevelTaxonomyKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("level_taxonomy_key"); + + b.Property("LevelTaxonomySchemaVersion") + .HasColumnType("integer") + .HasColumnName("level_taxonomy_schema_version"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("SlugKey") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("slug_key"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_courses"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ux_courses_tenant_id_id"); + + b.HasIndex("TenantId", "OrganizationId") + .HasDatabaseName("ix_courses_tenant_id_organization_id"); + + b.HasIndex("TenantId", "SlugKey") + .IsUnique() + .HasDatabaseName("ux_courses_tenant_id_slug_key") + .HasFilter("deleted_at IS NULL"); + + b.HasIndex("TenantId", "OrganizationId", "CreatedAt", "Id") + .HasDatabaseName("ix_courses_tenant_id_organization_id_created_at_id") + .HasFilter("deleted_at IS NULL"); + + b.ToTable("courses", null, t => + { + t.HasCheckConstraint("ck_courses_content_access", "content_access IN ('public', 'enrollment_required')"); + + t.HasCheckConstraint("ck_courses_level_reference", "(level_taxonomy_key IS NULL AND level_taxonomy_schema_version IS NULL AND level_band_key IS NULL)\nOR (level_taxonomy_key IS NOT NULL AND level_taxonomy_schema_version IS NOT NULL\n AND level_taxonomy_schema_version > 0 AND level_band_key IS NOT NULL)"); + + t.HasCheckConstraint("ck_courses_slug_key_format", "slug_key ~ '^[a-z0-9]+(-[a-z0-9]+)*$' AND slug_key !~ '^[0-9a-f]{32}$' AND slug_key !~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'"); + + t.HasCheckConstraint("ck_courses_status", "status IN ('draft', 'published')"); + }); + }); + + modelBuilder.Entity("LearnStack.Modules.Education.Domain.CourseTranslation", b => + { + b.Property("CourseId") + .HasColumnType("uuid") + .HasColumnName("course_id"); + + b.Property("Locale") + .HasMaxLength(35) + .HasColumnType("character varying(35)") + .HasColumnName("locale"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("slug"); + + b.Property("Summary") + .HasColumnType("text") + .HasColumnName("summary"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.HasKey("CourseId", "Locale") + .HasName("pk_course_translations"); + + b.HasAlternateKey("TenantId", "Locale", "Slug") + .HasName("ux_course_translations_tenant_id_locale_slug"); + + b.HasIndex("TenantId", "CourseId") + .HasDatabaseName("ix_course_translations_tenant_id_course_id"); + + b.HasIndex("TenantId", "OrganizationId") + .HasDatabaseName("ix_course_translations_tenant_id_organization_id"); + + b.ToTable("course_translations", null, t => + { + t.HasCheckConstraint("ck_course_translations_slug_format", "slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$' AND slug !~ '^[0-9a-f]{32}$' AND slug !~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'"); + }); + }); + + modelBuilder.Entity("LearnStack.Modules.Education.Domain.Lesson", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ContentTypeKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("content_type_key"); + + b.Property("ContentTypeSchemaVersion") + .HasColumnType("integer") + .HasColumnName("content_type_schema_version"); + + b.Property("CourseId") + .HasColumnType("uuid") + .HasColumnName("course_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("Sort") + .HasColumnType("integer") + .HasColumnName("sort"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_lessons"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ux_lessons_tenant_id_id"); + + b.HasIndex("TenantId", "CourseId") + .HasDatabaseName("ix_lessons_tenant_id_course_id"); + + b.HasIndex("TenantId", "OrganizationId") + .HasDatabaseName("ix_lessons_tenant_id_organization_id"); + + b.HasIndex("TenantId", "CourseId", "Sort", "Id") + .HasDatabaseName("ix_lessons_tenant_id_course_id_sort_id") + .HasFilter("deleted_at IS NULL"); + + b.ToTable("lessons", null, t => + { + t.HasCheckConstraint("ck_lessons_content_type_schema_version", "content_type_schema_version > 0"); + + t.HasCheckConstraint("ck_lessons_sort", "sort >= 0"); + + t.HasCheckConstraint("ck_lessons_status", "status IN ('draft', 'published')"); + }); + }); + + modelBuilder.Entity("LearnStack.Modules.Education.Domain.LessonTranslation", b => + { + b.Property("LessonId") + .HasColumnType("uuid") + .HasColumnName("lesson_id"); + + b.Property("Locale") + .HasMaxLength(35) + .HasColumnType("character varying(35)") + .HasColumnName("locale"); + + b.Property("Body") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("body"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("slug"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.HasKey("LessonId", "Locale") + .HasName("pk_lesson_translations"); + + b.HasAlternateKey("TenantId", "Locale", "Slug") + .HasName("ux_lesson_translations_tenant_id_locale_slug"); + + b.HasIndex("TenantId", "LessonId") + .HasDatabaseName("ix_lesson_translations_tenant_id_lesson_id"); + + b.HasIndex("TenantId", "OrganizationId") + .HasDatabaseName("ix_lesson_translations_tenant_id_organization_id"); + + b.ToTable("lesson_translations", null, t => + { + t.HasCheckConstraint("ck_lesson_translations_body_object", "jsonb_typeof(body) = 'object'"); + + t.HasCheckConstraint("ck_lesson_translations_slug_format", "slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$' AND slug !~ '^[0-9a-f]{32}$' AND slug !~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'"); + }); + }); + + modelBuilder.Entity("LearnStack.Modules.Education.Domain.CourseTranslation", b => + { + b.HasOne("LearnStack.Modules.Education.Domain.Course", null) + .WithMany("Translations") + .HasForeignKey("TenantId", "CourseId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_course_translations_course"); + }); + + modelBuilder.Entity("LearnStack.Modules.Education.Domain.Lesson", b => + { + b.HasOne("LearnStack.Modules.Education.Domain.Course", null) + .WithMany() + .HasForeignKey("TenantId", "CourseId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_lessons_course"); + }); + + modelBuilder.Entity("LearnStack.Modules.Education.Domain.LessonTranslation", b => + { + b.HasOne("LearnStack.Modules.Education.Domain.Lesson", null) + .WithMany("Translations") + .HasForeignKey("TenantId", "LessonId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_lesson_translations_lesson"); + }); + + modelBuilder.Entity("LearnStack.Modules.Education.Domain.Course", b => + { + b.Navigation("Translations"); + }); + + modelBuilder.Entity("LearnStack.Modules.Education.Domain.Lesson", b => + { + b.Navigation("Translations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Migrations/20261001233219_add_course_content_access.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Migrations/20261001233219_add_course_content_access.cs new file mode 100644 index 00000000..2386fc53 --- /dev/null +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Migrations/20261001233219_add_course_content_access.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LearnStack.Modules.Education.Infrastructure.Persistence.Migrations +{ + /// + public partial class add_course_content_access : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // ADD COLUMN's default backfills legacy rows without a row UPDATE or + // an RLS bypass. Publication, translations, pins and scope stay intact. + migrationBuilder.AddColumn( + name: "content_access", + table: "courses", + type: "text", + nullable: false, + defaultValue: "enrollment_required"); + + migrationBuilder.AddCheckConstraint( + name: "ck_courses_content_access", + table: "courses", + sql: "content_access IN ('public', 'enrollment_required')"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Technical reversal for disposable migration tests only. A live + // rollback to ADR-0048 readers would expose restricted content; keep + // this column or stop public Education reads (ADR-0050). + migrationBuilder.DropCheckConstraint( + name: "ck_courses_content_access", + table: "courses"); + + migrationBuilder.DropColumn( + name: "content_access", + table: "courses"); + } + } +} diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Migrations/EducationDbContextModelSnapshot.cs b/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Migrations/EducationDbContextModelSnapshot.cs index 757c6e3c..08ae5110 100644 --- a/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Migrations/EducationDbContextModelSnapshot.cs +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Infrastructure/Persistence/Migrations/EducationDbContextModelSnapshot.cs @@ -28,6 +28,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uuid") .HasColumnName("id"); + b.Property("ContentAccess") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("enrollment_required") + .HasColumnName("content_access"); + b.Property("CreatedAt") .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); @@ -111,6 +118,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("courses", null, t => { + t.HasCheckConstraint("ck_courses_content_access", "content_access IN ('public', 'enrollment_required')"); + t.HasCheckConstraint("ck_courses_level_reference", "(level_taxonomy_key IS NULL AND level_taxonomy_schema_version IS NULL AND level_band_key IS NULL)\nOR (level_taxonomy_key IS NOT NULL AND level_taxonomy_schema_version IS NOT NULL\n AND level_taxonomy_schema_version > 0 AND level_band_key IS NOT NULL)"); t.HasCheckConstraint("ck_courses_slug_key_format", "slug_key ~ '^[a-z0-9]+(-[a-z0-9]+)*$' AND slug_key !~ '^[0-9a-f]{32}$' AND slug_key !~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'"); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Locales/ITenantLocaleEligibilityReader.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Locales/ITenantLocaleEligibilityReader.cs new file mode 100644 index 00000000..4a4333ee --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Locales/ITenantLocaleEligibilityReader.cs @@ -0,0 +1,13 @@ +using LearnStack.SharedKernel.Results; + +namespace LearnStack.Modules.Tenancy.Application.Contracts.Locales; + +/// +/// Reads canonical enabled membership in the announced tenant on the ambient +/// transaction, without caching, fallback or an implicit default language. +/// Invalid stored configuration is a bounded validation refusal (P02d-2). +/// +public interface ITenantLocaleEligibilityReader +{ + Task> ReadEligibleAsync(string locale, CancellationToken cancellationToken); +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Seeding/SeedStateQueries.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Seeding/SeedStateQueries.cs new file mode 100644 index 00000000..62ddc616 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Seeding/SeedStateQueries.cs @@ -0,0 +1,24 @@ +using System.Collections.Immutable; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Modules.Tenancy.Application.Contracts.Seeding; + +// Trusted contextual verification only: no public marker, endpoint or tenant input. +public sealed record SeedLookup(T? State) where T : class; + +public sealed record TenantLocaleSeedDto(string Locale, bool IsEnabled, bool IsDefault, short Sort); +public sealed record TenantSeedDto(TenantId Id, string Slug, string DisplayName, string Status, + OrganizationId? DefaultOrganizationId, long Version, ImmutableArray Locales); +public sealed record OrganizationSeedDto(OrganizationId Id, TenantId TenantId, string Slug, + string DisplayName, string Status); +public sealed record HostMappingSeedDto(string Host, TenantId TenantId, OrganizationId? OrganizationId, + bool IsActive, bool IsPubliclyLive); +public sealed record SettingSeedDto(Guid Id, TenantId TenantId, OrganizationId? OrganizationId, + string Key, string Value, long Version); + +public sealed record GetTenantSeedStateQuery() : IRequest>>; +public sealed record GetOrganizationSeedStateQuery(OrganizationId OrganizationId) : IRequest>>; +public sealed record GetHostMappingSeedStateQuery(string Host) : IRequest>>; +public sealed record GetSettingSeedStateQuery(Guid SettingId) : IRequest>>; diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Abstractions/ISeedStateReader.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Abstractions/ISeedStateReader.cs new file mode 100644 index 00000000..f07a3cf7 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Abstractions/ISeedStateReader.cs @@ -0,0 +1,13 @@ +using LearnStack.Modules.Tenancy.Application.Contracts.Seeding; +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.Modules.Tenancy.Application.Abstractions; + +/// Filtered, uncached verification on the caller's announced transaction. +public interface ISeedStateReader +{ + Task ReadTenantAsync(CancellationToken cancellationToken); + Task ReadOrganizationAsync(OrganizationId organizationId, CancellationToken cancellationToken); + Task ReadHostMappingAsync(string host, CancellationToken cancellationToken); + Task ReadSettingAsync(Guid settingId, CancellationToken cancellationToken); +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Audit/TenancyAuditCatalogSource.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Audit/TenancyAuditCatalogSource.cs index 900bd059..9ef08ff0 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Audit/TenancyAuditCatalogSource.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Audit/TenancyAuditCatalogSource.cs @@ -1,3 +1,4 @@ +using LearnStack.Modules.Tenancy.Application.Contracts.Seeding; using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; using LearnStack.Modules.Tenancy.Domain; using LearnStack.SharedKernel.Audit; @@ -36,6 +37,11 @@ public void Describe(IAuditCatalogBuilder builder) { ArgumentNullException.ThrowIfNull(builder); + builder.Off(); + builder.Off(); + builder.Off(); + builder.Off(); + builder // TWO entries for one command, and the reason is the whole of ADR-0044 § 3: // ProvisionTenantCommand writes two aggregate roots on one transaction and the diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Seeding/SeedStateQueryHandlers.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Seeding/SeedStateQueryHandlers.cs new file mode 100644 index 00000000..d0bc8522 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Seeding/SeedStateQueryHandlers.cs @@ -0,0 +1,76 @@ +using LearnStack.Modules.Tenancy.Application.Abstractions; +using LearnStack.Modules.Tenancy.Application.Contracts.Seeding; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using MediatR; + +namespace LearnStack.Modules.Tenancy.Application.Seeding; + +internal sealed class GetTenantSeedStateQueryHandler(ISeedStateReader reader, ITenantContext context) + : IRequestHandler>> +{ + public async Task>> Handle( + GetTenantSeedStateQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (!context.IsResolved) + { + return Result>.Fail(new Error(new LocalizedMessage("lockey_tenant_mismatch"))); + } + + return Result.Ok(new SeedLookup( + await reader.ReadTenantAsync(cancellationToken))); + } +} + +internal sealed class GetOrganizationSeedStateQueryHandler(ISeedStateReader reader, ITenantContext context) + : IRequestHandler>> +{ + public async Task>> Handle( + GetOrganizationSeedStateQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (!context.IsResolved) + { + return Result>.Fail(new Error(new LocalizedMessage("lockey_tenant_mismatch"))); + } + + return Result.Ok(new SeedLookup( + await reader.ReadOrganizationAsync(request.OrganizationId, cancellationToken))); + } +} + +internal sealed class GetHostMappingSeedStateQueryHandler(ISeedStateReader reader, ITenantContext context) + : IRequestHandler>> +{ + public async Task>> Handle( + GetHostMappingSeedStateQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (!context.IsResolved) + { + return Result>.Fail(new Error(new LocalizedMessage("lockey_tenant_mismatch"))); + } + + return Result.Ok(new SeedLookup( + await reader.ReadHostMappingAsync(request.Host, cancellationToken))); + } +} + +internal sealed class GetSettingSeedStateQueryHandler(ISeedStateReader reader, ITenantContext context) + : IRequestHandler>> +{ + public async Task>> Handle( + GetSettingSeedStateQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (!context.IsResolved) + { + return Result>.Fail(new Error(new LocalizedMessage("lockey_tenant_mismatch"))); + } + + return Result.Ok(new SeedLookup( + await reader.ReadSettingAsync(request.SettingId, cancellationToken))); + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Seeding/SeedStateQueryValidators.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Seeding/SeedStateQueryValidators.cs new file mode 100644 index 00000000..50a63264 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Seeding/SeedStateQueryValidators.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using LearnStack.Modules.Tenancy.Application.Contracts.Seeding; +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.Modules.Tenancy.Application.Seeding; + +internal sealed class GetOrganizationSeedStateQueryValidator : AbstractValidator +{ + public GetOrganizationSeedStateQueryValidator() + { + RuleFor(request => request.OrganizationId).Must(id => id.IsInitialized() && id.Value != Guid.Empty).WithErrorCode("lockey_identifier_required"); + } +} + +internal sealed class GetHostMappingSeedStateQueryValidator : AbstractValidator +{ + public GetHostMappingSeedStateQueryValidator() + { + RuleFor(request => request.Host).Must(host => EffectiveHost.Normalize(host) is not null).WithErrorCode("lockey_host_not_resolvable"); + } +} + +internal sealed class GetSettingSeedStateQueryValidator : AbstractValidator +{ + public GetSettingSeedStateQueryValidator() + { + RuleFor(request => request.SettingId).NotEmpty().WithErrorCode("lockey_identifier_required"); + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancySeedStateReader.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancySeedStateReader.cs new file mode 100644 index 00000000..369406db --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancySeedStateReader.cs @@ -0,0 +1,51 @@ +using System.Collections.Immutable; +using LearnStack.Modules.Tenancy.Application.Abstractions; +using LearnStack.Modules.Tenancy.Application.Contracts.Seeding; +using LearnStack.Modules.Tenancy.Domain; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; + +public sealed class TenancySeedStateReader(TenancyDbContext context, ITenantContext tenantContext) : ISeedStateReader +{ + public async Task ReadTenantAsync(CancellationToken cancellationToken) + { + var row = await context.Tenants.AsNoTracking().Include(tenant => tenant.Locales) + .SingleOrDefaultAsync(tenant => tenant.Id == tenantContext.TenantId && tenant.DeletedAt == null, + cancellationToken); + return row is null ? null : new TenantSeedDto(row.Id, row.Slug, row.DisplayName, row.Status.ToString(), + row.DefaultOrganizationId, row.Version, row.Locales.OrderBy(locale => locale.Sort) + .ThenBy(locale => locale.Locale, StringComparer.Ordinal) + .Select(locale => new TenantLocaleSeedDto(locale.Locale, locale.IsEnabled, locale.IsDefault, locale.Sort)) + .ToImmutableArray()); + } + + public async Task ReadOrganizationAsync( + OrganizationId organizationId, CancellationToken cancellationToken) + { + var row = await context.Organizations.AsNoTracking().SingleOrDefaultAsync( + organization => organization.Id == organizationId && organization.DeletedAt == null, cancellationToken); + return row is null ? null : new OrganizationSeedDto(row.Id, row.TenantId, row.Slug, row.DisplayName, + row.Status.ToString()); + } + + public async Task ReadHostMappingAsync(string host, CancellationToken cancellationToken) + { + var canonical = EffectiveHost.Normalize(host); + var row = await context.PlatformHostMappings.AsNoTracking().SingleOrDefaultAsync( + mapping => mapping.Host == canonical && mapping.TenantId == tenantContext.TenantId, cancellationToken); + return row is null ? null : new HostMappingSeedDto(row.Host, row.TenantId, row.OrganizationId, + row.IsActive, row.IsPubliclyLive); + } + + public async Task ReadSettingAsync(Guid settingId, CancellationToken cancellationToken) + { + var id = TenantSettingId.From(settingId); + var row = await context.TenantSettings.AsNoTracking().SingleOrDefaultAsync( + setting => setting.Id == id && setting.DeletedAt == null, cancellationToken); + return row is null ? null : new SettingSeedDto(row.Id.Value, row.TenantId, row.OrganizationId, + row.Key, row.Value, row.Version); + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenantLocaleEligibilityReader.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenantLocaleEligibilityReader.cs new file mode 100644 index 00000000..6769207f --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenantLocaleEligibilityReader.cs @@ -0,0 +1,48 @@ +using LearnStack.Modules.Tenancy.Application.Contracts.Locales; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; + +public sealed class TenantLocaleEligibilityReader(TenancyDbContext context, ITenantContext tenantContext) + : ITenantLocaleEligibilityReader +{ + public async Task> ReadEligibleAsync(string locale, CancellationToken cancellationToken) + { + if (!tenantContext.IsResolved || string.IsNullOrEmpty(locale) || locale.Length > LocaleTag.MaxLength) + { + return Refused(); + } + + try + { + LocaleTag.EnsureWellFormed(locale, nameof(locale)); + } + catch (ArgumentException) + { + return Refused(); + } + + var tenant = await context.Tenants.AsNoTracking().Include(row => row.Locales) + .SingleOrDefaultAsync(row => row.Id == tenantContext.TenantId && row.DeletedAt == null, + cancellationToken); + if (tenant is null || tenant.Locales.Any(row => row.IsDefault && !row.IsEnabled) + || (tenant.Locales.Any(row => row.IsEnabled) && tenant.Locales.Count(row => row.IsDefault) != 1)) + { + return Refused(); + } + + var canonical = LocaleTag.Canonicalize(locale); + return tenant.Locales.Any(row => row.Locale == canonical && row.IsEnabled) + ? Result.Ok(canonical) : Refused(); + } + + private static Result Refused() => Result.Fail(new Error( + new LocalizedMessage("lockey_validation_failed"), + new Dictionary>(StringComparer.Ordinal) + { + ["Locale"] = [new LocalizedMessage("lockey_locale_invalid")], + })); +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/CourseContentAccessMigrationTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/CourseContentAccessMigrationTests.cs new file mode 100644 index 00000000..1685cc0d --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/CourseContentAccessMigrationTests.cs @@ -0,0 +1,134 @@ +using System.Data.Common; +using FluentAssertions; +using LearnStack.Modules.Education.Infrastructure.Persistence; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class CourseContentAccessMigrationTests(SchemaFixture schema) +{ + [Fact] + public async Task Legacy_rows_are_restricted_without_changing_payload_pins_scope_or_publication_and_reapply_is_safe() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var context = new EducationDbContext(new DbContextOptionsBuilder() + .UseNpgsql(database.MigrationConnectionString, + provider => provider.MigrationsHistoryTable(EducationDbContextFactory.HistoryTable)).Options, + StaticTenantContextAccessor.Unresolved); + context.Database.HasPendingModelChanges().Should().BeFalse(); + var previous = context.Database.GetMigrations().Reverse().Skip(1).First(); + // Technical reversal is confined to this disposable database. ADR-0050 + // forbids using it as a live rollback with old anonymous public readers. + await context.GetService().MigrateAsync(previous); + await using (var app = await PostgresFixture.OpenAsync(database.AppConnectionString)) + { + await using var insert = new NpgsqlCommand(LegacyRows, (NpgsqlConnection)app); + await insert.ExecuteNonQueryAsync(); + } + + var before = await SnapshotAsync(database.AppConnectionString); + await context.Database.MigrateAsync(); + (await SnapshotAsync(database.AppConnectionString)).Should().Be(before); + await using (var app = await PostgresFixture.OpenAsync(database.AppConnectionString)) + { + await using var transaction = await app.BeginTransactionAsync(); + await EducationSchemaSeed.AnnounceAsync(app, transaction, SchemaFixture.TenantA, SchemaFixture.OrgA1); + await using var read = new NpgsqlCommand( + "SELECT count(*) FROM courses WHERE content_access = 'enrollment_required'", + (NpgsqlConnection)app, (NpgsqlTransaction)transaction); + (await read.ExecuteScalarAsync()).Should().Be(2L); + await transaction.CommitAsync(); + } + + await context.GetService().MigrateAsync(previous); + (await SnapshotAsync(database.AppConnectionString)).Should().Be(before); + await context.Database.MigrateAsync(); + (await SnapshotAsync(database.AppConnectionString)).Should().Be(before); + context.Database.HasPendingModelChanges().Should().BeFalse(); + await AssertDefaultAndCheckAsync(database.AppConnectionString); + } + + private static async Task AssertDefaultAndCheckAsync(string connectionString) + { + await using var app = await PostgresFixture.OpenAsync(connectionString); + await using var transaction = await app.BeginTransactionAsync(); + await EducationSchemaSeed.AnnounceAsync(app, transaction, SchemaFixture.TenantA, null); + var course = Guid.CreateVersion7(); + await EducationSchemaSeed.InsertAsync(app, transaction, "courses", SchemaFixture.TenantA, + null, course, course, "fresh-default"); + await using var read = new NpgsqlCommand("SELECT content_access FROM courses WHERE id = @id", + (NpgsqlConnection)app, (NpgsqlTransaction)transaction); + read.Parameters.AddWithValue("id", course); + (await read.ExecuteScalarAsync()).Should().Be("enrollment_required"); + await using var change = new NpgsqlCommand("UPDATE courses SET content_access = @policy WHERE id = @id", + (NpgsqlConnection)app, (NpgsqlTransaction)transaction); + change.Parameters.AddWithValue("policy", "public"); + change.Parameters.AddWithValue("id", course); + (await change.ExecuteNonQueryAsync()).Should().Be(1); + change.Parameters["policy"].Value = "PUBLIC"; + var rejected = async () => await change.ExecuteNonQueryAsync(); + var error = (await rejected.Should().ThrowAsync()).Which; + error.SqlState.Should().Be(PostgresErrorCodes.CheckViolation); + error.ConstraintName.Should().Be("ck_courses_content_access"); + await transaction.RollbackAsync(); + } + + private static async Task SnapshotAsync(string connectionString) + { + await using var app = await PostgresFixture.OpenAsync(connectionString); + await using var transaction = await app.BeginTransactionAsync(); + await EducationSchemaSeed.AnnounceAsync(app, transaction, SchemaFixture.TenantA, SchemaFixture.OrgA1); + var snapshots = new List(); + foreach (var table in EducationSchemaSeed.Tables) + { + await using var read = new NpgsqlCommand( + $"SELECT jsonb_agg(to_jsonb(row) - 'content_access' ORDER BY to_jsonb(row)::text)::text FROM {table} row", + (NpgsqlConnection)app, (NpgsqlTransaction)transaction); + snapshots.Add((string)(await read.ExecuteScalarAsync())!); + } + + await transaction.CommitAsync(); + return string.Join('\n', snapshots); + } + + private const string LegacyRows = """ + BEGIN; + SET LOCAL app.tenant_id = '11111111-1111-7111-8111-111111111111'; + INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) + VALUES ('11111111-1111-7111-8111-111111111111', 'policy-proof', 'Policy', 'Trial', now(), + '00000000-0000-7000-8000-000000000001', 0); + INSERT INTO organizations (id, tenant_id, slug, display_name, status, created_at, created_by, row_version) + VALUES ('aaaaaaaa-1111-7111-8111-111111111111', '11111111-1111-7111-8111-111111111111', + 'main', 'Main', 'Active', now(), '00000000-0000-7000-8000-000000000001', 0); + INSERT INTO courses (id, tenant_id, slug_key, status, level_taxonomy_key, level_taxonomy_schema_version, + level_band_key, created_at, created_by, row_version) + VALUES ('cccccccc-0000-7000-8000-000000000001', '11111111-1111-7111-8111-111111111111', + 'legacy-wide', 'published', 'difficulty', 7, 'intro', now(), '00000000-0000-7000-8000-000000000001', 8); + SET LOCAL app.organization_id = 'aaaaaaaa-1111-7111-8111-111111111111'; + INSERT INTO courses (id, tenant_id, organization_id, slug_key, status, created_at, created_by, row_version) + VALUES ('cccccccc-0000-7000-8000-000000000002', '11111111-1111-7111-8111-111111111111', + 'aaaaaaaa-1111-7111-8111-111111111111', 'legacy-scoped', 'draft', now(), + '00000000-0000-7000-8000-000000000001', 4); + SET LOCAL app.organization_id = ''; + INSERT INTO course_translations (course_id, tenant_id, locale, title, summary, slug) + VALUES ('cccccccc-0000-7000-8000-000000000001', '11111111-1111-7111-8111-111111111111', + 'en', 'Legacy course', 'Preserved summary', 'legacy-course'); + SET LOCAL app.organization_id = 'aaaaaaaa-1111-7111-8111-111111111111'; + INSERT INTO lessons (id, tenant_id, organization_id, course_id, sort, status, + content_type_key, content_type_schema_version, created_at, created_by, row_version) + VALUES ('dddddddd-0000-7000-8000-000000000001', '11111111-1111-7111-8111-111111111111', + 'aaaaaaaa-1111-7111-8111-111111111111', 'cccccccc-0000-7000-8000-000000000002', 3, + 'published', 'text-card', 9, now(), '00000000-0000-7000-8000-000000000001', 2); + INSERT INTO lesson_translations (lesson_id, tenant_id, organization_id, locale, title, slug, body) + VALUES ('dddddddd-0000-7000-8000-000000000001', '11111111-1111-7111-8111-111111111111', + 'aaaaaaaa-1111-7111-8111-111111111111', 'en', 'Legacy lesson', 'legacy-lesson', '{"body":"Private 🧘"}'); + COMMIT; + """; +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/EducationPersistenceTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/EducationPersistenceTests.cs index be614c27..6f9dda06 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/EducationPersistenceTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/EducationPersistenceTests.cs @@ -76,7 +76,8 @@ public async Task PersistedGraphs_RoundTripPinsTranslationsAndContainedAuditWhil await using (var creating = await Operation.OpenAsync(provider)) { var course = Course.Create(CourseId, TenantId.From(SchemaFixture.TenantA), organization, - "persisted-course", Clock, Actor, "difficulty", 7, "intro"); + "persisted-course", organizationScoped ? CourseContentAccess.EnrollmentRequired : CourseContentAccess.Public, + Clock, Actor, "difficulty", 7, "intro"); course.AddTranslation("EN-us", "Course 日本語", null, "course-en", Clock, Actor).IsSuccess.Should().BeTrue(); creating.Context.Courses.Add(course); await creating.Context.SaveChangesAsync(); @@ -155,6 +156,7 @@ public async Task PersistedGraphs_RoundTripPinsTranslationsAndContainedAuditWhil storedCourse.LevelBandKey.Should().Be("intro"); storedCourse.Version.Should().Be(3); storedCourse.Status.Should().Be(PublicationStatus.Published); + storedCourse.ContentAccess.Should().Be(organizationScoped ? CourseContentAccess.EnrollmentRequired : CourseContentAccess.Public); storedCourse.CreatedAt.Should().Be(Clock.UtcNow); storedCourse.UpdatedAt.Should().Be(Later.UtcNow); storedCourse.Translations.Select(translation => translation.Locale).Should().BeEquivalentTo("en-US", "fr"); @@ -234,7 +236,7 @@ private static async Task CreateRootsAsync(ServiceProvider provider, bool lesson await using (var creating = await Operation.OpenAsync(provider)) { creating.Context.Courses.Add(Course.Create(CourseId, TenantId.From(SchemaFixture.TenantA), null, - "concurrency", Clock, Actor)); + "concurrency", CourseContentAccess.EnrollmentRequired, Clock, Actor)); await creating.Context.SaveChangesAsync(); await creating.Frame.CompleteAsync(); } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/P02d2FoundationTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/P02d2FoundationTests.cs new file mode 100644 index 00000000..1523f882 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/P02d2FoundationTests.cs @@ -0,0 +1,183 @@ +using FluentAssertions; +using LearnStack.Modules.Customization.Application.Contracts.Customization; +using LearnStack.Modules.Customization.Application.Contracts.Definitions; +using LearnStack.Modules.Customization.Application.Contracts.Seeding; +using LearnStack.Modules.Education.Application.Contracts.Seeding; +using LearnStack.Modules.Tenancy.Application.Contracts.Locales; +using LearnStack.Modules.Tenancy.Application.Contracts.Seeding; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.Tools.Seeder; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class P02d2FoundationTests(SchemaFixture schema) +{ + private const string Profile = """ + {"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object", + "properties":{"a":{"type":"string"},"b":{"type":"string"}}, + "additionalProperties":false, + "x-fields":[{"name":"b","label":{"en":"Second"}},{"name":"a","label":{"en":"First"}}]} + """; + + private static readonly Dictionary Label = new(StringComparer.Ordinal) { ["en"] = "Profile" }; + + [Fact] + public async Task Exact_reads_distinguish_new_bindings_from_pins_and_preserve_stored_descriptor_order() + { + await using var dataSource = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var provider = SeedComposition.Build(dataSource, new Context(SchemaFixture.TenantA), NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(scope.ServiceProvider.GetRequiredService()); + var sender = scope.ServiceProvider.GetRequiredService(); + var reader = scope.ServiceProvider.GetRequiredService(); + var first = Guid.CreateVersion7(); + var next = Guid.CreateVersion7(); + var key = "foundation-profile-" + Guid.NewGuid().ToString("N"); + (await sender.Send(new RegisterTenantContentTypeCommand(first, key, 1, Label, Profile, "default-card"))) + .IsSuccess.Should().BeTrue(); + (await reader.ReadContentTypeAsync(key, 1, DefinitionReadPurpose.NewBinding, default)).IsFailure.Should().BeTrue(); + (await reader.ReadContentTypeAsync(key, 1, DefinitionReadPurpose.ExistingPin, default)).IsFailure.Should().BeTrue(); + (await sender.Send(new PublishTenantContentTypeCommand(first))).IsSuccess.Should().BeTrue(); + var active = await reader.ReadContentTypeAsync(key, 1, DefinitionReadPurpose.NewBinding, default); + active.IsSuccess.Should().BeTrue(); + active.Value!.Id.Should().Be(first); + active.Value.Fields.Select(field => field.Name).Should().Equal("b", "a"); + (await sender.Send(new RegisterTenantContentTypeCommand(next, key, 2, Label, Profile, "default-card"))) + .IsSuccess.Should().BeTrue(); + (await sender.Send(new PublishTenantContentTypeCommand(next))).IsSuccess.Should().BeTrue(); + (await reader.ReadContentTypeAsync(key, 1, DefinitionReadPurpose.NewBinding, default)).IsFailure.Should().BeTrue(); + var pinned = await reader.ReadContentTypeAsync(key, 1, DefinitionReadPurpose.ExistingPin, default); + pinned.IsSuccess.Should().BeTrue(); + pinned.Value!.Id.Should().Be(first); + pinned.Value.Status.Should().Be(DefinitionStatus.Deprecated); + (await reader.ReadContentTypeAsync(key, 2, DefinitionReadPurpose.NewBinding, default)).Value!.Id.Should().Be(next); + (await reader.ReadContentTypeAsync(key, 3, DefinitionReadPurpose.ExistingPin, default)).IsFailure.Should().BeTrue(); + (await reader.ReadContentTypeAsync(key, 1, (DefinitionReadPurpose)99, default)).IsFailure.Should().BeTrue(); + (await sender.Send(new GetContentTypeSeedStateQuery(first))).Value!.State!.JsonSchema.Should().Contain("x-fields"); + var taxonomyId = Guid.CreateVersion7(); + var taxonomyNext = Guid.CreateVersion7(); + (await sender.Send(new RegisterTenantLevelTaxonomyCommand(taxonomyId, key, 1, Label, + [new TaxonomyItemInput("intro", Label, 0)]))).IsSuccess.Should().BeTrue(); + (await reader.ReadTaxonomyAsync(key, 1, DefinitionReadPurpose.ExistingPin, default)).IsFailure.Should().BeTrue(); + (await sender.Send(new PublishTenantLevelTaxonomyCommand(taxonomyId))).IsSuccess.Should().BeTrue(); + (await reader.ReadTaxonomyAsync(key, 1, DefinitionReadPurpose.NewBinding, default)) + .Value!.Bands.Should().ContainSingle().Which.Key.Should().Be("intro"); + (await sender.Send(new RegisterTenantLevelTaxonomyCommand(taxonomyNext, key, 2, Label, + [new TaxonomyItemInput("later", Label, 0)]))).IsSuccess.Should().BeTrue(); + (await sender.Send(new PublishTenantLevelTaxonomyCommand(taxonomyNext))).IsSuccess.Should().BeTrue(); + (await reader.ReadTaxonomyAsync(key, 1, DefinitionReadPurpose.NewBinding, default)).IsFailure.Should().BeTrue(); + (await reader.ReadTaxonomyAsync(key, 1, DefinitionReadPurpose.ExistingPin, default)) + .Value!.Bands.Should().ContainSingle().Which.Key.Should().Be("intro"); + (await sender.Send(new GetTaxonomySeedStateQuery(taxonomyId))) + .Value!.State!.Items.Should().ContainSingle().Which.Key.Should().Be("intro"); + await frame.FailAsync(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Every_seed_query_runs_on_the_composed_pipeline_and_hides_foreign_or_sibling_roots(bool scoped) + { + await using var dataSource = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var provider = SeedComposition.Build(dataSource, + new Context(SchemaFixture.TenantA, scoped ? SchemaFixture.OrgA1 : null), NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var sender = scope.ServiceProvider.GetRequiredService(); + var visible = EducationSchemaSeed.Find(SchemaFixture.TenantA, scoped ? SchemaFixture.OrgA1 : null); + var sibling = EducationSchemaSeed.Find(SchemaFixture.TenantA, SchemaFixture.OrgA2); + var foreign = EducationSchemaSeed.Find(SchemaFixture.TenantB, null); + var tenant = (await sender.Send(new GetTenantSeedStateQuery())).Value!.State; + tenant!.Id.Should().Be(TenantId.From(SchemaFixture.TenantA)); + tenant.Locales.Should().ContainSingle().Which.Locale.Should().Be("tr-TR"); + (await sender.Send(new GetOrganizationSeedStateQuery(OrganizationId.From(SchemaFixture.OrgA1)))) + .Value!.State!.TenantId.Should().Be(tenant.Id); + (await sender.Send(new GetOrganizationSeedStateQuery(OrganizationId.From(SchemaFixture.OrgB1)))) + .Value!.State.Should().BeNull(); + (await sender.Send(new GetHostMappingSeedStateQuery(SchemaFixture.HostA))).Value!.State!.TenantId.Should().Be(tenant.Id); + (await sender.Send(new GetHostMappingSeedStateQuery(SchemaFixture.HostB))).Value!.State.Should().BeNull(); + (await sender.Send(new GetSettingSeedStateQuery(Guid.CreateVersion7()))).Value!.State.Should().BeNull(); + (await sender.Send(new GetContentTypeSeedStateQuery(Guid.CreateVersion7()))).Value!.State.Should().BeNull(); + (await sender.Send(new GetTaxonomySeedStateQuery(Guid.CreateVersion7()))).Value!.State.Should().BeNull(); + (await sender.Send(new GetCourseSeedStateQuery(visible.CourseId))).Value!.State!.Id.Should().Be(visible.CourseId); + (await sender.Send(new GetLessonSeedStateQuery(visible.LessonId))).Value!.State!.CourseId.Should().Be(visible.CourseId); + foreach (var hidden in new[] { sibling, foreign }) + { + (await sender.Send(new GetCourseSeedStateQuery(hidden.CourseId))).Value!.State.Should().BeNull(); + (await sender.Send(new GetLessonSeedStateQuery(hidden.LessonId))).Value!.State.Should().BeNull(); + } + } + + [Fact] + public async Task Locale_and_exact_definition_reads_use_the_same_announced_connection_and_do_not_fall_back() + { + await using var dataSource = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var provider = SeedComposition.Build(dataSource, new Context(SchemaFixture.TenantA), NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(scope.ServiceProvider.GetRequiredService()); + var locales = scope.ServiceProvider.GetRequiredService(); + (await locales.ReadEligibleAsync("TR-tr", default)).Value.Should().Be("tr-TR"); + (await locales.ReadEligibleAsync("en", default)).IsFailure.Should().BeTrue(); + (await locales.ReadEligibleAsync("tr_TR", default)).IsFailure.Should().BeTrue(); + var reader = scope.ServiceProvider.GetRequiredService(); + // No implicit substitution of a different live definition. + (await reader.ReadContentTypeAsync("card", 1, DefinitionReadPurpose.NewBinding, default)).IsFailure.Should().BeTrue(); + var absent = await reader.ReadTaxonomyAsync("absent", 1, DefinitionReadPurpose.ExistingPin, default); + absent.IsFailure.Should().BeTrue(); + (await reader.ReadTaxonomyAsync("proficiency", 1, DefinitionReadPurpose.NewBinding, default)) + .Value!.Bands.Should().ContainSingle().Which.Key.Should().Be("beginner"); + await using (var other = SeedComposition.Build(dataSource, new Context(SchemaFixture.TenantB), NullLoggerFactory.Instance)) + await using (var otherScope = other.CreateAsyncScope()) + { + var otherUnit = otherScope.ServiceProvider.GetRequiredService(); + await using var otherFrame = await otherUnit.BeginTransactionAsync(); + await otherUnit.SetTenantContextAsync(otherScope.ServiceProvider.GetRequiredService()); + var otherReader = otherScope.ServiceProvider.GetRequiredService(); + var foreignType = (await otherReader.ReadContentTypeAsync("announcement", 1, DefinitionReadPurpose.NewBinding, default)).Value!; + var foreignTaxonomy = (await otherReader.ReadTaxonomyAsync("proficiency", 1, DefinitionReadPurpose.NewBinding, default)).Value!; + foreignTaxonomy.Bands.Should().ContainSingle().Which.Key.Should().Be("starter"); + var sender = scope.ServiceProvider.GetRequiredService(); + (await sender.Send(new GetContentTypeSeedStateQuery(foreignType.Id))).Value!.State.Should().BeNull(); + (await sender.Send(new GetTaxonomySeedStateQuery(foreignTaxonomy.Id))).Value!.State.Should().BeNull(); + await otherFrame.FailAsync(); + } + + await using (var legacy = new NpgsqlCommand(""" + INSERT INTO tenant_locales (tenant_id, locale, is_default, is_enabled, sort) + VALUES (@tenant, 'en', false, false, 1); + UPDATE tenant_locales SET is_default = false WHERE tenant_id = @tenant; + """, (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)unit.Transaction!)) + { + legacy.Parameters.AddWithValue("tenant", SchemaFixture.TenantA); + await legacy.ExecuteNonQueryAsync(); + } + + (await locales.ReadEligibleAsync("tr-TR", default)).IsFailure.Should().BeTrue(); + (await locales.ReadEligibleAsync("en", default)).IsFailure.Should().BeTrue(); + await frame.FailAsync(); + } + + private sealed class Context(Guid tenantId, Guid? organizationId = null) : ITenantContext + { + public bool IsResolved => true; + public TenantId TenantId => TenantId.From(tenantId); + public OrganizationId? OrganizationId => organizationId is { } value + ? LearnStack.SharedKernel.Identifiers.OrganizationId.From(value) : null; + public UserId? UserId => null; + public TenantContextOrigin? Origin => TenantContextOrigin.HostAndClaim; + public string? CorrelationId => null; + public string? ModuleName => null; + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Education/EducationAggregateTests.cs b/backend/tests/LearnStack.Tests.Unit/Education/EducationAggregateTests.cs index 0bef7aa9..e79f77d2 100644 --- a/backend/tests/LearnStack.Tests.Unit/Education/EducationAggregateTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Education/EducationAggregateTests.cs @@ -18,7 +18,7 @@ public sealed class EducationAggregateTests private static readonly LessonId LessonId = LessonId.From(Guid.Parse("aaaaaaaa-1111-7111-8111-111111111111")); private static Course NewCourse(OrganizationId? organization = null, string slug = "course") => - Course.Create(CourseId, Tenant, organization, slug, Clock, Actor); + Course.Create(CourseId, Tenant, organization, slug, CourseContentAccess.EnrollmentRequired, Clock, Actor); private static Lesson NewLesson(Course? course = null, int sort = 0, string key = "content", int version = 1) => Lesson.Create(LessonId, course ?? NewCourse(), sort, key, version, Clock, Actor); @@ -56,6 +56,27 @@ public void Create_DerivesScopeAndStartsDraftWithoutMutatingCourse(bool scoped) lesson.UpdatedAt.Should().BeNull(); } + [Theory] + [InlineData(CourseContentAccess.Public)] + [InlineData(CourseContentAccess.EnrollmentRequired)] + public void Content_access_is_explicit_and_independent_of_publication(CourseContentAccess policy) + { + var course = Course.Create(CourseId, Tenant, null, "course", policy, Clock, Actor); + course.ContentAccess.Should().Be(policy); + course.Publish(Later, Actor).IsSuccess.Should().BeTrue(); + course.ContentAccess.Should().Be(policy); + var lesson = NewLesson(course); + lesson.Publish(Later, Actor).IsSuccess.Should().BeTrue(); + course.ContentAccess.Should().Be(policy); + } + + [Fact] + public void Undefined_content_access_cannot_create_a_course() + { + var create = () => Course.Create(CourseId, Tenant, null, "course", (CourseContentAccess)99, Clock, Actor); + create.Should().Throw(); + } + [Fact] public void Publish_EmptyRootsSucceedIndependentlyAndRepeatedCallsPreserveState() { @@ -230,7 +251,7 @@ public void Translations_ExposedCollectionCannotBeMutatedByDowncast() [InlineData("taxonomy", 1, "")] public void Create_PartialOrInvalidLevelPinRefuses(string? key, int? version, string? band) { - var create = () => Course.Create(CourseId, Tenant, null, "course", Clock, Actor, key, version, band); + var create = () => Course.Create(CourseId, Tenant, null, "course", CourseContentAccess.EnrollmentRequired, Clock, Actor, key, version, band); create.Should().Throw(); } @@ -239,7 +260,7 @@ public void Create_CompleteLevelPinRemainsExactAcrossPublication() { var key = new string('k', 100); var band = new string('b', 100); - var course = Course.Create(CourseId, Tenant, null, "course", Clock, Actor, key, 7, band); + var course = Course.Create(CourseId, Tenant, null, "course", CourseContentAccess.EnrollmentRequired, Clock, Actor, key, 7, band); course.Publish(Later, Actor).IsSuccess.Should().BeTrue(); course.LevelTaxonomyKey.Should().Be(key); course.LevelTaxonomySchemaVersion.Should().Be(7); diff --git a/backend/tests/LearnStack.Tests.Unit/Education/EducationInputTests.cs b/backend/tests/LearnStack.Tests.Unit/Education/EducationInputTests.cs index 700faae8..3aa8ca55 100644 --- a/backend/tests/LearnStack.Tests.Unit/Education/EducationInputTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Education/EducationInputTests.cs @@ -16,7 +16,7 @@ public sealed class EducationInputTests private static readonly LessonId LessonId = LessonId.From(Guid.Parse("aaaaaaaa-1111-7111-8111-111111111111")); private static Course NewCourse(string slug = "course") => - Course.Create(CourseId, Tenant, null, slug, Clock, Actor); + Course.Create(CourseId, Tenant, null, slug, CourseContentAccess.EnrollmentRequired, Clock, Actor); private static Lesson NewLesson() => Lesson.Create(LessonId, NewCourse(), 0, "content", 1, Clock, Actor); @@ -82,8 +82,8 @@ public void Slug_UsesEducationWidthAndPreservesValidValues() public void PinKey_InvalidShapeRefusesInEveryPinPosition(string key) { var content = () => Lesson.Create(LessonId, NewCourse(), 0, key, 1, Clock, Actor); - var taxonomy = () => Course.Create(CourseId, Tenant, null, "course", Clock, Actor, key, 1, "band"); - var band = () => Course.Create(CourseId, Tenant, null, "course", Clock, Actor, "taxonomy", 1, key); + var taxonomy = () => Course.Create(CourseId, Tenant, null, "course", CourseContentAccess.EnrollmentRequired, Clock, Actor, key, 1, "band"); + var band = () => Course.Create(CourseId, Tenant, null, "course", CourseContentAccess.EnrollmentRequired, Clock, Actor, "taxonomy", 1, key); content.Should().Throw(); taxonomy.Should().Throw(); band.Should().Throw(); @@ -224,12 +224,12 @@ public void Create_UnassignedOrEmptyScopeAndIdentifiersRefuse() var id = (new CourseId[1])[0]; var tenant = (new TenantId[1])[0]; var organization = (new OrganizationId[1])[0]; - var invalidId = () => Course.Create(id, Tenant, null, "course", Clock, Actor); - var invalidTenant = () => Course.Create(CourseId, tenant, null, "course", Clock, Actor); - var invalidOrganization = () => Course.Create(CourseId, Tenant, organization, "course", Clock, Actor); - var sentinel = () => Course.Create(CourseId, TenantId.PlatformSentinel, null, "course", Clock, Actor); - var emptyId = () => Course.Create(CourseId.From(Guid.Empty), Tenant, null, "course", Clock, Actor); - var emptyOrganization = () => Course.Create(CourseId, Tenant, OrganizationId.From(Guid.Empty), "course", Clock, Actor); + var invalidId = () => Course.Create(id, Tenant, null, "course", CourseContentAccess.EnrollmentRequired, Clock, Actor); + var invalidTenant = () => Course.Create(CourseId, tenant, null, "course", CourseContentAccess.EnrollmentRequired, Clock, Actor); + var invalidOrganization = () => Course.Create(CourseId, Tenant, organization, "course", CourseContentAccess.EnrollmentRequired, Clock, Actor); + var sentinel = () => Course.Create(CourseId, TenantId.PlatformSentinel, null, "course", CourseContentAccess.EnrollmentRequired, Clock, Actor); + var emptyId = () => Course.Create(CourseId.From(Guid.Empty), Tenant, null, "course", CourseContentAccess.EnrollmentRequired, Clock, Actor); + var emptyOrganization = () => Course.Create(CourseId, Tenant, OrganizationId.From(Guid.Empty), "course", CourseContentAccess.EnrollmentRequired, Clock, Actor); var invalidLessonId = () => Lesson.Create((new LessonId[1])[0], NewCourse(), 0, "content", 1, Clock, Actor); invalidId.Should().Throw(); invalidTenant.Should().Throw(); diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Customization/CustomizationCommandTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Customization/CustomizationCommandTests.cs index 1fd938f5..93f935d3 100644 --- a/backend/tests/LearnStack.Tests.Unit/Modules/Customization/CustomizationCommandTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Customization/CustomizationCommandTests.cs @@ -360,6 +360,30 @@ public async Task Every_taxonomy_a_document_names_is_asked_about_in_one_query() .Which.Should().Be("proficiency,second", "and it asks about each distinct key"); } + [Fact] + public async Task A_recognized_extension_without_a_resolver_is_refused_before_any_write() + { + var (sender, stores) = Build(gate: Reporting(("/x-future", "x-future", "value"))); + var result = await sender.Send(RegisterContentType()); + result.IsFailure.Should().BeTrue(); + result.Error!.Details.Should().ContainKey("/x-future"); + stores.Writes.Should().BeEmpty(); + } + + [Fact] + public async Task Text_card_semantic_failure_happens_after_admission_and_before_persistence() + { + var (sender, stores) = Build(); + var command = RegisterContentType() with + { + JsonSchema = """{"type":"object","properties":{"body":{"type":"string"}},"additionalProperties":false,"x-fields":[{"name":"unknown","label":{"en":"Label"}}]}""", + }; + var result = await sender.Send(command); + result.IsFailure.Should().BeTrue(); + result.Error!.Details.Should().ContainKey("/x-fields/0/name"); + stores.Writes.Should().BeEmpty(); + } + [Fact] public async Task An_x_language_is_admitted_because_its_registry_does_not_exist() { diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Customization/TextCardPresentationTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Customization/TextCardPresentationTests.cs new file mode 100644 index 00000000..3b105bc5 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Customization/TextCardPresentationTests.cs @@ -0,0 +1,109 @@ +using System.Text.Json.Nodes; +using FluentAssertions; +using LearnStack.Infrastructure.Validation; +using LearnStack.Modules.Customization.Application.Customization; +using Xunit; + +namespace LearnStack.Tests.Unit.Modules.Customization; + +public sealed class TextCardPresentationTests +{ + private readonly JsonSchemaNetValidator _validator = new(); + private const string Schema = """ + {"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object", + "properties":{"first":{"type":"string","minLength":1},"second":{"type":"string"}}, + "additionalProperties":false,"required":["first"], + "x-fields":[{"name":"second","label":{"TR-tr":"İkinci","en":"Second"}}, + {"name":"first","label":{"en":"First"}}]} + """; + + [Fact] + public void Descriptors_preserve_array_order_and_canonical_localized_fallback() + { + _validator.AdmitSchema(Schema).IsSuccess.Should().BeTrue(); + var result = TextCardPresentation.Resolve(Schema, "default-card"); + result.IsSuccess.Should().BeTrue(); + result.Value.Select(field => field.Name).Should().Equal("second", "first"); + result.Value[0].Label.Locales.Should().Equal("en", "tr-TR"); + result.Value[1].Label.Resolve("tr-TR").Should().Be("First"); + _validator.ValidateInstance(Schema, """{"first":""}""").IsFailure.Should().BeTrue(); + _validator.ValidateInstance(Schema, """{"first":"https://example.com/