diff --git a/.claude/skills/README.md b/.claude/skills/README.md index 6021d7f..9b26dcf 100644 --- a/.claude/skills/README.md +++ b/.claude/skills/README.md @@ -2,7 +2,7 @@ Reusable, task-focused instruction packs ("skills") for AI coding agents working on **LearnStack Hub**. Each subdirectory is one skill; its `SKILL.md` carries YAML frontmatter (`name`, `description`) so the agent runtime can pick or skip it without reading the whole body. -Skills are **project-local** to this repo (`.claude/skills/`). An agent running from the `learnstack-hub` root loads them automatically. They cite LearnStack core's standards / ADRs by sibling path (`../learnstack/docs/...`) for cross-cutting authority and carry only the **Hub-specific delta** on top — they never duplicate the LearnStack standards corpus. +Skills are **project-local** to this repo (`.claude/skills/`). An agent running from the `learnstack-hub` root loads them automatically. They cite LearnStack core's standards / ADRs by sibling path (`../LearnStack/docs/...`) for cross-cutting authority and carry only the **Hub-specific delta** on top — they never duplicate the LearnStack standards corpus. ## The Hub deltas every skill assumes @@ -38,7 +38,7 @@ Pick the entry point matching the user's intent. Only **one** entry point runs p | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | [implement-task](implement-task/SKILL.md) | The default entry point for substantive work — scope, implement, self-check, test, docs, commit, review prompt. | | [start-task](start-task/SKILL.md) | Lightweight scoping-only entry point. Reading order + alignment check. | -| [write-adr](write-adr/SKILL.md) | Capturing a **Hub-internal** decision (`HUB-NNNN` series). Cross-cutting decisions go in `../learnstack/docs/decisions/`. | +| [write-adr](write-adr/SKILL.md) | Capturing a **Hub-internal** decision (`HUB-NNNN` series). Cross-cutting decisions go in `../LearnStack/docs/decisions/`. | | [update-glossary](update-glossary/SKILL.md) | Introducing a Hub-specific term in `docs/glossary.md`. | | [commit-and-pr](commit-and-pr/SKILL.md) | Conventional Commit + AI trailer + Hub PR conventions (incl. cross-repo coordination). | @@ -102,6 +102,6 @@ Body structure: **Purpose**, **When to use** / **When not to use**, **Inputs**, ## What skills are not -- **Not duplicates of standards.** A skill is a _workflow_. LearnStack's [Standards corpus](../../../learnstack/docs/standards/) is the authority; skills cite it. -- **Not decisions.** Decisions live in ADRs (LearnStack `../learnstack/docs/decisions/` for cross-cutting; this repo's `docs/decisions/` for `HUB-NNNN`). +- **Not duplicates of standards.** A skill is a _workflow_. LearnStack's [Standards corpus](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/) is the authority; skills cite it. +- **Not decisions.** Decisions live in ADRs (LearnStack `../LearnStack/docs/decisions/` for cross-cutting; this repo's `docs/decisions/` for `HUB-NNNN`). - **Not scratch space.** Exploratory notes go in `docs/analysis/` (gitignored). diff --git a/.claude/skills/add-architecture-test/SKILL.md b/.claude/skills/add-architecture-test/SKILL.md index 19dc551..f42552a 100644 --- a/.claude/skills/add-architecture-test/SKILL.md +++ b/.claude/skills/add-architecture-test/SKILL.md @@ -30,7 +30,7 @@ Make a structural rule mechanical + non-skippable. Hub's architecture suite is t ## The Hub architecture-test set -The tests `LearnStack.Hub.Tests.Architecture` should carry as P02c-1 lands modules (authoritative list: [Architecture 24 § 10](../../../../learnstack/docs/architecture/24-learnstack-hub.md), Hub-side): +The tests `LearnStack.Hub.Tests.Architecture` should carry as P02c-1 lands modules (authoritative list: [Architecture 24 § 10](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md), Hub-side): | Test | Asserts | | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/.claude/skills/add-ef-migration/SKILL.md b/.claude/skills/add-ef-migration/SKILL.md index baae02e..0de674c 100644 --- a/.claude/skills/add-ef-migration/SKILL.md +++ b/.claude/skills/add-ef-migration/SKILL.md @@ -12,7 +12,7 @@ description: > ## Purpose -Produce a migration that follows Hub's database conventions: `hub` schema, snake_case, the right index/constraint naming, JSONB for dictionaries, **no RLS** (the load-bearing difference from LearnStack's migrations) and the forward-only / two-step destructive rules from [Standards 05](../../../../learnstack/docs/standards/05-database.md). +Produce a migration that follows Hub's database conventions: `hub` schema, snake_case, the right index/constraint naming, JSONB for dictionaries, **no RLS** (the load-bearing difference from LearnStack's migrations) and the forward-only / two-step destructive rules from [Standards 05](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/05-database.md). ## When to use diff --git a/.claude/skills/add-feature-key/SKILL.md b/.claude/skills/add-feature-key/SKILL.md index eb3f730..6357c4d 100644 --- a/.claude/skills/add-feature-key/SKILL.md +++ b/.claude/skills/add-feature-key/SKILL.md @@ -24,13 +24,13 @@ Hub is the **authoring** side of entitlements: operators build plans whose `feat - Reading a flag at runtime → that's LearnStack core's `IFeatureFlags.IsEnabledAsync`, not Hub. Hub never _reads_ entitlements for gating; it _authors_ them. - Per-request toggling → forbidden; entitlements are plan-projected. -- A domain-flavoured key (`english.placement`, `yoga.asana`) → forbidden; keys are generic platform capabilities ([ADR-0021](../../../../learnstack/docs/decisions/0021-feature-based-entitlement.md)). +- A domain-flavoured key (`english.placement`, `yoga.asana`) → forbidden; keys are generic platform capabilities ([ADR-0021](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0021-feature-based-entitlement.md)). ## Workflow ### Step 1 — Confirm shape + naming -Per [ADR-0021 Amendment 1](../../../../learnstack/docs/decisions/0021-feature-based-entitlement.md) + [entitlement-projection.md § key-shape rules](../../../docs/architecture/entitlement-projection.md): +Per [ADR-0021 Amendment 1](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0021-feature-based-entitlement.md) + [entitlement-projection.md § key-shape rules](../../../docs/architecture/entitlement-projection.md): - `FeatureKey` value: dotted snake_case, **no `.enabled` suffix** (every feature is implicitly boolean). e.g. `classroom.recording`, `tenancy.custom_domain`, `identity.sso.saml`. - `LimitKey` value: `limits.` prefix. e.g. `limits.max_users`. `-1` = unlimited, `0` = unavailable. @@ -46,7 +46,7 @@ public static readonly LimitKey = new("limits."); ### Step 3 — Keep it aligned with LearnStack core -The wire-format string **must** match LearnStack core's `FeatureKeys`/`LimitKeys` registry (`../learnstack/backend/src/LearnStack.SharedKernel/FeatureFlags/`) exactly — LearnStack core reads the key from the projection by string. A mismatch means LearnStack silently never sees the feature. If you add a key Hub authors but LearnStack core doesn't yet read, note the pending LearnStack-side addition (a cross-repo registry-sync follow-up; the durable fix is a shared `LearnStack.Contracts` package, Phase 11 — see [plans.md § Registry sync](../../../docs/modules/plans.md)). +The wire-format string **must** match LearnStack core's `FeatureKeys`/`LimitKeys` registry (`../LearnStack/backend/src/LearnStack.SharedKernel/FeatureFlags/`) exactly — LearnStack core reads the key from the projection by string. A mismatch means LearnStack silently never sees the feature. If you add a key Hub authors but LearnStack core doesn't yet read, note the pending LearnStack-side addition (a cross-repo registry-sync follow-up; the durable fix is a shared `LearnStack.Contracts` package, Phase 11 — see [plans.md § Registry sync](../../../docs/modules/plans.md)). ### Step 4 — Plan validator diff --git a/.claude/skills/add-hub-module/SKILL.md b/.claude/skills/add-hub-module/SKILL.md index 46a1126..17e3bf5 100644 --- a/.claude/skills/add-hub-module/SKILL.md +++ b/.claude/skills/add-hub-module/SKILL.md @@ -15,7 +15,7 @@ description: > ## Purpose -Stand up a new Hub modular-monolith module that complies with the dependency-direction rules from day one: four packages, the right references, a registration extension, a DbContext in the `hub` schema (no RLS), and architecture-test coverage. Mirrors LearnStack's [add-backend-module](../../../../learnstack/.claude/skills/add-backend-module/SKILL.md) minus the tenant-isolation layer. +Stand up a new Hub modular-monolith module that complies with the dependency-direction rules from day one: four packages, the right references, a registration extension, a DbContext in the `hub` schema (no RLS), and architecture-test coverage. Mirrors LearnStack's [add-backend-module](https://github.com/HodeTech/LearnStack/blob/main/.claude/skills/add-backend-module/SKILL.md) minus the tenant-isolation layer. ## When to use diff --git a/.claude/skills/add-integration-event/SKILL.md b/.claude/skills/add-integration-event/SKILL.md index b0f7437..09cd39d 100644 --- a/.claude/skills/add-integration-event/SKILL.md +++ b/.claude/skills/add-integration-event/SKILL.md @@ -29,7 +29,7 @@ Wire a cross-boundary event the right way: outbox-written in the same transactio ## When not to use - Intra-module notification → plain MediatR `INotification` (`IDomainEvent`), in-process, same transaction. No outbox. -- A request/response contract → that's one of the four HTTPS endpoints (a fifth needs an ADR in `../learnstack/docs/decisions/`). +- A request/response contract → that's one of the four HTTPS endpoints (a fifth needs an ADR in `../LearnStack/docs/decisions/`). ## Workflow diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index 416364a..1655ffc 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -59,11 +59,11 @@ Walk all five. Cite `file:line` for every finding; classify Blocker / Major / Mi - Small single-responsibility methods; descriptive names; no dead code / commented-out blocks; no undated TODO. - No premature abstraction (three similar lines beat a premature helper); no half-finished implementation. -- The change mirrors the LearnStack pattern it's based on (open `../learnstack/backend/src/...` and compare) rather than improvising a parallel shape. +- The change mirrors the LearnStack pattern it's based on (open `../LearnStack/backend/src/...` and compare) rather than improvising a parallel shape. ### 5. Hub-structural (the delta lens) -- Run the full [standards-check](../standards-check/SKILL.md) checklist as the structural backbone: no RLS, `OperatorId` not `UserId`, 6-step pipeline, `hub` schema, no tenant content, no LearnStack-core imports, closed four-endpoint surface. +- Run the full [standards-check](../standards-check/SKILL.md) checklist as the structural backbone: no RLS, `OperatorId` not `UserId`, 6-step pipeline, `hub` schema, no tenant content, no LearnStack-core imports, and the two ADR-0034 contract invariants (Hub stores no tenant content; every crossing goes through a named adapter). - Architecture tests cover the new structure (`Hub_NeverStores_TenantData` scans the new module; dependency-direction test includes it). - Adjacent docs updated (module deep dive, glossary, roadmap, ADR). @@ -73,7 +73,7 @@ When delegating to a second agent, compose a self-contained prompt that: - Sets Hub context + **the five Hub deltas** ([../README.md](../README.md)). - Names the commit / branch / file list under review (`git log --oneline`, `git show --stat`). -- Points at the LearnStack-side authority (`../learnstack/docs/...`) the change derives from. +- Points at the LearnStack-side authority (`../LearnStack/docs/...`) the change derives from. - Tells the agent to walk all five lenses above. - Defines the output: verdict + findings as Blocker / Major / Minor / Suggestion with `file:line` + recommendation. - Insists on reading the surrounding Hub docs (`docs/architecture/`, `docs/modules/`) — don't review in isolation. diff --git a/.claude/skills/commit-and-pr/SKILL.md b/.claude/skills/commit-and-pr/SKILL.md index 7108ece..2c27567 100644 --- a/.claude/skills/commit-and-pr/SKILL.md +++ b/.claude/skills/commit-and-pr/SKILL.md @@ -40,7 +40,7 @@ Conventional Commits `type(scope): subject`: - **Hub scopes:** `hub` (cross-cutting), `hub-domain` (aggregates / modules), `hub-infra` (compose / APISIX / Dapr / Vault / EF), `hub-portal` (operator portal), `hub-docs` (documentation). - **subject:** imperative, ≤ 72 chars. - **body:** one short paragraph saying _why_ (the diff is _what_). For a packet, name the packet (`P02c-1`). -- **trailer:** `Co-Authored-By: Claude Opus 4.7 (1M context) ` (or `Codex …` for Codex sessions). HEREDOC every multi-line message. +- **trailer:** `Co-Authored-By: Claude Opus 5 (1M context) ` (or `Codex …` for Codex sessions). HEREDOC every multi-line message. ```bash git commit -m "$(cat <<'EOF' @@ -48,7 +48,7 @@ feat(hub-domain): P02c-1 — LearnStackTenant aggregate + DbContext + migration -Co-Authored-By: Claude Opus 4.7 (1M context) +Co-Authored-By: Claude Opus 5 (1M context) EOF )" ``` @@ -63,18 +63,18 @@ Feature branch `feat/phase-02c-packet-N-`. Push with `-u` only when the us ### Step 5 — Cross-repo coordination (if the packet spans both repos) -Per [CLAUDE.md § Cross-repo coordination](../../../CLAUDE.md): the Hub-side PR opens first (it carries the canonical contract shape); the LearnStack-side PR references the Hub PR's commit hash; both merge in the same session. Adding/changing a contract endpoint requires a new ADR in `../learnstack/docs/decisions/` first. **Do not** push or merge anything in `../learnstack` without explicit user permission — another agent may be active there. +Per [CLAUDE.md § Cross-repo coordination](../../../CLAUDE.md): the Hub-side PR opens first (it carries the canonical contract shape); the LearnStack-side PR references the Hub PR's commit hash; both merge in the same session. Adding/changing a contract endpoint requires a new ADR in `../LearnStack/docs/decisions/` first. **Do not** push or merge anything in `../LearnStack` without explicit user permission — another agent may be active there. ## Validation - Commit subject imperative ≤ 72 chars with a valid Hub scope. - Body explains _why_; AI co-author trailer present. - Multi-line message via HEREDOC. -- Cross-repo packets: paired-PR plan stated; no unilateral `../learnstack` push. +- Cross-repo packets: paired-PR plan stated; no unilateral `../LearnStack` push. ## Common pitfalls - **Pushing without being asked.** Default is local commit. - **Amending / force-pushing `main`.** Forbidden — new commits only. -- **A contract change without an ADR.** The four-endpoint surface is closed; a fifth needs an ADR in `../learnstack/docs/decisions/`. -- **Touching `../learnstack` branch state from a Hub session.** Coordinate; don't interfere with a parallel agent. +- **A contract change without an ADR.** Adding or reshaping an endpoint on the contract surface needs an ADR in `../LearnStack/docs/decisions/` first — it is a cross-repository agreement (ADR-0034). +- **Touching `../LearnStack` branch state from a Hub session.** Coordinate; don't interfere with a parallel agent. diff --git a/.claude/skills/implement-task/SKILL.md b/.claude/skills/implement-task/SKILL.md index 1757d99..ed6746e 100644 --- a/.claude/skills/implement-task/SKILL.md +++ b/.claude/skills/implement-task/SKILL.md @@ -45,15 +45,15 @@ The ten steps are mandatory; skipping any is the bug this skill prevents. ### Step 1 — Scope and alignment -Run [start-task](../start-task/SKILL.md): read the right docs in order (Hub docs under `docs/`, then LearnStack authority under `../learnstack/docs/`), confirm phase fit against `docs/roadmap/README.md`, walk the [CLAUDE.md hard rules](../../../CLAUDE.md), and pick the specific workflow skill(s) you'll invoke ([add-hub-module](../add-hub-module/SKILL.md), [add-hub-aggregate](../add-hub-aggregate/SKILL.md), [add-mediatr-handler](../add-mediatr-handler/SKILL.md), [wire-cross-cutting-foundation](../wire-cross-cutting-foundation/SKILL.md), …). Output: a one-paragraph problem statement in your own words, the packet it belongs to, the standards that govern it, the skill(s) you'll use. +Run [start-task](../start-task/SKILL.md): read the right docs in order (Hub docs under `docs/`, then LearnStack authority under `../LearnStack/docs/`), confirm phase fit against `docs/roadmap/README.md`, walk the [CLAUDE.md hard rules](../../../CLAUDE.md), and pick the specific workflow skill(s) you'll invoke ([add-hub-module](../add-hub-module/SKILL.md), [add-hub-aggregate](../add-hub-aggregate/SKILL.md), [add-mediatr-handler](../add-mediatr-handler/SKILL.md), [wire-cross-cutting-foundation](../wire-cross-cutting-foundation/SKILL.md), …). Output: a one-paragraph problem statement in your own words, the packet it belongs to, the standards that govern it, the skill(s) you'll use. ### Step 2 — Inspect and understand -Read every file the change touches **before** editing. Trace one hop out (who calls this, who reads this table, what events flow). Read the relevant Hub design spec (`docs/architecture/*.md`, `docs/modules/*.md`) and the LearnStack-side ADR/standard it derives from. If `git log` shows recent edits, read the commit messages for direction. **Mirror, don't invent:** if a pattern exists in `../learnstack/backend/src/`, open it and reproduce it (adjusting for the Hub deltas), rather than improvising. +Read every file the change touches **before** editing. Trace one hop out (who calls this, who reads this table, what events flow). Read the relevant Hub design spec (`docs/architecture/*.md`, `docs/modules/*.md`) and the LearnStack-side ADR/standard it derives from. If `git log` shows recent edits, read the commit messages for direction. **Mirror, don't invent:** if a pattern exists in `../LearnStack/backend/src/`, open it and reproduce it (adjusting for the Hub deltas), rather than improvising. ### Step 3 — Plan -State the plan briefly: (1) what you'll do in 1-3 sentences; (2) which files/dirs (paths only); (3) which validation you'll run; (4) any assumption that, if wrong, invalidates the plan. Ask for confirmation **only** when the plan touches: more than one module's `Domain`; an Accepted ADR (either repo); the Hub HTTPS contract surface; a destructive migration; or anything in `../learnstack`. For routine Hub work, state the plan and continue. +State the plan briefly: (1) what you'll do in 1-3 sentences; (2) which files/dirs (paths only); (3) which validation you'll run; (4) any assumption that, if wrong, invalidates the plan. Ask for confirmation **only** when the plan touches: more than one module's `Domain`; an Accepted ADR (either repo); the Hub HTTPS contract surface; a destructive migration; or anything in `../LearnStack`. For routine Hub work, state the plan and continue. ### Step 4 — Implement @@ -99,5 +99,5 @@ Compose the review prompt per [code-review § review-agent prompt](../code-revie - **Rushing Step 2.** The next eight steps cost an order of magnitude more when inspect is sloppy. - **Forgetting the Hub deltas.** Copying a LearnStack pattern verbatim drags in RLS / `UserId` / `TenantContextBehavior` that Hub must not have. Adjust as you mirror. - **Skipping the dotnet PATH fix.** `dotnet` is .NET 9; use `~/.dotnet/dotnet`. #1 time-sink. -- **Touching `../learnstack` without coordination.** Hub-side packets are Hub-only; LearnStack-side work is a separate coordinated packet. +- **Touching `../LearnStack` without coordination.** Hub-side packets are Hub-only; LearnStack-side work is a separate coordinated packet. - **Stale docs / dropped Turkish summary / missing review prompt.** All three are part of "done." diff --git a/.claude/skills/local-dev-setup/SKILL.md b/.claude/skills/local-dev-setup/SKILL.md index 8d36baa..718f255 100644 --- a/.claude/skills/local-dev-setup/SKILL.md +++ b/.claude/skills/local-dev-setup/SKILL.md @@ -30,7 +30,7 @@ Get the Hub stack running. Hub's compose is deliberately minimal — it runs onl ## Prerequisites -- The sibling repo at `../learnstack` (Hub depends on it for shared backends + the Keycloak realms). +- The sibling repo at `../LearnStack` (Hub depends on it for shared backends + the Keycloak realms). - Docker daemon (OrbStack / Docker Desktop). - **.NET 10 SDK** at `~/.dotnet/dotnet` (system `dotnet` is .NET 9). `export PATH="$HOME/.dotnet:$PATH"`. - pnpm 9.12.3 + Node ≥ 20.11 for the operator portal. @@ -47,15 +47,15 @@ make install # restores backend NuGet + frontend pnpm + activates .githooks ### Step 2 — LearnStack core compose FIRST (shared backends) ```bash -cd ../learnstack && make dev +cd ../LearnStack && make dev ``` -This brings up Postgres (5432), Valkey (6379), Vault (8200), Kafka (9092), Keycloak (8080, with **both** the `learnstack` and `learnstack-hub` realms imported from `../learnstack/infra/keycloak/realms/`), and Mailpit. Hub does NOT run these — it reaches them via `host.docker.internal`. +This brings up Postgres (5432), Valkey (6379), Vault (8200), Kafka (9092), Keycloak (8080, with **both** the `learnstack` and `learnstack-hub` realms imported from `../LearnStack/infra/keycloak/realms/`), and Mailpit. Hub does NOT run these — it reaches them via `host.docker.internal`. ### Step 3 — Hub compose ```bash -cd ../learnstack-hub && make dev +cd ../LearnStack-Hub && make dev ``` Brings up the Hub-only services: Dapr placement (50006), Dapr sidecar (3501/50002), Hub APISIX (9180/9543/9191), and the `postgres-hub-init` one-shot that creates the `learnstack_hub` database in the shared Postgres. If LearnStack core compose isn't up, `postgres-hub-init` waits up to 120s then fails with an actionable message — start LearnStack core first. @@ -63,20 +63,20 @@ Brings up the Hub-only services: Dapr placement (50006), Dapr sidecar (3501/5000 ### Step 4 — Hub API ```bash -cd ../learnstack-hub/backend +cd ../LearnStack-Hub/backend ~/.dotnet/dotnet run --project src/Core/LearnStack.Hub.Api # binds 0.0.0.0:5181 ``` ### Step 5 — Operator portal (when working on it) ```bash -cd ../learnstack-hub/frontend && pnpm dev # operator-portal on 3100 +cd ../LearnStack-Hub/frontend && pnpm dev # operator-portal on 3100 ``` ### Step 6 — Seed (optional) ```bash -cd ../learnstack-hub && make seed # demo plans + demo tenant + demo operator (P02c-1+ fills real data) +cd ../LearnStack-Hub && make seed # demo plans + demo tenant + demo operator (P02c-1+ fills real data) ``` ## Health verification @@ -86,7 +86,7 @@ curl -i http://localhost:9180/healthz # Hub APISIX curl -i http://localhost:5181/healthz # Hub API direct curl -s http://localhost:8080/realms/learnstack-hub/.well-known/openid-configuration | jq .issuer # expect "http://localhost:8080/realms/learnstack-hub" -docker compose -f ../learnstack/infra/compose/dev.yml exec postgres psql -U learnstack -lqt | grep learnstack_hub +docker compose -f ../LearnStack/infra/compose/dev.yml exec postgres psql -U learnstack -lqt | grep learnstack_hub ``` ## Port map (no collisions with LearnStack core) @@ -103,7 +103,7 @@ Shared (Hub consumes via `host.docker.internal`): Postgres 5432, Valkey 6379, Va ## Common pitfalls -- **Starting Hub compose before LearnStack core.** `postgres-hub-init` hangs ~120s then fails. Start `../learnstack` first. +- **Starting Hub compose before LearnStack core.** `postgres-hub-init` hangs ~120s then fails. Start `../LearnStack` first. - **The dotnet PATH trap.** `dotnet run` fails on the SDK pin without `~/.dotnet` on PATH. -- **Expecting Hub to run its own Keycloak.** It doesn't — the `learnstack-hub` realm lives in LearnStack core's Keycloak (the realm JSON is in `../learnstack/infra/keycloak/realms/`). +- **Expecting Hub to run its own Keycloak.** It doesn't — the `learnstack-hub` realm lives in LearnStack core's Keycloak (the realm JSON is in `../LearnStack/infra/keycloak/realms/`). - **Port confusion.** Hub deliberately offsets every port from LearnStack core; check the map above. diff --git a/.claude/skills/standards-check/SKILL.md b/.claude/skills/standards-check/SKILL.md index a939923..f48c651 100644 --- a/.claude/skills/standards-check/SKILL.md +++ b/.claude/skills/standards-check/SKILL.md @@ -40,20 +40,20 @@ Walk these against the diff. Each item is pass/fail; a fail blocks merge until f - [ ] **`hub` schema.** Every DbContext `HasDefaultSchema("hub")`; tables at `learnstack_hub.hub.`. - [ ] **No tenant content.** No `Course`/`Lesson`/`Enrollment`/`LiveSession`/`LessonItem`/`MediaAsset`/tenant-`User` types or tables (`Hub_NeverStores_TenantData`). -### Module + dependency structure ([01-architecture-standards.md](../../../../learnstack/docs/standards/01-architecture-standards.md)) +### Module + dependency structure ([01-architecture-standards.md](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/01-architecture-standards.md)) - [ ] Module `Domain` references only `LearnStack.Hub.SharedKernel` (+ analyzer); no cross-module `Domain` refs; no `Application`/`Infrastructure` refs from `Domain`. - [ ] No imports of `LearnStack.SharedKernel` / `LearnStack.Domain` / `LearnStack.Infrastructure` / `LearnStack.Modules.*` from the sibling repo. - [ ] Cross-module reads go through `Application.Contracts`, not another module's `Domain` / DbContext. - [ ] One DbContext per module; cross-module FKs are plain `uuid` columns + index, not EF navigations. -### Backend coding ([02-backend-coding.md](../../../../learnstack/docs/standards/02-backend-coding.md)) +### Backend coding ([02-backend-coding.md](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/02-backend-coding.md)) - [ ] Strongly-typed IDs via Vogen (`[ValueObject(LearnStackHubVogenDefaults.IdMask)]`); no raw `Guid` on entity surfaces. - [ ] Handlers return `Result`; business-rule violations → `Result.Fail(...)`, never `throw DomainException`. - [ ] FluentValidation validators for every command; file-scoped namespaces; records where idiomatic. -### Database ([05-database.md](../../../../learnstack/docs/standards/05-database.md), Hub-adjusted) +### Database ([05-database.md](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/05-database.md), Hub-adjusted) - [ ] `snake_case` plural tables, `snake_case` columns, `id` PK, `_id` FKs, `ix_`/`ux_` indexes. - [ ] Migrations forward-only after merge; destructive changes follow the two-step deprecation. @@ -61,7 +61,7 @@ Walk these against the diff. Each item is pass/fail; a fail blocks merge until f ### Contract surface + boundary ([CLAUDE.md hard rules](../../../CLAUDE.md)) -- [ ] No fifth Hub HTTPS endpoint without an ADR in `../learnstack/docs/decisions/`. +- [ ] No fifth Hub HTTPS endpoint without an ADR in `../LearnStack/docs/decisions/`. - [ ] `/api/internal/*` not internet-exposed (when those endpoints land). - [ ] No Kubernetes-credential / K8s-state writes to LearnStack's cluster. - [ ] `learnstack-hub` realm boundary respected. diff --git a/.claude/skills/start-task/SKILL.md b/.claude/skills/start-task/SKILL.md index 7dd49b8..94b8075 100644 --- a/.claude/skills/start-task/SKILL.md +++ b/.claude/skills/start-task/SKILL.md @@ -33,7 +33,7 @@ Orient correctly before writing code: read the right docs, confirm the work fits 1. [README.md](../../../README.md) — direction at a glance. 2. The relevant Hub design spec: `docs/architecture/module-topology.md`, `docs/architecture/cross-cutting-foundation.md`, `docs/architecture/entitlement-projection.md`, and the `docs/modules/.md` for the module(s) in scope. -3. The LearnStack-side authority the spec derives from: the cited ADR(s) under `../learnstack/docs/decisions/` and standard(s) under `../learnstack/docs/standards/`. +3. The LearnStack-side authority the spec derives from: the cited ADR(s) under `../LearnStack/docs/decisions/` and standard(s) under `../LearnStack/docs/standards/`. 4. [docs/roadmap/README.md](../../../docs/roadmap/README.md) — which packet owns this work. 5. [docs/glossary.md](../../../docs/glossary.md) — terms. @@ -45,11 +45,11 @@ Confirm the task belongs to the current/next packet (`docs/roadmap/README.md`). ### Step 3 — Walk the Hub deltas + hard rules -Confirm the planned change honours [the Hub deltas](../README.md) and [CLAUDE.md § Hard rules](../../../CLAUDE.md): no RLS, `OperatorId` not `UserId`, 6-step pipeline, `hub` schema, no tenant content, no LearnStack-core imports, the closed four-endpoint surface. Name any rule the change brushes against. +Confirm the planned change honours [the Hub deltas](../README.md) and [CLAUDE.md § Hard rules](../../../CLAUDE.md): no RLS, `OperatorId` not `UserId`, 6-step pipeline, `hub` schema, no tenant content, no LearnStack-core imports, the two ADR-0034 contract invariants. Name any rule the change brushes against. ### Step 4 — Name the workflow skill(s) -Identify which `add-*` / `wire-*` skill the implementation will invoke (e.g. [add-hub-aggregate](../add-hub-aggregate/SKILL.md) + [add-ef-migration](../add-ef-migration/SKILL.md) + [add-mediatr-handler](../add-mediatr-handler/SKILL.md)). If none fits, fall back to LearnStack's [Standards index](../../../../learnstack/docs/standards/README.md) + the relevant Hub architecture doc. +Identify which `add-*` / `wire-*` skill the implementation will invoke (e.g. [add-hub-aggregate](../add-hub-aggregate/SKILL.md) + [add-ef-migration](../add-ef-migration/SKILL.md) + [add-mediatr-handler](../add-mediatr-handler/SKILL.md)). If none fits, fall back to LearnStack's [Standards index](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/README.md) + the relevant Hub architecture doc. ### Step 5 — Produce the plan @@ -64,4 +64,4 @@ A short plan: problem statement (your words), the packet, the governing standard - **Skimming instead of reading.** The implementation cost of a sloppy scope is paid back tenfold. - **Pulling future-packet work forward.** Respect the packet boundary. -- **Missing the cross-repo flag.** A task that needs `../learnstack` changes is not a Hub-only packet. +- **Missing the cross-repo flag.** A task that needs `../LearnStack` changes is not a Hub-only packet. diff --git a/.claude/skills/update-glossary/SKILL.md b/.claude/skills/update-glossary/SKILL.md index 3629904..e965730 100644 --- a/.claude/skills/update-glossary/SKILL.md +++ b/.claude/skills/update-glossary/SKILL.md @@ -6,7 +6,7 @@ description: > Hub APISIX, learnstack-hub realm, etc.) that other Hub docs cite; fixing a stale entry; removing a deprecated term. DO NOT USE FOR: cross-cutting terms shared with LearnStack core (Tenant, Organization, IModule, DeploymentMode — those live in - ../learnstack/docs/glossary.md; link to them), general programming terms, or terms + ../LearnStack/docs/glossary.md; link to them), general programming terms, or terms only used inside docs/analysis/ (gitignored). --- @@ -23,7 +23,7 @@ Keep `docs/glossary.md` the single source of truth for Hub-specific terminology. ## When not to use -- The term is cross-cutting (shared with LearnStack core) → it lives in `../learnstack/docs/glossary.md`; link to it, don't duplicate. +- The term is cross-cutting (shared with LearnStack core) → it lives in `../LearnStack/docs/glossary.md`; link to it, don't duplicate. - General programming term → link to its canonical source. - Term used only in `docs/analysis/` → not glossary-worthy. diff --git a/.claude/skills/wire-cross-cutting-foundation/SKILL.md b/.claude/skills/wire-cross-cutting-foundation/SKILL.md index aedcc33..0c9013a 100644 --- a/.claude/skills/wire-cross-cutting-foundation/SKILL.md +++ b/.claude/skills/wire-cross-cutting-foundation/SKILL.md @@ -31,7 +31,7 @@ Stand up the Hub's foundation so domain code programs against the same `Result` audit columns, `CapturedContext`). There is no tenant `UserId` in Hub. - **`HubException`** replaces `LearnStackException` as the base exception (namespace `LearnStack.Hub.SharedKernel.Errors`); `DomainException` / `InfrastructureException` / `ProviderException` derive from it. @@ -92,4 +92,4 @@ Mirroring LearnStack's `DomainExceptionThrowAnalyzer` as `LearnStack.Hub.Analyze - **Leaving `UserId` in the mirror.** Substitute `OperatorId` everywhere. - **Registering the OTel LoggerProvider alongside Serilog.** Double-exports every log line. - **Reading `DeploymentMode` inside a module.** Branch once at the composition root. -- **Improvising shapes.** Open `../learnstack/backend/src/LearnStack.SharedKernel/` + `LearnStack.Api/Common/` + `LearnStack.Application/Pipeline/` and reproduce. +- **Improvising shapes.** Open `../LearnStack/backend/src/LearnStack.SharedKernel/` + `LearnStack.Api/Common/` + `LearnStack.Application/Pipeline/` and reproduce. diff --git a/.claude/skills/write-adr/SKILL.md b/.claude/skills/write-adr/SKILL.md index 708f381..9f36916 100644 --- a/.claude/skills/write-adr/SKILL.md +++ b/.claude/skills/write-adr/SKILL.md @@ -7,7 +7,7 @@ description: > background-job orchestration, operator-portal-specific pattern). DO NOT USE FOR: cross-cutting decisions that touch the Hub↔LearnStack contract, entitlement projection shape, custom-domain lifecycle, two-realm boundary, or deployment model - (those are LearnStack ADRs — file in ../learnstack/docs/decisions/), editing an + (those are LearnStack ADRs — file in ../LearnStack/docs/decisions/), editing an Accepted ADR's decision section (write a superseding ADR), or day-to-day choices (those go in code review / commit messages). --- @@ -25,7 +25,7 @@ Capture a Hub-only architectural decision durably, with its drivers and the opti ## When not to use -- The decision touches the Hub↔LearnStack contract / entitlement shape / custom-domain lifecycle / two-realm boundary / deployment model → it's a **LearnStack** ADR; file under `../learnstack/docs/decisions/` (coordinated, with user permission). +- The decision touches the Hub↔LearnStack contract / entitlement shape / custom-domain lifecycle / two-realm boundary / deployment model → it's a **LearnStack** ADR; file under `../LearnStack/docs/decisions/` (coordinated, with user permission). - Editing an Accepted ADR's Decision section → write a new ADR that supersedes it. - A routine implementation choice → commit message / code review. diff --git a/.env.example b/.env.example index 83e1644..8e34b52 100644 --- a/.env.example +++ b/.env.example @@ -91,14 +91,14 @@ LEARNSTACK_CORE_API_URL=http://host.docker.internal:5080 LEARNSTACK_CORE_INTERNAL_PATH_PREFIX=/api/internal # ─── Hub HMAC body-signature shared secret (Vault path in production) ───── -# The shared secret both halves of the four-endpoint contract surface +# The shared secret both halves of the contract surface # (Hub → LearnStack and LearnStack → Hub) use to sign `X-Signature` # bodies per ADR-0019 § Inter-system contracts. # # Authoritative production Vault path: # ADR-0019 § Inter-system contracts names the LearnStack-side path: # learnstack/hub/internal-api-hmac-key -# (See ../learnstack/docs/decisions/0019-learnstack-hub.md.) +# (See ../LearnStack/docs/decisions/0019-learnstack-hub.md.) # # The Hub-side path that pulls the SAME secret has not been formalised # yet — ADR-0019 covers the cross-repo contract but stops short of diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 346b6dc..9cf6edd 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,7 +2,7 @@ @@ -25,9 +25,9 @@ Which packet does this PR advance? Pick one: ## Cross-repo coordination diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83d2c54..b9e0c89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,8 +5,8 @@ # - Solution is `LearnStack.Hub.slnx`. # - Frontend monorepo runs `pnpm -r typecheck/lint/build/test` against # `apps/operator-portal` + `packages/{config,sdk,ui}`. -# - `backend-integration` job is `if: false` until P02c-2 lands the first -# Testcontainers-backed test. +# - `backend-integration` runs from P02c-1, which landed the first +# Testcontainers-backed tests (the entitlement-rebuild round trip). # - `openapi-diff` job is `if: false` until P02c-2 produces the first # generated OpenAPI spec. # - No `lighthouse-budget` job — operator portal is internal-only; the @@ -206,11 +206,11 @@ jobs: - name: Markdown link audit (changed docs) # Mirror of LearnStack core's link audit. Skips externals + anchors. - # Hub's docs cross-link to LearnStack core via `../learnstack/...` + # Hub's docs cross-link to LearnStack core via `../LearnStack/...` # relative paths — those links resolve only on the developer's # workstation (sibling layout). CI can't validate them because the # LearnStack core repo isn't checked out alongside; the audit skips - # `../learnstack/` paths explicitly. + # `../LearnStack/` paths explicitly. env: EVENT_NAME: ${{ github.event_name }} PR_BASE_REF: ${{ github.event.pull_request.base.ref }} @@ -233,23 +233,14 @@ jobs: case "$link" in http://*|https://*|mailto:*|tel:*|ftp://*) continue ;; esac - # Sibling-relative LearnStack link at any `../` depth. - # Hub docs reference LearnStack core at varying depths - # depending on the source file's own location: - # - root files (README, CLAUDE.md): ../learnstack/... - # - docs/*.md: ../../learnstack/... - # - docs/{architecture,decisions}/*.md: ../../../learnstack/... - # - backend/src/Modules/README.md: ../../../../learnstack/... - # The trailing piece is optional — `../learnstack` (a bare - # directory link to the sibling repo's root) is just as - # legitimate as `../learnstack/docs/...`. CI cannot reach - # the sibling repo, so skip the file-existence check for - # any `(../)+learnstack` link, with or without a trailing - # path. The links are validated locally on the developer's - # workstation. - if [[ "$link" =~ ^(\.\./)+learnstack(/|$) ]]; then - continue - fi + # There is deliberately NO exemption for cross-repo links. + # Hub docs link into LearnStack core by absolute URL + # (https://github.com/HodeTech/LearnStack/blob/main/...), + # which the external-URL skip above already covers. A + # sibling-relative `../LearnStack/...` link is a defect: it + # 404s on github.com and depends on a local checkout that is + # present and identically capitalised. It now fails here + # instead of reaching a reader. link_path="${link%%#*}" link_path="${link_path%%\?*}" [[ -z "$link_path" ]] && continue diff --git a/AGENTS.md b/AGENTS.md index e78250b..e8b26c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,16 +9,26 @@ There are no rule differences. The only thing that varies between agent runtimes is the **`Co-Authored-By` commit trailer**, which names the assistant that contributed: - Claude Code sessions: - `Co-Authored-By: Claude Opus 4.7 (1M context) ` + `Co-Authored-By: Claude Opus 5 (1M context) ` - OpenAI Codex sessions: `Co-Authored-By: Codex Opus 4.7 (1M context) ` -If multiple agents contributed materially to the same commit, include one trailer per agent. The full trailer convention lives in [learnstack/docs/standards/14-git-workflow.md § Trailers](../learnstack/docs/standards/14-git-workflow.md#trailers). +If multiple agents contributed materially to the same commit, include one trailer per agent. [LearnStack's Git Workflow Standards § Trailers](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/14-git-workflow.md#trailers) is the authority for both strings; if this file and that section disagree, that section wins. ## Skills -Hub repo does not maintain its own skill catalogue. Both Claude Code and Codex consume the catalogue declared in the sibling LearnStack repo at [`learnstack/.claude/skills/`](../learnstack/.claude/skills/). The same entry-point selection rules (use `implement-task` for substantive work, `start-task` for scoping, `standards-check` + `code-review` for review) apply here. +**This repository maintains its own skill catalogue** at [`.claude/skills/`](.claude/skills/README.md) — 18 skills, git-tracked through an un-ignore rule in `.gitignore`. Load them from here, not from the sibling LearnStack repo. + +The Hub catalogue is Hub-tailored: the `add-hub-*` workflows encode Hub's deltas from LearnStack core — no Row Level Security, `OperatorId` rather than `UserId`, a six-step MediatR pipeline rather than eight, the `hub` schema, the `learnstack_hub` database. Skills cite LearnStack's standards and ADRs by absolute URL (`https://github.com/HodeTech/LearnStack/blob/main/docs/...`) for cross-cutting authority and carry only the Hub-specific workflow on top; they do not duplicate the standards. + +Entry-point selection is the same as in LearnStack core: [implement-task](.claude/skills/implement-task/SKILL.md) for substantive work, [start-task](.claude/skills/start-task/SKILL.md) for scoping only, [standards-check](.claude/skills/standards-check/SKILL.md) followed by [code-review](.claude/skills/code-review/SKILL.md) for review. Pick exactly one entry point; it dispatches the rest. + +## Sibling paths + +The two repositories sit side by side on disk as `LearnStack/` and `LearnStack-Hub/`, with those exact capitalisations. That layout governs **shell commands and filesystem paths** — `cd ../LearnStack`. A lower-cased spelling works on macOS's case-insensitive filesystem and fails on Linux CI. + +Documentation **links** into LearnStack are absolute URLs: `https://github.com/HodeTech/LearnStack/blob/main/docs/...`. A relative link does not cross a repository boundary on github.com, and the CI link audit no longer exempts cross-repo links — so a relative one now fails the build rather than reaching a reader. ## Maintaining this file -Do **not** copy CLAUDE.md content into AGENTS.md. If a guidance rule needs to change, change CLAUDE.md; the rule applies everywhere by virtue of the redirect above. +Do **not** copy CLAUDE.md content into AGENTS.md. If a guidance rule needs to change, change CLAUDE.md; the rule applies everywhere by virtue of the redirect above. The two sections above are the only content that legitimately lives here, because they describe this file's own runtime rather than the project's rules. diff --git a/CLAUDE.md b/CLAUDE.md index aa1b8dd..49cf299 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,67 +4,116 @@ This file is read first by Claude Code (and any other agent following the conven ## What this is -**LearnStack Hub** is the control plane companion to [LearnStack core](../learnstack) — a separate codebase that owns tenant lifecycle, subscription / plan / billing, license issuance, entitlement projection, custom-domain administration, compliance caps, and the operator portal. The two repos communicate through a **closed four-endpoint HTTPS contract surface** with mTLS + signed JWT + HMAC body signature on every call. See [ADR-0019](../learnstack/docs/decisions/0019-learnstack-hub.md). +**LearnStack Hub** is the control plane companion to [LearnStack core](https://github.com/HodeTech/LearnStack/blob/main/) — a separate codebase that owns tenant lifecycle, subscription / plan / billing, license issuance, entitlement projection, custom-domain administration, compliance caps, and the operator portal. The two repos communicate over an internal HTTPS surface carrying mTLS + signed JWT + HMAC body signature on every call. See [ADR-0019](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0019-learnstack-hub.md) and [ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md). Hub **never** stores tenant content. Hub holds tenant _metadata_ (plan, subscription, license, custom domain, compliance caps); tenant _data_ (courses, lessons, learners, enrollments, classroom sessions) lives exclusively inside LearnStack core. This separation is enforced by the `Hub_NeverStores_TenantData` architecture test. ## What state this is in -**Phase 02c — Hub Domain Core (P02c-1)** ✅. The SharedKernel, the 6-step cross-cutting foundation, and the four domain modules (`TenantLifecycle`, `Plans`, `Subscriptions`, `Entitlements`) with their DbContexts, migrations, and the entitlement projection are in place. Next is P02c-2 (Hub-side internal API + outbound `LearnStackApiClient`). - -| Packet | State | -| ---------------------------------------------------------------------------------------------------------------------------------------- | ------- | -| P02c-0 — Repository bootstrap | ✅ | -| P02c-1 — Hub Domain Core (`LearnStackTenant`, `Plan`, `HubSubscription`, `Entitlement`) | ✅ | -| P02c-2 — Hub-side internal API + outbound `LearnStackApiClient` | ⏳ next | -| P02c-3 — LearnStack core PR (`HubEntitlementProvider`, `IUsageReporter`, internal-API handlers) — **blocked on LearnStack P02a-5/6/7/9** | ⏳ | -| P02c-4 — Operator portal MVP | ⏳ | -| P02c-5 — Custom domain lifecycle | ⏳ | -| P02c-6 — License key skeleton | ⏳ | -| P02c-7 — End-to-end exit gate | ⏳ | +**P02c-0 (Repository bootstrap)** ✅ and **P02c-1 (Hub Domain Core)** ✅ are on `main`. +P02c-1 landed the Hub SharedKernel, the six-behavior cross-cutting foundation, and the four +domain modules (`TenantLifecycle`, `Plans`, `Subscriptions`, `Entitlements`) with their +DbContexts, migrations and the entitlement projection. + +**P02c-2 onward is frozen by owner decision (2026-08-08).** The freeze is on the track's +*forward motion*, not on what has already shipped — P02c-1 was reviewed against the +restructured corpus and merged because its domain code conflicts with none of the three +decisions that moved (its entitlement wire shape already carries `grace_until` and +`generation`, it hosts no endpoint, and its audit behavior is a shell). What is frozen is +everything that would build **on** the boundary those decisions redrew: +[ADR-0033](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0033-audit-durability-model.md) +(audit durability), +[ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) +(the real endpoint set, plus host-mapping and TLS key delivery), and +[ADR-0035](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0035-demand-gated-infrastructure.md) +(which makes the Hub a demand-gated integration rather than a Phase-02a prerequisite). + +**P02c-2 unfreezes when both hold:** + +1. The [ADR-0035](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0035-demand-gated-infrastructure.md) + trigger for `IEntitlementProvider` fires — **a tenant must be billed or plan-gated**. + Until then LearnStack runs on `NullEntitlementProvider` and needs nothing from the Hub. +2. The contract surface is built against ADR-0034's **two invariants**, not against an + endpoint count — the enumerated set, the `host-mappings` path, and the single auth + chain in both directions. + +Two reconciliations are owed on the merged P02c-1 code and are tracked in +[p02c-1-hub-domain-core.md](docs/roadmap/p02c-1-hub-domain-core.md): the audit seam +(`AuditLogBehavior` decides, `TransactionBehavior` writes before `COMMIT`, per ADR-0033 — +today's shell writes nothing, so nothing misbehaves yet) and the SharedKernel's alignment +with LearnStack **Packet 3b**, whose three defects the mirrored kernel inherited. + +## Roadmap + +**This repository owns the Hub plan.** [`docs/roadmap/`](docs/roadmap/README.md) holds a document per packet — Goal, Scope, Deliverables, Completion Criteria, Risks, Phase Exit Decision — not a status mirror of a LearnStack file. LearnStack's [phase-02c](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-02c-hub-foundation.md) covers only LearnStack's side of the boundary and points here for the rest. + +| Packet | Document | State | +| ------ | -------- | ----- | +| P02c-0 — Repository bootstrap | [p02c-0-repository-bootstrap.md](docs/roadmap/p02c-0-repository-bootstrap.md) | ✅ Shipped | +| P02c-1 — Hub Domain Core (`LearnStackTenant`, `Plan`, `HubSubscription`, `Entitlement`) | [p02c-1-hub-domain-core.md](docs/roadmap/p02c-1-hub-domain-core.md) | ✅ Shipped — two reconciliations owed | +| P02c-2 — Hub-side internal API + outbound `LearnStackApiClient` | [p02c-2-internal-api-and-contract.md](docs/roadmap/p02c-2-internal-api-and-contract.md) | ⏸ Frozen — ADR-0035 trigger | +| P02c-3 — LearnStack-side integration (`HubEntitlementProvider`, `IUsageReporter`, internal-API handlers) | [p02c-3-learnstack-integration.md](docs/roadmap/p02c-3-learnstack-integration.md) | ⏸ | +| P02c-4 — Operator portal MVP | [p02c-4-operator-portal.md](docs/roadmap/p02c-4-operator-portal.md) | ⏸ | +| P02c-5 — Custom domain lifecycle | [p02c-5-custom-domain-lifecycle.md](docs/roadmap/p02c-5-custom-domain-lifecycle.md) | ⏸ | +| P02c-6 — License key skeleton | [p02c-6-license-key.md](docs/roadmap/p02c-6-license-key.md) | ⏸ | +| P02c-7 — End-to-end exit gate | [p02c-7-exit-gate.md](docs/roadmap/p02c-7-exit-gate.md) | ⏸ | + +Post-02c Hub tracks — [hub-billing.md](docs/roadmap/hub-billing.md) and [hub-marketplace.md](docs/roadmap/hub-marketplace.md) — also live here; LearnStack's `phase-09b` and `phase-12` are pointers at those documents. + +The `P02c-N` identifiers are load-bearing across both repositories (branch names, PR titles, architecture-test registrations, cross-repo blocking tables). Do not renumber them. ## Where to start For any task in this repo, read in this order: 1. [README.md](README.md) — direction at a glance. -2. [ADR-0019 LearnStack Hub](../learnstack/docs/decisions/0019-learnstack-hub.md) — the boundary contract. -3. [Architecture 24 LearnStack Hub](../learnstack/docs/architecture/24-learnstack-hub.md) — the deep dive. -4. [Standards 20 § Hub HTTPS Contract Surface](../learnstack/docs/standards/20-infrastructure-stack.md) — the four-endpoint rule. -5. [docs/roadmap/README.md](docs/roadmap/README.md) — P02c packet plan. -6. [docs/architecture/contract-with-learnstack.md](docs/architecture/contract-with-learnstack.md) — pointers to LearnStack-side authority. -7. [docs/glossary.md](docs/glossary.md) — Hub-specific terms. +2. [ADR-0019 LearnStack Hub](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0019-learnstack-hub.md) — why the Hub is a separate repository. +3. [ADR-0034 Hub Contract Surface Invariant](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) — the two invariants and the real endpoint set. +4. [Architecture 24 LearnStack Hub](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md) — the deep dive. +5. [Standards 20 § Hub HTTPS Contract Surface](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/20-infrastructure-stack.md) — the rule as written for reviewers. +6. [docs/roadmap/README.md](docs/roadmap/README.md) — the Hub plan, owned here. +7. [docs/architecture/contract-with-learnstack.md](docs/architecture/contract-with-learnstack.md) — pointers to LearnStack-side authority. +8. [docs/glossary.md](docs/glossary.md) — Hub-specific terms. Then pick an entry-point skill from **this repo's** catalogue at [`.claude/skills/`](.claude/skills/README.md). Hub maintains its own Hub-tailored skill set — the `add-hub-*` workflows encode Hub's deltas from LearnStack core (no RLS, `OperatorId` not `UserId`, the 6-step MediatR pipeline, the `hub` schema, the `learnstack_hub` database). The entry point for substantive work is [implement-task](.claude/skills/implement-task/SKILL.md); for scoping-only use [start-task](.claude/skills/start-task/SKILL.md); for review run [standards-check](.claude/skills/standards-check/SKILL.md) then [code-review](.claude/skills/code-review/SKILL.md). -> Hub skills are project-local: an agent running from the `learnstack-hub` root loads them from `.claude/skills/`. They cite LearnStack core's standards / ADRs by sibling path (`../learnstack/docs/...`) for the cross-cutting authority and carry only the Hub-specific workflow on top — they do not duplicate the standards. +> Hub skills are project-local: an agent running from the `LearnStack-Hub` root loads them from `.claude/skills/`. They cite LearnStack core's standards / ADRs by sibling path (`../LearnStack/docs/...`) for the cross-cutting authority and carry only the Hub-specific workflow on top — they do not duplicate the standards. ## Hard rules These rules are **non-negotiable** for any change in this repo: -- **No tenant content tables.** Hub schema must NOT contain `course`, `lesson`, `user` (tenant users — operator users are separate), `enrollment`, `live_session`, `lesson_item`, `media_asset`, or any tenant data table. Architecture test `Hub_NeverStores_TenantData` enforces. +- **No tenant content tables.** Hub schema must NOT contain `course`, `lesson`, `user` (tenant users — operator users are separate), `enrollment`, `live_session`, `lesson_item`, `media_asset`, or any tenant data table. Architecture test `Hub_NeverStores_TenantData` enforces. This is invariant 1 of [ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md). - **No imports from LearnStack core internals.** Hub modules may reference **only** LearnStack `Application.Contracts` DTOs (and even those are kept as local copies in this repo until a shared NuGet package is introduced — Phase 11). LearnStack `Domain` / `Infrastructure` / `Modules.*` types are off-limits. Architecture test `Hub_Modules_DoNotReference_LearnStack_Internals` enforces. -- **The Hub HTTPS contract surface is closed at four endpoints.** Adding a fifth requires a new ADR (filed in `learnstack/docs/decisions/`, not here, since the surface is LearnStack-facing). +- **The contract surface is governed by two invariants, not by a count** ([ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md)). The old "closed at four endpoints" rule was never true — the corpus enumerated six paths while claiming four, and protecting the number is what pushed TLS private keys into the entitlement payload. The rules that replace it: + 1. **The Hub stores no tenant content.** + 2. **Every LearnStack↔Hub crossing goes through a named adapter** — `IEntitlementProvider`, `IUsageReporter`, `IHubTenantSync`. No other type may hold a Hub client, and nothing resolves a host by calling the Hub. + + Adding an endpoint still requires a new ADR filed in `../LearnStack/docs/decisions/`, not here — the surface is a cross-repository contract and both repositories have to agree. ADR-0034 carries the authoritative endpoint table. +- **TLS certificates and private keys never travel in the entitlement payload.** Host mappings go through `PUT /api/internal/tenants/{id}/host-mappings`; cert material moves between the Hub-owned and LearnStack-owned secret stores by secret-store replication and is referenced from the host-mapping payload **by path, never by value**. [ADR-0022 Amendment 1](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0022-custom-domain-tls.md)'s step 3 is superseded by [ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md). - **`/api/internal/*` endpoints are NOT internet-exposed.** They are bound to an internal listener (or a dedicated APISIX route guarded by mTLS SSL-object). Architecture test `Internal_API_Endpoints_AreNot_Public` enforces. -- **Hub never writes to LearnStack's K8s state.** Cert + route propagation flows through Dapr pub/sub events + the `PUT /api/internal/tenants/{id}/entitlements` push (per [ADR-0022 Amendment 1](../learnstack/docs/decisions/0022-custom-domain-tls.md)). Hub never holds Kubernetes credentials on LearnStack's cluster. +- **Hub never writes to LearnStack's K8s state.** Cert + route propagation flows through published events and the internal-API pushes. Hub never holds Kubernetes credentials on LearnStack's cluster — the guarantee [ADR-0022 Amendment 1](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0022-custom-domain-tls.md) exists to protect, and the one part of it ADR-0034 leaves unchanged. - **Two-realm Keycloak boundary.** Hub authenticates against the `learnstack-hub` realm only. The `learnstack` realm is rejected on Hub endpoints; the `learnstack-hub` realm is rejected on LearnStack tenant-facing endpoints. - **Modular monolith.** Hub follows the same module boundary rules as LearnStack — no cross-module Domain dependencies; cross-module communication through Application.Contracts or integration events. -- **English documentation.** All docs in English per [ADR-0007](../learnstack/docs/decisions/0007-documentation-language-and-conventions.md). +- **English documentation.** All docs in English per [ADR-0007](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0007-documentation-language-and-conventions.md). - **Mermaid for diagrams.** Fenced ` ```mermaid ` blocks; remain readable as text for renderers that don't support Mermaid. -- **Single source of truth.** Cross-cutting architectural decisions live in `learnstack/docs/decisions/`; Hub-internal-only decisions live in `docs/decisions/` here with the `HUB-NNNN` numbering series. +- **Single source of truth.** Cross-cutting architectural decisions live in `../LearnStack/docs/decisions/`; Hub-internal-only decisions live in `docs/decisions/` here with the `HUB-NNNN` numbering series. The **Hub roadmap** is the exception that runs the other way: it is owned here, and LearnStack links to it. ## Things to never do - Import `LearnStack.Domain`, `LearnStack.Infrastructure`, or `LearnStack.Modules.*` types into this repo. Use local DTO copies of `LearnStack.Application.Contracts` until a shared NuGet package is introduced. - Add `course`, `lesson`, `enrollment`, `user` (tenant), `live_session`, or any other tenant-content table to the Hub schema. - Bind `/api/internal/*` endpoints to the internet-facing listener. -- Hold Kubernetes credentials on LearnStack's cluster from Hub code. Cert + route propagation goes through Dapr events and the entitlement-push internal API. -- Import Stripe / Iyzico SDK types outside `LearnStack.Hub.Infrastructure.Stripe` / `.Iyzico` adapter projects (those don't exist yet — Phase 09b creates them). -- Import `Sentry.SentrySdk` from a module assembly. Error capture goes through `IErrorTrackingProvider` (Phase 02c-1+). -- Add a fifth endpoint to the Hub HTTPS contract surface — requires a new ADR in `learnstack/docs/decisions/`. -- Bypass `WebhookLedger` for Stripe / Iyzico webhook processing (Phase 09b). Idempotency unique constraint enforces. -- Throw `DomainException` for expected business-rule violations — return `Result.Fail(business_rule_violation, ...)` instead (mirrors LearnStack [ADR-0032 § Sub-decision 4](../learnstack/docs/decisions/0032-exception-handling-logging-and-observability.md)). +- Hold Kubernetes credentials on LearnStack's cluster from Hub code. Route propagation goes through `PUT /api/internal/tenants/{id}/host-mappings`; cert material goes through secret-store replication. +- Import Stripe / Iyzico SDK types outside `LearnStack.Hub.Infrastructure.Stripe` / `.Iyzico` adapter projects (those don't exist yet — [hub-billing.md](docs/roadmap/hub-billing.md) creates them). +- Import `Sentry.SentrySdk` from a module assembly. Error capture goes through `IErrorTrackingProvider` (P02c-1+). +- Add an endpoint to the LearnStack↔Hub contract surface without a new ADR in `../LearnStack/docs/decisions/`, landed in both repositories. +- Carry TLS certificates or private keys in any payload LearnStack caches, logs, audits or mirrors — including the entitlement projection. +- Serve a host lookup from the Hub. `IHostToTenantResolver` on the LearnStack side reads `platform_host_to_tenant` and nothing else; a Hub outage must not take tenant public pages down. +- Start P02c-2 or anything downstream of it while the track is frozen. P02c-1 is merged; what is frozen is the work that builds on the boundary ADR-0033/0034/0035 redrew. See [What state this is in](#what-state-this-is-in) for the two conditions that unfreeze it. +- Build the Hub HTTPS contract surface to an endpoint **count**. ADR-0034 governs it by two invariants, and protecting a count is what caused TLS private keys to be tunnelled through the entitlement payload. +- Bypass `WebhookLedger` for Stripe / Iyzico webhook processing. Idempotency unique constraint enforces. +- Throw `DomainException` for expected business-rule violations — return `Result.Fail(business_rule_violation, ...)` instead (mirrors LearnStack [ADR-0032 § Sub-decision 4](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0032-exception-handling-logging-and-observability.md)). - Edit an Accepted ADR's decision section in either repo. Write a new ADR that supersedes it. - Reuse an ADR number. @@ -72,10 +121,10 @@ These rules are **non-negotiable** for any change in this repo: Hub follows LearnStack's engineering standards by reference unless explicitly overridden by a Hub-internal ADR: -- C# / .NET 10, strongly-typed IDs via Vogen, records, MediatR pipeline (Phase 02c-1), EF Core with per-module `DbContext`. -- TypeScript strict + Next.js App Router for the operator portal. +- C# / .NET 10, strongly-typed IDs via Vogen, records, MediatR pipeline (P02c-1), EF Core with per-module `DbContext`. +- TypeScript strict + Next.js App Router for the operator portal (`frontend/apps/operator-portal`). - REST + RFC 7807 Problem Details + cursor pagination + idempotency keys. -- OpenTelemetry + correlation ID end to end (Phase 02c-1 wires the same cross-cutting foundation as LearnStack core P02a-3). +- OpenTelemetry + correlation ID end to end. P02c-1 wires the Hub's mirror of the LearnStack P02a-3 cross-cutting foundation — with **six** pipeline steps, not eight: Hub has no `TenantContextBehavior`, because Hub data is operator-administered rather than tenant-isolated. - Permission keys `{module}.{resource}.{action}` with closed action set; operator-scope permissions only (LearnStack handles tenant permissions). ## Commit conventions @@ -83,7 +132,7 @@ Hub follows LearnStack's engineering standards by reference unless explicitly ov - Conventional Commits: `type(scope): subject`. Subject in imperative mood; ≤ 72 chars. - Hub-specific scopes: `hub`, `hub-portal`, `hub-domain`, `hub-infra`, `hub-docs`. - Commits made with AI assistance carry the trailer: - `Co-Authored-By: Claude Opus 4.7 (1M context) ` + `Co-Authored-By: Claude Opus 5 (1M context) ` ## Cross-repo coordination @@ -93,4 +142,6 @@ When a packet changes both repos (e.g., P02c-3 lands the LearnStack-side interna 2. LearnStack-side PR references the Hub-side PR's commit hash. 3. Both PRs merge in the same session; either-side merge alone leaves the contract dangling. -Adding or changing a cross-repo contract endpoint requires a new ADR in `learnstack/docs/decisions/` (per Hub HTTPS Contract Surface rule). +The wire shape is pinned by a checked-in `entitlement-v1.schema.json` and a snapshot test in **each** repository, per [ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md). Both snapshots move together or the contract has drifted. + +Cross-repo **references** to LearnStack core use absolute URLs — `https://github.com/HodeTech/LearnStack/blob/main/docs/...`. Never a sibling-relative path: it 404s on github.com and depends on a local checkout. Shell commands and filesystem paths still say `../LearnStack`, and the on-disk sibling layout is `LearnStack/` and `LearnStack-Hub/` with those exact capitalisations — a lower-cased spelling works on macOS and fails on Linux. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6dbe4fa..96dc69f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to LearnStack Hub -LearnStack Hub follows the same engineering rigour as [LearnStack core](../learnstack). Most engineering standards are defined once in LearnStack's [Standards corpus](../learnstack/docs/standards/) and apply here by reference. +LearnStack Hub follows the same engineering rigour as [LearnStack core](https://github.com/HodeTech/LearnStack/blob/main/). Most engineering standards are defined once in LearnStack's [Standards corpus](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/) and apply here by reference. ## Branch protection @@ -11,7 +11,7 @@ Required status checks on `main`: - `meta` — `make lint`-style format verification + Markdown link audit - `secret-scan` — Leakwatch scan (gates per LearnStack Standards 12 § Secrets Management) -`backend-integration` is **deferred** with `if: false` until P02c-2 lands the first Testcontainers-backed test. +`backend-integration` runs from **P02c-1**, which landed the first Testcontainers-backed tests (the entitlement-rebuild round trip). It was gated `if: false` from P02c-0 until then. ## Commit conventions @@ -23,8 +23,8 @@ Required status checks on `main`: - `hub-domain` — Hub domain model - `hub-infra` — Hub infrastructure (compose, APISIX, Dapr, Vault) - `hub-docs` — Hub documentation -- AI co-author trailer (per [LearnStack AGENTS.md § Trailers](../learnstack/AGENTS.md)): - - Claude Code: `Co-Authored-By: Claude Opus 4.7 (1M context) ` +- AI co-author trailer (per [LearnStack AGENTS.md § Trailers](https://github.com/HodeTech/LearnStack/blob/main/AGENTS.md)): + - Claude Code: `Co-Authored-By: Claude Opus 5 (1M context) ` - Codex: `Co-Authored-By: Codex Opus 4.7 (1M context) ` Example commit message: @@ -36,7 +36,7 @@ P02c-1 brings the Hub-side mirror of LearnStack's Tenant aggregate. Hub holds only metadata fields (id, slug, display_name, status, deployment_mode, created_at, last_phone_home_at). -Co-Authored-By: Claude Opus 4.7 (1M context) +Co-Authored-By: Claude Opus 5 (1M context) ``` ## Cross-repo PRs @@ -47,7 +47,7 @@ When a packet changes both `learnstack` and `learnstack-hub` (e.g. P02c-3 lands 2. Open the LearnStack-side PR referencing the Hub PR's commit hash. 3. Merge both in the same session — either-side merge alone leaves the contract dangling. -Adding or changing a cross-repo contract endpoint requires a **new ADR in `learnstack/docs/decisions/`** (per the Hub HTTPS Contract Surface rule). See [Standards 20 § Hub HTTPS Contract Surface](../learnstack/docs/standards/20-infrastructure-stack.md). +Adding or changing a cross-repo contract endpoint requires a **new ADR in `../LearnStack/docs/decisions/`** (per the Hub HTTPS Contract Surface rule). See [Standards 20 § Hub HTTPS Contract Surface](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/20-infrastructure-stack.md). ## Pre-commit hook @@ -88,7 +88,7 @@ Hub's `LearnStack.Hub.SharedKernel` mirrors LearnStack core's `LearnStack.Shared ### Hub Keycloak realm JSON lives in the LearnStack core repo -The `learnstack-hub` realm export (`../learnstack/infra/keycloak/realms/learnstack-hub.json`) physically lives in the sibling repo because LearnStack core's compose stack imports both realms at first boot. See [`infra/keycloak/README.md`](infra/keycloak/README.md) for the operational topology. +The `learnstack-hub` realm export (`../LearnStack/infra/keycloak/realms/learnstack-hub.json`) physically lives in the sibling repo because LearnStack core's compose stack imports both realms at first boot. See [`infra/keycloak/README.md`](infra/keycloak/README.md) for the operational topology. ### Pre-commit Leakwatch is a repo-root scan, not a staged-files-only scan diff --git a/README.md b/README.md index a080cb1..b1d6e4e 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,43 @@ # LearnStack Hub -The control plane application for [LearnStack](https://github.com/cemililik/learnstack) — a separate codebase that owns tenant lifecycle, subscription / plan / billing, license issuance, entitlement projection, custom-domain administration, compliance caps, and the operator portal. +The control plane application for [LearnStack](https://github.com/HodeTech/LearnStack) — a separate codebase that owns tenant lifecycle, subscription / plan / billing, license issuance, entitlement projection, custom-domain administration, compliance caps, and the operator portal. LearnStack Hub is **not** an LMS, **not** a tenant-facing surface, and **never** stores tenant content. Hub holds tenant _metadata_ (plan, subscription, license, custom domain, compliance caps); tenant _data_ (courses, lessons, learners, enrollments, classroom sessions) lives exclusively inside LearnStack core. ## Status -**Phase 02c — Repository Bootstrap (P02c-0)** ✅. Solution scaffold + frontend monorepo + compose stack + CI + docs skeleton are in place. No Hub domain code yet — that lands in P02c-1 (Hub Domain Core: `LearnStackTenant` mirror, `Plan`, `HubSubscription`, `Entitlement`). +**P02c-0 — Repository bootstrap** ✅. Solution scaffold, frontend monorepo, compose stack, CI, and the docs skeleton are in place. No Hub domain code is on `main`. -See [docs/roadmap/README.md](docs/roadmap/README.md) for the per-packet breakdown (P02c-0 through P02c-7) and the [authoritative LearnStack-side roadmap](../learnstack/docs/roadmap/phase-02c-hub-foundation.md). +**P02c-1 (Hub Domain Core) shipped 2026-08-09.** It was reviewed against the restructured corpus first: although it was written before [ADR-0033](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0033-audit-durability-model.md), [ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) and [ADR-0035](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0035-demand-gated-infrastructure.md), it implements none of what they changed — its entitlement wire shape already carries `grace_until` and `generation`, it hosts no endpoint, and its audit behavior is a shell. + +**The track is frozen from P02c-2 onward** (owner decision, 2026-08-08), on the ADR-0035 trigger for `IEntitlementProvider`: a tenant must be billed or plan-gated. Until then LearnStack runs on `NullEntitlementProvider` and needs nothing from the Hub. See [CLAUDE.md](CLAUDE.md) and [docs/roadmap/p02c-1-hub-domain-core.md](docs/roadmap/p02c-1-hub-domain-core.md). + +**The Hub plan lives in this repository.** [`docs/roadmap/`](docs/roadmap/README.md) carries a document per packet (P02c-0 … P02c-7) plus the post-02c [billing](docs/roadmap/hub-billing.md) and [marketplace](docs/roadmap/hub-marketplace.md) tracks. LearnStack's [phase-02c](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-02c-hub-foundation.md) covers only LearnStack's side of the boundary. ```bash make install # one-time: deps + git hooks -make dev # bring Hub-side compose stack up (requires learnstack compose already running) +make dev # bring Hub-side compose stack up (requires the LearnStack compose already running) ``` ## Why a Separate Repo -LearnStack Hub ships as a **separate git repository** per [ADR-0019](../learnstack/docs/decisions/0019-learnstack-hub.md) — separate CI/CD, separate release cadence, separate Keycloak realm (`learnstack-hub` vs. `learnstack`), separate operator audit stream. The two repos communicate through a **closed four-endpoint HTTPS contract surface** with mTLS + signed JWT + HMAC body signature on every call. +LearnStack Hub ships as a **separate git repository** per [ADR-0019](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0019-learnstack-hub.md) — separate CI/CD, separate release cadence, separate Keycloak realm (`learnstack-hub` vs. `learnstack`), separate operator audit stream. The two repos communicate over an internal HTTPS surface carrying mTLS + signed JWT + HMAC body signature on every call. -The architecture deep-dive lives in the sibling repo: [docs/architecture/24-learnstack-hub.md](../learnstack/docs/architecture/24-learnstack-hub.md). +The architecture deep-dive lives in the sibling repo: [docs/architecture/24-learnstack-hub.md](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md). ## Sibling Layout Expected -Hub repo's documentation cross-links to LearnStack core via relative sibling paths (`../learnstack/...`). The expected on-disk layout is: +Hub documentation **links** to LearnStack core by absolute URL (`https://github.com/HodeTech/LearnStack/blob/main/docs/...`), per [Documentation Standards § Layout](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/13-documentation.md). A relative path does not cross a repository boundary on github.com and depends on a sibling checkout being present and identically capitalised; an absolute URL works everywhere. Shell commands and filesystem references still use `../LearnStack` — those are paths, not links — so the expected on-disk layout still matters: ``` / -├── learnstack/ (https://github.com/cemililik/learnstack) -└── learnstack-hub/ (this repo) +├── LearnStack/ (https://github.com/HodeTech/LearnStack) +└── LearnStack-Hub/ (this repo) ``` -If you check out the two repos somewhere other than as siblings, cross-repo doc links will 404. Multi-root workspace files (`*.code-workspace`) are gitignored — each developer keeps their own. +The capitalisation matters for the shell paths: a lower-cased spelling works on macOS's case-insensitive filesystem and fails on Linux CI. Documentation links are unaffected — they are absolute, and the CI link audit now checks every relative link it finds, because a relative cross-repo link is a defect rather than an exemption. + +If you check out the two repos somewhere other than as siblings, the shell commands below need adjusting; the documentation links keep working. Multi-root workspace files (`*.code-workspace`) are gitignored — each developer keeps their own. ## Dev Workflow @@ -41,19 +47,19 @@ Hub repo's compose stack is **deliberately minimal**: it only runs services that ```bash # 1. LearnStack core compose (shared backends) -cd ../learnstack +cd ../LearnStack make dev # 2. Hub-side compose (Dapr sidecar + Hub APISIX + Postgres init for learnstack_hub DB) -cd ../learnstack-hub +cd ../LearnStack-Hub make dev # 3. LearnStack core API -cd ../learnstack/backend +cd ../LearnStack/backend dotnet run --project src/LearnStack.Api # 4. Hub API -cd ../learnstack-hub/backend +cd ../LearnStack-Hub/backend dotnet run --project src/LearnStack.Hub.Api ``` @@ -66,50 +72,72 @@ In production, the two repos deploy independently — the shared compose is a de - **Cache / Pub-Sub / Secrets:** Valkey 8 (shared instance, `hub:*` namespace), Kafka (shared cluster, `learnstack.hub.*` topic prefix), Vault (shared instance, `learnstack-hub/*` path prefix) — all accessed via **Hub's own Dapr sidecar**. - **API Gateway:** APISIX in standalone YAML mode on its own port (9180 / 9543); separate instance from LearnStack core's APISIX. - **Frontend:** Next.js 15.5 (App Router) operator portal under `frontend/apps/operator-portal`; flat-config + Next 16 migration tracked in P02c-4 (`apps/operator-portal/.eslintrc.cjs` TODO). Authenticates against the `learnstack-hub` Keycloak realm with MFA required. -- **Identity:** Keycloak `learnstack-hub` realm (separate from `learnstack` tenant-facing realm). The realm export lives in the sibling repo at `learnstack/infra/keycloak/realms/learnstack-hub.json` because LearnStack core's compose imports both realms at first boot. +- **Identity:** Keycloak `learnstack-hub` realm (separate from `learnstack` tenant-facing realm). The realm export lives in the sibling repo at `../LearnStack/infra/keycloak/realms/learnstack-hub.json` because LearnStack core's compose imports both realms at first boot. - **Architecture:** Modular monolith (mirrors LearnStack's pattern) with explicit module contracts. -## Hub HTTPS Contract Surface +## Contract Surface + +Governed by two invariants rather than by a count, per [ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md): + +1. **The Hub stores no tenant content** — enforced by `Hub_NeverStores_TenantData`. +2. **Every LearnStack↔Hub crossing goes through a named adapter** — `IEntitlementProvider`, `IUsageReporter`, `IHubTenantSync`. Nothing else holds a Hub client, and nothing resolves a host by calling the Hub. + +**Hub → LearnStack** (`/api/internal/*`, internal listener only, hosted in LearnStack core): + +| Method | Path | Purpose | +| -------- | --------------------------------------------- | -------------------------------------------------------- | +| `POST` | `/api/internal/tenants` | Create tenant + default organization | +| `PUT` | `/api/internal/tenants/{id}/entitlements` | Push the entitlement projection | +| `PUT` | `/api/internal/tenants/{id}/status` | Suspend / activate / archive | +| `DELETE` | `/api/internal/tenants/{id}` | Terminate | +| `GET` | `/api/internal/tenants/{id}/usage` | Pull aggregated usage | +| `PUT` | `/api/internal/tenants/{id}/host-mappings` | Push host → `(tenant_id, organization_id?)` mappings | + +**LearnStack → Hub** (hosted here): + +| Method | Path | Purpose | +| ------ | --------------------------------------- | ------------------------------------------ | +| `POST` | `/api/v1/internal/license/verify` | Verify / pull the entitlement projection | +| `POST` | `/api/v1/internal/license/refresh` | Scheduled phone-home refresh | +| `POST` | `/api/v1/usage/report` | Report a usage metric (idempotent) | -Four endpoints, closed list ([Standards 20 § Hub HTTPS Contract Surface](../learnstack/docs/standards/20-infrastructure-stack.md)): +Every call carries **mTLS** (LearnStack-internal CA) + **RS256 JWT** (`aud=learnstack-internal`, exp ≤ 5min) + **HMAC-SHA256 body signature** in `X-Signature`. Adding an endpoint requires a new ADR in `../LearnStack/docs/decisions/`, landed in both repositories. -| Direction | Method + Path | Purpose | Hosted in | -| ---------------- | --------------------------------------------- | ------------------------------------------------------------------------- | --------------- | -| Hub → LearnStack | `POST /api/internal/tenants` | Create tenant + default org | LearnStack core | -| Hub → LearnStack | `PUT /api/internal/tenants/{id}/entitlements` | Push entitlement projection (incl. host mapping per ADR-0022 Amendment 1) | LearnStack core | -| LearnStack → Hub | `POST /api/v1/internal/license/verify` | License verify | **Hub** | -| LearnStack → Hub | `POST /api/v1/usage/report` | Usage telemetry | **Hub** | +The Hub's own tenant-facing and operator-facing APIs (`/api/v1/tenants/*`, `/api/v1/subscriptions/*`, `/api/v1/webhooks/*`) are **not** part of this surface — they are the Hub's public API, governed here. -Every call carries **mTLS** (LearnStack-internal CA) + **RS256 JWT** (`aud=learnstack-internal`, exp ≤ 5min) + **HMAC-SHA256 body signature** in `X-Signature`. Adding a fifth endpoint requires a new ADR. +TLS certificates and private keys never travel in the entitlement payload. Host mappings go through `PUT /api/internal/tenants/{id}/host-mappings`; cert material moves between the two secret stores by replication and is referenced by path. ## Documentation Map ### Hub-specific (this repo) - [docs/README.md](docs/README.md) — Hub doc catalogue. +- [docs/roadmap/README.md](docs/roadmap/README.md) — **the Hub plan**, owned here: one document per packet plus the cross-repo blocking table in both directions. +- [docs/architecture/README.md](docs/architecture/README.md) — Hub architecture index. - [docs/architecture/contract-with-learnstack.md](docs/architecture/contract-with-learnstack.md) — Pointers to LearnStack-side authoritative contracts. - [docs/architecture/repository-layout.md](docs/architecture/repository-layout.md) — Hub internal folder structure + module topology. -- [docs/decisions/](docs/decisions/) — Hub-internal ADRs (start with `HUB-0001`). -- [docs/operations/](docs/operations/) — Hub operational runbooks (Phase 11+). -- [docs/modules/](docs/modules/) — Hub module deep dives (Phase 02c-1+). -- [docs/roadmap/README.md](docs/roadmap/README.md) — P02c-0..7 packet plan. +- [docs/decisions/](docs/decisions/) — Hub-internal ADRs (`HUB-NNNN` series). +- [docs/operations/](docs/operations/) — Hub operational runbooks. +- [docs/modules/](docs/modules/) — Hub module deep dives. - [docs/glossary.md](docs/glossary.md) — Hub-specific terms. ### Authoritative cross-cutting (LearnStack core) -- [ADR-0019 LearnStack Hub](../learnstack/docs/decisions/0019-learnstack-hub.md) -- [ADR-0020 Triple Deployment + Hybrid License](../learnstack/docs/decisions/0020-triple-deployment-hybrid-license.md) -- [ADR-0021 Feature-Based Entitlement](../learnstack/docs/decisions/0021-feature-based-entitlement.md) -- [ADR-0022 Custom Domain + TLS](../learnstack/docs/decisions/0022-custom-domain-tls.md) (Amendment 1) -- [ADR-0004 Authentication Strategy](../learnstack/docs/decisions/0004-authentication-strategy.md) (Amendment 1 — `learnstack-hub` realm) -- [Architecture 24 LearnStack Hub](../learnstack/docs/architecture/24-learnstack-hub.md) -- [Standards 20 Infrastructure Stack](../learnstack/docs/standards/20-infrastructure-stack.md) -- [Phase 02c roadmap](../learnstack/docs/roadmap/phase-02c-hub-foundation.md) +- [ADR-0019 LearnStack Hub](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0019-learnstack-hub.md) +- [ADR-0020 Triple Deployment + Hybrid License](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0020-triple-deployment-hybrid-license.md) +- [ADR-0021 Feature-Based Entitlement](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0021-feature-based-entitlement.md) +- [ADR-0022 Custom Domain + TLS](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0022-custom-domain-tls.md) (Amendment 1; its cert-delivery step is superseded by ADR-0034) +- [ADR-0004 Authentication Strategy](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0004-authentication-strategy.md) (Amendment 1 — `learnstack-hub` realm) +- [ADR-0034 Hub Contract Surface Invariant](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) — the two invariants and the authoritative endpoint table +- [ADR-0035 Demand-Gated Infrastructure](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0035-demand-gated-infrastructure.md) — why the Hub track waits on a trigger rather than a date +- [Architecture 24 LearnStack Hub](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md) +- [Standards 20 Infrastructure Stack](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/20-infrastructure-stack.md) +- [Phase 02c — LearnStack side](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-02c-hub-foundation.md) ## Conventions -- All documentation in **English** (mirrors [ADR-0007](../learnstack/docs/decisions/0007-documentation-language-and-conventions.md)). +- All documentation in **English** (mirrors [ADR-0007](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0007-documentation-language-and-conventions.md)). - Diagrams use **Mermaid** in fenced code blocks. -- Cross-cutting architectural decisions live in **LearnStack core** under `learnstack/docs/decisions/`; Hub-internal-only decisions live here under `docs/decisions/` with the `HUB-NNNN` numbering series so they never collide with LearnStack ADR numbers. -- Engineering rules from LearnStack's [Standards corpus](../learnstack/docs/standards/) apply here unless explicitly overridden by a Hub-internal ADR. +- Cross-cutting architectural decisions live in **LearnStack core** under `../LearnStack/docs/decisions/`; Hub-internal-only decisions live here under `docs/decisions/` with the `HUB-NNNN` numbering series so they never collide with LearnStack ADR numbers. The Hub **roadmap** runs the other way — it is owned here, and LearnStack links to it. +- Engineering rules from LearnStack's [Standards corpus](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/) apply here unless explicitly overridden by a Hub-internal ADR. - Single source of truth: each piece of knowledge lives in exactly one place. Linking is preferred over copying. diff --git a/backend/src/Core/LearnStack.Hub.Api/Program.cs b/backend/src/Core/LearnStack.Hub.Api/Program.cs index 323993f..d9fea1d 100644 --- a/backend/src/Core/LearnStack.Hub.Api/Program.cs +++ b/backend/src/Core/LearnStack.Hub.Api/Program.cs @@ -8,11 +8,23 @@ using LearnStack.Hub.Modules.TenantLifecycle.Infrastructure; using LearnStack.Hub.SharedKernel.Hosting; -// TODO(P02c-2): wire the four-endpoint Hub HTTPS contract surface — -// POST /api/v1/internal/license/verify and POST /api/v1/usage/report are HOSTED -// here; the outbound POST /api/internal/tenants + PUT .../entitlements calls -// live in LearnStack.Hub.Infrastructure.LearnStackApiClient. Bind /api/internal/* -// to the internal listener only (Internal_API_Endpoints_AreNot_Public). +// TODO(P02c-2): wire the Hub HTTPS contract surface. It is governed by the two +// invariants in ADR-0034 — the Hub stores no tenant content, and every crossing +// goes through a named adapter — NOT by an endpoint count. Do not build "the four +// endpoints": ADR-0034 records that protecting that count is what caused TLS +// private keys to be tunnelled through the entitlement payload in the first place. +// +// HOSTED here (Hub receives): POST /api/v1/internal/license/verify, +// POST /api/v1/internal/license/refresh, POST /api/v1/usage/report, and +// POST /api/v1/internal/tenants/{id}/custom-domains. +// OUTBOUND (LearnStack.Hub.Infrastructure.LearnStackApiClient): POST /api/internal/tenants, +// PUT .../entitlements, PUT .../status, PUT .../host-mappings, DELETE /tenants/{id}, +// GET .../usage. The host-mappings push is the endpoint that exists so certificate +// material stops riding the entitlement payload — do not omit it. +// +// Bind /api/internal/* to the internal listener only (Internal_API_Endpoints_AreNot_Public). +// Authoritative set: +// https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md var builder = WebApplication.CreateBuilder(args); diff --git a/backend/src/Core/LearnStack.Hub.Application/LearnStack.Hub.Application.csproj b/backend/src/Core/LearnStack.Hub.Application/LearnStack.Hub.Application.csproj index 8e2ac26..00c0205 100644 --- a/backend/src/Core/LearnStack.Hub.Application/LearnStack.Hub.Application.csproj +++ b/backend/src/Core/LearnStack.Hub.Application/LearnStack.Hub.Application.csproj @@ -1,9 +1,9 @@ diff --git a/backend/src/Core/LearnStack.Hub.Application/Pipeline/MediatRPipelineRegistration.cs b/backend/src/Core/LearnStack.Hub.Application/Pipeline/MediatRPipelineRegistration.cs index 38aba5e..d75f0bc 100644 --- a/backend/src/Core/LearnStack.Hub.Application/Pipeline/MediatRPipelineRegistration.cs +++ b/backend/src/Core/LearnStack.Hub.Application/Pipeline/MediatRPipelineRegistration.cs @@ -5,11 +5,11 @@ namespace LearnStack.Hub.Application.Pipeline; /// -/// Composition-root extension that registers the canonical 6-step -/// Hub MediatR pipeline. Hub drops the two tenant-isolation steps LearnStack -/// core's 8-step pipeline carries (TenantContextBehavior and the -/// tenant-scoped concerns) because Hub is operator-administered, not -/// tenant-isolated. Outermost (validation) first, innermost (handler) last; the +/// Composition-root extension that registers the canonical six-behavior +/// Hub MediatR pipeline. Hub drops exactly one behavior LearnStack core's seven +/// carry — TenantContextBehavior — because Hub is operator-administered, +/// not tenant-isolated. (ADR-0032 writes core's as an eight-step list because it +/// counts the Handler; this file counts behaviors.) Outermost (validation) first, innermost (handler) last; the /// MediatR_Pipeline_Order_Matches_Canonical_Sequence architecture test /// asserts this DI registration order. /// diff --git a/backend/src/Modules/README.md b/backend/src/Modules/README.md index ae2a50a..8d1ff8a 100644 --- a/backend/src/Modules/README.md +++ b/backend/src/Modules/README.md @@ -4,7 +4,7 @@ This directory is intentionally **empty** in P02c-0. Hub-side module aggregates ## Planned module topology -The 11 Hub modules ([ADR-0019 § Hub data model](../../../../learnstack/docs/decisions/0019-learnstack-hub.md)) arrive across multiple Phase 02c packets: +The 11 Hub modules ([ADR-0019 § Hub data model](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0019-learnstack-hub.md)) arrive across multiple Phase 02c packets: | Module | Lands in | Aggregates | | ---------------------------------------- | ------------- | ---------------------------------------------------------------------------- | diff --git a/backend/tests/LearnStack.Hub.Tests.Architecture/HubBoundaryTests.cs b/backend/tests/LearnStack.Hub.Tests.Architecture/HubBoundaryTests.cs index bcba312..11165d2 100644 --- a/backend/tests/LearnStack.Hub.Tests.Architecture/HubBoundaryTests.cs +++ b/backend/tests/LearnStack.Hub.Tests.Architecture/HubBoundaryTests.cs @@ -5,7 +5,7 @@ namespace LearnStack.Hub.Tests.Architecture; /// /// Hub-specific boundary rules. The authoritative list of Hub-side architecture -/// tests is in `../learnstack/docs/architecture/24-learnstack-hub.md` § 10 +/// tests is in `../LearnStack/docs/architecture/24-learnstack-hub.md` § 10 /// (six tests): /// /// 1. `Hub_NeverStores_TenantContent` (Architecture 24 spelling; ADR-0019 diff --git a/backend/tests/LearnStack.Hub.Tests.Architecture/PipelineOrderTests.cs b/backend/tests/LearnStack.Hub.Tests.Architecture/PipelineOrderTests.cs index 44c1b67..17162e1 100644 --- a/backend/tests/LearnStack.Hub.Tests.Architecture/PipelineOrderTests.cs +++ b/backend/tests/LearnStack.Hub.Tests.Architecture/PipelineOrderTests.cs @@ -5,8 +5,8 @@ namespace LearnStack.Hub.Tests.Architecture; /// -/// Asserts the Hub MediatR pipeline is the canonical 6-step -/// sequence (LearnStack core's 8 minus the two tenant-isolation steps), in +/// Asserts the Hub MediatR pipeline is the canonical six-behavior +/// sequence — LearnStack core's seven minus TenantContextBehavior — in /// order — Validation → Logging → AuditLog → Authorization → Transaction → /// OutboxFlush. No TenantContextBehavior. See cross-cutting-foundation.md § 2. /// diff --git a/backend/tests/LearnStack.Hub.Tests.Architecture/RepositoryLayoutTests.cs b/backend/tests/LearnStack.Hub.Tests.Architecture/RepositoryLayoutTests.cs index 9af3d4d..d85c625 100644 --- a/backend/tests/LearnStack.Hub.Tests.Architecture/RepositoryLayoutTests.cs +++ b/backend/tests/LearnStack.Hub.Tests.Architecture/RepositoryLayoutTests.cs @@ -29,7 +29,7 @@ public void No_Source_Folder_Named_Verticals() offenders.Should().BeEmpty( "ADR-0018 (LearnStack core) applies to Hub by reference: tenant-specific shapes " + - "live as data, not code. See ../learnstack/docs/decisions/0018-tenant-driven-customization-model.md."); + "live as data, not code. See ../LearnStack/docs/decisions/0018-tenant-driven-customization-model.md."); } /// diff --git a/docs/README.md b/docs/README.md index aeba834..c62c3b5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,42 +1,50 @@ # LearnStack Hub — Documentation Catalogue -Hub documentation is intentionally **slim** — most architectural decisions live in the LearnStack core repo (cross-cutting authority) and Hub docs cross-link to them. The Hub-internal `docs/` directory holds only: +Hub documentation is intentionally **slim**. Cross-cutting architectural decisions live in the LearnStack core repository and Hub docs link to them rather than restating them. What lives here is what only the Hub can own: -1. Hub-specific operational runbooks -2. Hub module deep-dives (Hub-only implementation details) -3. Hub-internal ADRs (decisions that affect only Hub) -4. Hub-specific glossary terms -5. Hub roadmap mirror (per-packet status) +1. The **Hub roadmap** — the plan for this repository, one document per packet +2. Hub-internal architecture (repository layout, module topology, the contract pointers) +3. Hub module deep-dives (Hub-only implementation details) +4. Hub-internal ADRs (decisions that affect only the Hub) +5. Hub operational runbooks +6. Hub-specific glossary terms ## Directory layout -| Directory | Purpose | Numbering | -| -------------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------- | -| [`architecture/`](architecture/) | Hub-internal architecture (repository layout, module topology) + contract pointers to LearnStack core | numbered `NN-topic.md` | -| [`decisions/`](decisions/) | Hub-internal ADRs (Hub-only decisions; `HUB-NNNN` series) | `HUB-NNNN-topic.md` | -| [`operations/`](operations/) | Hub operational runbooks (deployment, incident response, cert rotation) | unnumbered | -| [`modules/`](modules/) | Per-Hub-module deep dives + audit-coverage matrices | per-module subdirs | -| [`roadmap/`](roadmap/) | Phase 02c packet mirror (`P02c-0..P02c-7`) | unnumbered | -| [`glossary.md`](glossary.md) | Hub-specific terms (Plan, HubSubscription, etc.) | single file | +| Directory | Purpose | Numbering | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | +| [`roadmap/`](roadmap/README.md) | **The authoritative Hub plan.** One document per packet (`P02c-0` … `P02c-7`) plus the post-02c billing and marketplace tracks. Not a mirror of a LearnStack file — LearnStack's `phase-02c` covers only its own side of the boundary and links here for the rest. | `pNNc-N-topic.md` | +| [`architecture/`](architecture/README.md) | Hub-internal architecture — repository layout, module topology, cross-cutting foundation, entitlement projection — plus the contract pointers to LearnStack core | flat, unnumbered | +| [`decisions/`](decisions/) | Hub-internal ADRs (Hub-only decisions; `HUB-NNNN` series so they never collide with LearnStack ADR numbers) | `HUB-NNNN-topic.md` | +| [`operations/`](operations/) | Hub operational runbooks (deployment, incident response, cert rotation) | unnumbered | +| [`modules/`](modules/) | Per-Hub-module deep dives + audit-coverage matrices | per-module files | +| [`glossary.md`](glossary.md) | Hub-specific terms (`Plan`, `HubSubscription`, `Entitlement`, `Hub Operator`) | single file | -## Authoritative cross-cutting docs (LearnStack core, not here) +Every roadmap packet document carries the same six sections as a LearnStack phase document: Goal, Scope, Deliverables, Completion Criteria, Risks, Phase Exit Decision. -Hub docs cite these via sibling-relative paths (`../../learnstack/...`): +## Authoritative cross-cutting docs (LearnStack core, not here) -- [ADR-0019 LearnStack Hub](../../learnstack/docs/decisions/0019-learnstack-hub.md) — the boundary contract -- [ADR-0020 Triple Deployment + Hybrid License](../../learnstack/docs/decisions/0020-triple-deployment-hybrid-license.md) -- [ADR-0021 Feature-Based Entitlement](../../learnstack/docs/decisions/0021-feature-based-entitlement.md) -- [ADR-0022 Custom Domain + TLS](../../learnstack/docs/decisions/0022-custom-domain-tls.md) (Amendment 1) -- [ADR-0004 Authentication Strategy](../../learnstack/docs/decisions/0004-authentication-strategy.md) (Amendment 1 — `learnstack-hub` realm) -- [Architecture 24 LearnStack Hub](../../learnstack/docs/architecture/24-learnstack-hub.md) — deep dive -- [Standards 20 § Hub HTTPS Contract Surface](../../learnstack/docs/standards/20-infrastructure-stack.md) -- [Standards 21 Architecture Tests Catalogue](../../learnstack/docs/standards/21-architecture-tests-catalogue.md) -- [Phase 02c roadmap](../../learnstack/docs/roadmap/phase-02c-hub-foundation.md) +Hub docs cite these by absolute URL (`https://github.com/HodeTech/LearnStack/blob/main/docs/...`), per [Documentation Standards § Layout](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/13-documentation.md). Relative paths do not cross a repository boundary on github.com and depend on a sibling checkout being present and identically capitalised. + +- [ADR-0019 LearnStack Hub](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0019-learnstack-hub.md) — why the Hub is a separate repository +- [ADR-0034 Hub Contract Surface Invariant](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) — the two invariants that replaced the "closed at four endpoints" rule, and the authoritative endpoint table +- [ADR-0035 Demand-Gated Infrastructure](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0035-demand-gated-infrastructure.md) — why the Hub integration waits on a written trigger +- [ADR-0033 Audit Durability Model](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0033-audit-durability-model.md) — MUST-class audit as durable intent (supersedes ADR-0016) +- [ADR-0020 Triple Deployment + Hybrid License](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0020-triple-deployment-hybrid-license.md) +- [ADR-0021 Feature-Based Entitlement](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0021-feature-based-entitlement.md) +- [ADR-0022 Custom Domain + TLS](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0022-custom-domain-tls.md) (Amendment 1; its cert-delivery step is superseded by ADR-0034) +- [ADR-0004 Authentication Strategy](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0004-authentication-strategy.md) (Amendment 1 — `learnstack-hub` realm) +- [Architecture 24 LearnStack Hub](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md) — the deep dive +- [Standards 20 Infrastructure Stack](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/20-infrastructure-stack.md) +- [Standards 21 Architecture Tests Catalogue](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/21-architecture-tests-catalogue.md) +- [Phase 02c — LearnStack side](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-02c-hub-foundation.md) ## Engineering standards -Hub follows LearnStack's [Standards corpus](../../learnstack/docs/standards/) by reference. Hub does not maintain its own standards. If a Hub-internal-only rule emerges (e.g. "Stripe webhook idempotency strategy"), it lands as a Hub-internal ADR (`HUB-NNNN`), not as a standards file. +Hub follows LearnStack's [Standards corpus](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/) by reference. Hub does not maintain its own standards. If a Hub-internal-only rule emerges (for example a Stripe webhook idempotency strategy), it lands as a Hub-internal ADR (`HUB-NNNN`), not as a standards file. ## Skills -Hub repo does not maintain its own skill catalogue. Both Claude Code and Codex consume `../learnstack/.claude/skills/`. The same entry-point selection rules apply (use `implement-task` for substantive work, `start-task` for scoping, `standards-check` + `code-review` for review). +**This repository maintains its own skill catalogue** at [`.claude/skills/`](../.claude/skills/README.md) — 18 skills, git-tracked through an un-ignore rule in `.gitignore`. Agents running from the Hub repository root load them from there, not from the sibling LearnStack repo. + +The catalogue is Hub-tailored: the `add-hub-*` workflows encode Hub's deltas from LearnStack core — no Row Level Security, `OperatorId` rather than `UserId`, a six-step MediatR pipeline rather than eight, the `hub` schema, the `learnstack_hub` database. Entry-point selection is unchanged: `implement-task` for substantive work, `start-task` for scoping, `standards-check` then `code-review` for review. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 494a82e..efd62af 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -1,6 +1,6 @@ # Hub Architecture -Hub-internal architecture documentation. Cross-cutting architectural decisions (LearnStack ↔ Hub contracts, deployment models, entitlement projection, custom-domain lifecycle) live in the [LearnStack core architecture corpus](../../../learnstack/docs/architecture/) — not duplicated here. +Hub-internal architecture documentation. Cross-cutting architectural decisions (LearnStack ↔ Hub contracts, deployment models, entitlement projection, custom-domain lifecycle) live in the [LearnStack core architecture corpus](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/) — not duplicated here. ## Contents diff --git a/docs/architecture/contract-with-learnstack.md b/docs/architecture/contract-with-learnstack.md index 4f4e9dc..cb71877 100644 --- a/docs/architecture/contract-with-learnstack.md +++ b/docs/architecture/contract-with-learnstack.md @@ -1,6 +1,6 @@ # Contract with LearnStack core -Hub's boundary with LearnStack core is a **closed four-endpoint HTTPS contract surface**. This file is **pointer-only**: every load-bearing rule (endpoint list, auth chain, what Hub MUST NOT do) lives in LearnStack-side documents. Read those — do not duplicate their content here. +Hub's boundary with LearnStack core is governed by **two invariants** — the Hub stores no tenant content, and every crossing goes through a named adapter ([ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md)). This file is **pointer-only**: every load-bearing rule (endpoint list, auth chain, what Hub MUST NOT do) lives in LearnStack-side documents. Read those — do not duplicate their content here. ## Authoritative source documents @@ -8,24 +8,25 @@ Each link below is the **single source of truth** for the topic. If you find any | Topic | Source | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **The four endpoints + auth chain (mTLS + RS256 JWT + HMAC body sig)** | [ADR-0019 § Inter-system contracts](../../../learnstack/docs/decisions/0019-learnstack-hub.md), [Standards 20 § Hub HTTPS Contract Surface](../../../learnstack/docs/standards/20-infrastructure-stack.md) | -| **The closed-list invariant (no fifth endpoint without an ADR)** | [Standards 20 § Hub HTTPS Contract Surface](../../../learnstack/docs/standards/20-infrastructure-stack.md), [ADR-0019 § Architecture tests](../../../learnstack/docs/decisions/0019-learnstack-hub.md) | -| **Architecture deep dive (Hub data model, sequence diagrams)** | [Architecture 24 LearnStack Hub](../../../learnstack/docs/architecture/24-learnstack-hub.md) | -| **Triple deployment model + license (`IEntitlementProvider` impls)** | [ADR-0020 Triple Deployment + Hybrid License](../../../learnstack/docs/decisions/0020-triple-deployment-hybrid-license.md) | -| **Feature-based entitlement projection shape** | [ADR-0021 Feature-Based Entitlement](../../../learnstack/docs/decisions/0021-feature-based-entitlement.md) | -| **Custom-domain + TLS lifecycle (Hub never writes LearnStack K8s state)** | [ADR-0022 Custom Domain + TLS](../../../learnstack/docs/decisions/0022-custom-domain-tls.md) (Amendment 1) | -| **Two-realm Keycloak boundary** (`learnstack-hub` realm rejected on tenant routes; `learnstack` realm rejected on `/api/internal/*`) | [ADR-0004 Authentication Strategy](../../../learnstack/docs/decisions/0004-authentication-strategy.md) (Amendment 1) | -| **Architecture tests that enforce the boundary** (`LearnStack_Modules_DoNotReference_Hub`, `Hub_Modules_DoNotReference_LearnStack_Internals`, `Internal_API_Endpoints_AreNot_Public`, `Hub_NeverStores_TenantData`, `Hub_Operator_JWT_NeverAccepted_On_LearnStack_Routes`, plus the Stripe / Iyzico SDK isolation tests once Phase 09b lands) | [Architecture 24 § 10 Architecture tests](../../../learnstack/docs/architecture/24-learnstack-hub.md) (authoritative list of 6 Hub-side tests), [ADR-0019 § Architecture tests](../../../learnstack/docs/decisions/0019-learnstack-hub.md) (Accepted decision; lists 4 of the 6), [Standards 21 Architecture Tests Catalogue](../../../learnstack/docs/standards/21-architecture-tests-catalogue.md) (canonical identifier registry; Hub tests still under "to be backfilled") | +| **The endpoint set — the authoritative enumeration** | [ADR-0034 § The endpoint set](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md). Mirrored in [Standards 20 § The endpoint set](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/20-infrastructure-stack.md); the two are kept identical. | +| **The two invariants + the ADR gate on adding an endpoint** | [ADR-0034 § Decision](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md), restated in [Standards 20 § Hub HTTPS Contract Surface](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/20-infrastructure-stack.md) | +| **Auth chain (mTLS + RS256 JWT + HMAC body signature), both directions, and the separate-repository decision** | [ADR-0034 § One auth chain, both directions](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) and [ADR-0019 § Inter-system contracts](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0019-learnstack-hub.md) — ADR-0034 replaces ADR-0019's "closed at four endpoints" rule **and** its per-instance API key on the LearnStack → Hub direction; both directions now carry the same three layers | +| **Architecture deep dive (Hub data model, sequence diagrams)** | [Architecture 24 LearnStack Hub](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md) | +| **Triple deployment model + license (`IEntitlementProvider` impls)** | [ADR-0020 Triple Deployment + Hybrid License](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0020-triple-deployment-hybrid-license.md) | +| **Feature-based entitlement projection shape** | [ADR-0021 Feature-Based Entitlement](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0021-feature-based-entitlement.md) | +| **Custom-domain + TLS lifecycle (Hub never writes LearnStack K8s state)** | [ADR-0022 Custom Domain + TLS](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0022-custom-domain-tls.md), as amended by ADR-0034 — the lifecycle stands; Amendment 1's step-3 delivery mechanism is superseded | +| **Two-realm Keycloak boundary** (`learnstack-hub` realm rejected on tenant routes; `learnstack` realm rejected on `/api/internal/*`) | [ADR-0004 Authentication Strategy](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0004-authentication-strategy.md) (Amendment 1) | +| **Architecture tests that enforce the boundary** (`LearnStack_Modules_DoNotReference_Hub`, `Hub_Modules_DoNotReference_LearnStack_Internals`, `Internal_API_Endpoints_AreNot_Public`, `Hub_NeverStores_TenantData`, `Hub_Operator_JWT_NeverAccepted_On_LearnStack_Routes`, `Hub_Client_Referenced_Only_By_Named_Adapters`, plus the Stripe / Iyzico SDK isolation tests once Phase 09b lands) | [Architecture 24 § 10 Architecture tests](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md) (authoritative list of the 7 Hub-side tests), [ADR-0019 § Architecture tests](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0019-learnstack-hub.md) (Accepted decision; lists 4 of them, and predates ADR-0034's seventh), [Standards 21 Architecture Tests Catalogue](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/21-architecture-tests-catalogue.md) (canonical identifier registry — the Hub-side tests now carry full entries there, owned and run by this repository) | ## Phase 02c packet ownership | Packet | Endpoint work (cross-references the authoritative spec above) | | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **P02c-2** | Hub-side handlers for the two LearnStack → Hub endpoints. Outbound `LearnStackApiClient` for the two Hub → LearnStack endpoints (mTLS + JWT + HMAC chain). | -| **P02c-3** | LearnStack-side handlers for the two Hub → LearnStack endpoints (paired PR into the LearnStack core repo). `HubEntitlementProvider` + `IUsageReporter` adapters on the LearnStack side. | -| **P02c-5** | LearnStack-side custom-domain handler reacts to `learnstack.hub.custom-domain.activated/.deactivated/.renewed` Dapr events (per ADR-0022 Amendment 1). | +| **P02c-2** | Hub-side handlers for every LearnStack → Hub path in ADR-0034's set. Outbound `LearnStackApiClient` for every Hub → LearnStack path (mTLS + JWT + HMAC chain, both directions). | +| **P02c-3** | LearnStack-side handlers for every Hub → LearnStack path (paired PR into the LearnStack core repo). `HubEntitlementProvider` + `IUsageReporter` adapters on the LearnStack side. | +| **P02c-5** | LearnStack-side host-mapping handler behind `IHubTenantSync` for `PUT /api/internal/tenants/{id}/host-mappings` — the push endpoint is the authority for `platform_host_to_tenant`; the `learnstack.hub.custom-domain.activated/.deactivated/.renewed` Dapr events are the cache-invalidation signal, not the mapping channel. Certificate material moves by secret-store replication and is referenced by path, never by value (ADR-0034, superseding ADR-0022 Amendment 1 step 3). | ## What this file is NOT -- **Not a quick-reference cheat sheet.** Duplicating the four-endpoint table here breaks single-source-of-truth: if the LearnStack-side spec evolves, the cheat sheet ages out silently. Use the links above. +- **Not a quick-reference cheat sheet.** Duplicating the endpoint table here breaks single-source-of-truth: if the LearnStack-side spec evolves, the cheat sheet ages out silently. Use the links above. - **Not a place to add Hub-internal rules.** Hub-internal-only decisions live in [docs/decisions/](../decisions/) under the `HUB-NNNN` series. diff --git a/docs/architecture/cross-cutting-foundation.md b/docs/architecture/cross-cutting-foundation.md index fe0805a..862df14 100644 --- a/docs/architecture/cross-cutting-foundation.md +++ b/docs/architecture/cross-cutting-foundation.md @@ -1,6 +1,6 @@ # Hub cross-cutting foundation -This document specs the Hub-side cross-cutting foundation that lands in **P02c-1**. It mirrors LearnStack core's Phase 02a Packet 3 ([ADR-0032](../../../learnstack/docs/decisions/0032-exception-handling-logging-and-observability.md)) — same shapes, Hub-adjusted where Hub's operator-scoped (not tenant-scoped) model demands it. +This document specs the Hub-side cross-cutting foundation that lands in **P02c-1**. It mirrors LearnStack core's Phase 02a Packet 3 ([ADR-0032](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0032-exception-handling-logging-and-observability.md)) — same shapes, Hub-adjusted where Hub's operator-scoped (not tenant-scoped) model demands it. The intent: when domain code starts landing in Hub modules, it programs against the _same_ `Result` / MediatR-pipeline / exception-handling / observability surface a LearnStack core developer already knows. Patterns are copied, not invented. @@ -27,7 +27,7 @@ The intent: when domain code starts landing in Hub modules, it programs against | `Hosting/` | `DeploymentMode` | Same five values: Development, SaaS, Dedicated, SelfHostedOnline, SelfHostedAirGapped | | (root) | `LearnStackHubVogenDefaults.IdMask` | `EfCoreValueConverter \| SystemTextJson \| TypeConverter` | -The exact public API shapes are LearnStack core's — reproduce them verbatim (adjusting the namespace + the `OperatorId` substitution). The agent prompt points the implementer at the live LearnStack source under `../learnstack/backend/src/LearnStack.SharedKernel/` for the canonical signatures. +The exact public API shapes are LearnStack core's — reproduce them verbatim (adjusting the namespace + the `OperatorId` substitution). The agent prompt points the implementer at the live LearnStack source under `../LearnStack/backend/src/LearnStack.SharedKernel/` for the canonical signatures. ### `HubException` naming @@ -44,9 +44,9 @@ This is the load-bearing Hub adjustment. LearnStack core's `AuditableEntity ## 2. MediatR pipeline — Hub's behavior set -LearnStack core runs an 8-step pipeline: Validation → Logging → AuditLog → TenantContext → Authorization → Transaction → OutboxFlush → Handler. +LearnStack core registers **seven** pipeline behaviors: Validation → Logging → AuditLog → TenantContext → Authorization → Transaction → OutboxFlush, then the Handler. [ADR-0032 § Sub-decision 2](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0032-exception-handling-logging-and-observability.md) writes that as an eight-step list because it counts the Handler; this document counts behaviors, so the numbers below are behavior counts throughout. -Hub runs a **6-step** pipeline. The two LearnStack steps that drop out are tenant-isolation concerns Hub does not have: +Hub registers **six**. The one behavior that drops out is a tenant-isolation concern Hub does not have: | # | Behavior | P02c-1 state | Why | | --- | ----------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -62,14 +62,14 @@ Hub runs a **6-step** pipeline. The two LearnStack steps that drop out are tenan - **`TenantContextBehavior`** — REMOVED. Hub has no per-request tenant context to assert / no RLS GUC to set. Hub requests are operator-scoped; the operator identity rides on the JWT, resolved by an `OperatorContext` (P02c-4), not by a tenant resolver. -The `MediatR_Pipeline_Order_Matches_Canonical_Sequence` architecture test (if mirrored) asserts the 6-step Hub order, not LearnStack's 8. +The `MediatR_Pipeline_Order_Matches_Canonical_Sequence` architecture test (if mirrored) asserts the six-behavior Hub order, not LearnStack's seven. ## 3. Exception handling - **L1 handler:** `HubExceptionHandler : IExceptionHandler` in `LearnStack.Hub.Api/Common/` — mirror of `LearnStackExceptionHandler`. Captures via `IErrorTrackingProvider`, reads `CapturedContext` (operator + correlation), maps to RFC 7807 Problem Details, logs through `ILogger`. - **`Result.ToActionResult()`** in `LearnStack.Hub.Api/Common/ResultExtensions.cs` — explicit at every controller endpoint (no action filter). Success → `OkObjectResult`; failure → `ProblemDetailsActionResult(error)`. - **`ProblemDetailsFactory` + `HttpStatusMap`** — mirror of LearnStack core's. Problem-type prefix `https://errors.hub.learnstack.dev/` (Hub's own error domain). -- **Exception hierarchy:** `HubException` (base) → `DomainException`, `InfrastructureException`, `ProviderException`. `DomainException` is reserved for programmer errors / aggregate-invariant bugs; expected business-rule violations return `Result.Fail(...)` (mirror of [ADR-0032 § Sub-decision 4](../../../learnstack/docs/decisions/0032-exception-handling-logging-and-observability.md)). +- **Exception hierarchy:** `HubException` (base) → `DomainException`, `InfrastructureException`, `ProviderException`. `DomainException` is reserved for programmer errors / aggregate-invariant bugs; expected business-rule violations return `Result.Fail(...)` (mirror of [ADR-0032 § Sub-decision 4](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0032-exception-handling-logging-and-observability.md)). - **No `ExceptionHandlingBehavior` in the pipeline** — `AuditLogBehavior` (catch + rethrow) plus the L1 `HubExceptionHandler` cover every exception path, same as LearnStack core. ## 4. Observability @@ -95,7 +95,7 @@ P02c-1 grows `Program.cs` from the P02c-0 minimal `/healthz` host into the found 1. `builder.Host.UseSerilog(...)` (console + OTLP). 2. `builder.Services.AddOpenTelemetry()...` (tracing + metrics; no LoggerProvider). 3. `builder.Services.AddExceptionHandler()` + `AddProblemDetails()`. -4. MediatR registration with the 6-step pipeline in canonical order. +4. MediatR registration with the six-behavior pipeline in canonical order. 5. `IClock` / `IGuidFactory` / `IRandom` / `ISecretProvider` / `IErrorTrackingProvider` / `IProviderResilience<>` registered, `DeploymentMode`-branched at the composition root (modules never read `DeploymentMode`). 6. Each module's `AddModule(...)` extension (DbContext + handlers + validators). 7. `/healthz` stays; real endpoints arrive in P02c-2. diff --git a/docs/architecture/entitlement-projection.md b/docs/architecture/entitlement-projection.md index df2491c..e00dca5 100644 --- a/docs/architecture/entitlement-projection.md +++ b/docs/architecture/entitlement-projection.md @@ -2,11 +2,11 @@ The `Entitlement` aggregate is the **single most load-bearing thing Hub produces**: a flattened, denormalised projection of `Plan` + `HubSubscription` (+ `CompliancePolicy`, from P02c-5) per tenant. It is the only shape LearnStack core's `HubEntitlementProvider` consumes, and the only shape pushed across the `PUT /api/internal/tenants/{id}/entitlements` contract. -Authoritative sources: [ADR-0021 Feature-Based Entitlement](../../../learnstack/docs/decisions/0021-feature-based-entitlement.md) (+ Amendment 1), [Architecture 24 § 4 Entitlement projection](../../../learnstack/docs/architecture/24-learnstack-hub.md), [ADR-0020 Triple Deployment + Hybrid License](../../../learnstack/docs/decisions/0020-triple-deployment-hybrid-license.md). +Authoritative sources: [ADR-0021 Feature-Based Entitlement](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0021-feature-based-entitlement.md) (+ Amendment 1), [Architecture 24 § 4 Entitlement projection](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md), [ADR-0020 Triple Deployment + Hybrid License](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0020-triple-deployment-hybrid-license.md). ## Projection shape (the wire contract) -This is the JSON the projection serialises to (per [Architecture 24 § 4](../../../learnstack/docs/architecture/24-learnstack-hub.md)). It is the contract LearnStack core mirrors into `platform_entitlement_cache`: +This is the JSON the projection serialises to (per [Architecture 24 § 4](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md)). It is the contract LearnStack core mirrors into `platform_entitlement_cache`: ```json { @@ -53,7 +53,7 @@ This is the JSON the projection serialises to (per [Architecture 24 § 4](../../ ### Key-shape rules (load-bearing; do not drift) -- **Feature keys** use the dotted snake_case form with **no `.enabled` suffix** (dropped in [ADR-0021 Amendment 1](../../../learnstack/docs/decisions/0021-feature-based-entitlement.md)). Every feature is implicitly boolean. e.g. `classroom.recording`, `tenancy.custom_domain`, `identity.sso.saml`. +- **Feature keys** use the dotted snake_case form with **no `.enabled` suffix** (dropped in [ADR-0021 Amendment 1](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0021-feature-based-entitlement.md)). Every feature is implicitly boolean. e.g. `classroom.recording`, `tenancy.custom_domain`, `identity.sso.saml`. - **Limit keys** carry the `limits.` prefix; value `-1` = unlimited, `0` = not available. e.g. `limits.max_users`. - **Compliance-cap keys** keep their own `.enabled` portion as part of the cap name (it is NOT a redundant suffix), and the value is a `{ allowed, forced, value? }` object — not a bare bool. - The `tier` mirrors the `Plan.tier` (`starter | growth | scale | enterprise | custom`). @@ -62,7 +62,7 @@ P02c-1 stores `features` / `limits` / `compliance_caps` as **JSONB columns** on ## `Entitlement` aggregate (P02c-1) -Per [Architecture 24 § 2 ERD](../../../learnstack/docs/architecture/24-learnstack-hub.md): +Per [Architecture 24 § 2 ERD](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md): | Column | Type | Notes | | ----------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------ | @@ -122,7 +122,7 @@ RETURN: Result ### When recompute fires (P02c-1 triggers) -Per [Architecture 24 § 4 Recompute rule](../../../learnstack/docs/architecture/24-learnstack-hub.md), recompute fires on: +Per [Architecture 24 § 4 Recompute rule](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md), recompute fires on: | Trigger | P02c-1? | Mechanism | | ------------------------------------------------------------------------------------ | --------- | ------------------------------------------------------------------------------------------- | @@ -139,11 +139,18 @@ The cross-module trigger (Subscriptions / Plans → Entitlements) uses an in-pro These are LearnStack-core-side behaviours that consume the projection; documented here so the Hub-side shape stays compatible. They are **not** P02c-1 deliverables: - `NullEntitlementProvider` (LearnStack Development mode): all features `true`, all limits `null`. -- `HubEntitlementProvider` + Hub unreachable: serve cached projection until `expires_at`; within `grace_until` keep serving; past `grace_until` → read-only mode. +- `HubEntitlementProvider` + Hub unreachable: the read path is normative and lives on + the LearnStack side — L1 in-process cache → L2 distributed cache → the durable + `platform_entitlement_cache` row carrying its own grace window → the Hub. Past the + grace window, resolution is per feature-key class: fail-open keys stay enabled, + fail-closed keys are refused. It never throws out of a feature-flag check. See + [ADR-0034 § The entitlement read path](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) + and [LearnStack Phase 02c](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-02c-hub-foundation.md); this document + does not restate them. Superseded detail: serve cached projection until `expires_at`; within `grace_until` keep serving; past `grace_until` → read-only mode. Hub's only obligation is to keep emitting a projection whose shape matches the contract above, with a correct monotonic `generation`. ## Architecture-test hooks -- `EntitlementProjection_Shape_IsStable` (from [ADR-0021 § Architecture tests](../../../learnstack/docs/decisions/0021-feature-based-entitlement.md)) — snapshot-test the serialised projection JSON against a checked-in `entitlement-v1.schema.json`. A breaking change requires a schema-version bump. Recommended to land this in P02c-1 since the projection serialiser is the contract surface. +- `EntitlementProjection_Shape_IsStable` (from [ADR-0021 § Architecture tests](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0021-feature-based-entitlement.md)) — snapshot-test the serialised projection JSON against a checked-in `entitlement-v1.schema.json`. A breaking change requires a schema-version bump. Recommended to land this in P02c-1 since the projection serialiser is the contract surface. - `generation` monotonicity is not an architecture test (it's a runtime invariant) — cover it with a unit test on the `Entitlement.Recompute` method (generation strictly increases) and an integration test on the projection service. diff --git a/docs/architecture/module-topology.md b/docs/architecture/module-topology.md index 5e2d81f..8be93d5 100644 --- a/docs/architecture/module-topology.md +++ b/docs/architecture/module-topology.md @@ -2,7 +2,7 @@ This document is the design spec for the Hub modular monolith as it stands after **P02c-1 (Hub Domain Core)**. It describes the four modules that land in P02c-1, the dependency direction rules, and how cross-module communication works inside Hub. Modules that arrive in later packets (CustomDomains, Compliance, Usage, LicenseKeys, Audit, Operators, Invoicing) are listed for context but specced in their own packets. -Authoritative cross-cutting sources: [ADR-0019 § Hub data model](../../../learnstack/docs/decisions/0019-learnstack-hub.md), [Architecture 24 § 2 / § 7](../../../learnstack/docs/architecture/24-learnstack-hub.md), [ADR-0010 Cross-Module Communication](../../../learnstack/docs/decisions/0010-cross-module-communication.md). +Authoritative cross-cutting sources: [ADR-0019 § Hub data model](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0019-learnstack-hub.md), [Architecture 24 § 2 / § 7](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md), [ADR-0010 Cross-Module Communication](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0010-cross-module-communication.md). ## Modules in P02c-1 @@ -36,7 +36,7 @@ Identical rules to LearnStack core (enforced by `ModuleDependencyTests`): ## Cross-module communication -Hub uses the same four mechanisms as LearnStack core ([ADR-0010](../../../learnstack/docs/decisions/0010-cross-module-communication.md)) — no fifth: +Hub uses the same four mechanisms as LearnStack core ([ADR-0010](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0010-cross-module-communication.md)) — no fifth: 1. **Application contract** — synchronous in-process call to another module's `Application.Contracts` interface. 2. **Intra-module domain event** — `IDomainEvent : INotification`, dispatched in-process via MediatR inside the same transaction. @@ -70,7 +70,7 @@ In P02c-1 the trigger is an **in-process call** from the Subscriptions / Plans h ## Database isolation model — Hub does NOT use RLS -This is the load-bearing difference from LearnStack core. LearnStack core enforces tenant isolation with PostgreSQL Row-Level Security ([Standards 05 § Tenant-Owned Tables](../../../learnstack/docs/standards/05-database.md)) because tenant users must never see another tenant's data. **Hub has no such requirement** — Hub operators act _across_ all tenants by design (that is the entire point of a control plane). Therefore: +This is the load-bearing difference from LearnStack core. LearnStack core enforces tenant isolation with PostgreSQL Row-Level Security ([Standards 05 § Tenant-Owned Tables](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/05-database.md)) because tenant users must never see another tenant's data. **Hub has no such requirement** — Hub operators act _across_ all tenants by design (that is the entire point of a control plane). Therefore: - Hub tables carry a `tenant_id` **foreign-key / reference column** where they relate to a tenant, but it is an ordinary FK, **not** an RLS-policy boundary. - Hub tables do **not** get `ENABLE ROW LEVEL SECURITY`, do **not** get `*_tenant_isolation` policies, do **not** get EF global query filters keyed on a tenant context. @@ -81,9 +81,9 @@ Hub's isolation guarantee is a different one: **Hub never stores tenant content* ## Schema + database - **Database:** `learnstack_hub` (separate database; in dev it lives in the shared Postgres instance per `infra/postgres/init/01-create-hub-database.sql`; in production it is a separate Postgres instance). -- **Schema:** `hub` (per [Architecture 24 intro](../../../learnstack/docs/architecture/24-learnstack-hub.md)). Every module's DbContext sets `modelBuilder.HasDefaultSchema("hub")`. Tables therefore live at `learnstack_hub.hub.
`. -- **Naming:** `snake_case` plural tables, `snake_case` columns, `id` PK (`uuid`), `_id` FKs, `ix_`/`ux_` index prefixes — same conventions as [Standards 05 § Naming](../../../learnstack/docs/standards/05-database.md), minus the RLS-policy / org-isolation rows that don't apply to Hub. -- **One DbContext per module** ([Standards 05 § Database](../../../learnstack/docs/standards/05-database.md)). Migrations live with the owning module. +- **Schema:** `hub` (per [Architecture 24 intro](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md)). Every module's DbContext sets `modelBuilder.HasDefaultSchema("hub")`. Tables therefore live at `learnstack_hub.hub.
`. +- **Naming:** `snake_case` plural tables, `snake_case` columns, `id` PK (`uuid`), `_id` FKs, `ix_`/`ux_` index prefixes — same conventions as [Standards 05 § Naming](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/05-database.md), minus the RLS-policy / org-isolation rows that don't apply to Hub. +- **One DbContext per module** ([Standards 05 § Database](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/05-database.md)). Migrations live with the owning module. - **Audit columns:** Hub aggregates that inherit `AuditableEntity` carry `created_at` / `created_by` / `updated_at` / `updated_by` / `deleted_at` / `deleted_by` / `version` where `*_by` is an `OperatorId` (NOT a tenant `UserId` — see [cross-cutting-foundation.md § OperatorId](cross-cutting-foundation.md)). ## Module registration diff --git a/docs/architecture/repository-layout.md b/docs/architecture/repository-layout.md index e718e79..747fe89 100644 --- a/docs/architecture/repository-layout.md +++ b/docs/architecture/repository-layout.md @@ -1,9 +1,17 @@ # Repository layout -LearnStack Hub mirrors LearnStack core's modular-monolith repository pattern. +LearnStack Hub mirrors LearnStack core's modular-monolith repository pattern. The two +repositories sit side by side on disk as `LearnStack/` and `LearnStack-Hub/`, with those +exact capitalisations; cross-repo links from this file are written +`../../../LearnStack/...`. + +This tree describes what is on `main`. `backend/src/Modules/` carries the four +[P02c-1](../roadmap/p02c-1-hub-domain-core.md) modules — `TenantLifecycle`, `Plans`, +`Subscriptions`, `Entitlements` — merged 2026-08-09. The remaining seven land in later +packets, which are frozen from P02c-2 onward. ``` -learnstack-hub/ +LearnStack-Hub/ ├── backend/ │ ├── LearnStack.Hub.slnx # .NET solution │ ├── Directory.Build.props # .NET central config @@ -18,15 +26,20 @@ learnstack-hub/ │ │ │ ├── LearnStack.Hub.Infrastructure/ # EF Core DbContexts, provider adapters, LearnStackApiClient │ │ │ ├── LearnStack.Hub.Infrastructure.Audit/ # Hub operator audit pipeline │ │ │ └── LearnStack.Hub.Api/ # ASP.NET Core host -│ │ └── Modules/ # Hub modules — DIRECTORY EXISTS, contents PLANNED (see below) +│ │ └── Modules/ # 4 modules on main (P02c-1); 7 planned (see below) │ │ └── README.md # only file on disk today; describes planned topology │ └── tests/ -│ ├── LearnStack.Hub.Tests.Unit/ # domain + application unit tests -│ ├── LearnStack.Hub.Tests.Integration/ # Testcontainers (P02c-2+) -│ ├── LearnStack.Hub.Tests.Architecture/ # NetArchTest rules (mandatory, non-skippable) -│ └── LearnStack.Hub.Tests.Contract/ # OpenAPI contract assertions (P02c-2+) +│ ├── LearnStack.Hub.Tests.Unit/ # domain + application unit tests (SmokeTests.cs) +│ ├── LearnStack.Hub.Tests.Integration/ # Testcontainers — placeholder until P02c-2 +│ ├── LearnStack.Hub.Tests.Architecture/ # NetArchTest + file-system rules (mandatory, non-skippable) +│ │ ├── HubBoundaryTests.cs # Hub_NeverStores_TenantData, Hub_Modules_DoNotReference_LearnStack_Internals +│ │ ├── ModuleDependencyTests.cs # module dependency direction +│ │ ├── RepositoryLayoutTests.cs # No_Source_Folder_Named_Verticals, Frontend_Has_Only_The_OperatorPortal_App +│ │ └── RepositoryPaths.cs # working-copy path resolution shared by the file-system rules +│ └── LearnStack.Hub.Tests.Contract/ # OpenAPI contract assertions — placeholder until P02c-2 ├── frontend/ │ ├── pnpm-workspace.yaml +│ ├── pnpm-lock.yaml │ ├── apps/ │ │ └── operator-portal/ # Next.js 15.5 App Router (P02c-0 scaffold; P02c-4 content; flat-config + Next 16 migration tracked in P02c-4) │ └── packages/ @@ -35,7 +48,8 @@ learnstack-hub/ │ └── ui/ # operator portal design-system primitives (P02c-4) ├── infra/ │ ├── compose/ -│ │ ├── dev.yml # Hub-only services (Dapr sidecar + APISIX + Postgres init) +│ │ ├── README.md # what this stack runs and what it borrows from LearnStack core +│ │ ├── dev.yml # Hub-only services (Postgres init + Dapr placement + Dapr sidecar + APISIX) │ │ └── e2e.yml # ephemeral overlay │ ├── apisix/ │ │ ├── config.yaml # APISIX standalone mode @@ -50,15 +64,21 @@ learnstack-hub/ ├── docs/ │ ├── README.md # this directory's catalogue │ ├── architecture/ +│ │ ├── README.md # architecture index with per-doc status │ │ ├── contract-with-learnstack.md # pointers to LearnStack-side authority +│ │ ├── cross-cutting-foundation.md # Hub SharedKernel mirror; the 6-step MediatR pipeline; OperatorId +│ │ ├── entitlement-projection.md # projection wire-shape, generation counter, recompute triggers +│ │ ├── module-topology.md # module dependency graph; the "Hub does NOT use RLS" model │ │ └── repository-layout.md # (this file) -│ ├── decisions/ # Hub-internal ADRs (HUB-NNNN series) -│ ├── operations/ # runbooks (Phase 11+) -│ ├── modules/ # per-module deep dives (P02c-1+) -│ ├── roadmap/ # P02c packet status mirror +│ ├── roadmap/ # the authoritative Hub plan — one document per packet +│ ├── decisions/ # Hub-internal ADRs (HUB-NNNN series) — README + template only today +│ ├── operations/ # runbooks — README only today +│ ├── modules/ # per-module deep dives — 4 shipped (see below) │ └── glossary.md # Hub-specific terms ├── scripts/ -│ └── seed.sh # idempotent dev seed (P02c-1+ fills with real data) +│ └── seed.sh # idempotent dev seed (orchestrator only until P02c-1 unfreezes) +├── .claude/ +│ └── skills/ # 18 Hub-tailored skills + README; git-tracked via a .gitignore un-ignore rule ├── .github/ │ ├── workflows/ci.yml # backend + frontend + meta + secret-scan │ ├── CODEOWNERS @@ -78,38 +98,54 @@ learnstack-hub/ └── .leakwatchignore ``` -## Planned module topology (NOT YET ON DISK) +## Documentation on disk + +`docs/` is no longer a skeleton. What is written today: + +| Directory | Files on disk | State | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | +| `docs/architecture/` | `README.md`, `contract-with-learnstack.md`, `cross-cutting-foundation.md`, `entitlement-projection.md`, `module-topology.md`, `repository-layout.md` | Five architecture docs plus the index. `learnstack-api-client.md` (P02c-2) and `operator-portal.md` (P02c-4) are still to come — see [architecture/README.md](README.md). | +| `docs/modules/` | `README.md`, `tenant-lifecycle.md`, `plans.md`, `subscriptions.md`, `entitlements.md` | Four module specs shipped, one per P02c-1 module. They were authored as design specification ahead of the implementation; they become the living description when P02c-1 unfreezes and lands. | +| `docs/roadmap/` | `README.md`, one document per packet, `P02c-1-implementation-prompt.md` | The authoritative Hub plan, owned in this repository. | +| `docs/decisions/` | `README.md`, `template.md` | No `HUB-NNNN` ADR has been needed yet. | +| `docs/operations/` | `README.md` | Runbooks land with the first non-dev deployment. | +| `docs/glossary.md` | single file | Hub-specific terms. | + +## Module topology (four on disk, seven planned) -P02c-0 ships **zero** module subdirectories under `backend/src/Modules/`. The 11 modules below land across later Phase 02c + Phase 09b packets. Until then `backend/src/Modules/` contains only its own `README.md` documenting this plan. +The four [P02c-1](../roadmap/p02c-1-hub-domain-core.md) modules — `TenantLifecycle`, `Plans`, `Subscriptions`, `Entitlements` — **are on `main`** as of 2026-08-09, with their specifications under `docs/modules/`. The seven below them are not yet on disk and land across the remaining Hub packets, which are frozen from P02c-2 onward. -| Module subdirectory under `backend/src/Modules/` | Lands in | Aggregates | -| ------------------------------------------------ | --------- | ----------------------------------------------- | -| `TenantLifecycle/` | P02c-1 | `LearnStackTenant` (mirror) | -| `Plans/` | P02c-1 | `Plan`, `PlanTier` | -| `Subscriptions/` | P02c-1 | `HubSubscription` | -| `Entitlements/` | P02c-1 | `Entitlement` (projection) | -| `CustomDomains/` | P02c-5 | `CustomDomain` | -| `Compliance/` | P02c-5 | `CompliancePolicy` | -| `Usage/` | P02c-2 | `UsageAggregate` | -| `LicenseKeys/` | P02c-6 | `LicenseKey` | -| `Invoicing/` | Phase 09b | `HubInvoice`, `HubInvoiceLine`, `WebhookLedger` | -| `Audit/` | P02c-4 | `AuditEntry` (operator audit) | -| `Operators/` | P02c-4 | Operator role + permission mapping | +| Module subdirectory under `backend/src/Modules/` | Lands in | Aggregates | +| ------------------------------------------------ | -------------------------------------------- | ----------------------------------------------- | +| `TenantLifecycle/` | P02c-1 (spec written) | `LearnStackTenant` (mirror) | +| `Plans/` | P02c-1 (spec written) | `Plan`, `PlanTier` | +| `Subscriptions/` | P02c-1 (spec written) | `HubSubscription` | +| `Entitlements/` | P02c-1 (spec written) | `Entitlement` (projection) | +| `Usage/` | P02c-2 | `UsageAggregate` | +| `Audit/` | P02c-4 | `AuditEntry` (operator audit) | +| `Operators/` | P02c-4 | Operator role + permission mapping | +| `CustomDomains/` | P02c-5 | `CustomDomain` | +| `Compliance/` | P02c-5 | `CompliancePolicy` | +| `LicenseKeys/` | P02c-6 | `LicenseKey` | +| `Invoicing/` | [hub-billing](../roadmap/hub-billing.md) | `HubInvoice`, `HubInvoiceLine`, `WebhookLedger` | Each module follows the same four-layer pattern as LearnStack core (`Application.Contracts`, `Application`, `Domain`, `Infrastructure`). See [`backend/src/Modules/README.md`](../../backend/src/Modules/README.md) for the same table maintained co-located with the code. ## Comparison with LearnStack core -| Concern | LearnStack core | LearnStack Hub | -| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Top-level layout | backend / frontend / docs / infra / scripts | Same | -| Backend solution | LearnStack.slnx | LearnStack.Hub.slnx | -| Core projects | 7 (SharedKernel, Domain, Application.Contracts, Application, Infrastructure, Infrastructure.Audit, Api) | 7 (same names, Hub prefix) | -| Modules | 7 (Tenancy, Identity, Customization, Audit, Content, Media, Education) | 11 planned (TenantLifecycle, Plans, Subscriptions, Entitlements, CustomDomains, Compliance, Usage, LicenseKeys, Invoicing, Audit, Operators) — none in P02c-0 | -| Test projects | 4 (Unit, Integration, Architecture, Contract) | 4 (same names) | -| Frontend apps | apps/web (tenant-facing) | apps/operator-portal (operator-facing) | -| Frontend packages | config / sdk / ui (`@learnstack/*`) | config / sdk / ui (`@learnstack-hub/*`) | -| Compose services | 15 (Postgres, Valkey, SeaweedFS, Mailpit, Meilisearch, Keycloak, LiveKit, Coturn, Kafka, kafka-ui, Vault, Dapr placement + sidecar, APISIX) | ~4 (Dapr placement, Dapr sidecar, APISIX, Postgres init). Shares LearnStack core's Postgres / Valkey / Vault / Kafka / Keycloak / Mailpit via host.docker.internal | -| APISIX port | 9080 / 9443 / 9091 | 9180 / 9543 / 9191 (no collision) | -| Dapr ports | placement 50005, sidecar 3500/50001 | placement 50006, sidecar 3501/50002 | -| API port | 5080 | 5181 | +| Concern | LearnStack core | LearnStack Hub | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Top-level layout | backend / frontend / docs / infra / scripts | Same | +| Backend solution | `LearnStack.slnx` | `LearnStack.Hub.slnx` | +| Core project root | Flat under `backend/src/` | Nested under `backend/src/Core/` | +| Core projects | 10 (SharedKernel, Domain, Application.Contracts, Application, Infrastructure, Infrastructure.Audit, Infrastructure.ErrorTracking, Infrastructure.Observability, Infrastructure.Resilience, Api) + a Roslyn analyzer project under `backend/analyzers/` | 7 (SharedKernel, Domain, Application.Contracts, Application, Infrastructure, Infrastructure.Audit, Api) — Hub prefix. The three extra LearnStack infrastructure projects landed with its P02a-3 cross-cutting foundation; Hub folds the equivalents into `Infrastructure` | +| MediatR pipeline | 8 steps | **6 steps** — no `TenantContextBehavior`, because Hub data is operator-administered rather than tenant-isolated. See [cross-cutting-foundation.md](cross-cutting-foundation.md) | +| Row Level Security | Every tenant-owned table | None. Hub stores no tenant content | +| Modules | 7 (Tenancy, Identity, Customization, Audit, Content, Media, Education) — directories exist, domain code lands across Phase 02a packets 6–9 | 11 planned; **four on `main`** — `TenantLifecycle`, `Plans`, `Subscriptions`, `Entitlements`, shipped by P02c-1 | +| Test projects | 4 (Unit, Integration, Architecture, Contract) | 4 (same names) | +| Frontend apps | `apps/web` (tenant-facing) | `apps/operator-portal` (operator-facing), pinned by `Frontend_Has_Only_The_OperatorPortal_App` | +| Frontend packages | config / sdk / ui (`@learnstack/*`) | config / sdk / ui (`@learnstack-hub/*`) | +| Compose services | 14 (Postgres, Valkey, SeaweedFS, Mailpit, Meilisearch, Keycloak, LiveKit, Coturn, Kafka, kafka-ui, Vault, Dapr placement, Dapr sidecar, APISIX). LearnStack P02a-5 moves the demand-gated ones behind a non-default profile per ADR-0035 | 4 (Postgres init, Dapr placement, Dapr sidecar, APISIX). Shares LearnStack core's Postgres / Valkey / Vault / Kafka / Keycloak / Mailpit via `host.docker.internal` | +| APISIX port | 9080 / 9443 / 9091 | 9180 / 9543 / 9191 (no collision) | +| Dapr ports | placement 50005, sidecar 3500/50001 | placement 50006, sidecar 3501/50002 | +| API port | 5080 | 5181 | diff --git a/docs/decisions/README.md b/docs/decisions/README.md index a690ea2..3c9bd7b 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -1,6 +1,6 @@ # Hub-Internal ADRs -Architecture Decision Records that affect **only** Hub. Cross-cutting decisions (LearnStack ↔ Hub contracts, deployment models, entitlement projection, custom-domain lifecycle) live in the [LearnStack core decisions corpus](../../../learnstack/docs/decisions/) — not duplicated here. +Architecture Decision Records that affect **only** Hub. Cross-cutting decisions (LearnStack ↔ Hub contracts, deployment models, entitlement projection, custom-domain lifecycle) live in the [LearnStack core decisions corpus](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/) — not duplicated here. ## Numbering convention diff --git a/docs/glossary.md b/docs/glossary.md index bdc4a41..539fc3d 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -1,6 +1,6 @@ # Hub Glossary -Hub-specific terms. Cross-cutting glossary terms (`Tenant`, `Organization`, `IModule`, `Entitlement`, etc.) live in the [LearnStack core glossary](../../learnstack/docs/glossary.md) — not duplicated here. +Hub-specific terms. Cross-cutting glossary terms (`Tenant`, `Organization`, `IModule`, `Entitlement`, etc.) live in the [LearnStack core glossary](https://github.com/HodeTech/LearnStack/blob/main/docs/glossary.md) — not duplicated here. ## Hub aggregates @@ -14,13 +14,13 @@ The catalogue entry that defines a tier — `name`, `tier` (starter / growth / s The per-tenant binding to a plan. Tracks the lifecycle state machine — `Trial → Active → PastDue → Canceled → Expired` — plus billing cycle anchors (`current_period_start`, `current_period_end`, `cancel_at_period_end`) and the payment-provider link (`stripe` / `iyzico`). Lands in P02c-1. **`Entitlement`** -The flattened, denormalised projection of a tenant's effective feature + limit + compliance-cap set. Recomputed atomically whenever a `Plan`, `HubSubscription`, `CompliancePolicy`, or `LicenseKey` changes. Pushed to LearnStack core via `PUT /api/internal/tenants/{id}/entitlements` and mirrored into `platform_entitlement_cache`. The `generation` field is the monotonic version counter that cache invalidation rides on. Lands in P02c-1. +The flattened, denormalised computation over `Plan` + `HubSubscription` + `CompliancePolicy` (+ `LicenseKey`) that yields a tenant's effective feature + limit + compliance-cap set. LearnStack's glossary calls this the *Entitlement Aggregate (Hub-side)*; it is the only writer of an entitlement anywhere. What crosses the wire and lands in `platform_entitlement_cache` is the *Entitlement Projection* — a distinct term, defined in [LearnStack's glossary](https://github.com/HodeTech/LearnStack/blob/main/docs/glossary.md). Recomputed atomically whenever a `Plan`, `HubSubscription`, `CompliancePolicy`, or `LicenseKey` changes. Pushed to LearnStack core via `PUT /api/internal/tenants/{id}/entitlements` and mirrored into `platform_entitlement_cache`. The `generation` field is the monotonic version counter that cache invalidation rides on. Lands in P02c-1. **`generation`** -Monotonic version counter on `Entitlement`. Incremented atomically on every recompute. Cache invalidation rides on the inequality `cached.generation >= received.generation` — older generations are rejected to prevent stale-overwrite. Never decrements. +Monotonic version counter on `Entitlement`. Starts at 1, incremented atomically by exactly one on every recompute, never resets and never decrements. The acceptance rule LearnStack applies on a push is stated once, in [entitlement-projection.md § The `generation` counter](architecture/entitlement-projection.md). **`LicenseKey`** -The metadata Hub stores for an RSA-2048-signed `.lic` file issued to a Self-Hosted tenant. Holds the public key hash, the embedded entitlement snapshot at issuance, `valid_from` / `valid_until` / `grace_until`, and revocation state. The signed file itself is delivered to the customer; Hub stores only metadata + the issuance audit trail. Lands in P02c-6. +The metadata Hub stores for an RSA-2048-signed `.lic` file issued to a Self-Hosted tenant. Holds the signing key id (`kid`, which survives rotation), the `entitlement_generation` frozen into the payload at issuance, `issued_at` / `expires_at` / `grace_until`, and `revoked_at` / `revocation_reason`. The column list is authoritative in [P02c-6](roadmap/p02c-6-license-key.md); this entry follows it. The signed file itself is delivered to the customer; Hub stores only metadata + the issuance audit trail. Lands in P02c-6. **`CustomDomain`** The per-tenant custom-domain registration. Tracks the lifecycle state machine — `Pending → Verifying → Active → Failed → Revoked` — plus DNS challenge type (`Dns01` / `Http01`), cert metadata (Vault key, expiry, last renewal), and verification attempt counter. Lands in P02c-5. @@ -37,13 +37,13 @@ Rolled-up monthly tenant usage metric — `tenant_id`, `metric_key`, `period` (` ## Hub infrastructure terms **`learnstack-hub` realm** -The Keycloak realm that authenticates LearnStack operators. Separate from the `learnstack` realm (which authenticates tenant users). MFA (TOTP) is required for every operator account. Tokens from this realm are rejected on tenant-facing endpoints; tokens from the `learnstack` realm are rejected on `/api/internal/*` Hub endpoints. The realm export lives in `../learnstack/infra/keycloak/realms/learnstack-hub.json` because LearnStack core's compose stack imports both realms at first boot. +The Keycloak realm that authenticates LearnStack operators. Separate from the `learnstack` realm (which authenticates tenant users). MFA (TOTP) is required for every operator account. Tokens from this realm are rejected on tenant-facing endpoints; tokens from the `learnstack` realm are rejected on `/api/internal/*` Hub endpoints. The realm export lives in `../LearnStack/infra/keycloak/realms/learnstack-hub.json` because LearnStack core's compose stack imports both realms at first boot. **`LearnStackApiClient`** The Hub-internal typed `HttpClient` wrapper that calls into LearnStack core's `/api/internal/*` endpoints. Carries the mTLS + RS256 JWT + HMAC-SHA256 body-signature chain. The **only** sanctioned outbound path from Hub to LearnStack — no module is allowed to call LearnStack core directly. Lands in P02c-2. **Hub HTTPS Contract Surface** -The **closed four-endpoint** boundary between Hub and LearnStack core: `POST /api/internal/tenants`, `PUT /api/internal/tenants/{id}/entitlements`, `POST /api/v1/internal/license/verify`, `POST /api/v1/usage/report`. Adding a fifth endpoint requires a new ADR in `../learnstack/docs/decisions/`. +The boundary between Hub and LearnStack core, governed by **two invariants** rather than by an endpoint count ([ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md)): the Hub stores no tenant content, and every crossing goes through a named adapter (`IEntitlementProvider` / `IUsageReporter` / `IHubTenantSync`). Every call carries mTLS + RS256 JWT + HMAC body signature. The enumerated endpoint set lives in ADR-0034; adding one still requires an ADR in `../LearnStack/docs/decisions/`, because the surface is a cross-repository contract. (This entry previously claimed a closed set of four — the corpus enumerated six paths while claiming four, and defending the number is what pushed TLS private keys into the entitlement payload.) **Operator portal** The Next.js single-page application (`apps/operator-portal`) deployed at `hub.learnstack.dev`. Tenant list, plan editor, custom-domain admin, license-key issuance UI, operator audit log. Single-brand (no tenant theming); accessed only via the `learnstack-hub` Keycloak realm with MFA. Lands in P02c-4. diff --git a/docs/modules/README.md b/docs/modules/README.md index a4c66c4..2b29230 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -33,9 +33,9 @@ When a module ships, its deep-dive carries: - **MediatR contracts** — commands + queries the module exposes, with idempotency keys where applicable. - **Integration events** — Dapr topic names (`learnstack.hub..*`), payload shape, consumer modules. - **Permission keys** — operator-scope permissions the module declares. -- **Audit-coverage matrix** — MUST / SHOULD / MAY classification per operation (mirrors LearnStack [Standards 18 § Audit Coverage](../../../learnstack/docs/standards/18-audit-coverage.md)). +- **Audit-coverage matrix** — MUST / SHOULD / MAY classification per operation (mirrors LearnStack [Standards 18 § Audit Coverage](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/18-audit-coverage.md)). - **Architecture-test rules** — module-specific NetArchTest assertions. ## Audit-coverage matrices -Until the Hub operator-audit pipeline lands in **P02c-4** (`LearnStack.Hub.Modules.Audit`), each module doc carries its MUST/SHOULD/MAY audit table **inline** (see the "Audit coverage" section in each module's `.md`). When P02c-4 wires the live audit writer, these inline matrices either move to a per-module `audit.md` or are consumed in place — that split is a P02c-4 decision. The matrices follow the same shape as LearnStack core ([Standards 18 § Audit Coverage](../../../learnstack/docs/standards/18-audit-coverage.md)). +Until the Hub operator-audit pipeline lands in **P02c-4** (`LearnStack.Hub.Modules.Audit`), each module doc carries its MUST/SHOULD/MAY audit table **inline** (see the "Audit coverage" section in each module's `.md`). When P02c-4 wires the live audit writer, these inline matrices either move to a per-module `audit.md` or are consumed in place — that split is a P02c-4 decision. The matrices follow the same shape as LearnStack core ([Standards 18 § Audit Coverage](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/18-audit-coverage.md)). diff --git a/docs/modules/entitlements.md b/docs/modules/entitlements.md index 81fe822..70b7117 100644 --- a/docs/modules/entitlements.md +++ b/docs/modules/entitlements.md @@ -4,7 +4,7 @@ The full design lives in [../architecture/entitlement-projection.md](../architecture/entitlement-projection.md) — this file is the module-level summary. Read the architecture doc for the recompute algorithm, the wire-format contract, and the `generation` semantics. -Authoritative sources: [ADR-0021 Feature-Based Entitlement](../../../learnstack/docs/decisions/0021-feature-based-entitlement.md), [Architecture 24 § 4](../../../learnstack/docs/architecture/24-learnstack-hub.md), [ADR-0020](../../../learnstack/docs/decisions/0020-triple-deployment-hybrid-license.md). +Authoritative sources: [ADR-0021 Feature-Based Entitlement](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0021-feature-based-entitlement.md), [Architecture 24 § 4](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md), [ADR-0020](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0020-triple-deployment-hybrid-license.md). ## Aggregate: `Entitlement` @@ -47,7 +47,7 @@ In P02c-1 the service's final step — publishing `learnstack.hub.entitlement` v ## Architecture / contract tests (recommended in P02c-1) -- `EntitlementProjection_Shape_IsStable` ([ADR-0021 § Architecture tests](../../../learnstack/docs/decisions/0021-feature-based-entitlement.md)) — snapshot-test the serialised `EntitlementProjectionDto` JSON against a checked-in `entitlement-v1.schema.json` under `LearnStack.Hub.Tests.Contract`. This is the contract guard; land it here because P02c-1 is where the projection serialiser is born. +- `EntitlementProjection_Shape_IsStable` ([ADR-0021 § Architecture tests](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0021-feature-based-entitlement.md)) — snapshot-test the serialised `EntitlementProjectionDto` JSON against a checked-in `entitlement-v1.schema.json` under `LearnStack.Hub.Tests.Contract`. This is the contract guard; land it here because P02c-1 is where the projection serialiser is born. - Unit test: `generation` strictly increases across successive `Recompute` calls; starts at 1. - Integration test (Testcontainers, lights up the `backend-integration` CI job): create tenant → trial subscription → recompute → assert `Entitlement` row exists with generation 1, correct tier/features/limits; change plan → recompute → assert generation 2 + updated fields. diff --git a/docs/modules/plans.md b/docs/modules/plans.md index 96d1c99..2d8fbb4 100644 --- a/docs/modules/plans.md +++ b/docs/modules/plans.md @@ -2,7 +2,7 @@ `LearnStack.Hub.Modules.Plans` owns the **plan catalogue** — the set of plans operators author, each carrying the feature toggles, numeric limits, and (later) compliance defaults that a subscription projects into a tenant's entitlement. -Authoritative sources: [ADR-0021 Feature-Based Entitlement](../../../learnstack/docs/decisions/0021-feature-based-entitlement.md) (+ Amendment 1), [Architecture 24 § 2 ERD + § 8 plan tiers](../../../learnstack/docs/architecture/24-learnstack-hub.md). +Authoritative sources: [ADR-0021 Feature-Based Entitlement](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0021-feature-based-entitlement.md) (+ Amendment 1), [Architecture 24 § 2 ERD + § 8 plan tiers](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md). ## Aggregate: `Plan` @@ -26,7 +26,7 @@ Strongly-typed id: `PlanId` — `[ValueObject(LearnStackHubVogenDefaults.I ## Feature / Limit key registries -Hub mirrors LearnStack core's typed `FeatureKey` / `LimitKey` value objects ([ADR-0021 Amendment 1](../../../learnstack/docs/decisions/0021-feature-based-entitlement.md)): +Hub mirrors LearnStack core's typed `FeatureKey` / `LimitKey` value objects ([ADR-0021 Amendment 1](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0021-feature-based-entitlement.md)): ```csharp public readonly record struct FeatureKey(string Value); @@ -34,14 +34,14 @@ public readonly record struct LimitKey(string Value); ``` - These live in `LearnStack.Hub.SharedKernel.FeatureFlags` (mirror of `LearnStack.SharedKernel.FeatureFlags`). -- The `FeatureKeys` / `LimitKeys` static registries enumerate the known keys. **Hub is the authoring side** — the plan editor (P02c-4) writes these keys into `Plan.features` / `Plan.limits`. The wire-format strings (snake_case dotted, no `.enabled` suffix) must match LearnStack core's registry exactly so the projection LearnStack consumes lines up. +- The `FeatureKeys` / `LimitKeys` static registries enumerate the known keys. **Hub is the authoring side** — the plan editor writes these keys into `Plan.features` / `Plan.limits`; it ships in [Hub Billing](../roadmap/hub-billing.md), not in P02c-4. The wire-format strings (snake_case dotted, no `.enabled` suffix) must match LearnStack core's registry exactly so the projection LearnStack consumes lines up. - A `Plan` validator checks that every key in `features` / `limits` is a known registry key — an unknown key is a `Result.Fail(validation_failed)`, not a silent accept. (This is the Hub-side analogue of LearnStack's `FeatureKey_AllReferences_AreInRegistry` architecture test; in Hub it's a runtime validator because keys arrive as data, not code references.) -> **Registry sync.** Because the two repos each keep their own copy of the key registries, they can drift. P02c-1 ships the Hub registry seeded from [ADR-0021 Amendment 1](../../../learnstack/docs/decisions/0021-feature-based-entitlement.md) + [Architecture 24 § 4](../../../learnstack/docs/architecture/24-learnstack-hub.md). A future cross-repo reconciliation (or a shared `LearnStack.Contracts` package, Phase 11) is the durable fix. Note this in the Hub roadmap. +> **Registry sync.** Because the two repos each keep their own copy of the key registries, they can drift. P02c-1 ships the Hub registry seeded from [ADR-0021 Amendment 1](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0021-feature-based-entitlement.md) + [Architecture 24 § 4](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md). A future cross-repo reconciliation (or a shared `LearnStack.Contracts` package, Phase 11) is the durable fix. Note this in the Hub roadmap. ## Plan-change → entitlement recompute fan-out -A `Plan` definition change must recompute the entitlement of **every** subscription bound to that plan ([Architecture 24 § 4 Recompute rule](../../../learnstack/docs/architecture/24-learnstack-hub.md)). In P02c-1: +A `Plan` definition change must recompute the entitlement of **every** subscription bound to that plan ([Architecture 24 § 4 Recompute rule](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md)). In P02c-1: - `UpdatePlanCommand` handler, after persisting the plan change, asks the Subscriptions module (via Application.Contracts) for all subscription tenant-ids bound to the plan, then calls the Entitlements projection service per tenant. - For a large fan-out this would be a background job (Hangfire) — but P02c-1 has no real tenant volume; an in-process loop is acceptable, with a TODO noting the Hangfire migration when volume warrants (Phase 09b / 11). @@ -64,7 +64,7 @@ Validators: tier in enum, billing cycle in enum, currency ISO 4217, every featur ## Seed data (P02c-1) -`scripts/seed.sh` (and/or an EF seed) provisions the four illustrative tiers from [Architecture 24 § 8](../../../learnstack/docs/architecture/24-learnstack-hub.md): Starter ($49), Growth ($199), Scale ($799), Enterprise (custom). These give P02c-1's tenant-creation flow a plan to bind to and exercise the projection. Keep them as **data**, not code constants. +`scripts/seed.sh` (and/or an EF seed) provisions the four illustrative tiers from [Architecture 24 § 8](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md): Starter ($49), Growth ($199), Scale ($799), Enterprise (custom). These give P02c-1's tenant-creation flow a plan to bind to and exercise the projection. Keep them as **data**, not code constants. ## Audit coverage (formal matrix lands in P02c-4) @@ -77,6 +77,6 @@ Validators: tier in enum, billing cycle in enum, currency ISO 4217, every featur ## Out of scope for P02c-1 -- The operator plan-editor UI (P02c-4). +- The operator plan-editor UI — deferred out of [P02c-4](../roadmap/p02c-4-operator-portal.md) into [Hub Billing](../roadmap/hub-billing.md) so that a plan's feature payload and its price are edited on one form. Until then plans are authored by P02c-1 seed data and changed through `CreatePlanCommand` / `UpdatePlanCommand` over the API. - `compliance_defaults` content — the column ships empty; CompliancePolicy + caps land in P02c-5. - Hangfire-backed fan-out for large plan-change recomputes (Phase 09b / 11). diff --git a/docs/modules/subscriptions.md b/docs/modules/subscriptions.md index 30c2a9c..700fd70 100644 --- a/docs/modules/subscriptions.md +++ b/docs/modules/subscriptions.md @@ -2,7 +2,7 @@ `LearnStack.Hub.Modules.Subscriptions` owns the **per-tenant binding to a plan** and the subscription lifecycle state machine. It is the second input (alongside `Plan`) to the entitlement projection. -Authoritative sources: [ADR-0019 § Hub data model](../../../learnstack/docs/decisions/0019-learnstack-hub.md), [Architecture 24 § 2 ERD + § 5 plan-upgrade sequence](../../../learnstack/docs/architecture/24-learnstack-hub.md), [ADR-0020 Triple Deployment + Hybrid License](../../../learnstack/docs/decisions/0020-triple-deployment-hybrid-license.md). +Authoritative sources: [ADR-0019 § Hub data model](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0019-learnstack-hub.md), [Architecture 24 § 2 ERD + § 5 plan-upgrade sequence](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md), [ADR-0020 Triple Deployment + Hybrid License](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0020-triple-deployment-hybrid-license.md). ## Aggregate: `HubSubscription` @@ -53,7 +53,7 @@ P02c-1 implements the transitions that don't require a payment provider: ## Relationship to tenant creation -When `CreateTenantCommand` (TenantLifecycle) runs, it creates the initial `HubSubscription` in `Trial` bound to the chosen plan, then triggers the first entitlement recompute (generation = 1). In P02c-1 there's no payment step — the trial is created directly. The Stripe/Iyzico checkout that precedes this in production ([Architecture 24 § 5 provisioning sequence](../../../learnstack/docs/architecture/24-learnstack-hub.md)) is Phase 09b. +When `CreateTenantCommand` (TenantLifecycle) runs, it creates the initial `HubSubscription` in `Trial` bound to the chosen plan, then triggers the first entitlement recompute (generation = 1). In P02c-1 there's no payment step — the trial is created directly. The Stripe/Iyzico checkout that precedes this in production ([Architecture 24 § 5 provisioning sequence](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md)) is Phase 09b. ## Commands + queries (P02c-1) diff --git a/docs/modules/tenant-lifecycle.md b/docs/modules/tenant-lifecycle.md index aa67f82..23e16d2 100644 --- a/docs/modules/tenant-lifecycle.md +++ b/docs/modules/tenant-lifecycle.md @@ -2,7 +2,7 @@ `LearnStack.Hub.Modules.TenantLifecycle` owns the Hub-side mirror of LearnStack's `Tenant` aggregate. It is the root of the Hub domain graph: every subscription, entitlement, custom domain, compliance policy, and usage aggregate hangs off a `LearnStackTenant`. -Authoritative sources: [ADR-0019 § Hub data model](../../../learnstack/docs/decisions/0019-learnstack-hub.md), [Architecture 24 § 2 ERD](../../../learnstack/docs/architecture/24-learnstack-hub.md). +Authoritative sources: [ADR-0019 § Hub data model](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0019-learnstack-hub.md), [Architecture 24 § 2 ERD](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md). ## Aggregate: `LearnStackTenant` @@ -55,7 +55,7 @@ Transition rules enforced as aggregate methods (each returns `Result` and emits - `Activate()` — `Trial | Suspended → Active`. Emits `TenantActivatedDomainEvent`. - `Suspend(reason)` — `Active → Suspended`. Emits `TenantSuspendedDomainEvent`. - `Archive()` — `Active | Suspended → Archived`. Emits `TenantArchivedDomainEvent`. -- `Terminate()` — `Archived → Terminated`. Emits `TenantTerminatedDomainEvent`. (Hard-delete-with-confirmation flow lands in a later packet; P02c-1 only needs the status transition.) +- `Terminate()` — `Archived → Terminated`. Emits `TenantTerminatedDomainEvent`. (The hard-delete-with-confirmation flow lands in [P02c-4](../roadmap/p02c-4-operator-portal.md), which builds the operator surface that confirms it; P02c-1 only needs the status transition.) - `RecordPhoneHome(at)` — sets `last_phone_home_at` (no status change). P02c-6 caller; method shape ships now. Each status change is a **trigger for entitlement recompute** when it affects the projection's `status`/`tier` (Activate, Suspend) — the handler calls the Entitlements projection service after a successful transition. See [../architecture/entitlement-projection.md](../architecture/entitlement-projection.md). @@ -96,5 +96,5 @@ The Hub operator-audit pipeline lands in P02c-4 (`LearnStack.Hub.Modules.Audit`) - The `POST /api/internal/tenants` push to LearnStack core (P02c-3). - Keycloak `learnstack` realm provisioning of the tenant admin (P02c-3 / Phase 03). -- Hard-delete-with-confirmation termination flow (later packet). +- Hard-delete-with-confirmation termination flow — [P02c-4](../roadmap/p02c-4-operator-portal.md). - Phone-home `last_phone_home_at` updates from a live caller (P02c-6). diff --git a/docs/operations/README.md b/docs/operations/README.md index 935543a..dfaba8d 100644 --- a/docs/operations/README.md +++ b/docs/operations/README.md @@ -16,5 +16,5 @@ Hub-specific operational runbooks. Phase 11 fills this directory with the produc ## Out of scope here -- LearnStack core operational runbooks (those live in `../../../learnstack/docs/operations/` once Phase 11 adds them). +- LearnStack core operational runbooks (those live in `../../../LearnStack/docs/operations/` once Phase 11 adds them). - Local-dev setup (see [../../README.md § Dev Workflow](../../README.md#dev-workflow) and [../../infra/compose/README.md](../../infra/compose/README.md)). diff --git a/docs/roadmap/P02c-1-implementation-prompt.md b/docs/roadmap/P02c-1-implementation-prompt.md index 4661541..83effba 100644 --- a/docs/roadmap/P02c-1-implementation-prompt.md +++ b/docs/roadmap/P02c-1-implementation-prompt.md @@ -3,7 +3,7 @@ > **Purpose.** A copy-paste kickoff prompt for the agent that implements Phase 02c > Packet 1 (Hub Domain Core). Hand this whole file to the agent at session start. > -> **The agent runs from `learnstack-hub` root.** Sibling LearnStack core repo is at `../learnstack/`. +> **The agent runs from `learnstack-hub` root.** Sibling LearnStack core repo is at `../LearnStack/`. > > **Use the project's own workflow skills.** This repo carries a Hub-tailored > `.claude/skills/` catalogue (see [`.claude/skills/README.md`](../../.claude/skills/README.md)). @@ -20,9 +20,9 @@ ## 0. Environment (read first — these will bite otherwise) - **.NET 10 SDK is NOT on the default PATH.** The system `dotnet` (`/usr/local/bin/dotnet`) is .NET 9; the project pins `10.0.100` in `backend/global.json`. The real SDK is at `~/.dotnet/dotnet` (10.0.101, rolls forward from the pin). Run `export PATH="$HOME/.dotnet:$PATH"` once at the start of each shell, or prefix every command. -- **Sibling layout is assumed.** Cross-repo references use `../learnstack/...`. If `../learnstack/` is missing, stop and report — you cannot mirror the SharedKernel patterns without it. +- **Sibling layout is assumed.** Cross-repo references use `../LearnStack/...`. If `../LearnStack/` is missing, stop and report — you cannot mirror the SharedKernel patterns without it. - **Pre-commit hook.** `make install` activates `.githooks/pre-commit` (Leakwatch repo-root scan + `dotnet format` + prettier + ESLint). Backend-only commits don't trip the ESLint step. `.claude/*` is gitignored except `.claude/skills/`, so runtime lock files don't corrupt staged content. -- **Branch.** Work on `feat/phase-02c-packet-1-hub-domain-core`. **Do not touch the `../learnstack` repo** — another agent may be active there. P02c-1 is entirely Hub-side; LearnStack-side coordination is P02c-3. +- **Branch.** Work on `feat/phase-02c-packet-1-hub-domain-core`. **Do not touch the `../LearnStack` repo** — another agent may be active there. P02c-1 is entirely Hub-side; LearnStack-side coordination is P02c-3. ## 1. What P02c-1 delivers @@ -50,21 +50,21 @@ The Hub repo currently (commit `784a5ca`) has empty scaffold projects (AssemblyM ### LearnStack-side authority (the cross-cutting decisions — do not contradict) -6. `../learnstack/docs/decisions/0019-learnstack-hub.md` — Hub data model, boundary. -7. `../learnstack/docs/decisions/0021-feature-based-entitlement.md` (incl. Amendment 1) — `FeatureKey`/`LimitKey`, projection shape. -8. `../learnstack/docs/decisions/0020-triple-deployment-hybrid-license.md` — `DeploymentMode`, entitlement consumption. -9. `../learnstack/docs/decisions/0023-strongly-typed-id-source-generator.md` — Vogen pattern, `IdMask`. -10. `../learnstack/docs/decisions/0032-exception-handling-logging-and-observability.md` — the cross-cutting architecture you mirror. -11. `../learnstack/docs/architecture/24-learnstack-hub.md` — the full Hub ERD + projection + module list. -12. `../learnstack/docs/standards/02-backend-coding.md` + `05-database.md` — coding + DB conventions. **Ignore Standards 05's RLS / tenant-isolation rows** — they don't apply to Hub. +6. `../LearnStack/docs/decisions/0019-learnstack-hub.md` — Hub data model, boundary. +7. `../LearnStack/docs/decisions/0021-feature-based-entitlement.md` (incl. Amendment 1) — `FeatureKey`/`LimitKey`, projection shape. +8. `../LearnStack/docs/decisions/0020-triple-deployment-hybrid-license.md` — `DeploymentMode`, entitlement consumption. +9. `../LearnStack/docs/decisions/0023-strongly-typed-id-source-generator.md` — Vogen pattern, `IdMask`. +10. `../LearnStack/docs/decisions/0032-exception-handling-logging-and-observability.md` — the cross-cutting architecture you mirror. +11. `../LearnStack/docs/architecture/24-learnstack-hub.md` — the full Hub ERD + projection + module list. +12. `../LearnStack/docs/standards/02-backend-coding.md` + `05-database.md` — coding + DB conventions. **Ignore Standards 05's RLS / tenant-isolation rows** — they don't apply to Hub. ### LearnStack-side CODE to mirror verbatim -13. `../learnstack/backend/src/LearnStack.SharedKernel/` — the **canonical source** for every type to reproduce in `backend/src/Core/LearnStack.Hub.SharedKernel/` with the `LearnStack.Hub.SharedKernel.*` namespace and the `OperatorId`-for-`UserId` substitution. -14. `../learnstack/backend/src/LearnStack.Api/Common/` — `LearnStackExceptionHandler`, `ResultExtensions`, `ProblemDetailsFactory`, `HttpStatusMap`. Mirror as `HubExceptionHandler` etc. -15. `../learnstack/backend/src/LearnStack.Application/Pipeline/` — the MediatR behaviors. Mirror the 6 Hub keeps (drop `TenantContextBehavior`). -16. `../learnstack/backend/src/Modules//` — a representative four-project layout, aggregate shape, DbContext, handler. -17. `../learnstack/backend/Directory.Packages.props` — current package versions. +13. `../LearnStack/backend/src/LearnStack.SharedKernel/` — the **canonical source** for every type to reproduce in `backend/src/Core/LearnStack.Hub.SharedKernel/` with the `LearnStack.Hub.SharedKernel.*` namespace and the `OperatorId`-for-`UserId` substitution. +14. `../LearnStack/backend/src/LearnStack.Api/Common/` — `LearnStackExceptionHandler`, `ResultExtensions`, `ProblemDetailsFactory`, `HttpStatusMap`. Mirror as `HubExceptionHandler` etc. +15. `../LearnStack/backend/src/LearnStack.Application/Pipeline/` — the MediatR behaviors. Mirror the 6 Hub keeps (drop `TenantContextBehavior`). +16. `../LearnStack/backend/src/Modules//` — a representative four-project layout, aggregate shape, DbContext, handler. +17. `../LearnStack/backend/Directory.Packages.props` — current package versions. ## 3. dotnet tooling for migrations @@ -94,7 +94,7 @@ Build bottom-up so each layer compiles before the next depends on it. Commit at ### Step A — Hub SharedKernel → [`wire-cross-cutting-foundation`](../../.claude/skills/wire-cross-cutting-foundation/SKILL.md) § Step 1. -Mirror `../learnstack/backend/src/LearnStack.SharedKernel/` folder-for-folder with namespace `LearnStack.Hub.SharedKernel.*` and **two substitutions**: `OperatorId` for `UserId`; `HubException` for `LearnStackException`. Add `FeatureFlags/` with `FeatureKey`/`LimitKey` value objects + initial registries seeded from ADR-0021 Amendment 1 + Architecture 24 § 4. Unit-test the load-bearing types (Result, LocalizedMessage prefix invariant, Entity equality, FixedClock, AuditableEntity audit columns). +Mirror `../LearnStack/backend/src/LearnStack.SharedKernel/` folder-for-folder with namespace `LearnStack.Hub.SharedKernel.*` and **two substitutions**: `OperatorId` for `UserId`; `HubException` for `LearnStackException`. Add `FeatureFlags/` with `FeatureKey`/`LimitKey` value objects + initial registries seeded from ADR-0021 Amendment 1 + Architecture 24 § 4. Unit-test the load-bearing types (Result, LocalizedMessage prefix invariant, Entity equality, FixedClock, AuditableEntity audit columns). ### Step B — Cross-cutting foundation @@ -134,7 +134,7 @@ Every skill repeats these; they are listed here as the kickoff checklist. - **FeatureKey/LimitKey wire-strings match LearnStack core's registry exactly.** - **`hub` schema, `learnstack_hub` database.** Every DbContext `HasDefaultSchema("hub")`. - **One DbContext per module.** Cross-module FKs are plain `uuid` + index, not EF navigations. -- **English docs; Conventional Commits; AI co-author trailer** (`Co-Authored-By: Claude Opus 4.7 (1M context) `). +- **English docs; Conventional Commits; AI co-author trailer** (`Co-Authored-By: Claude Opus 5 (1M context) `). ## 6. Verification @@ -179,14 +179,14 @@ Each commit must independently build + pass the non-integration tests. Push the ## 9. Out of scope (do NOT build — later packets) -- The four HTTPS contract endpoints + outbound `LearnStackApiClient` + mTLS/JWT/HMAC chain → **P02c-2**. +- The Hub HTTPS contract endpoints + outbound `LearnStackApiClient` + mTLS/JWT/HMAC chain → **P02c-2**. - `learnstack.hub.entitlement` Dapr publish + real `IOutbox` → **P02c-2** (ship the OutboxFlush shell only). - `Usage` module + `POST /api/v1/usage/report` → **P02c-2**. - Operator portal UI, Operators module, Audit module + live audit writer, `AuthorizationBehavior` real logic → **P02c-4**. - `CustomDomains` + `Compliance` modules (the projection's `compliance_caps` stays empty `{}`) → **P02c-5**. - `LicenseKeys` + `.lic` + `grace_until` → **P02c-6**. - Stripe / Iyzico / Invoicing / dunning (`payment_provider` stays null, `MarkPastDue`/`Cure` stay shells) → **Phase 09b**. -- Any change to the `../learnstack` repo → **P02c-3** (a separate, coordinated packet). +- Any change to the `../LearnStack` repo → **P02c-3** (a separate, coordinated packet). ## 10. Definition of done diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 9ee5e26..7a4e36a 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -1,42 +1,58 @@ -# Hub Roadmap — Phase 02c Packet Status - -The authoritative phase plan lives in [LearnStack core's `phase-02c-hub-foundation.md`](../../../learnstack/docs/roadmap/phase-02c-hub-foundation.md). This file is a **status mirror** — it tracks per-packet state inside the Hub repo. - -## Packet status - -| Packet | Title | State | PR | -| ---------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------- | ---------------- | -| **P02c-0** | Repository bootstrap | ✅ Shipped | (initial commit) | -| **P02c-1** | Hub Domain Core (`LearnStackTenant`, `Plan`, `HubSubscription`, `Entitlement`) | ✅ Shipped | this branch | -| **P02c-2** | Hub-side Internal API + Outbound `LearnStackApiClient` | ⏳ Next | — | -| **P02c-3** | LearnStack core PR (`HubEntitlementProvider`, `IUsageReporter`, internal-API handlers) — **blocked on LearnStack P02a-5/6/7/9** | ⏳ | — | -| **P02c-4** | Operator Portal MVP | ⏳ | — | -| **P02c-5** | Custom Domain Lifecycle | ⏳ | — | -| **P02c-6** | License Key (functional skeleton) | ⏳ | — | -| **P02c-7** | End-to-End Exit Gate | ⏳ | — | - -## P02c-0 deliverables (this commit) - -- ✅ Sibling `learnstack-hub` repo at `/Users/dev/Documents/Projects/learnstack-hub` (`git init`) -- ✅ Top-level config files (`.gitignore`, `.editorconfig`, `.gitattributes`, `.env.example`, `.leakwatch.yaml`, `.leakwatchignore`) -- ✅ Top-level docs (`README.md`, `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`) -- ✅ Makefile + `.githooks/pre-commit` (chmod +x; activated via `make hooks`) -- ✅ Backend solution (`LearnStack.Hub.slnx`) + 7 core projects + 4 test projects -- ✅ Frontend pnpm monorepo (`apps/operator-portal` + `packages/{config,sdk,ui}`) -- ✅ Infra: `compose/dev.yml` + `compose/e2e.yml` + APISIX config + Dapr components + Postgres init + Keycloak README -- ✅ `scripts/seed.sh` (orchestrator only; real seed data in P02c-1+) -- ✅ `.github/workflows/ci.yml` + CODEOWNERS + PR template -- ✅ `docs/` scaffolding (architecture pointers, decisions template, operations placeholder, modules placeholder, glossary) -- ✅ Architecture test placeholders (meta-test + `No_Source_Folder_Named_Verticals` + `Hub_NeverStores_TenantData` placeholder) - -## P02c-1 deliverables (this branch) - -- ✅ Hub SharedKernel (mirror of LearnStack P02a-2): `Result`/`Error`, `LocalizedMessage`, `Entity`/`AuditableEntity` (audit columns on `OperatorId`), Vogen ids (`OperatorId`, `LearnStackTenantId`), `IClock`/`IGuidFactory`/`IRandom`, pagination, `HubException` hierarchy, secrets, observability (`CapturedContext` operator-scoped), resilience, `DeploymentMode`, `FeatureFlags` registries, `IUnitOfWork` -- ✅ Cross-cutting foundation: `HubExceptionHandler` + Problem Details, the **6-step** MediatR pipeline (no `TenantContextBehavior`; live `TransactionBehavior`), Serilog + OpenTelemetry, `IErrorTrackingProvider` (NoOp / Sentry shell / LocalFile) branched by `DeploymentMode`, Polly `IProviderResilience` -- ✅ Four modules — `TenantLifecycle` / `Plans` / `Subscriptions` / `Entitlements` — each with aggregate + state machine + commands/queries + validators + DbContext + EF config + repository + migration (`hub` schema, `learnstack_hub` db, per-module history table, no RLS) -- ✅ `EntitlementProjectionService` rebuilds the projection from `Plan` + `HubSubscription` with monotonic `generation`; `learnstack.hub.entitlement` Dapr publish stays a no-op shell (P02c-2) -- ✅ Tests: architecture (boundary, per-module dependency, aggregate-id, pipeline-order, meta) + unit (aggregate state machines + generation monotonicity) + contract (`EntitlementProjection_Shape_IsStable` vs `entitlement-v1.schema.json`) + integration (Testcontainers full flow); `backend-integration` CI job activated -- ✅ Seed: 4 plan tiers + demo tenant via `dotnet run -- --seed` / `make seed` (idempotent) +# Hub Roadmap + +LearnStack Hub is the **control plane** for LearnStack: tenant lifecycle, plan catalogue, subscriptions, the entitlement projection, custom-domain administration, licence issuance, and the operator portal. It holds tenant _metadata_ and never tenant _content_. + +**This directory is the authoritative plan for Hub work.** LearnStack's [Phase 02c](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-02c-hub-foundation.md) covers only LearnStack's side of the boundary — the `HubEntitlementProvider` / `IUsageReporter` / `IHubTenantSync` adapters and the `/api/internal/*` handlers — and points here for everything Hub-side. Cross-cutting decisions (ADRs, engineering standards) remain LearnStack's; see [Where cross-cutting authority lives](#where-cross-cutting-authority-lives). + +## How this plan is governed + +- **Hub is a demand-gated track, not a prerequisite.** LearnStack runs on `NullEntitlementProvider` and resolves hosts from `platform_host_to_tenant` until a tenant must be billed or plan-gated. That sentence is the literal trigger condition [ADR-0035](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0035-demand-gated-infrastructure.md) records for the Hub entitlement adapter. It is the **first** of two conditions that resume this plan; the second is that the contract surface is built against [ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md)'s two invariants rather than an endpoint count. Both are stated in [CLAUDE.md](../../CLAUDE.md) and in [The freeze](#the-freeze). +- **The `P02c-N` identifiers are stable and load-bearing.** They appear in branch names, commit subjects, skill bodies, design specs, CI comments, and in LearnStack's roadmap. They are not renumbered, and `P02c-3` in this repo means the same packet as `P02c-3` in LearnStack's. +- **Every packet doc carries the same six sections** — `## Goal`, `## Scope`, `## Deliverables`, `## Completion Criteria`, `## Risks`, `## Phase Exit Decision` — matching LearnStack's roadmap convention. +- **No owners, no effort estimates, no timeboxes.** This is a dependency and scope plan. Sequencing decisions belong here; capacity decisions do not. + +## Packets + +| Packet | Title | State | +| ---------------------------------------------------------- | -------------------------------------------------------- | --------------------------------- | +| [**P02c-0**](p02c-0-repository-bootstrap.md) | Repository bootstrap | ✅ Shipped | +| [**P02c-1**](p02c-1-hub-domain-core.md) | Hub domain core + Hub cross-cutting foundation | ✅ Shipped — reconciliations owed | +| [**P02c-2**](p02c-2-internal-api-and-contract.md) | Internal API handlers + outbound `LearnStackApiClient` | ⏸ Frozen — [ADR-0035 trigger](#the-freeze) | +| [**P02c-3**](p02c-3-learnstack-integration.md) | LearnStack integration (cross-repo, two coordinated PRs) | ⏸ Frozen — also [blocked](#hub-waits-on-learnstack) | +| [**P02c-4**](p02c-4-operator-portal.md) | Operator portal MVP + Operators + Audit modules | ⏸ Frozen | +| [**P02c-5**](p02c-5-custom-domain-lifecycle.md) | Custom-domain lifecycle + Compliance module | ⏸ Frozen | +| [**P02c-6**](p02c-6-license-key.md) | Licence key (functional skeleton) | ⏸ Frozen | +| [**P02c-7**](p02c-7-exit-gate.md) | End-to-end exit gate | ⏸ Frozen | + +### The freeze + +P02c-1 is **merged**. Everything after it is frozen by owner decision (2026-08-08), on the +[ADR-0035](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0035-demand-gated-infrastructure.md) +trigger for `IEntitlementProvider`: **a tenant must be billed or plan-gated**. Until then +LearnStack runs on `NullEntitlementProvider` and consumes nothing from the Hub, so building +P02c-2's contract surface would be building against a boundary with no caller. + +P02c-1 merged rather than waiting because its domain code conflicts with none of the three +decisions that moved: the entitlement wire shape already carries `grace_until` and +`generation`, no endpoint is hosted, and `AuditLogBehavior` is a shell that writes nothing. +Freezing the artefact would have cost the SharedKernel reconciliation against LearnStack +Packet 3b, which grows with every packet built on top. + +### Reconciliations owed on the merged P02c-1 + +Tracked here rather than in the packet document, because they are follow-ups on shipped +code and P02c-1's own record is closed: + +- **Audit seam (ADR-0033).** `AuditLogBehavior` is step 3 and wraps `TransactionBehavior`; + its success-path TODO writes after commit. ADR-0033 puts the MUST-class write on the + ambient transaction immediately before `COMMIT`. Nothing misbehaves today — the behavior + writes nothing — but the TODO specifies the superseded shape. Lands with the Audit + module in P02c-4. +- **SharedKernel vs LearnStack Packet 3b.** The mirrored kernel inherited all three + defects Packet 3b exists to repair: `Results.Unit` colliding with `MediatR.Unit`, missing + `[MemberNotNullWhen]` on `Result`, and `Entity` boxing on every equality check. + Unlike LearnStack, the Hub already has four modules of consumers, so this grows with + each packet. ### Deferred from P02c-1 (tracked follow-ups) @@ -47,13 +63,131 @@ The authoritative phase plan lives in [LearnStack core's `phase-02c-hub-foundati ## Dependency on LearnStack core packets -Phase 02c P02c-3 is **blocked** until the following LearnStack core packets ship: +Execution artifacts live alongside the packet docs: [`P02c-1-implementation-prompt.md`](P02c-1-implementation-prompt.md) is the kickoff prompt the P02c-1 agent ran against. + +## Post-MVP tracks + +These are Hub-owned phases that sit outside the P02c series. LearnStack's [Phase 09b](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-09b-hub-billing.md) and [Phase 12](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-12-hub-marketplace.md) are pointers at them. + +| Track | What it covers | Trigger | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------- | +| [Hub Billing](hub-billing.md) | Stripe / Iyzico adapters, `Invoicing` module, `WebhookLedger`, dunning, proration, the storefront flow | Commercial billing is needed | +| [Hub Marketplace](hub-marketplace.md) | Shared customization packages, listing, install — free-only; paid listings and revenue splits are out of the roadmap | Demonstrated cross-tenant duplication **and** an accepted ADR resolving the ADR-0034 collision | +| Hub Operations (unscheduled) | Hub production deployment, HA topology, backup and restore drill, release process, and the operational runbooks the [P02c-7](p02c-7-exit-gate.md) gate names | A Hub instance serves a paying tenant | + +Hub Operations has no plan document yet. It is listed so the work has a named owner in this repository rather than being handed to LearnStack Phase 11, which scopes itself to LearnStack ([Phase 11 § Release Engineering](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-11-production-hardening.md)). + +## Intra-Hub dependency map + +```mermaid +flowchart TB + p0[P02c-0
Repository bootstrap] + p1[P02c-1
Domain core + foundation] + p2[P02c-2
Internal API + client] + p3[P02c-3
LearnStack integration] + p4[P02c-4
Operator portal] + p5[P02c-5
Custom domains + compliance] + p6[P02c-6
Licence key] + p7[P02c-7
Exit gate] + + p0 --> p1 + p1 -- "aggregates + projection serialiser" --> p2 + p1 -- "AuditLog + Authorization shells promoted" --> p4 + p1 -- "projection fields to extend" --> p5 + p1 -- "expires_at / grace_until owner" --> p6 + p2 -- "contract shape the LearnStack PR mirrors" --> p3 + p2 -- "OpenAPI + generated SDK" --> p4 + p2 -- "outbound client for host-mappings push" --> p5 + p2 -- "verify endpoint the .lic path calls" --> p6 + p3 --> p7 + p4 --> p7 + p5 --> p7 + p6 --> p7 +``` + +**Reading the map**, for renderers without Mermaid: + +- **P02c-0 → P02c-1.** The bootstrap ships empty projects; P02c-1 fills them. +- **P02c-1 → P02c-2.** The internal API serves the `Entitlement` projection and the `Plan` / `HubSubscription` aggregates P02c-1 creates. P02c-2 also promotes P02c-1's `OutboxFlushBehavior` shell to a live `IOutbox` flush — until then there is nothing to publish. +- **P02c-1 → P02c-4.** The operator portal reads the four aggregates, and P02c-4 promotes the other two P02c-1 pipeline shells: `AuditLogBehavior` gains the operator-audit writer and `AuthorizationBehavior` gains the operator permission check. Both are registration-order-only in P02c-1. +- **P02c-1 → P02c-5 / P02c-6.** `CompliancePolicy` fills the projection's `compliance_caps`, which P02c-1 ships as an empty JSONB shape; licence issuance fills `expires_at` / `grace_until`, which P02c-1 leaves null. +- **P02c-2 → P02c-3.** The Hub PR carries the canonical contract shape; the LearnStack PR is written against it. +- **P02c-2 → P02c-4.** The portal consumes the generated SDK produced from P02c-2's OpenAPI document. +- **P02c-2 → P02c-5.** The host-mapping push uses the outbound `LearnStackApiClient`. P02c-2 builds the client with no caller for that path; P02c-5 is the caller. +- **P02c-3 … P02c-6 → P02c-7.** The exit gate rehearses the full SaaS and Self-Hosted scenarios end to end, so it gates on every preceding packet. + +## Cross-repo blocking + +Both repositories block each other in places. Neither table is a wish list — every row names the artefact that unblocks it. + +### Hub waits on LearnStack + +| Hub packet | LearnStack packet | What it provides | +| ---------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| P02c-3 | [P02a-5 Foundation ports](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-02a-kernel-tenancy.md) | `IEventBus` / `ICacheService` / `ISecretProvider` and their default implementations, which the LearnStack-side handlers use | +| P02c-3 | [P02a-6 Tenancy schema](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-02a-kernel-tenancy.md) | `platform_entitlement_cache`, `platform_host_to_tenant` and `outbox_messages` tables | +| P02c-3 | [P02a-7 Resolution + isolation](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-02a-kernel-tenancy.md) | `IHostToTenantResolver`, `TenantResolverMiddleware`, and the `HubCorrelationMiddleware` seam that populates `ITenantContext` on `/api/internal/*` | +| P02c-3 | [P02a-9 Audit + entitlement socket](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-02a-kernel-tenancy.md) | The `IEntitlementProvider` socket with `NullEntitlementProvider` as its only implementation | +| P02c-3 | [Phase 02b Identity Integration + Events](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-02b-events-auth.md) | The `OutboxProcessor` and its claim protocol, `IInboxGuard` and the per-module `inbox_messages` tables, and handler-scope tenant-context restoration. `IUsageReporter` dispatches through the outbox rather than inline, and the `learnstack.hub.entitlement` invalidation consumer is an ordinary `IIntegrationEventHandler` behind the same inbox guard | +| P02c-6 | [P02a-9 Audit + entitlement socket](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-02a-kernel-tenancy.md) | The `IEntitlementProvider` socket the LearnStack-side `SignedLicenseKeyEntitlementProvider` skeleton plugs into, in a coordinated pull request | +| P02c-5 | [LearnStack Phase 02c](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-02c-hub-foundation.md) | The LearnStack-side `host-mappings` handler and its `platform_host_to_tenant` mirroring — the paired half of this packet, merged in the same session | +| P02c-5 | [LearnStack Phase 11](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-11-production-hardening.md) | The LearnStack **edge** half only: certificate installation at the gateway, demand-gated per [ADR-0035](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0035-demand-gated-infrastructure.md). P02c-5 does **not** wait on it — host resolution works from the `platform_host_to_tenant` row alone | + +P02c-0 and P02c-1 have shipped. Of what remains, **P02c-2 and P02c-4 are unblocked by LearnStack** — they touch no LearnStack code and can proceed as soon as the Hub track resumes. P02c-3, P02c-5 and P02c-6 each land as two coordinated pull requests; see the tables above and [Coordination protocol](#coordination-protocol). P02c-3 is the only packet gated on the LearnStack spine reaching **Phase 02b**, not merely Phase 02a. + +### LearnStack waits on Hub + +| LearnStack artefact | Hub packet | Why | +| ----------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `entitlement-v1.schema.json` + its snapshot test | P02c-1 | The schema is born with the Hub projection serialiser. Both repositories check in the same file and assert it independently. | +| `HubEntitlementProvider` (Phase 02c) | P02c-2 | The last hop of the entitlement read path in [ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) is `POST /api/v1/internal/license/verify`, which is a Hub handler. | +| `IUsageReporter` (Phase 02c) | P02c-2 | Needs `POST /api/v1/usage/report` live, with its idempotency semantics fixed Hub-side. | +| LearnStack `/api/internal/*` handlers (Phase 02c) | P02c-2 | The Hub PR carries the canonical request / response shapes and the mTLS + JWT + HMAC chain the handlers validate against. | +| `NullEntitlementProvider_NotRegistered_OutsideDevelopment` | P02c-3 | The rule is vacuous until a second `IEntitlementProvider` implementation exists. | +| `platform_host_to_tenant` mirror handler (Phase 02c) | P02c-5 | The Hub is the certificate issuer and authors the `PUT /api/internal/tenants/{id}/host-mappings` payload shape. | +| `SignedLicenseKeyEntitlementProvider` skeleton | P02c-6 (coordinated LearnStack PR) | Needs the `.lic` format, the claim set, and the `kid`-addressed public key set the Self-Hosted instance ships with. Hardened later in [LearnStack Phase 11](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-11-production-hardening.md). | + +### Coordination protocol + +A packet that changes both repositories lands as two pull requests in one session: the Hub PR opens first and carries the canonical contract shape, the LearnStack PR references the Hub PR's commit hash, and both merge together. A one-sided merge leaves the contract dangling. [P02c-3](p02c-3-learnstack-integration.md) is the packet where this matters most and states the protocol in full. + +## Status ledger + +**Updated 2026-08-08.** This ledger tracks the state of _artefacts_, not commits. A row changes when the artefact changes state, not when a commit touches it — the previous commit-bound version of this file went stale four commits after it was written. + +| Artefact | State | Where | +| ------------------------------------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Repository bootstrap | Shipped on `main` | [p02c-0-repository-bootstrap.md](p02c-0-repository-bootstrap.md) | +| Hub agent skill catalogue (18 skills) | Shipped on `main` | [`.claude/skills/README.md`](../../.claude/skills/README.md) | +| Hub architecture design specs (3) | Shipped on `main` | [module-topology](../architecture/module-topology.md), [cross-cutting-foundation](../architecture/cross-cutting-foundation.md), [entitlement-projection](../architecture/entitlement-projection.md) | +| Hub module design specs (4) | Shipped on `main` | [tenant-lifecycle](../modules/tenant-lifecycle.md), [plans](../modules/plans.md), [subscriptions](../modules/subscriptions.md), [entitlements](../modules/entitlements.md) | +| P02c-1 implementation prompt | Shipped on `main` | [P02c-1-implementation-prompt.md](P02c-1-implementation-prompt.md) | +| P02c-1 implementation | **Shipped on `main`** | Merged 2026-08-09, ~222 files. Two reconciliations owed on the merged code — the SharedKernel against LearnStack Packet 3b, and the audit seam against ADR-0033 — both listed in [p02c-1-hub-domain-core.md](p02c-1-hub-domain-core.md). | +| Hub roadmap (this directory) | Authoritative | This file plus the eight packet docs | +| P02c-2 and everything after it | **Frozen**, not started | Owner decision 2026-08-08. Resumes on the ADR-0035 trigger — see [The freeze](#the-freeze). | + +Known reconciliations still open, each named in its owning packet doc so it cannot be lost: + +- ~~The `backend-integration` CI job's owning packet.~~ **Closed 2026-08-09.** P02c-1 shipped the first Testcontainers-backed tests and activated the job with them, which is the correct assignment; the three files on `main` that said P02c-2 were written when P02c-1 was expected to ship without integration tests, and are corrected. +- Operator-portal naming: **closed 2026-08-08.** LearnStack's `docs/` corpus now says `operator-portal` everywhere. Three residues remain outside it and are tracked rather than open: `frontend/README.md` and `frontend/apps/web/README.md` still say `learnstack-hub-web`, and [ADR-0015](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0015-api-gateway-apisix.md) § Frontend apps says `apps/hub-web` — a third spelling, corrected by the next PR that touches that ADR. LearnStack's frozen [Phase 01](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-01-repository-tooling.md) record keeps the old name deliberately, annotated in place. The Keycloak client id `learnstack-hub-web` is an OIDC identifier, not an app name, and does not change. +- Feature-key / limit-key registry drift between the two repositories, which each keep their own copy. Recorded in [plans.md § Registry sync](../modules/plans.md). + +## Where cross-cutting authority lives + +Hub does not maintain its own standards corpus and does not restate LearnStack's decisions. Each row below is a single source of truth; if anything in this repository contradicts one, this repository is what gets fixed. -| LearnStack packet | What it provides | Status | -| --------------------------- | ------------------------------------------------------------------------------------------------------- | ------ | -| P02a-5 (Dapr + APISIX) | `IEventBus` / `ICacheService` / `ISecretProvider` interfaces + APISIX `/api/internal/*` SSL-object stub | ⏳ | -| P02a-6 (Tenancy schema) | `platform_entitlement_cache` + `platform_host_to_tenant` tables | ⏳ | -| P02a-7 (Tenant resolution) | `IHostToTenantResolver` + `TenantResolverMiddleware` + `HubCorrelationMiddleware` stub | ⏳ | -| P02a-9 (Entitlement socket) | `IEntitlementProvider` interface + `NullEntitlementProvider` default | ⏳ | +| Topic | Authority | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| The Hub boundary, separate repository, internal API | [ADR-0019](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0019-learnstack-hub.md) | +| Contract-surface invariants + the real endpoint set | [ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) | +| What ships now versus on demand, and the triggers | [ADR-0035](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0035-demand-gated-infrastructure.md) | +| Deployment modes + hybrid licence | [ADR-0020](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0020-triple-deployment-hybrid-license.md) | +| Entitlement projection shape | [ADR-0021](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0021-feature-based-entitlement.md) | +| Custom domain + TLS | [ADR-0022](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0022-custom-domain-tls.md) | +| Two-realm Keycloak boundary | [ADR-0004](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0004-authentication-strategy.md) | +| Exception handling, logging, observability | [ADR-0032](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0032-exception-handling-logging-and-observability.md) | +| Engineering standards | [Standards corpus](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/README.md) | +| Architecture-test identifiers | [Standards 21](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/21-architecture-tests-catalogue.md) | +| Hub-internal decisions only | [`docs/decisions/`](../decisions/README.md) — the `HUB-NNNN` series | -P02c-0, P02c-1, P02c-2, P02c-4 are **unblocked** — they don't depend on LearnStack P02a packets and can ship in parallel. +> **One correction worth stating explicitly.** [ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) replaces the "the Hub contract surface is closed at four endpoints" rule with two invariants: the Hub stores no tenant content, and every LearnStack↔Hub crossing goes through a named adapter. Adding an endpoint still requires an ADR. Pointer text elsewhere in this repository that still says "four endpoints" predates that ADR and is corrected as those files are touched. diff --git a/docs/roadmap/hub-billing.md b/docs/roadmap/hub-billing.md new file mode 100644 index 0000000..3a11d60 --- /dev/null +++ b/docs/roadmap/hub-billing.md @@ -0,0 +1,260 @@ +# Hub Billing and Invoicing + +> **This is the authoritative plan.** It was migrated out of LearnStack's +> `phase-09b-hub-billing.md`, which is now a pointer at this file. Hub billing is Hub work +> and belongs in the repository that ships it. +> +> Identifier note: the track keeps the **09b** slot in LearnStack's phase numbering +> because that identifier appears in commit messages, branch names and cross-repository +> references. The plan lives here; the slot stays there. + +## Goal + +Charge tenants for LearnStack. + +[P02c-1](p02c-1-hub-domain-core.md) shipped `Plan` and `HubSubscription` as +**provisioning** primitives — enough to bind a tenant to a feature set and project an +entitlement, not enough to take money. A subscription there has a period and a status; it +has no invoice, no payment method, no dunning, and no consequence when a card declines. +This track fills in the commercial half: a ledger, metered usage, payment adapters, and a +grace-and-degradation path that turns non-payment into a graded response rather than an +outage. + +It runs in parallel with +[LearnStack Phase 09](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-09-billing-integrations-analytics.md), +which builds a completely different billing system. The two never overlap: + +| Concern | Owner | +|---|---| +| A tenant sells courses to learners | **LearnStack Phase 09** — the core `Billing` module, storefront billing | +| LearnStack charges a tenant for their LearnStack subscription | **This track** — Hub-side platform billing | + +The trigger that makes this track urgent is simple and worth stating: **a tenant must be +invoiced**. Until then, subscriptions stay in `Trial` and `Active` as provisioning states +and nothing here is missing. + +## Scope + +### Aggregates + +- **`HubInvoice` / `HubInvoiceLine`** — the per-tenant invoice ledger. An issued invoice + is **immutable**: a correction is a credit note plus a new invoice, never an edit. A + ledger you can edit is not a ledger, and seven-year retention on a mutable record is + retention of whatever the last writer decided. +- **`UsageAggregate`** — usage rolled up by `(tenant_id, month, metric)`. Metrics: + concurrent classroom sessions, total classroom minutes, storage GB, media bandwidth GB, + learner count, custom-domain count. +- **`DunningPolicy`** — per-plan escalation rules for missed payment: retry schedule, + grace window, notification cadence, terminal action. +- **`PaymentProviderAccount`** — the vendor's own Stripe / Iyzico / wire-only + configuration. Not a tenant's payment configuration; that is LearnStack Phase 09's + concern. +- **`WebhookLedger`** — one row per received provider event, unique on + `(provider, provider_event_id)`. This is the idempotency mechanism, and bypassing it is + a hard rule in [CLAUDE.md](../../CLAUDE.md). + +### `HubSubscription` billing-state extension + +The subscription gains `billing_state`, `dunning_state` and `grace_until`. + +It does **not** gain `current_period_start`, `current_period_end` or +`cancel_at_period_end` — those already ship in P02c-1 +([subscriptions.md](../modules/subscriptions.md)) because the entitlement projection needs +`current_period_end` to populate `expires_at`. The migrated LearnStack text listed all six +as new; three of them are already there. + +`payment_provider` and `provider_subscription_id` ship nullable and empty in P02c-1 and are +populated here. + +The `MarkPastDue()` / `Cure()` transitions, which P02c-1 ships as method shells with no +live caller, get their driver here. + +### Money representation + +Pinned once, in one place, because getting it wrong is expensive and silent: + +- Amounts are stored as **integer minor units** with an explicit ISO 4217 currency. No + floating-point type touches a monetary value at any layer, including JSON payloads. +- An invoice records the currency it was issued in and is **never** re-denominated. FX + conversion, if it is ever needed, produces a new document, not a recalculated old one. +- The rounding rule is declared once and applied at line level before summation, so an + invoice's total always equals the sum of its printed lines. + +### Usage ingestion and aggregation + +- `POST /api/v1/usage/report` — already in + [ADR-0034's endpoint set](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) + and already handled since [P02c-2](p02c-2-internal-api-and-contract.md) — produces the + raw stream from LearnStack's `IUsageReporter`. +- A Hangfire job rolls raw reports into `UsageAggregate` daily. +- Reports carry an idempotency key. A replayed report **must not double-count**: the + ingestion path is idempotent on that key, and the daily rollup is recomputed from raw + rows rather than incremented in place, so a replay corrects itself rather than + compounding. +- Late-arriving reports for a closed period are recorded against the period they belong to + and surfaced as an adjustment, not silently dropped or silently folded into the current + month. +- Soft-limit alerts are **produced by LearnStack**, not by the Hub: the gated call site + compares current usage against the limit it already holds from the entitlement + projection and emits `usage.alert.soft_limit_reached` over + `POST /api/v1/usage/report` + ([LearnStack Architecture 21 § Soft vs Hard Limits](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/21-feature-flags.md)). + [P02c-2](p02c-2-internal-api-and-contract.md) ingests that stream. **This track adds + the Hub half**: distinguishing an alert row from an ordinary usage row, retaining it, + surfacing it in the operator portal, and optionally notifying the tenant admin. + +### Billing lifecycle + +- A tenant created through [P02c-1](p02c-1-hub-domain-core.md)'s flow starts at + `billing_state = trial`. +- Trial → active generates the first invoice. +- Period end closes the period, generates a `HubInvoice`, and emits + `learnstack.hub.invoice.generated`. +- A failed payment enters `dunning_state = grace` for the policy's window (default 14 + days). **During grace the entitlement projection is unchanged** — the tenant keeps + working while a human sorts out a card. +- On grace expiry the projection is **recomputed** to a read-only feature set. This is the + important design point: the downgrade is an ordinary recompute that bumps `generation` + and travels the ordinary push path + ([entitlement-projection.md](../architecture/entitlement-projection.md)). It is not a + special-case flag, not a side channel, and not a separate endpoint — which means the + cache-coherency guarantees that hold for a plan upgrade hold for a suspension too. +- Cancellation honours `cancel_at_period_end`; no immediate access loss. + +### Payment provider adapters + +All behind `IHubPaymentProvider`: + +- **Stripe** — cards, ACH, SEPA. +- **Iyzico** — Turkish market. +- **Manual / wire transfer** — an operator marks payment received; the audit entry is the + receipt. + +Adding a fourth is a code edit, not an ADR. + +> **Naming.** `IHubPaymentProvider` is deliberately distinct from LearnStack core's +> `IPaymentProvider` (Phase 09, tenant-facing storefront). The two share a shape — +> idempotency key, webhook signature verification, status mapping — but describe different +> billing relationships: `IPaymentProvider` charges *learners* on behalf of a tenant; +> `IHubPaymentProvider` charges *tenants* on behalf of LearnStack. Hub adapters live in +> `LearnStack.Hub.Infrastructure.Payments.{Stripe,Iyzico,Manual}`; LearnStack adapters live +> in `LearnStack.Infrastructure.Payments.*` in the other repository. Running both in one +> process is forbidden by the codebase separation invariant +> ([ADR-0019](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0019-learnstack-hub.md)). + +Provider SDK types never leave their adapter assembly; SDK exceptions are translated into +`ProviderException` at the boundary, and `IProviderResilience` carries +retry, circuit breaker, timeout and bulkhead. + +### Operator portal extensions + +Built on the [P02c-4](p02c-4-operator-portal.md) shell: + +- **Plan editor** — feature toggles, limit inputs, price, billing cycle. Deferred out of + P02c-4 to here so that a plan's feature payload and its price are edited on one form. + The editor validates every key against the `FeatureKey` / `LimitKey` registries and + refuses unknown keys ([plans.md](../modules/plans.md)). +- Per-tenant billing tab: subscription state, current period, recent invoices, payment + provider, dunning state, grace expiry. +- Invoice viewer and PDF export. +- Plan-change workflow with proration, re-pushing the entitlement projection. +- Usage-metrics screens, per tenant and per metric. +- Bulk invoice export (CSV) for accounting. + +### Tenant-facing hook — in LearnStack, not a new Hub crossing + +A read-only billing tab in LearnStack's Admin Studio shows a tenant their own subscription +state, recent invoices and next payment due, served by a thin LearnStack-side proxy over +`IEntitlementProvider`'s billing-info extension. + +The migrated text justified this as "no new endpoint on the four-endpoint surface". Under +[ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) the +rule is stated differently and the justification is stronger: the crossing goes through a +**named adapter**, and no type outside `IEntitlementProvider`, `IUsageReporter` and +`IHubTenantSync` holds a Hub client — asserted by +`Hub_Client_Referenced_Only_By_Named_Adapters`. The count was never the point. + +### Compliance + +- Tax per region — Stripe Tax for the Stripe adapter, a maintained rate table for the + others. +- Invoice retention of seven years, above the Hub audit retention floor. +- Every operator billing action (plan change, manual invoice, refund, write-off) is a + MUST-class audit entry with `actor.hubOperator = true` + ([P02c-4](p02c-4-operator-portal.md)). + +## Deliverables + +- `LearnStack.Hub.Modules.Invoicing` — `HubInvoice`, `HubInvoiceLine`, `WebhookLedger`, + with the immutability rule enforced in the aggregate rather than by convention. +- `LearnStack.Hub.Modules.Usage` extended with the daily aggregation job and adjustment + handling. +- `DunningPolicy` and `PaymentProviderAccount` aggregates; the `HubSubscription` + billing-state extension and the live `MarkPastDue` / `Cure` drivers. +- Stripe, Iyzico and Manual adapters behind `IHubPaymentProvider`, with webhook receivers + guarded by `WebhookLedger`. +- Invoice PDF generation. +- Operator portal: plan editor, billing tab, invoice viewer, usage screens, bulk export. +- LearnStack-side proxy endpoint for the Studio billing tab, in a coordinated pull + request. +- Recompute-driven read-only downgrade on grace expiry, travelling the ordinary + entitlement push path. + +## Completion Criteria + +- An operator moves a tenant trial → active → cancelled, and the correct invoices and + entitlement refreshes exist at each step. +- A failed payment enters dunning; the tenant continues operating unchanged through the + grace window; on expiry the projection downgrades to read-only with an incremented + `generation`, and LearnStack's Studio shows the notice banner. +- A provider webhook delivered twice produces exactly one ledger row and one state + transition. A webhook delivered out of order does not move the subscription backwards. +- A usage report replayed with the same idempotency key does not double-count in + `UsageAggregate`. +- An issued invoice cannot be edited through any code path; a correction produces a credit + note and a new invoice, and the two reconcile. +- Every invoice total equals the sum of its printed lines, in integer minor units, in one + declared currency. +- The tenant's Studio billing tab shows accurate subscription and invoice data through the + proxy, with no Hub client outside the three named adapters. +- The plan editor refuses an unknown feature or limit key. +- Operator audit captures every billing action with `actor.hubOperator = true`. +- Hub architecture suite green; LearnStack's boundary tests + (`LearnStack_Modules_DoNotReference_Hub`, + `Hub_Client_Referenced_Only_By_Named_Adapters`) still green. + +## Risks + +- **Two-billing confusion.** Tenant admins conflating the money they collect from learners + with the money they pay LearnStack. Mitigated by hard UI separation — storefront under + Studio's catalogue, platform subscription under Settings — and by never using the word + "billing" unqualified in either surface. +- **A mutable ledger.** The pressure to "just fix" a wrong invoice is constant and the fix + is always one `UPDATE`. Mitigated by making `HubInvoice` immutable after issue at the + aggregate level, so the correction path is the only path. +- **Webhook replay and reordering.** Providers retry aggressively and deliver out of + order; a naive handler can charge twice or resurrect a cancelled subscription. Mitigated + by the `WebhookLedger` unique constraint and by handlers that assert the expected + current state rather than blindly applying a transition. +- **Payment provider drift.** Stripe and Iyzico ship breaking changes on their own + schedule. Mitigated by the adapter boundary and by contract tests against recorded + fixtures, so an API change fails a test rather than a customer's payment. +- **Grace-period gaming.** A tenant cycling through grace repeatedly to avoid paying. + Mitigated by continuing to report usage through grace and by surfacing serial-grace + tenants in the operator portal, where a human decides. +- **Money in floating point.** It enters through a DTO, a chart, or a CSV export rather + than through the ledger. Mitigated by the integer-minor-unit rule applying to every + layer including serialisation, and by a test that asserts no monetary field on any DTO + is a floating-point type. + +## Phase Exit Decision + +This track is complete when the SaaS deployment can charge a real tenant end to end: +operator provisions a tenant → trial → active → invoice generated → payment captured → +next period rolls over — and when a declined payment produces a graded, reversible +degradation rather than an outage. + +Self-Hosted tenants do not need this track at all: they pay by purchasing a licence key +([P02c-6](p02c-6-license-key.md)), not by subscription billing. A Self-Hosted-only customer +base is a valid state of the world in which this track never ships, which is why its +trigger is "a tenant must be invoiced" rather than a position in a sequence. diff --git a/docs/roadmap/hub-marketplace.md b/docs/roadmap/hub-marketplace.md new file mode 100644 index 0000000..922c22f --- /dev/null +++ b/docs/roadmap/hub-marketplace.md @@ -0,0 +1,204 @@ +# Hub Marketplace + +> **This is the authoritative plan.** It was migrated out of LearnStack's +> `phase-12-hub-marketplace.md`, which is now a pointer at this file. The **12** slot stays +> in LearnStack's phase numbering because the identifier is referenced across both +> repositories; the plan lives here. +> +> **Status: post-MVP and optional.** The platform works fully without it. If the +> marketplace never ships, no LearnStack feature breaks. The track exists because +> customization-as-data creates an obvious sharing opportunity — not because anything +> depends on it. + +## Goal + +Let tenants publish and install reusable **tenant customization data**. + +A yoga studio that has authored a good asana content type, its level taxonomy, its page +blocks and its completion rules has built something a second yoga studio would pay in time +to skip. Under +[ADR-0018](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0018-tenant-driven-customization-model.md) +all of that is data, so it is transferable in principle: a bundle, a validation pass, an +install. + +The track answers whether it is transferable in practice — and whether the Hub is the +right place to hold it, which is a genuinely open question this document does not pretend +to have settled. + +## Scope + +Everything below is **tentative**. The track is not scheduled and its activation +conditions are in the [Phase Exit Decision](#phase-exit-decision). + +### The genericity boundary decides what a bundle may contain + +[ADR-0018's 2026-08-08 Amendment](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0018-tenant-driven-customization-model.md) +draws the line the marketplace has to respect. Inside the boundary — **content shape**, +**presentation**, and **pure rule evaluation** — everything is a pure function of tenant +data and already-recorded state, and everything is bundleable: + +- `TenantContentType` JSON Schemas. +- `TenantPageBlock` definitions and their composite renderer-key references. +- `TenantLessonItemType` definitions and their player-key references. +- `TenantLevelTaxonomy` definitions. +- `TenantScoringRule` / `TenantCompletionRule` expressions — these are **evaluated**, not + executed, which is what keeps them inside the boundary. +- `TenantCustomFieldDef` definitions. +- `TenantTemplateLibrary` templates. +- A manifest: required LearnStack version, required feature keys, sample fixtures. + +Outside the boundary — and therefore **not extension points and not marketplace +content**: + +| Excluded | Why | +|---|---| +| **Stateful entitlement** — credit packs, session quotas, make-up-class allowances | Requires a balance that is decremented, refunded, expired and audited. A JSON Schema declares shape; it cannot declare a ledger | +| **Tenant code execution** — running a learner's submitted code, scoring pronunciation from audio, any external capability invocation | Requires a sandbox, a runtime, a resource budget and a security boundary | + +**Tenant code execution is out.** This is the clearest thing in the track and it needs +saying plainly, because a "code challenge runner" bundle is the single most requested +marketplace listing in any education platform. Installing one would mean installing a +sandbox as data. There is no extension point that can carry it, and inventing one would +reopen the plugin model that +[ADR-0011](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0011-extension-points.md) described and +ADR-0018 superseded. A tenant that needs code execution needs a **LearnStack release** — +a platform feature gated by plan — or an adapter to an external provider. It does not need, +and cannot have, a marketplace listing. + +### The unresolved collision with ADR-0034 + +[ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md)'s +first invariant is that **the Hub stores no tenant content**. A marketplace listing is +authored by a tenant, describes that tenant's product, and often carries sample fixtures +drawn from that tenant's data. On the plainest reading, a listing *is* tenant content, and +storing listings in the Hub violates the invariant that the whole Hub/LearnStack +separation rests on. + +This is not a detail to be worked out during implementation. It is the design question the +track opens with, and it must be answered by an ADR before any code. Three shapes worth +considering, none of them chosen here: + +1. **Metadata in the Hub, body outside.** The Hub stores listing metadata — title, author + tenant, version, review state, install count — and the bundle body lives in object + storage the Hub references by key and never parses. Whether an opaque blob the Hub owns + the pointer to counts as "storing" is exactly the question the ADR has to answer. +2. **A separate service.** The marketplace is neither Hub nor LearnStack, with its own + boundary and its own invariants. Cleanest against ADR-0034, most expensive to operate. +3. **A narrow amendment.** ADR-0034 gains a carve-out for published, tenant-consented, + schema-only bundles containing no learner data. Cheapest, and the option most likely to + be regretted, because carve-outs to an invariant are how invariants stop being + invariants — which is precisely the failure ADR-0034 was written to correct. + +### Aggregates (Hub-side, if option 1 or 3 is chosen) + +- `MarketplaceListing` — a publishable bundle: name, description, author tenant, licence, + review state. +- `MarketplaceListingVersion` — a semver-versioned snapshot. +- `MarketplaceInstall` — a record of a tenant installing a listing version. +- `MarketplaceReview` — optional tenant feedback. + +### Publisher flow + +- A tenant admin exports a subset of their customization data as a draft listing from + LearnStack's Admin Studio. +- **Sandbox validation** boots a clean tenant fixture and applies the bundle end to end; + any failure blocks publish. This is the gate that makes a listing trustworthy, and it is + the most expensive part of the track to build. +- Operator review in the Hub for compliance and content sanity before public listing. +- Approved listings appear in the marketplace. + +### Consumer flow + +- A tenant admin browses the marketplace from Studio. +- Install pushes the bundle into the tenant's customization aggregates. **The transport for + this push is part of the ADR above** — it is a crossing that carries tenant content, so + it cannot be a casual addition to the endpoint set. +- Key collisions (a `key` the tenant already uses) prompt for rename or skip; they are + never silently overwritten. + +### Pricing + +**Paid listings are out of the roadmap pending product evidence.** No phase owns them, and +no slot is reserved. The first iteration, if it ships at all, is free-only: no listing +fees, no per-install pricing, no revenue split. If a free marketplace demonstrates real +demand, a pricing model is scoped at that point against what the demand actually looks +like. + +## Deliverables + +Tentative, and contingent on the ADR above: + +- The ADR resolving the ADR-0034 collision — **first, and blocking everything else**. +- Marketplace aggregates and schema, wherever the ADR puts them. +- The sandbox validator: a clean LearnStack tenant fixture, bundle application, and a + pass/fail report a publisher can act on. +- Operator review queue and approval flow in the + [operator portal](p02c-4-operator-portal.md). +- Tenant-facing marketplace browser inside LearnStack's Admin Studio. +- The install transport, in whatever form the ADR sanctions. +- A publishing policy covering what a bundle may contain, enforced by the validator rather + than by review. + +## Completion Criteria + +If the track is activated, it is complete when: + +- A tenant exports a customization bundle, it passes sandbox validation against a clean + fixture, an operator approves it, and a second tenant installs it and renders the + result — with no code change on either side. +- An installed bundle collides with an existing key and the consumer is asked, not + overwritten. +- A bundle containing anything outside the genericity boundary — a stateful-entitlement + definition, an external-capability invocation, anything that would need a runtime — is + rejected by the validator, with a message naming why. +- No published bundle contains learner data, tenant user records, or any content entry the + publisher did not explicitly mark as a sample fixture. +- `Hub_NeverStores_TenantData` is green under whatever storage arrangement the ADR chose — + which is the mechanical expression of the collision being genuinely resolved rather than + argued away. + +## Risks + +- **The invariant collision resolved by convenience.** The cheapest answer is to amend + ADR-0034 and move on. That is how "closed at four endpoints" ended up tunnelling a + private key through a cached projection. Mitigated by requiring the ADR to state what + the invariant still forbids after the amendment — an invariant that forbids nothing new + is not an invariant. +- **Publisher data leakage.** A bundle's sample fixtures are the natural place for real + learner data to escape one tenant into every installer. Mitigated by the validator + refusing any fixture that is not explicitly authored as a fixture, and by treating a + leak here as a data-protection incident rather than a bug. +- **Bundles as an injection surface.** A bundle is JSON, but JSON that names renderer keys + and can carry `embed-html` block content is executable-adjacent. Mitigated by the + sanitisation contract in + [Architecture 32](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/32-tenant-customization-model.md) + applying to installed content exactly as it applies to authored content — installation + is not a trust boundary crossing that grants privileges. +- **Operator review burden at scale.** Manual review is the quality gate and the + bottleneck; it does not survive volume. Mitigated by pushing everything mechanisable + into the validator, so review is a judgement call on a small residue. +- **Drift after local customization.** A tenant installs a bundle, edits it, and the + publisher ships v2. There is no obvious correct merge. This is unsolved and should be + scoped honestly if the track activates — the likely answer is that installs are + snapshots and upgrades are re-installs with a diff, not merges. +- **Building it before the demand exists.** The most expensive risk. The sandbox validator + alone is a substantial system, and a marketplace with four listings is worse than no + marketplace because it advertises abandonment. + +## Phase Exit Decision + +This track has **no scheduled entry**. It activates only when two conditions are met +together, and neither is a matter of judgement: + +1. **Demonstrated duplication.** At least two tenants in the same domain have + independently authored substantially equivalent customization data. Until that has + happened in production, sharing is a hypothesis about a market, not an observation of + one. [LearnStack Phase 10](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-10-english-learning-mvp.md) + exercises one tenant in depth; it is the evidence base, not the trigger. +2. **The ADR-0034 collision is resolved by a written, accepted ADR** that says where + listings live and why that does not make the Hub a store of tenant content. + +Until both hold, the roadmap reserves the slot and nothing more. If only the first ever +happens, the answer may still be that tenants share bundles by exporting and emailing +them — which costs nothing to support and is exactly how a real signal would show up +before anyone builds a marketplace to catch it. diff --git a/docs/roadmap/p02c-0-repository-bootstrap.md b/docs/roadmap/p02c-0-repository-bootstrap.md new file mode 100644 index 0000000..1412d0c --- /dev/null +++ b/docs/roadmap/p02c-0-repository-bootstrap.md @@ -0,0 +1,86 @@ +# P02c-0: Repository Bootstrap + +> **Status: ✅ Shipped (2026-05-21), on `main`.** Landed as the initial commit plus three review passes. Nothing in this document is planned work; it is the delivery record for the packet. + +## Goal + +Stand up the `learnstack-hub` repository as a second, independently buildable codebase that a LearnStack developer can be productive in on the first day — same directory shape, same solution layout, same CI gates, same commit conventions — before any Hub domain code exists. + +This is the packet that makes [ADR-0019](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0019-learnstack-hub.md)'s "separate repository" decision real. Everything it fixes is cheap to fix now and expensive later: the project graph, the schema and database names, the dev-stack port allocations that must not collide with LearnStack's, and the CI gates that every subsequent commit is measured against. + +It deliberately ships **zero** domain code. A bootstrap that also carries aggregates cannot be reviewed as a bootstrap. + +## Scope + +### Repository shape + +The full layout is documented in [repository-layout.md](../architecture/repository-layout.md) and mirrors LearnStack core's: `backend/`, `frontend/`, `infra/`, `docs/`, `scripts/`, plus the top-level tooling and agent-guidance files. + +### Backend + +- `LearnStack.Hub.slnx` with the seven core projects (`SharedKernel`, `Domain`, `Application.Contracts`, `Application`, `Infrastructure`, `Infrastructure.Audit`, `Api`) and the four test projects (`Unit`, `Integration`, `Architecture`, `Contract`) — the same names as LearnStack core with the `Hub` prefix. +- `backend/src/Modules/` exists with a `README.md` describing the planned module topology and no module subdirectories. Modules land from P02c-1 onward. +- .NET 10 pinned in `global.json`; central package versions in `Directory.Packages.props`. + +### Frontend + +- pnpm monorepo with `apps/operator-portal` (Next.js App Router) and `packages/{config,sdk,ui}` under the `@learnstack-hub/*` scope. Scaffold only — the portal's content is [P02c-4](p02c-4-operator-portal.md), and the SDK is generated from P02c-2's OpenAPI document. + +### Infrastructure + +- `infra/compose/dev.yml` plus an `e2e.yml` ephemeral overlay running only the Hub-specific services: Dapr placement + sidecar, APISIX, and the `learnstack_hub` database init. Postgres, Valkey, Vault, Kafka, Keycloak and Mailpit are shared with LearnStack core's stack over `host.docker.internal`. +- Every Hub port is offset from LearnStack core's so both stacks run side by side: APISIX 9180 / 9543 / 9191, Dapr placement 50006, Dapr sidecar 3501 / 50002, API 5181. +- `infra/keycloak/README.md` documents the shared-instance, two-realm topology per [ADR-0004 Amendment 1](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0004-authentication-strategy.md). + +### Developer experience and CI + +- Repo-root `Makefile` (`dev` / `build` / `test` / `lint` / `format` / `seed` / `install` / `hooks`), `.env.example` as the single source of truth for dev environment variables, and a `.githooks/pre-commit` running `dotnet format` + prettier + ESLint + a Leakwatch scan. +- `.github/workflows/ci.yml` mirroring LearnStack core's job shape: backend, frontend, meta, and secret-scan. `backend-integration` ships gated `if: false` — it has no test to run until a Testcontainers-backed suite exists. +- `scripts/seed.sh` as an orchestrator shell with no seed data behind it. + +### Documentation skeleton + +- `docs/architecture/contract-with-learnstack.md` as a pointer-only file: every load-bearing contract rule stays in LearnStack's corpus and is linked, never mirrored. +- `docs/decisions/` reserving the `HUB-NNNN` series for Hub-internal-only decisions, with a template. +- `docs/glossary.md` for Hub-specific terms, `docs/operations/` and `docs/modules/` as placeholders. +- `CLAUDE.md` carrying the hard rules, `AGENTS.md` redirecting to it, `CONTRIBUTING.md`, and a `README.md`. + +### Architecture-test placeholders + +Three tests land as structural placeholders that become real when module assemblies exist: the meta-test that keeps the suite non-skippable, `No_Source_Folder_Named_Verticals`, and `Hub_NeverStores_TenantData`. + +## Deliverables + +- Sibling `learnstack-hub` git repository (GitHub: `HodeTech/LearnStack-Hub`), initialised and pushed. +- Top-level config: `.gitignore`, `.editorconfig`, `.gitattributes`, `.env.example`, `.leakwatch.yaml`, `.leakwatchignore`. +- Top-level docs: `README.md`, `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`. +- `Makefile` + `.githooks/pre-commit` (executable; activated by `make hooks`). +- Backend solution `LearnStack.Hub.slnx` with 7 core projects + 4 test projects, building green at 0 warnings / 0 errors. +- Frontend pnpm monorepo (`apps/operator-portal` + `packages/{config,sdk,ui}`) that typechecks, lints and builds. +- `infra/`: `compose/dev.yml`, `compose/e2e.yml`, APISIX config, Dapr components, Postgres init for the `learnstack_hub` database, Keycloak topology README. +- `scripts/seed.sh` orchestrator. +- `.github/workflows/ci.yml` + `CODEOWNERS` + pull-request template. +- `docs/` scaffolding: architecture pointers, decisions template, operations and modules placeholders, glossary. +- Four architecture tests and one smoke test passing. + +## Completion Criteria + +- `dotnet build LearnStack.Hub.slnx` is clean at 0 warnings and 0 errors; the architecture and smoke suites pass. +- The frontend monorepo installs, typechecks, lints and builds. +- Both compose files parse (`docker compose config`), and bringing the Hub stack up alongside LearnStack core's stack produces no port collision. +- CI is green on the backend, frontend, meta and secret-scan jobs; `backend-integration` is explicitly gated rather than silently absent. +- The pre-commit hook runs the formatter chain and the Leakwatch scan, and a repo-wide scan reports no findings. +- No Hub domain code exists. `backend/src/Modules/` holds only its own `README.md`. + +## Risks + +- **Silent divergence from LearnStack core's conventions.** Two repositories with the same shape drift the moment one changes and the other does not. Mitigated by keeping the project names, solution layout, CI job names and commit conventions mechanically identical, so a divergence is visible in a diff rather than buried in behaviour. +- **Port and service collisions with the LearnStack dev stack.** Two full compose stacks on one workstation is the normal case, not the exception. Mitigated by offsetting every Hub port and by sharing the heavy backing services instead of duplicating them. +- **The SharedKernel copy drifts from its original.** Hub reproduces LearnStack's `SharedKernel` patterns rather than importing them, because the two repositories release independently. The copy is made in [P02c-1](p02c-1-hub-domain-core.md), not here, but the decision belongs to the bootstrap: a shared `LearnStack.Foundation` package is a Phase 11 re-evaluation, and until then the two copies are reconciled by review. +- **Agent guidance that points at the wrong repository.** The bootstrap originally deferred to LearnStack's skill catalogue "by reference", which an agent running from the Hub root cannot load. Fixed after the fact by the Hub-local catalogue at [`.claude/skills/`](../../.claude/skills/README.md). + +## Phase Exit Decision + +P02c-0 is complete, and was complete when a clean checkout could build the backend solution, build the frontend monorepo, bring both compose stacks up together, and pass CI — with no Hub domain code present and no unresolved reference to a LearnStack assembly anywhere in the project graph. + +[P02c-1](p02c-1-hub-domain-core.md) begins from that state. diff --git a/docs/roadmap/p02c-1-hub-domain-core.md b/docs/roadmap/p02c-1-hub-domain-core.md new file mode 100644 index 0000000..90f660d --- /dev/null +++ b/docs/roadmap/p02c-1-hub-domain-core.md @@ -0,0 +1,132 @@ +# P02c-1: Hub Domain Core + +> **Status: ✅ Shipped**, merged 2026-08-09 (~222 files). Reviewed against the restructured corpus before merging: its domain code conflicts with none of the three decisions that moved after it was written — the entitlement wire shape already carries `grace_until` and `generation`, no endpoint is hosted, and `AuditLogBehavior` is a shell. Two reconciliations are owed on the merged code and are listed in [Phase Exit Decision](#phase-exit-decision). +> +> **The freeze recorded on 2026-08-08 stands, on the track's forward motion.** It moved to [P02c-2](p02c-2-internal-api-and-contract.md), which is where the contract surface ADR-0034 redrew actually gets built, and which is genuinely gated on the ADR-0035 trigger. Freezing this packet's merged artefact would have bought nothing and cost the SharedKernel reconciliation, which grows with every packet built on top. + +## Goal + +Give the Hub a domain: the four aggregates the control plane is built out of — `LearnStackTenant`, `Plan`, `HubSubscription`, `Entitlement` — plus the Hub-side cross-cutting foundation they run inside, so that every later packet programs against a `Result` / MediatR / exception-handling surface a LearnStack developer already knows. + +The load-bearing deliverable is the **entitlement projection**. Everything the Hub exists to do reduces to producing one correct, monotonically versioned projection per tenant; the internal API in [P02c-2](p02c-2-internal-api-and-contract.md) is transport for it, and the operator portal in [P02c-4](p02c-4-operator-portal.md) is an editor for its inputs. If the projection's shape or its `generation` counter is wrong here, every consumer downstream inherits the error. + +This packet is also where the Hub's structural difference from LearnStack core becomes code rather than prose: **no Row Level Security, no tenant context, `OperatorId` instead of `UserId`, six pipeline behaviors instead of seven.** Those four deltas are not simplifications — they follow from the Hub being operator-administered and holding no tenant content. + +## Scope + +### Hub SharedKernel + +A mirror of LearnStack's `LearnStack.SharedKernel`, folder for folder, under the `LearnStack.Hub.SharedKernel.*` namespace, with two substitutions: `OperatorId` replaces `UserId`, and `HubException` replaces `LearnStackException` as the base of the exception hierarchy. There is no tenant `UserId` type anywhere in the Hub. + +It is a **copy, not a package reference**. The two repositories release independently and the Hub imports no LearnStack assembly; a shared `LearnStack.Foundation` package is a Phase 11 re-evaluation. The full surface — `Results/`, `Localization/`, `Domain/`, `Identifiers/`, `Time/`, `Random/`, `Pagination/`, `Persistence/`, `Errors/`, `Secrets/`, `Observability/`, `Resilience/`, `Hosting/` — is enumerated in [cross-cutting-foundation.md § 1](../architecture/cross-cutting-foundation.md). + +Adds `FeatureFlags/` with the `FeatureKey` / `LimitKey` value objects and their registries. The Hub is the **authoring** side of these keys; their wire strings must match LearnStack core's registry exactly or the projection the Hub emits will not line up with what LearnStack reads. + +### Cross-cutting foundation + +Mirrors LearnStack's Phase 02a Packet 3 surface, Hub-adjusted. Specified in full in [cross-cutting-foundation.md](../architecture/cross-cutting-foundation.md): + +- `HubExceptionHandler : IExceptionHandler` as the single L1 handler, `Result.ToActionResult()` explicit at every endpoint, `ProblemDetailsFactory` + `HttpStatusMap` with the `https://errors.hub.learnstack.dev/` problem-type prefix. +- The **six-step MediatR pipeline**: Validation → Logging → AuditLog → Authorization → Transaction → OutboxFlush → Handler. `Validation`, `Logging` and `Transaction` are live; `AuditLog`, `Authorization` and `OutboxFlush` ship as shells whose registration order is the binding part. +- **No `TenantContextBehavior`.** The Hub has no per-request tenant context to assert and no Row Level Security session variable to set. Hub requests are operator-scoped, and the operator identity rides on the `learnstack-hub` realm JWT. +- Serilog as the primary logger with an OTLP sink, and the OpenTelemetry SDK for traces and metrics — with the OTel `LoggerProvider` deliberately not registered alongside, so nothing double-exports. No `TenantContextSpanProcessor`; an `OperatorContextSpanProcessor` waits for the Operators module in [P02c-4](p02c-4-operator-portal.md). +- `IErrorTrackingProvider` with its three implementations, and `IProviderResilience`, both branched once at the composition root by `DeploymentMode`. Modules never read `DeploymentMode`. + +`TransactionBehavior` is **live** here, unlike LearnStack's Packet 3 equivalent, because this packet ships real per-module `DbContext`s for it to open a unit of work on. + +### Four modules + +Each follows the four-project layout (`Domain`, `Application.Contracts`, `Application`, `Infrastructure`) with its own `DbContext`. Dependency rules and the cross-module flow are in [module-topology.md](../architecture/module-topology.md); the per-aggregate field lists, state machines, commands and queries are in the module deep dives. + +| Module | Aggregate | Deep dive | +| ----------------- | ------------------ | ------------------------------------------------ | +| `TenantLifecycle` | `LearnStackTenant` | [tenant-lifecycle.md](../modules/tenant-lifecycle.md) | +| `Plans` | `Plan` | [plans.md](../modules/plans.md) | +| `Subscriptions` | `HubSubscription` | [subscriptions.md](../modules/subscriptions.md) | +| `Entitlements` | `Entitlement` | [entitlements.md](../modules/entitlements.md) | + +Aggregates use Vogen strongly-typed identifiers, inherit `Entity` or `AuditableEntity`, express invalid transitions as `Result.Fail(...)` rather than `DomainException`, and raise domain events. Cross-module reads go through `Application.Contracts` only — the Plans fan-out asks Subscriptions for the tenant ids bound to a plan; it never touches the Subscriptions `DbContext`. + +### Schema and migrations + +One EF migration per module `DbContext`, all in the `hub` schema of the `learnstack_hub` database. Snake-case tables and columns, JSONB for the dictionary-shaped columns, Vogen converters registered per context. + +Generated SQL contains **no** `ENABLE ROW LEVEL SECURITY` and **no** `CREATE POLICY`. If it does, the `DbContext` has wrongly applied a tenant filter and the configuration is what gets fixed — not the migration. The reasoning is in [module-topology.md § Database isolation model](../architecture/module-topology.md): the Hub's isolation guarantee is that it stores no tenant content, not that rows are filtered per tenant. + +### The entitlement projection + +`EntitlementProjectionService` recomputes a tenant's `Entitlement` from its `HubSubscription` and the bound `Plan`, bumping the monotonic `generation` counter by exactly one and replacing the projection fields in the same transaction. The algorithm, the wire shape, the key-shape rules and the recompute triggers are in [entitlement-projection.md](../architecture/entitlement-projection.md). + +`compliance_caps` ships as an empty JSONB shape ([P02c-5](p02c-5-custom-domain-lifecycle.md) fills it) and `grace_until` ships null ([P02c-6](p02c-6-license-key.md) fills it). The columns exist now because their absence would be a migration against a table the LearnStack side already mirrors. + +The `learnstack.hub.entitlement` publish is a no-op shell here; it lights up with `IOutbox` in [P02c-2](p02c-2-internal-api-and-contract.md). + +### Tests and seed data + +- **Architecture:** the P02c-0 placeholders become real once module assemblies exist — `Hub_NeverStores_TenantData` scans them, per-module dependency tests assert the direction rules, and the two Hub deltas most likely to erode get their own assertions (no Row Level Security in the Hub schema; the six-step pipeline order). +- **Unit:** aggregate state-machine transitions, validators, and `generation` monotonicity — it starts at 1, only ever increments, and never resets across a downgrade. +- **Contract:** `EntitlementProjection_Shape_IsStable` snapshots the serialised projection against a checked-in `entitlement-v1.schema.json`. The same file is checked into LearnStack and asserted there independently, per [ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md). +- **Seed:** the four illustrative plan tiers, a demo tenant, a trial subscription and its entitlement — as data, not code constants. + +Hub integration tests have **no tenant-isolation pair**. There is no Row Level Security to prove, and a test that asserts isolation the Hub does not implement would be misleading. + +### Not in this packet + +| Capability | Owning packet | +| -------------------------------------------------------------- | -------------------------------------------------------- | +| Internal API endpoints, outbound client, mTLS + JWT + HMAC | [P02c-2](p02c-2-internal-api-and-contract.md) | +| `IOutbox` + the `learnstack.hub.entitlement` publish | [P02c-2](p02c-2-internal-api-and-contract.md) | +| `Usage` module | [P02c-2](p02c-2-internal-api-and-contract.md) | +| Any change to the LearnStack repository | [P02c-3](p02c-3-learnstack-integration.md) | +| Operator portal, `Operators` and `Audit` modules, live audit writer, real `AuthorizationBehavior` | [P02c-4](p02c-4-operator-portal.md) | +| `CustomDomains` and `Compliance` modules | [P02c-5](p02c-5-custom-domain-lifecycle.md) | +| `LicenseKeys`, the `.lic` format, `grace_until` semantics | [P02c-6](p02c-6-license-key.md) | +| Stripe / Iyzico, `Invoicing`, dunning, proration | [Hub Billing](hub-billing.md) | + +## Deliverables + +- `LearnStack.Hub.SharedKernel` mirroring LearnStack's, with `OperatorId` and `HubException`, unit-tested on the load-bearing types. +- The cross-cutting foundation wired in `LearnStack.Hub.Api`: L1 exception handler, six-step pipeline in canonical order, `ToActionResult`, Serilog + OpenTelemetry, `IErrorTrackingProvider`, `IProviderResilience`, single-site `DeploymentMode` branching. +- Four modules with aggregates, state machines, commands, queries, validators, `DbContext`s and EF configurations. +- Four EF migrations creating `hub`-schema tables in `learnstack_hub`, applying cleanly, with no Row Level Security in the generated SQL. +- `EntitlementProjectionService` with monotonic `generation`, matching the documented wire shape. +- `entitlement-v1.schema.json` plus the contract snapshot test. +- Architecture, unit and contract suites green; the four plan tiers plus a demo tenant seeded. +- [`P02c-1-implementation-prompt.md`](P02c-1-implementation-prompt.md) — the execution artifact the implementing agent ran against, kept as the record of how the packet was built. + +## Completion Criteria + +- `dotnet build LearnStack.Hub.slnx` is clean; the unit, architecture and contract suites are green; `dotnet format --verify-no-changes` exits zero; a Leakwatch scan reports nothing. +- Every migration applies to an empty `learnstack_hub` database and produces the `hub` schema with no policy or Row Level Security statement in it. +- Creating a tenant produces a `Trial` subscription and an `Entitlement` at `generation` 1; changing the bound plan produces `generation` 2 with updated fields; no path decrements or resets the counter. +- The serialised projection validates against `entitlement-v1.schema.json`, and the snapshot test fails on any shape change. +- No `UserId` type, no `[TenantOwned]` marker, no EF global query filter, no `app.tenant_id` reference exists anywhere in the repository. +- The pipeline registration asserts six behaviors in canonical order, and `TenantContextBehavior` is absent. +- No module assembly references `LearnStack.SharedKernel`, `LearnStack.Domain`, `LearnStack.Infrastructure` or any `LearnStack.Modules.*` type. + +## Risks + +- **The mirrored SharedKernel drifts from its original.** It already has. LearnStack's [Phase 02a Packet 3b](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-02a-kernel-tenancy.md) repairs three defects in the source this copy was taken from: `Results.Unit` collides with `MediatR.Unit` in any file importing both, `Result` carries no `[MemberNotNullWhen]` so the compiler cannot prove `Value` is non-null after an `IsSuccess` check, and `Entity` overrides `Equals(object?)` without implementing `IEquatable` or `operator ==` so every comparison boxes. All three are in the merged code, and all three were cheapest to fix before the first handler existed — that moment has passed, which is why reconciling against Packet 3b is the first reconciliation owed in [Phase Exit Decision](#phase-exit-decision). +- **The projection shape is a cross-repository contract with a single author.** Nothing stops the Hub from changing `EntitlementProjectionDto` and breaking LearnStack silently. The mitigation is the checked-in schema plus a snapshot test in each repository, which converts a silent break into two failing builds. +- **Feature-key and limit-key registries drift.** Each repository keeps its own copy, and a key spelled differently on the two sides produces a projection that parses and means nothing. Recorded in [plans.md § Registry sync](../modules/plans.md); the durable fix is a shared contract package, which is a Phase 11 question. +- **Row Level Security creeps in by habit.** Every LearnStack skill and standard assumes it. A `DbContext` copied from a LearnStack module brings a tenant filter with it, and the filter would silently hide operator rows rather than fail loudly. Caught by reviewing the generated migration SQL and by the architecture test that asserts the Hub schema has no policies. +- **~~The freeze rots the branch.~~ Discharged 2026-08-09 by merging.** Two hundred files that do not merge age against `main`, against the LearnStack SharedKernel they mirror, and against the ADRs written since. This is the risk that decided the merge: the review found the code conflicted with nothing, so holding it bought no safety and the rot was the only certain outcome. + +## Phase Exit Decision + +**The packet is implemented and merged.** It was green against its own definition of done, and the review that preceded the merge found no conflict with the three ADRs written after it. + +The freeze exists because the Hub is a demand-gated track. LearnStack runs on `NullEntitlementProvider` and resolves hosts from `platform_host_to_tenant`, so nothing in LearnStack is blocked by the Hub's absence — and merging a control plane before it has a consumer means maintaining it through every LearnStack refactor for no user-visible return. + +**This packet is closed.** It merged to `main` on 2026-08-09 with its suites green in CI, which was its own exit condition. + +**What it hands to [P02c-2](p02c-2-internal-api-and-contract.md) is a freeze, not a start.** The Phase 02c trigger in [ADR-0035](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0035-demand-gated-infrastructure.md) — **a tenant must be billed or plan-gated** — has not fired. LearnStack runs on `NullEntitlementProvider` and consumes nothing from the Hub, so P02c-2 would build a contract surface with no caller. + +Two reconciliations are owed on the code this packet merged. Neither blocks anything today; both get more expensive the longer they wait: + +1. **Reconcile the Hub SharedKernel against LearnStack's post-Packet-3b source** — the `Unit` rename, the `[MemberNotNullWhen]` annotations, and `Entity` equality. Also pick up Packet 4's `CursorPagination` binding fix, which returns 400 rather than surfacing an unhandled exception as a 500. This is the expensive one, and unlike LearnStack the Hub already has four modules of consumers. +2. **Reshape the operator-audit seam to ADR-0033** when [P02c-4](p02c-4-operator-portal.md) lights it up. `AuditLogBehavior` is step 3 and wraps `TransactionBehavior`, so its success-path TODO writes after commit — the shape [ADR-0033](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0033-audit-durability-model.md) supersedes. The behavior writes nothing today, so nothing misbehaves; what is wrong is the TODO that specifies the work. MUST-class audit is a durable intent written on the ambient transaction immediately before `COMMIT`, not a best-effort write after it. + +The projection was checked at merge and needs no reconciliation: it carries `grace_until` and `generation` with the wire names [ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) fixed, and no certificate, key, host or domain field. + +**[P02c-2](p02c-2-internal-api-and-contract.md) begins when both conditions hold** — not when this packet closed. First, the [ADR-0035](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0035-demand-gated-infrastructure.md) trigger fires: a tenant must be billed or plan-gated. Second, its contract surface is built against [ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md)'s two invariants rather than an endpoint count — the condition that exists because protecting a count is what tunnelled TLS private keys through the entitlement payload. diff --git a/docs/roadmap/p02c-2-internal-api-and-contract.md b/docs/roadmap/p02c-2-internal-api-and-contract.md new file mode 100644 index 0000000..133dce4 --- /dev/null +++ b/docs/roadmap/p02c-2-internal-api-and-contract.md @@ -0,0 +1,107 @@ +# P02c-2: Internal API and Contract Surface + +> **Status: ⏳ Not started.** Depends on [P02c-1](p02c-1-hub-domain-core.md) being merged. Unblocked by LearnStack — this packet touches no LearnStack code. + +## Goal + +Give the Hub a wire. [P02c-1](p02c-1-hub-domain-core.md) produces a correct entitlement projection that nothing outside the Hub can read; this packet builds both halves of the transport that carries it — the handlers for the calls LearnStack makes into the Hub, and the outbound client for the calls the Hub makes into LearnStack — behind the mTLS + signed-JWT + HMAC chain that makes those calls trustworthy. + +It also fixes the shape of the contract. Everything [P02c-3](p02c-3-learnstack-integration.md) writes on the LearnStack side is written against what this packet publishes, which is why the Hub pull request opens first in every cross-repo pairing. + +## Scope + +### The endpoint set + +The authoritative list of paths, methods and directions is the table in [ADR-0034 § The endpoint set](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md). It is not restated here — a second copy is a second thing to keep in step, and the corpus has already paid that price once. + +Two rules from that ADR govern this packet directly: + +- **The Hub stores no tenant content**, and **every LearnStack↔Hub crossing goes through a named adapter.** Those two invariants replace the older "closed at four endpoints" rule. Adding an endpoint still requires an ADR, because both repositories have to agree. +- **Certificate material never rides the entitlement payload.** TLS private keys move between the Hub-owned and LearnStack-owned secret stores by secret-store replication and are referenced by path, never by value, in anything LearnStack caches, logs, audits or mirrors. + +This packet ships: + +- **Hub-side handlers** for the LearnStack → Hub direction: licence verification, the scheduled refresh, and usage reporting. +- **The outbound `LearnStackApiClient`** for the Hub → LearnStack direction, covering every path in the set. `PUT /api/internal/tenants/{id}/host-mappings` is built here and has **no caller until [P02c-5](p02c-5-custom-domain-lifecycle.md)** — the client is complete so that P02c-5 adds a call site, not a transport. + +The Hub's own tenant-facing and operator-facing API (`/api/v1/tenants/*`, `/api/v1/subscriptions/*`, `/api/v1/webhooks/*`) is **not** part of this surface. It is the Hub's public API, governed here, and grows with [P02c-4](p02c-4-operator-portal.md) and [Hub Billing](hub-billing.md). + +### The authentication chain + +Three independent layers on every call in both directions, per [ADR-0019](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0019-learnstack-hub.md) and unchanged by ADR-0034: + +- **mTLS** with client certificates signed by the LearnStack-internal CA. +- **A signed RS256 JWT** with `aud=learnstack-internal` and an expiry of at most five minutes, replay-protected by a short-TTL inbox keyed on `jti`. +- **An HMAC-SHA256 body signature** in `X-Signature`, using a per-deployment shared secret. + +All three secrets — the client certificate and key, the JWT signing key, the HMAC key — are read through `ISecretProvider`. None of them appears in configuration files, in logs, or in an error message. Rejecting a request that is missing any one layer is a tested behaviour, not an assumed one. + +`/api/internal/*` binds to an internal listener and is never reachable on the internet-facing one. `Internal_API_Endpoints_AreNot_Public` enforces it. + +### The `Usage` module + +`UsageAggregate` and its `DbContext`, populated by the inbound usage-report handler. Usage reports are **idempotent**: a client that retries after a timeout must not double-count. The idempotency key and its retention window are settled here, because a metric that silently double-counts is worse than a metric that is missing. + +`GET /api/internal/tenants/{id}/usage` on the LearnStack side is the pull complement; the Hub's client for it lands here and its LearnStack-side handler in [P02c-3](p02c-3-learnstack-integration.md). + +### Outbox and the entitlement push + +`IOutbox` becomes real and the P02c-1 `OutboxFlushBehavior` shell is promoted to a live flush. + +Two things travel when a projection is recomputed, and they are not the same thing: + +- **The projection itself**, pushed over `PUT /api/internal/tenants/{id}/entitlements`. This is the contract-bearing path and it is HTTP. It carries the `generation` counter, which is what lets the receiver reject an out-of-order delivery instead of overwriting newer state with older. +- **An eager-invalidation signal**, published as `learnstack.hub.entitlement`. This is an optimisation over waiting for a cache to expire, and it is the first genuine cross-process integration event in either system — which is precisely the trigger [ADR-0035](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0035-demand-gated-infrastructure.md) names for promoting `IEventBus` from its in-process default to a broker-backed adapter. This packet ships the publish behind `IOutbox` and `IEventBus` so that the transport choice stays at the composition root and is settled by that trigger, not by this packet. + +Correctness does not depend on the signal arriving. If it is lost, the projection is still authoritative and the receiver still converges on the next read. + +### OpenAPI and SDK generation + +- An OpenAPI document generated from code, covering the endpoints that exist at this point. +- The `@learnstack-hub/sdk` package generated from it, which [P02c-4](p02c-4-operator-portal.md)'s operator portal consumes instead of hand-rolled `fetch` calls. +- A contract test asserting the document does not change silently. + +### CI + +**The `backend-integration` CI job is already active.** It was gated `if: false` from [P02c-0](p02c-0-repository-bootstrap.md) because there was nothing for it to run, and [P02c-1](p02c-1-hub-domain-core.md) activated it along with the first Testcontainers-backed tests — the entitlement-rebuild round trip. An earlier draft of this document assigned the activation here; that was written when P02c-1 was expected to ship without integration tests. This packet adds its own cases to a running job. + +### Documentation + +`docs/architecture/learnstack-api-client.md` lands with this packet: client-certificate provisioning, JWT issuance from the `learnstack-hub` realm service account, the HMAC body signer, and the retry and backoff posture. + +## Deliverables + +- Hub-side handlers for every LearnStack → Hub path in the ADR-0034 set, with the full three-layer authentication chain enforced. +- `LearnStackApiClient` covering every Hub → LearnStack path, decorated with `IProviderResilience` for retry, circuit breaking, timeout and bulkhead. +- `Usage` module with `UsageAggregate`, its `DbContext`, its migration, and idempotent report ingestion. +- Real `IOutbox` with the `OutboxFlushBehavior` promoted from shell to live, and the `learnstack.hub.entitlement` publish behind `IEventBus`. +- Internal listener binding, with `Internal_API_Endpoints_AreNot_Public` green. +- OpenAPI document, generated `@learnstack-hub/sdk`, and a contract test over the document. +- Testcontainers-backed integration tests and the activated `backend-integration` CI job. +- `docs/architecture/learnstack-api-client.md`. + +## Completion Criteria + +- A request missing the client certificate, the JWT, or the HMAC signature is rejected — each layer independently, each covered by a test. A request with a JWT past its five-minute expiry, or replaying a `jti` already seen, is rejected. +- No secret used by the chain is readable from configuration, a log line, an error response, or a trace attribute. +- `/api/internal/*` returns nothing on the internet-facing listener. +- Reporting the same usage metric twice with the same idempotency key records it once. +- Recomputing an entitlement enqueues exactly one outbox row, and flushing it produces one push carrying the current `generation`. +- The OpenAPI document generates an SDK that typechecks, and the contract test fails on an unannounced change. +- `backend-integration` runs on every pull request and is a required check. + +## Risks + +- **Three security layers, three ways to be accidentally optional.** A misconfigured listener, a validation branch that returns early, or a test harness that stubs the chain all produce a surface that looks guarded and is not. Mitigated by testing each layer's rejection independently rather than testing only the happy path, and by making the internal-listener binding an architecture-test assertion rather than a deployment convention. +- **Secret rotation is an outage waiting to happen.** Three secrets, two repositories, and no dual-key window means a rotation is a synchronised deploy. The rotation procedure and its dual-key overlap are designed here even though the first rotation is far away, because retrofitting a rotation window into a live contract is worse. +- **Retry semantics that corrupt rather than repeat.** A retried entitlement push that arrives out of order overwrites newer state; a retried usage report double-counts. Both are mitigated in the payload — the monotonic `generation` for the push, the idempotency key for the report — rather than by hoping the transport is exactly-once. +- **The contract drifts because only one side compiles against it.** The Hub can change a payload and stay green. Mitigated by the OpenAPI contract test here, by `entitlement-v1.schema.json` asserted in both repositories, and by the coordination protocol in [P02c-3](p02c-3-learnstack-integration.md). +- **The invalidation signal is treated as load-bearing.** If any behaviour comes to depend on `learnstack.hub.entitlement` arriving, a lost message becomes a correctness bug rather than a latency one. The push is the contract; the event is an optimisation, and reviews hold that line. + +## Phase Exit Decision + +P02c-2 is complete when a caller holding a valid certificate, a valid JWT and a valid body signature can verify a licence, refresh it, and report usage against a running Hub — and when the Hub can, over the same chain, exercise every Hub → LearnStack path against a contract double, including the host-mapping push that has no production caller yet. + +Concretely: every layer of the chain rejects independently when removed, the internal listener is not publicly reachable, a repeated usage report counts once, an out-of-order projection push is rejected by its `generation`, the generated SDK typechecks, and `backend-integration` is green as a required check. + +[P02c-3](p02c-3-learnstack-integration.md) begins from that state — it is the packet that replaces the contract double with the real LearnStack. diff --git a/docs/roadmap/p02c-3-learnstack-integration.md b/docs/roadmap/p02c-3-learnstack-integration.md new file mode 100644 index 0000000..3fcbc57 --- /dev/null +++ b/docs/roadmap/p02c-3-learnstack-integration.md @@ -0,0 +1,84 @@ +# P02c-3: LearnStack Integration + +> **Status: ⏳ Not started.** The first and largest of three packets that change both repositories; [P02c-5](p02c-5-custom-domain-lifecycle.md) and [P02c-6](p02c-6-license-key.md) each carry a smaller coordinated LearnStack-side half. It is the packet where the two-PR protocol matters most, and it states that protocol in full below. Depends on [P02c-2](p02c-2-internal-api-and-contract.md) Hub-side, and on LearnStack's Phase 02a packets 5, 6, 7 and 9 on the other side — see [the cross-repo blocking table](README.md#cross-repo-blocking). + +## Goal + +Connect the two systems. [P02c-2](p02c-2-internal-api-and-contract.md) builds the Hub's half of the contract against a test double; this packet replaces the double with the real LearnStack and proves the round trip: an operator creates a tenant in the Hub, and a tenant administrator logs into LearnStack and sees the feature set their plan grants. + +It is deliberately a single packet across two repositories rather than two packets that reference each other. A contract half-landed is worse than a contract not landed — one side ships an endpoint nothing calls, the other ships an adapter that 404s, and both look green. + +## Scope + +### Hub side + +The Hub-side work is small by design, because [P02c-2](p02c-2-internal-api-and-contract.md) already built the transport. What lands here is the **call sites**: + +- `CreateTenantCommand` completes its flow: after Hub-side persistence it pushes `POST /api/internal/tenants` so LearnStack creates the tenant and its default organization with the **same** tenant id the Hub minted. +- The status transitions in `TenantLifecycle` — activate, suspend, archive, terminate — push to their LearnStack counterparts. +- `EntitlementProjectionService` pushes the projection after every recompute, carrying the monotonic `generation`. +- `IUsageReporter`'s Hub-side counterpart pulls aggregated usage where the push model is not enough. + +Failures on these pushes are retried through `IProviderResilience` and are **not** allowed to roll back the Hub-side transaction. The Hub's state is authoritative for plan data; a LearnStack that has not caught up yet is a convergence problem, not a consistency one. + +### LearnStack side + +Described in full in [LearnStack Phase 02c](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-02c-hub-foundation.md). Summarised here only so this document is readable on its own — that file is the authority for it: + +- Handlers for every Hub → LearnStack path in the [ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) set, behind the same three-layer authentication chain. +- The `HubEntitlementProvider`, `IUsageReporter` and `IHubTenantSync` adapters. These three are the **only** types in the LearnStack codebase permitted to hold a Hub client. +- The entitlement read path in ADR-0034's normative order: in-process cache, then distributed cache, then the durable `platform_entitlement_cache` row with its `valid_until` and `grace_until`, then the Hub. A Hub outage on a cold cache falls through to the durable row and honours the recorded grace window — it does not throw out of a feature-flag check. +- `entitlement-v1.schema.json` copied verbatim from the Hub and asserted by a snapshot test on the LearnStack side too. +- The architecture tests the boundary needs: `LearnStack_Modules_DoNotReference_Hub`, `Hub_Client_Referenced_Only_By_Named_Adapters`, `IEntitlementProvider_Implementations_Are_Three`, and `NullEntitlementProvider_NotRegistered_OutsideDevelopment` — which is vacuous until this packet gives it a second implementation to be exclusive against. + +### A boundary this packet must not cross + +**Host resolution never calls the Hub.** `IHostToTenantResolver` reads `platform_host_to_tenant` and nothing else. An anonymous page load must not depend on a control plane being reachable, or a Hub outage takes tenant marketing sites down. `IHubClient.LookupHostAsync` does not exist; [ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) deleted it. + +### Pull-request coordination protocol + +Two pull requests, one session: + +1. **The Hub pull request opens first.** It carries the canonical contract shape — payload schemas, the authentication chain's expectations, and `entitlement-v1.schema.json`. Whatever it says is what the contract is. +2. **The LearnStack pull request opens second and references the Hub pull request's commit hash** in its body, so a reviewer on either side can find the other half. +3. **Both merge in the same session.** A one-sided merge leaves the contract dangling: an unreachable endpoint or an adapter with no server. +4. **If review forces a contract change, the Hub pull request is amended first** and the LearnStack pull request is re-pointed at the new hash. Never the reverse — a contract with two authors has no author. +5. **`entitlement-v1.schema.json` is byte-identical in both repositories.** A divergence blocks both merges. + +Adding or changing an endpoint in the contract surface requires a new ADR in LearnStack's `docs/decisions/`, not here, because the surface is a cross-repository agreement and LearnStack owns the decision corpus. + +## Deliverables + +- Hub-side call sites wired: tenant provisioning, status transitions, entitlement push, usage pull — each retried, none able to roll back Hub-side state. +- LearnStack-side handlers, the three named adapters, and the ADR-0034 entitlement read path, landed in the LearnStack repository. +- `entitlement-v1.schema.json` identical in both repositories, snapshot-tested in each. +- The boundary architecture tests green on the LearnStack side. +- An end-to-end integration test spanning both systems: create a tenant in the Hub, assert it exists in LearnStack with a default organization and a populated projection. +- A degraded-mode test: with the Hub unreachable and the in-process and distributed caches cold, LearnStack serves the durable projection and honours `grace_until`. +- Two merged pull requests referencing each other. + +## Completion Criteria + +- An operator creates a tenant in the Hub and, within seconds, the tenant exists in LearnStack with its default organization and its entitlement projection populated — same tenant id on both sides, and a request carrying `Host: {slug}.{platform-domain}` resolves to that tenant. +- Flipping a feature on the tenant's plan in the Hub reaches LearnStack's feature-flag reads within seconds. +- A projection push carrying an older `generation` than the one already stored is rejected rather than applied. +- With the Hub down and every cache cold, a feature-flag check returns an answer from `platform_entitlement_cache` and respects its grace window. Past the grace window, the documented degraded behaviour applies — no unhandled exception at any point. +- With the Hub down, an anonymous public page load is unaffected. Nothing on that path touches a Hub client. +- `NullEntitlementProvider` is not registered outside `Development`, and the test that says so is no longer vacuous. +- Only `IEntitlementProvider`, `IUsageReporter` and `IHubTenantSync` hold a Hub client anywhere in the LearnStack codebase. +- The internal API rejects a request missing any one of mTLS, the signed JWT, or the HMAC body signature — asserted against the real LearnStack, not a double. + +## Risks + +- **Split-brain merge.** One side merges, the other is delayed by review, and `main` in one repository describes a contract the other does not implement. Mitigated by the protocol above, and by the fact that neither half has a user until both land — there is no pressure to ship one early. +- **The blocking chain is long.** This packet waits on four LearnStack packets. If any of them changes shape — particularly `IEntitlementProvider` in P02a-9 or `platform_entitlement_cache` in P02a-6 — the adapters are written against a moving target. Mitigated by treating the LearnStack-side sockets as the contract and starting the Hub-side call sites only once they are merged. +- **The Hub becomes a single point of failure for tenants.** The mitigation is architectural, not operational: the durable `platform_entitlement_cache` row with its own grace window, plus host resolution that never consults the Hub. Both are testable, and both are in the completion criteria above so they cannot be quietly skipped. +- **Tenant id divergence.** The Hub mints the tenant id and LearnStack must adopt it. If either side generates its own, every subsequent push targets a tenant that does not exist. Mitigated by the Hub minting a version-7 UUID before flush and by an end-to-end test asserting the same id on both sides. +- **Provisioning partially applied.** The Hub commits and the LearnStack push fails, leaving a tenant with a plan and no product. Mitigated by retry plus a reconciliation path — the Hub re-issues `POST /api/internal/tenants` for a tenant LearnStack does not have, and the LearnStack-side `IHubTenantSync` handler is idempotent on the Hub-minted tenant id, so a replay creates nothing twice. The projection push is likewise idempotent under its `generation` guard. +- **The three-adapter rule erodes.** The easiest way to call the Hub is to inject its client where you need it. Mitigated by `Hub_Client_Referenced_Only_By_Named_Adapters`, which is the mechanical form of ADR-0034's second invariant. + +## Phase Exit Decision + +P02c-3 is complete when both pull requests are merged, referencing each other, and a reviewer on a clean checkout of both repositories can run one end-to-end scenario: create a tenant in the Hub, watch it appear in LearnStack with its default organization and projection, flip a plan feature and watch the flag change, then stop the Hub and confirm that public pages still render and feature checks still answer from the durable cache within their grace window. + +[P02c-4](p02c-4-operator-portal.md) can proceed in parallel with this packet — it depends on [P02c-1](p02c-1-hub-domain-core.md) and [P02c-2](p02c-2-internal-api-and-contract.md), not on the LearnStack side of the boundary. Both are prerequisites of [P02c-7](p02c-7-exit-gate.md). diff --git a/docs/roadmap/p02c-4-operator-portal.md b/docs/roadmap/p02c-4-operator-portal.md new file mode 100644 index 0000000..30e973c --- /dev/null +++ b/docs/roadmap/p02c-4-operator-portal.md @@ -0,0 +1,244 @@ +# P02c-4: Operator Portal MVP + +> **Status: ⏳ Not started.** Depends on [P02c-1](p02c-1-hub-domain-core.md) and [P02c-2](p02c-2-internal-api-and-contract.md). Runs in parallel with [P02c-3](p02c-3-learnstack-integration.md) — this packet touches no LearnStack code. + +## Goal + +Give a Hub operator a browser. + +Everything [P02c-1](p02c-1-hub-domain-core.md) and +[P02c-2](p02c-2-internal-api-and-contract.md) build is reachable only through `curl` and +`psql`. Tenant provisioning, plan binding and entitlement recompute are real operations +with real consequences, and until this packet ships they are performed by whoever is +willing to hand-write a JSON body. That is not an operations model — it is a temporary +condition that quietly becomes permanent. + +P02c-4 turns the Hub domain into an application: an authenticated, MFA-gated, +audit-recorded operator surface where provisioning a tenant is a form and reading a +tenant's entitlement is a page. It also lands the two modules that make operator actions +attributable — `Operators` (identity and permissions) and `Audit` (the operator audit +stream) — which is why several shells left open in P02c-1 close here rather than earlier. + +## Scope + +### Authentication — `learnstack-hub` realm, PKCE, BFF + +- OIDC Authorization Code flow with **PKCE** against the `learnstack-hub` Keycloak realm. + The `learnstack` realm is never accepted; the two-realm boundary from + [ADR-0004 Amendment 1](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0004-authentication-strategy.md) + is enforced on both sides of the login — the portal validates issuer and `azp`, and the + Hub API rejects any token whose issuer is the tenant realm. +- **Backend-for-frontend session.** Tokens are held server-side by the Next.js route + handlers under `apps/operator-portal/src/app/api/` and surfaced to the browser only as + an `HttpOnly`, `Secure`, `SameSite=Lax` session cookie. No access token, refresh token + or id token ever reaches `localStorage`, `sessionStorage`, or a client component. An + operator session is a platform-administrator session; an XSS bug that could read a + token would be a full control-plane compromise, so the token never sits where script + can read it. +- **MFA is enforced, not offered.** The realm's browser flow requires OTP, and the + `CONFIGURE_TOTP` required action is set on the operator realm role. The realm export + checked into `infra/keycloak/` carries this configuration, and an integration test + asserts that a login attempt that skips the OTP step does not produce a session. A + realm where MFA is optional is a misconfiguration the portal reports at startup rather + than tolerates. +- Refresh happens in the route handler on a short access-token lifetime; the session + cookie's lifetime is bounded by the refresh token, and logout revokes at the realm. + +### Operators module — identity and permissions + +`LearnStack.Hub.Modules.Operators` maps realm roles onto Hub permission keys and +resolves the `OperatorContext` that the rest of the pipeline reads. + +- Permission keys follow LearnStack's convention — `{module}.{resource}.{action}` with the + closed action set `read | write | delete | admin` + ([Standards 19](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/19-permissions.md)) — and are **all + Platform scope**. Hub has no tenant or organization scope: an operator either has a + capability across the control plane or does not have it. Examples: + `tenants.tenant.read`, `tenants.tenant.write`, `plans.plan.read`, `audit.entry.read`. +- `OperatorId` (not `UserId`) is the actor type throughout, per + [cross-cutting foundation § OperatorId](../architecture/cross-cutting-foundation.md). +- The `AuthorizationBehavior` shell from P02c-1 lights up: it reads the resolved + `OperatorContext` and the handler's declared permission requirement and returns + `Result.Fail(forbidden)` rather than throwing. +- Server-side enforcement is the authority. The portal hides what an operator cannot do, + but hiding is a courtesy — every mutation is checked again in the handler. + +### Audit module — the operator stream + +`LearnStack.Hub.Modules.Audit` ships the `AuditEntry` aggregate and lights up the +`AuditLogBehavior` shell from P02c-1. + +- Every entry carries **`actor.hubOperator = true`**. This flag is the discriminator that + lets a reader looking at Hub and LearnStack audit records side by side tell a + control-plane action from a tenant action. Without it, "who suspended this tenant" and + "who suspended this learner" become the same shape. +- Durability follows [ADR-0033](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0033-audit-durability-model.md): + MUST-class operator audit rows are enrolled in the **same `SaveChanges`** as the + business write, so a crash between the write and the audit cannot happen. Hub has no + Row Level Security, so ADR-0033's RLS-visibility argument does not apply here — but its + crash-loss argument applies in full, and the two systems having different audit + durability semantics would be a difference nobody could remember which way round. + SHOULD/MAY-class stays best-effort with the accepted loss written down. +- The inline MUST/SHOULD/MAY matrices carried in each P02c-1 module doc + ([tenant-lifecycle](../modules/tenant-lifecycle.md), [plans](../modules/plans.md), + [subscriptions](../modules/subscriptions.md), [entitlements](../modules/entitlements.md)) + are consumed by the live writer here. Whether they move to per-module `audit.md` files + or stay inline is decided in this packet and recorded in + [docs/modules/README.md](../modules/README.md). + +### Screens + +The MVP is a deliberate subset of the full portal tree in +[Architecture 24 § 6](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md): + +| Screen | Content | +|---|---| +| **Dashboard** | Tenant count by status and deployment mode, subscriptions by state, recent operator actions. No revenue KPIs — there is no revenue data until [hub-billing.md](hub-billing.md) | +| **Tenants → List** | Cursor-paginated, filterable by status, deployment mode and bound plan; slug and display-name search | +| **Tenants → Detail** | Identity (id, slug, display name, deployment mode, status), subscription summary, status-transition actions (activate / suspend / archive), and the entitlement viewer | +| **Tenants → Detail → Entitlement viewer** | **Read-only.** Renders the projection exactly as [entitlement-projection.md](../architecture/entitlement-projection.md) defines it: `tier`, the `features` map, the `limits` map with `-1` rendered as "unlimited", `compliance.caps` (empty until [P02c-5](p02c-5-custom-domain-lifecycle.md)), `expires_at`, `grace_until`, and the monotonic `generation`. It is a window onto a derived value; the projection is never edited here, because editing a projection instead of its inputs is how projections stop being derivable | +| **Plans → List** | Read-only list of the plan catalogue: name, tier, price, billing cycle, active flag, and the count of subscriptions bound to each | +| **Audit stream** | Filterable operator audit log — actor, action, target, before/after snapshot | + +Cursor pagination, RFC 7807 Problem Details rendering and idempotency-key handling follow +[Standards 04](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/04-api-design.md) through the generated +`@learnstack-hub/sdk` client from P02c-2. The portal never hand-rolls `fetch` against the +Hub API. + +### The plan editor is not in this packet + +[Architecture 24 § 6](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md) shows a +plan editor with feature toggles, limit inputs, price and billing cycle. A plan's feature +payload and its price are edited on one form — splitting them yields two editors for one +aggregate and a second place to get the `FeatureKey` registry wrong. The editor therefore +ships whole, in **[hub-billing.md](hub-billing.md)**, alongside the pricing fields it +shares a form with. + +Until then plans are authored by the P02c-1 seed data and changed through +`CreatePlanCommand` / `UpdatePlanCommand` over the API. That is enough for +[P02c-7](p02c-7-exit-gate.md)'s propagation gate, which flips a plan feature and watches +it reach LearnStack. + +### Frontend app — `operator-portal`, and a stale name + +The app is **`frontend/apps/operator-portal`**. + +LearnStack-side documents call it `learnstack-hub-web` +([Architecture 24 § 6](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md) and +LearnStack's `CLAUDE.md` among them). **That name is stale.** The Hub repository ships +`apps/operator-portal`, and +[`RepositoryLayoutTests.Frontend_Has_Only_The_OperatorPortal_App`](../../backend/tests/LearnStack.Hub.Tests.Architecture/RepositoryLayoutTests.cs) +fails the build if a second frontend app appears or that directory is renamed. P02c-4 +corrects the LearnStack-side references in the coordinated cross-repo pass described in +[CLAUDE.md § Cross-repo coordination](../../CLAUDE.md) — one name, asserted on the side +that owns the directory. + +Also landing here, per [repository-layout.md](../architecture/repository-layout.md): + +- `@learnstack-hub/ui` design-system primitives, previously an empty package. +- ESLint flat-config migration and the Next 16 upgrade the P02c-0 scaffold deferred. +- WCAG 2.2 AA across every screen + ([Standards 16](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/16-accessibility.md)). An internal + tool is still a tool someone uses all day. + +### Observability + +`OperatorContextSpanProcessor` lands here — the enricher +[cross-cutting foundation § 4](../architecture/cross-cutting-foundation.md) defers to +P02c-4 because it needs the Operators module to exist. It tags every span with +`operator.id` and `correlation.id`. It does **not** tag `tenant.id`: Hub spans describe +operator actions, and a Hub span carrying a tenant id would be the first step toward Hub +code thinking in tenant context. + +### Out of scope, with owners + +| Deferred | Owner | +|---|---| +| Plan editor, invoice viewer, billing tab, bulk export | [hub-billing.md](hub-billing.md) | +| Custom-domain pending queue, active list, renewal watch, compliance caps editor | [P02c-5](p02c-5-custom-domain-lifecycle.md) | +| Active licence keys, revocation-list generator, phone-home activity | [P02c-6](p02c-6-license-key.md) | +| Marketplace listing review queue | [hub-marketplace.md](hub-marketplace.md) | +| Usage metrics dashboards | [hub-billing.md](hub-billing.md) — `UsageAggregate` has no aggregation job before then | + +One item from Architecture 24 § 6 is **out of the roadmap entirely**: the +**"Read-only Tenant View"** support tool. Reading a tenant's settings from the Hub means +the Hub holds or caches tenant content, which +[ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md)'s +first invariant forbids. There is no phase that owns it, and there should not be one +until an ADR explains how a support read happens without the Hub touching tenant data. + +## Deliverables + +- `LearnStack.Hub.Modules.Operators` — realm-role to permission-key mapping, + `OperatorContext` resolution, live `AuthorizationBehavior`. +- `LearnStack.Hub.Modules.Audit` — `AuditEntry` aggregate, `IAuditStore`, live + `AuditLogBehavior` writing MUST-class entries inside the business transaction. +- Hub-side permission catalogue registered per module, with default operator role grants. +- `frontend/apps/operator-portal` — BFF session route handlers, dashboard, tenant list, + tenant detail with the read-only entitlement viewer, plan list, audit stream. +- `@learnstack-hub/ui` primitives; ESLint flat config; Next 16. +- `infra/keycloak/` realm export carrying the MFA-required browser flow and the operator + roles. +- `OperatorContextSpanProcessor`. +- Test coverage: unit tests for permission mapping; integration tests for the login flow + (including the MFA-skip rejection and the wrong-realm rejection); an audit test + asserting that a MUST-class command that fails rolls back its audit row with the + business write. + +## Completion Criteria + +- An operator authenticates against the `learnstack-hub` realm with OTP and reaches the + dashboard. A token from the `learnstack` realm is rejected at the portal and again at + the Hub API. +- No token material is readable from the browser: `localStorage`, `sessionStorage` and + `document.cookie` contain no JWT. +- The tenant list shows every tenant created in P02c-1, filters by status and deployment + mode, and pages with cursors rather than offsets. +- Tenant detail renders the entitlement projection with the same field names and value + semantics as `entitlement-v1.schema.json`, including a `generation` that visibly + increments after a plan change. +- Every mutating action performed through the portal produces an `AuditEntry` with + `actor.hubOperator = true`, the operator's id, and a before/after snapshot for the + columns the module's matrix marks. +- An operator without `tenants.tenant.write` cannot suspend a tenant — the button is + absent **and** a direct API call returns `403`. +- Every screen passes an automated WCAG 2.2 AA audit with no violations at the error + level. +- `LearnStack.Hub.Tests.Architecture` green, including + `Frontend_Has_Only_The_OperatorPortal_App` and `Hub_NeverStores_TenantData`. + +## Risks + +- **Token storage drifting to the client.** A future screen needs a token "just for this + one call", and the BFF boundary erodes. Mitigated by keeping the SDK client + session-cookie-based with no token parameter, so there is no signature that accepts one. +- **MFA becoming advisory.** A developer disables OTP locally for convenience and the + realm export follows. Mitigated by asserting the required action in an integration test + against the checked-in realm export, not against a running instance an operator + configured by hand. +- **The entitlement viewer becoming an editor.** The most requested next feature will be + "let me just override this one flag for this one tenant". Doing so makes the projection + no longer a projection and breaks `generation` monotonicity as a cache-coherency + primitive. The answer is a compliance cap ([P02c-5](p02c-5-custom-domain-lifecycle.md)) + or a custom plan — both of which are inputs, not overrides of an output. +- **A screen mutating outside MediatR.** A route handler that writes through the DbContext + directly skips validation, authorization, transaction and audit in one move. Mitigated + by the portal having no database access at all — it holds an HTTP client and nothing + else. +- **Permission keys accreting free-form verbs.** `tenants.tenant.suspend` reads naturally + and breaks the closed action set. Suspension is `tenants.tenant.write`; if it needs + separate authority it becomes a sub-resource, not a new verb. + +## Phase Exit Decision + +P02c-4 is complete when an operator can run the control plane from a browser without a +terminal: log in with MFA against the `learnstack-hub` realm, find a tenant by slug, +read its entitlement projection, suspend and reactivate it, and see both actions in the +audit stream attributed to themselves with `actor.hubOperator = true` — while a +`learnstack` realm token is refused at every door. + +The packet does **not** exit on screen count. A portal that renders every box in +Architecture 24 § 6 but writes audit rows outside the business transaction, or holds a +token where script can read it, has not passed. + +Next: [P02c-5 Custom Domain Lifecycle](p02c-5-custom-domain-lifecycle.md). diff --git a/docs/roadmap/p02c-5-custom-domain-lifecycle.md b/docs/roadmap/p02c-5-custom-domain-lifecycle.md new file mode 100644 index 0000000..f2a6d02 --- /dev/null +++ b/docs/roadmap/p02c-5-custom-domain-lifecycle.md @@ -0,0 +1,307 @@ +# P02c-5: Custom Domain Lifecycle + +> **Status: ⏳ Not started.** Depends on [P02c-1](p02c-1-hub-domain-core.md) and [P02c-2](p02c-2-internal-api-and-contract.md). **Cross-repo** — the LearnStack-side `host-mappings` handler ships in [LearnStack Phase 02c](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-02c-hub-foundation.md) in a coordinated pull request; TLS termination at the LearnStack edge is [Phase 11](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-11-production-hardening.md) and this packet does not wait on it. + +## Goal + +Let a tenant run under its own domain, and make the whole path — submission, ownership +proof, certificate issuance, renewal, revocation — an automated Hub workflow rather than +an operator editing a route file. + +Education platforms compete on brand. A yoga studio does not want its learners typing +`anatolia-yoga.learnstack.app`; the domain is part of the product +([ADR-0022](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0022-custom-domain-tls.md)). But the +mechanism that delivers that domain is the single most security-sensitive thing the Hub +does: it handles ACME challenges, private keys, and the mapping that decides which +tenant's data a request sees. P02c-5 builds it under two hard constraints that +[ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) +makes explicit — **certificate material never travels in the entitlement payload**, and +**the Hub never holds Kubernetes credentials on the LearnStack cluster**. + +This packet also lands the `Compliance` module, because compliance caps are the second +input the entitlement projection has been carrying as an empty `{}` since P02c-1, and +both belong to the same "per-tenant policy an operator sets" surface. + +## Scope + +### `CustomDomain` aggregate and state machine + +`LearnStack.Hub.Modules.CustomDomains` owns `CustomDomain : AuditableEntity`, +shaped as [ADR-0022 § Hub data model](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0022-custom-domain-tls.md) +specifies. + +```mermaid +stateDiagram-v2 + [*] --> Pending: Create(domain) + Pending --> Verifying: StartVerification() + Verifying --> Verifying: RecordVerificationFailure(error) + Verifying --> Failed: attempts exhausted + Verifying --> Active: MarkVerified(certRef, issuedAt, expiresAt) + Active --> Active: Renew(newExpiresAt) + Active --> Revoked: Revoke() + Failed --> Verifying: StartVerification() (operator retry) + Failed --> Revoked: Revoke() +``` + +States: `Pending | Verifying | Active | Failed | Revoked`. Text fallback — a submitted +domain is `Pending`; verification moves it to `Verifying`, where failures accumulate +until the attempt budget is exhausted (`Failed`) or the challenge succeeds (`Active`); +an active domain renews in place and can be revoked; a failed domain can be retried by an +operator or revoked. + +Every transition is a method returning `Result` and emitting a domain event. Invalid +transitions return `Result.Fail(business_rule_violation)` and never throw +`DomainException`. + +Validation at `Create`: + +- FQDN format, no scheme, no port, no wildcard. +- The registrable domain is checked against the bundled Mozilla **public suffix list** — a + tenant cannot claim `com`, `co.uk` or `gov.tr`. +- `ux_custom_domains_domain` — a host belongs to exactly one tenant at a time, enforced by + a unique index rather than by application check, because the failure mode of a duplicate + is one tenant serving another tenant's traffic. +- One `is_primary` domain per tenant. +- The `tenancy.custom_domain` feature key must be present in the tenant's entitlement + ([ADR-0021](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0021-feature-based-entitlement.md)); the + gate is checked at submission, not at activation, so a tenant is told immediately. + +Per [ADR-0022 § Architecture tests](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0022-custom-domain-tls.md), +`CustomDomain_TenantId_NeverReadFrom_RequestBody` — the tenant is always derived from the +authenticated context, never from a submitted field. + +### Challenge runner + +- **DNS-01 by default.** The Hub publishes the `_acme-challenge.{domain}` TXT record + through the tenant's DNS provider API where one is configured, or renders the record for + the tenant to add manually and polls for it. +- **HTTP-01 as fallback**, for tenants that cannot delegate DNS. This path requires the + CNAME to already point at the LearnStack edge, which makes the first issuance + order-dependent: the tenant must cut over DNS before the certificate exists, so the + first request after cutover and before issuance fails. The submission UI states this + ordering; DNS-01 has no such problem, which is why it is the default rather than a + preference. +- A Hangfire recurring job runs CNAME verification with a bounded attempt budget and + backoff (ADR-0022's default: every 60 seconds, up to 60 attempts). `VerificationAttempts` + and `LastVerificationError` are on the aggregate so the operator queue can show *why* a + domain is stuck, not just that it is. +- A second recurring job renews certificates inside their 30-day pre-expiry window. + +### `ITlsCertificateProvider` and the ACME adapter + +- Port `ITlsCertificateProvider` in `LearnStack.Hub.SharedKernel` — `IssueAsync`, + `RenewAsync`, `RevokeAsync`, all in terms of a domain name and an opaque certificate + reference. No ACME type crosses the port. +- Adapter `LearnStack.Hub.Infrastructure.Acme` wraps the Let's Encrypt / ACME client and + translates SDK exceptions into `ProviderException` at the boundary, per the standing + rule that provider SDK exception types never leave their adapter assembly. +- Rate limiting is modelled explicitly rather than folded into "transient failure": + Let's Encrypt's per-registered-domain and duplicate-certificate limits are weekly + windows, so a retry inside the window cannot succeed. The adapter surfaces a distinct + rate-limited outcome; the resilience decorator does not retry it, and the job defers to + the next day and raises an operator alert. +- `IProviderResilience` supplies retry, circuit breaker, timeout + and bulkhead from `appsettings.Resilience:tls:`. +- Development and CI run against an **ACME staging directory** (Pebble or the Let's + Encrypt staging endpoint). No test consumes production issuance quota, and no test + produces a publicly trusted certificate. + +### Integration events + +Published through `IOutbox` → `IEventBus` on the topics +[ADR-0022 Amendment 1](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0022-custom-domain-tls.md) +names: + +| Topic | Emitted when | LearnStack-side effect | +|---|---|---| +| `learnstack.hub.custom-domain.activated` | `MarkVerified` succeeds | **Invalidate** the resolver cache entry for the host. The mapping itself arrives over `PUT /api/internal/tenants/{id}/host-mappings` — see § Propagation below | +| `learnstack.hub.custom-domain.deactivated` | `Revoke` succeeds | **Invalidate** the resolver cache entry for the host. The row is removed by the same push endpoint | +| `learnstack.hub.custom-domain.renewed` | `Renew` succeeds | Refresh the certificate reference; no mapping change | + +Each payload carries the host, the tenant id, the optional organization id, and a +**reference** to the certificate — never the certificate. + +### Propagation — the part ADR-0034 changed + +This is the load-bearing section of the packet. + +[ADR-0022 Amendment 1](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0022-custom-domain-tls.md) +routed host mappings, and with them TLS certificate material including private keys, +through `PUT /api/internal/tenants/{id}/entitlements` — because that kept the contract +surface at four endpoints. The entitlement payload is cached in +`platform_entitlement_cache`, logged, audited and mirrored. Tunnelling a private key +through a cached projection is strictly worse than declaring another endpoint, and +[ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) +supersedes that step. + +Two channels, with different trust properties: + +1. **Host mappings — over HTTP.** + `PUT /api/internal/tenants/{id}/host-mappings` carries the host → + `(tenant_id, organization_id?)` tuple and a **secret-store path** identifying where the + certificate lives. It carries no certificate, no private key, and no chain. It is + cacheable, loggable and auditable precisely because everything in it is safe to cache, + log and audit. +2. **Certificate material — by secret-store replication.** + The Hub writes the issued certificate and key into the **Hub-owned** secret store under + `learnstack-hub/certs/{domain}`. It is replicated into the **LearnStack-owned** secret + store at the path named in the host-mapping payload. LearnStack's edge materialises it + from its own store. The material never transits an application payload in either + direction, and neither side reads the other's store directly. + +Consequences that must stay true: + +- `PUT /api/internal/tenants/{id}/entitlements` carries **no** host fields and **no** + certificate fields. `entitlement-v1.schema.json` sets `additionalProperties: false` so a + future convenience field cannot be added without failing the snapshot test in both + repositories. +- The Hub holds **no** Kubernetes credential on the LearnStack cluster. The route partial, + the APISIX SSL object and the Vault Agent annotation are all written inside the + LearnStack-owned cluster, by LearnStack code, reacting to the event and the push. This + guarantee is unchanged from ADR-0022 Amendment 1; only the transport for the material + changed. +- `Cert_PrivateKey_NeverLeavesVault_To_Logs` (ADR-0022) applies to Hub code as well as + LearnStack code: the log redaction filter strips any string containing a PEM private-key + header before emission. + +### Host resolution never calls the Hub + +LearnStack's `IHostToTenantResolver` reads `platform_host_to_tenant` and nothing else. +`IHubClient.LookupHostAsync` does not exist and must not be reintroduced. + +The reason is availability, not purity: host resolution runs on every anonymous public +page load. A resolver that calls the Hub on a cache miss puts the control plane on the hot +path of every tenant's marketing site, so a Hub deployment takes tenant homepages down +with it. The mapping is pushed, cached durably in a LearnStack-owned table, and read +locally. + +### `Compliance` module + +`LearnStack.Hub.Modules.Compliance` ships `CompliancePolicy` — the per-tenant caps that +merge with the plan's `compliance_defaults` into the projection's `compliance.caps` block. + +- Cap shape is `{ allowed, forced, value? }`, not a bare boolean, per + [entitlement-projection.md § Key-shape rules](../architecture/entitlement-projection.md). +- A `CompliancePolicy` change is a recompute trigger — the row marked `⏳ P02c-5` in that + document's trigger table becomes live here, and `generation` increments like any other + input change. +- Caps are the sanctioned answer to "make this one tenant behave differently". They are an + **input** to the projection, which is why they exist and why the entitlement viewer in + [P02c-4](p02c-4-operator-portal.md) stays read-only. + +### Operator portal screens + +Added to the P02c-4 shell: Custom Domains → Pending Queue, Active List, Renewal Watch +(certificates expiring within 30 days), and Compliance → Caps Editor. + +### What ships on the LearnStack side, and what does not + +The LearnStack-side event consumer, the `host-mappings` handler and the +`platform_host_to_tenant` writes are the paired half of this packet and live in LearnStack +[Phase 02c](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-02c-hub-foundation.md), merged in the +same session per [CLAUDE.md § Cross-repo coordination](../../CLAUDE.md). + +The LearnStack **edge** half is demand-gated. Per +[ADR-0035](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0035-demand-gated-infrastructure.md), both +the APISIX adapter and custom-domain TLS automation land in +[LearnStack Phase 11](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-11-production-hardening.md), +with the trigger *"a tenant needs its own domain in production"*. Before that trigger +fires, LearnStack terminates TLS with its default ASP.NET hosting rather than APISIX SSL +objects. + +The split is clean because routing and termination are separable: host **resolution** +works as soon as `platform_host_to_tenant` carries the row, so a request with a custom +`Host` header reaches the right tenant from this packet onward. What waits for Phase 11 is +serving that host on a publicly trusted certificate at the edge. + +## Deliverables + +- Inbound handler for `POST /api/v1/internal/tenants/{id}/custom-domains` — the submission + hop LearnStack's Admin Studio proxies, because a `learnstack` realm token is rejected + at the Hub ([ADR-0004](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0004-authentication-strategy.md)). Enumerated in + [ADR-0034 § The endpoint set](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md). + +- `LearnStack.Hub.Modules.CustomDomains` — aggregate, state machine, public-suffix + validation, uniqueness constraints, commands and queries. +- `LearnStack.Hub.Modules.Compliance` — `CompliancePolicy`, cap merge into the projection, + recompute trigger. +- `ITlsCertificateProvider` port plus the `LearnStack.Hub.Infrastructure.Acme` adapter, + resilience-decorated, with a distinct rate-limited outcome. +- DNS-01 and HTTP-01 challenge runners; the CNAME verification recurring job; the renewal + recurring job. +- `PUT /api/internal/tenants/{id}/host-mappings` on the outbound `LearnStackApiClient`, + carrying host tuples and certificate references only. +- Secret-store replication path from `learnstack-hub/certs/{domain}` to the LearnStack-side + path, with the LearnStack side referencing by path. +- The three `learnstack.hub.custom-domain.*` integration events. +- Operator portal: pending queue, active list, renewal watch, caps editor. +- Tests: unit tests for every state transition and every rejection at `Create`; an + integration test issuing against an ACME staging directory end to end; a contract test + asserting `entitlement-v1.schema.json` contains no host or certificate fields. + +## Completion Criteria + +- A domain submitted through the operator portal reaches `Active` against an ACME staging + directory without an operator touching a configuration file. +- The host-mapping push lands a `platform_host_to_tenant` row in LearnStack, and a request + carrying that `Host` header resolves to the correct tenant. +- The entitlement projection pushed for that tenant contains **no** certificate material + and **no** host fields; the schema snapshot test proves it in both repositories. +- No Hub log line, audit row, span attribute or error-tracking envelope contains a PEM + private-key header. +- Revoking the domain emits `.deactivated`, removes the mapping, and a request with that + `Host` header no longer resolves to the tenant. +- A domain already registered to another tenant is rejected at submission by the unique + index, not by a race-prone application check. +- A rate-limited issuance defers to the next day and raises an operator alert instead of + burning retries inside the window. +- A `CompliancePolicy` change increments `generation` and appears in the tenant's + projection. +- Hub architecture suite green, including `Hub_NeverStores_TenantData` — + `CustomDomain` holds a hostname and a certificate reference, which are tenant metadata, + not tenant content. + +## Risks + +- **Certificate material creeping back into the entitlement payload.** It is the shortest + path, it worked once, and the reasoning that justified it is still in ADR-0022's + amendment text. Mitigated by `additionalProperties: false` on the projection schema plus + a snapshot test on both sides — the shortcut fails CI rather than review. +- **Host takeover across tenants.** Tenant A releases a domain, tenant B claims it, and a + stale cached mapping serves B's traffic into A's data, or the reverse. Mitigated by the + unique index on host, by `.deactivated` removing the mapping before a new `.activated` + can be accepted for the same host, and by the resolver cache being invalidated on the + deactivation event rather than by TTL expiry. +- **ACME rate limits discovered in production.** Failed-validation limits bite hardest + during the first week of real customer onboarding, when misconfigured DNS is most + common. Mitigated by the bounded attempt budget, by staging everywhere except + production, and by surfacing `LastVerificationError` so an operator fixes the DNS + instead of retrying the job. +- **HTTP-01's ordering trap.** The tenant must point DNS at the edge before a certificate + exists, so there is a window of failed requests. Mitigated by defaulting to DNS-01 and + by stating the ordering in the submission UI rather than in a runbook nobody reads. +- **Split-brain between the mapping table and the certificate store.** A mapping exists + for a host whose certificate replication failed, so the host resolves but does not + serve. Mitigated by making `Active` conditional on both the mapping push and the + replication acknowledging, and by the renewal watch screen showing certificate state + next to mapping state. +- **Air-gapped deployments have no Hub at all.** Customer-provided certificates placed + directly in the customer's own secret store are the path there + ([ADR-0022 Amendment 2](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0022-custom-domain-tls.md) + — its SaaS / Dedicated bullet is superseded by ADR-0034, the air-gapped half stands); + none of this packet applies to `SelfHostedAirGapped`, and the `.lic` file's + `custom_domains` claim is how that mode tells LearnStack which hosts to expect + ([P02c-6](p02c-6-license-key.md)). + +## Phase Exit Decision + +P02c-5 is complete when a domain goes from submitted to serving without an operator +editing infrastructure, and when the two things ADR-0034 was written to protect are +demonstrably true: the entitlement payload is free of certificate material and host +fields, and no code path resolves a host by calling the Hub. + +The packet does **not** exit on a green issuance alone. If the mapping is correct but a +private key appears in a log, or the entitlement schema quietly grew a `certificate` +field, the packet is not done — those are the failure modes it exists to prevent. + +Next: [P02c-6 License Key](p02c-6-license-key.md). diff --git a/docs/roadmap/p02c-6-license-key.md b/docs/roadmap/p02c-6-license-key.md new file mode 100644 index 0000000..ec1df0a --- /dev/null +++ b/docs/roadmap/p02c-6-license-key.md @@ -0,0 +1,310 @@ +# P02c-6: License Key + +> **Status: ⏳ Not started.** Depends on [P02c-1](p02c-1-hub-domain-core.md) and [P02c-2](p02c-2-internal-api-and-contract.md). **Cross-repo** — the LearnStack-side `SignedLicenseKeyEntitlementProvider` skeleton ships in a coordinated pull request; its operational hardening is [LearnStack Phase 11](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-11-production-hardening.md). + +## Goal + +Make the Hub able to issue, and later withdraw, a portable statement of what a customer +bought. + +Every other entitlement path in this repository assumes a network. `HubEntitlementProvider` +calls the Hub; the projection push reaches LearnStack over HTTPS; a plan change propagates +in seconds. A Self-Hosted customer in a regulated or air-gapped environment has none of +that, and +[ADR-0020](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0020-triple-deployment-hybrid-license.md) +answers it with an RSA-signed `.lic` file that embeds the same entitlement projection — +no schema fork, no second entitlement model, just a different transport with a longer +refresh interval and bounded trust. + +P02c-6 builds the issuing half of that: the `LicenseKey` aggregate, the signing keypair +and its rotation seam, the file format, and the revocation mechanism. The consuming half +on the LearnStack side ships as a **functional skeleton** here and is hardened in +[LearnStack Phase 11](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-11-production-hardening.md), +whose trigger — per +[ADR-0035](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0035-demand-gated-infrastructure.md) — is +*"a Self-Hosted contract is signed"*. + +## Scope + +### `LicenseKey` aggregate + +`LearnStack.Hub.Modules.LicenseKeys` owns `LicenseKey : AuditableEntity`. + +| Column | Notes | +|---|---| +| `id` | uuid PK. **This is the `license_id` claim.** Revocation is keyed on it | +| `tenant_id` | the tenant the licence was issued for | +| `kid` | the signing key that produced this licence; survives key rotation | +| `deployment_mode` | `SelfHostedOnline \| SelfHostedAirGapped` | +| `entitlement_generation` | the projection generation frozen into the payload at issuance | +| `issued_at` / `expires_at` / `grace_until` | the licence's own clock | +| `status` | `Issued \| Superseded \| Revoked` | +| `revoked_at` / `revocation_reason` | set on revocation; both audited | + +Re-issuing for a tenant marks the previous key `Superseded` rather than deleting it — a +key that was valid yesterday needs a row today so that a customer presenting it gets an +accurate answer. + +Issuing and revoking are both MUST-class audited operator actions +([P02c-4](p02c-4-operator-portal.md)). + +### Signing keys + +- **RSA-2048 minimum**, RS256. The verifier rejects anything weaker; + `LicenseKey_Validation_Is_Pinned_RSA2048` + ([ADR-0020 § Architecture tests](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0020-triple-deployment-hybrid-license.md)) + asserts it. +- Private key lives in the Hub's secret store at + `secret/learnstack-hub/license-signing-key`, read through `ISecretProvider`. It is never + in the repository, never in a container image, never in an environment variable dump. +- Public keys are published as a JWKS-style key set addressed by `kid` and embedded into + LearnStack releases at build time, so an air-gapped verifier needs no network to + validate a signature. +- Rotation is a `kid` addition, not a cutover: both keys stay valid for a deprecation + window, and a key retired mid-window still validates licences issued before its + `RetiredAt`. The rotation *procedure* — key generation ceremony, custody, the release + that ships the new public key — is + [LearnStack Phase 11](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-11-production-hardening.md). + What ships here is the data model that makes rotation possible without a format change. + +### The `.lic` file + +Wire format is `..` — JWT +shape, distinguished by the header `typ: "LSL"` ("LearnStack License"), per +[Hybrid License Model § 1](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/26-hybrid-license-model.md). + +Header: `{ "alg": "RS256", "typ": "LSL", "kid": "lsl-signing-key-v1" }`. + +Payload claims: + +| Claim | Purpose | +|---|---| +| `license_id` | **the licence's identity — see below** | +| `iss` | always `learnstack-hub` | +| `sub` | tenant id | +| `iat` / `exp` | issued-at and expiry, numeric | +| `deployment_mode` | `SelfHostedOnline` or `SelfHostedAirGapped` | +| `entitlement` | the full projection: `tier`, `features`, `limits`, `compliance.caps`, `generation` — byte-identical in shape to what `PUT /api/internal/tenants/{id}/entitlements` carries | +| `grace_until` | end of degraded-but-functional operation after `exp` | +| `phone_home_url` | refresh endpoint, absent for `SelfHostedAirGapped` | +| `revocation_list_url` | where the signed revocation bundle is published | +| `custom_domains` | optional; the hosts an air-gapped deployment should expect, since there is no host-mapping push in that mode ([P02c-5](p02c-5-custom-domain-lifecycle.md)) | + +### The claim set must carry `license_id` + +The documented payload does not have one, and everything downstream assumes it does. + +[Hybrid License Model § 7](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/26-hybrid-license-model.md) +publishes a revocation bundle whose body is a list of `revoked_license_ids`, and its +verification sequence checks that the presented licence's `license_id` is not in that +set. The payload in § 1 of the same document carries `iss`, `sub`, `iat`, `exp`, +`deployment_mode`, `entitlement`, timestamps and two URLs — and no identifier for the +licence itself. A verifier holding that file cannot name the thing it is holding, so it +cannot look it up in a revocation list. Revocation, the entire break-glass mechanism +behind offline licensing, does not work against the payload as written. + +P02c-6 fixes it: + +- **`license_id` is a required claim**, carrying `LicenseKey.id`. Not optional, not + inferred from `sub` — a tenant can hold several licences over time and revoking one + must not revoke the tenant. +- `license-payload-v1.schema.json` is checked into **both** repositories and asserted by a + snapshot test in each, exactly as `entitlement-v1.schema.json` is. The schema is the + contract; prose in an architecture document is not. +- The same pass reconciles the payload's duplicate timestamp spellings: § 1 carries both + numeric `iat` / `exp` and ISO-8601 `issued_at` / `expires_at` for the same two instants. + One spelling is normative and the other is removed, in both repositories, in the same + change. Two spellings of one fact is how the two sides end up disagreeing about when a + licence expires. +- The payload shape sits inside + [ADR-0020's](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0020-triple-deployment-hybrid-license.md) + Decision section, so the correction lands as a **dated Amendment** to that ADR, never as + an edit to the Decision text. + +### Revocation list + +An issued licence is trusted until `exp` unless something withdraws it. That something is +a signed revocation bundle: + +```json +{ + "generated_at": "2026-08-08T00:00:00Z", + "revoked_license_ids": ["", ""], + "signature": "base64url(RS256 over the canonicalised body)" +} +``` + +**It is published as a signed static artefact at a fixed URL, not as an internal-API +endpoint.** +[ADR-0020](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0020-triple-deployment-hybrid-license.md) +describes it that way; [Hybrid License Model § 7](https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/26-hybrid-license-model.md) +shows it under `/api/v1/internal/license/revocations`, which is a path +[ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md)'s +enumerated LearnStack → Hub set does not contain. The static-artefact form is chosen +because it keeps the contract surface unchanged and because it is the form an air-gapped +customer can actually consume — a file they can carry in on media, verify offline, and +place next to their licence. If the endpoint form is ever preferred instead, it needs an +ADR, and the two documents are reconciled onto the static form here either way. + +The bundle is unauthenticated because its signature is its authentication and its contents +are opaque identifiers — no tenant name, no slug, no plan. An unauthenticated reader +learns how many licences have been revoked, not whose. + +### Issuance and phone-home flows + +- **Issue.** An operator picks a tenant; the Hub composes that tenant's current + `Entitlement` projection, wraps it with the claim set above, signs it, records the + `LicenseKey` row, and returns a `.lic` download. Issuance is a recompute trigger — the + row marked `⏳ P02c-6` in + [entitlement-projection.md § When recompute fires](../architecture/entitlement-projection.md) + becomes live, and re-issuance sets `expires_at` and `grace_until` on the `Entitlement` + row so the online and offline paths agree about the same tenant's expiry. +- **Revoke.** An operator marks the `LicenseKey` row `Revoked` with a reason; the next + bundle generation includes its id. +- **Phone-home.** `POST /api/v1/internal/license/refresh` — already in ADR-0034's endpoint + set and already handled since P02c-2 — gets its live caller. Each successful refresh + calls `LearnStackTenant.RecordPhoneHome(at)`, the method P02c-1 shipped without a + caller, so `last_phone_home_at` stops being permanently null and the operator portal can + show which Self-Hosted deployments have gone quiet. + +### Operator portal screens + +Added to the [P02c-4](p02c-4-operator-portal.md) shell: Licenses → Active Keys, +Revocation List Generator, Phone-Home Activity. + +### LearnStack side — a functional skeleton, and what it is not + +`SignedLicenseKeyEntitlementProvider` ships in LearnStack as the third +`IEntitlementProvider` implementation +([ADR-0020](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0020-triple-deployment-hybrid-license.md)), +doing the work that cannot be faked: + +- parse the `.lic` file, read `kid`, resolve it against the embedded public key set; +- verify the RS256 signature, reject weaker algorithms, reject an unknown or + wrongly-retired `kid`; +- validate the payload against `license-payload-v1.schema.json`; +- read the embedded projection and serve feature and limit lookups from it; +- honour `exp` and `grace_until` — functional before `exp`, degraded within grace, + read-only past it. This is the **licence-expiry** ladder and it is deliberately not + the same as the Hub-outage ladder that + [ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) + defines. An outage is a control-plane failure the tenant did not cause, so ADR-0034 + answers it with a durable grace window plus a per-key fail-open / fail-closed class; + an expired licence is the licence working as intended, so it degrades to read-only and + stays there. Do not sweep this paragraph to match the ADR-0034 wording. + +**Production hardening is [LearnStack Phase 11](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-11-production-hardening.md)**, +per [ADR-0035](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0035-demand-gated-infrastructure.md): + +- the signing-key rotation procedure and custody model; +- signed revocation-list distribution, the daily refresh job, and its offline fallback; +- the `SIGHUP` hot-reload runbook for dropping a new `.lic` into + `/var/learnstack/license/` without a restart; +- grace-period behaviour under load, and the read-only degradation path exercised against + a realistic workload; +- the air-gapped install runbook. + +Each of those is an operational procedure that needs a real deployment to be worth +writing. The skeleton proves the format and the verification; the hardening proves the +operations. + +## Deliverables + +- `LearnStack.Hub.Modules.LicenseKeys` — aggregate, issue / re-issue / revoke commands, + queries for the portal screens. +- RSA-2048 signing keypair provisioned through `ISecretProvider`; `kid`-addressed public + key set published for embedding into LearnStack releases. +- `.lic` signer producing the `typ: "LSL"` three-segment format. +- `license-payload-v1.schema.json` in both repositories, with `license_id` required and + the duplicate timestamp spellings reconciled; snapshot test on each side. +- Dated Amendment to ADR-0020 recording the claim-set correction. +- Signed revocation bundle generator publishing to a fixed URL. +- **Phone-home client certificate in the licence bundle.** + [ADR-0034 § One auth chain, both directions](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) + extends the mTLS + JWT + HMAC chain to the LearnStack → Hub direction, replacing the + per-instance API key. A `SelfHostedOnline` instance therefore cannot phone home + without a client certificate the Hub's CA will validate, and this packet is the only + one that hands a customer-run instance anything. The certificate is issued alongside + the `.lic` file, carries the same `license_id`, and is re-issued by the same + re-issue command on the same cadence — one artefact, one expiry, one revocation. A + revoked licence revokes the certificate with it, so a revoked instance loses the + transport before it loses the entitlement. `SelfHostedAirGapped` makes no outbound + call and is issued no certificate. +- Live `POST /api/v1/internal/license/refresh` path calling `RecordPhoneHome`. +- Recompute trigger on licence issuance, setting `expires_at` and `grace_until` on the + `Entitlement` row. +- Operator portal licence screens. +- LearnStack-side `SignedLicenseKeyEntitlementProvider` functional skeleton, in a + coordinated pull request. + +## Completion Criteria + +- An operator issues a `.lic` for a tenant and downloads it; the `LicenseKey` row records + `license_id`, `kid`, `entitlement_generation`, and the issuing operator. +- A LearnStack instance in `SelfHostedAirGapped` mode, with no network path to the Hub, + loads that file, verifies it against the embedded public key set, and answers feature + and limit lookups from the embedded projection. +- A file signed with a weaker algorithm, an unknown `kid`, or a tampered payload is + rejected — and the rejection names which check failed in the log, without echoing the + payload. +- `license_id` is present and required in `license-payload-v1.schema.json` in both + repositories, and the snapshot tests agree. +- Revoking the licence and refreshing the revocation bundle causes the same instance to + deny it **before** `exp`. +- Past `exp` but within `grace_until`, the instance is functional and shows the grace + banner; past `grace_until`, it is read-only rather than broken. +- A successful phone-home from a `SelfHostedOnline` deployment updates + `last_phone_home_at` and is visible on the Phone-Home Activity screen. It presents the + client certificate issued with its licence bundle; the same call with no certificate, + with an expired one, or with one belonging to a revoked licence is refused at the TLS + handshake — before any handler runs and before the JWT or the HMAC signature is + examined. +- No log line, span attribute or error envelope contains the private key or a full `.lic` + payload. + +## Risks + +- **Signing-key compromise trusts every licence ever issued.** There is no per-licence + secret; the keypair is the whole trust root. Mitigated by keeping the private key in the + secret store only, by shipping the `kid` rotation seam from day one so rotation is a + configuration change rather than a format change, and by treating the revocation bundle + as the break-glass. The custody procedure itself is Phase 11. +- **Revocation is only as good as the refresh.** An air-gapped customer may never fetch + the bundle, so an air-gapped licence is effectively trusted until `exp`. This is the + price of air-gap and it is written down here rather than implied: revocation is + effective at the next online window, and `exp` is the only bound that always holds. + Licence terms for air-gapped customers should therefore be short enough that `exp` is a + real control. +- **Clock manipulation.** Moving the system clock back extends a licence indefinitely. + Mitigated by recording a monotonic last-seen timestamp on the LearnStack side and + refusing to accept a wall clock that has moved backwards past it. +- **Two entitlement models drifting apart.** The licence path and the Hub path serve the + same feature checks; if their payloads diverge, a Self-Hosted customer and a SaaS + customer on the same plan get different behaviour. Mitigated by the licence embedding + the projection verbatim and by both paths validating against the same + `entitlement-v1.schema.json`. +- **The `license_id` omission recurring.** It survived from ADR-0020 through the + architecture deep dive because prose is not executable. Mitigated by making the schema — + not the document — the contract, asserted on both sides. +- **A skeleton read as finished.** `SignedLicenseKeyEntitlementProvider` verifying a file + correctly looks a great deal like a supported deployment mode. It is not: without the + rotation procedure, the revocation refresh and the hot-reload runbook, `SelfHostedOnline` + and `SelfHostedAirGapped` remain prepared seams rather than supported deployments, which + is exactly what + [ADR-0035](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0035-demand-gated-infrastructure.md) + says. Sales-facing material must say the same. + +## Phase Exit Decision + +P02c-6 is complete when a licence can be issued, verified offline, and withdrawn: an +operator issues a `.lic`, an air-gapped LearnStack instance runs on it with no network, +and revoking it plus refreshing the bundle denies that instance before its expiry — with +`license_id` a required claim in a schema both repositories assert. + +The packet does **not** claim Self-Hosted as a supported deployment mode. That claim +belongs to +[LearnStack Phase 11](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-11-production-hardening.md), +when the rotation, distribution and hot-reload procedures exist and a signed contract has +made them worth writing. + +Next: [P02c-7 End-to-End Exit Gate](p02c-7-exit-gate.md). diff --git a/docs/roadmap/p02c-7-exit-gate.md b/docs/roadmap/p02c-7-exit-gate.md new file mode 100644 index 0000000..19a8146 --- /dev/null +++ b/docs/roadmap/p02c-7-exit-gate.md @@ -0,0 +1,242 @@ +# P02c-7: End-to-End Exit Gate + +> **Status: ⏳ Not started.** The gate, not a build packet: it closes only when P02c-1 through P02c-6 have shipped. + +## Goal + +Decide, on evidence, whether Phase 02c is done. + +Packets [P02c-1](p02c-1-hub-domain-core.md) through +[P02c-6](p02c-6-license-key.md) each close on their own criteria, and each one is +reviewed against its own scope. That is exactly how a control plane ends up with six +green packets and a boundary that does not hold: the interesting failures in a two-repository, +two-realm, mutually-authenticated system live *between* the packets, and nobody owns +between. + +P02c-7 owns it. It writes no new features. It runs seven scenarios across both +repositories against a clean environment and states, as a single answer, whether the Hub +and LearnStack are actually integrated or merely both compiling. + +## Scope + +Seven gates. Each is an observable a reviewer can watch, not a claim they have to trust. + +### Gate 1 — Provisioning round trip + +An operator creates a tenant in the Hub operator portal, and it exists in LearnStack. + +- `CreateTenantCommand` persists `LearnStackTenant` (status `Trial`), the initial + `HubSubscription` bound to the chosen plan, and the initial `Entitlement` at + `generation = 1`. +- `POST /api/internal/tenants` reaches LearnStack, which creates the tenant **and its + default organization** — organization is not optional, and a tenant without one is a + tenant no `[OrganizationScoped]` write can target. +- The tenant carries the **same UUID on both sides**. The Hub mints it with + `Guid.CreateVersion7()` through `IGuidFactory` before flush, precisely so the two + systems never have to reconcile two identities for one tenant. +- LearnStack's `platform_entitlement_cache` holds the projection at generation 1, with the + plan's `features` and `limits` and an empty `compliance.caps`. + +### Gate 2 — Entitlement propagation + +An operator flips one feature on a plan, and LearnStack answers differently within +seconds. + +- `UpdatePlanCommand` persists the plan change and fans out a recompute over every + subscription bound to that plan. +- Each affected `Entitlement` increments `generation` by exactly one. +- `PUT /api/internal/tenants/{id}/entitlements` pushes the new projection; LearnStack + accepts it only if `received.generation >= cached.generation`, so a late or out-of-order + delivery cannot overwrite newer state. +- A feature check on the LearnStack side returns the new value **within the push round + trip** — not within a cache TTL. The push is what makes propagation seconds rather than + minutes; a system that only converges when the 15-minute TTL expires has not passed this + gate, it has merely waited. +- The L1 and L2 caches are invalidated by the push, and the durable + `platform_entitlement_cache` row is updated, in the order + [ADR-0034 § The entitlement read path](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) + makes normative. + +### Gate 3 — Custom domain resolves to the right tenant + +A domain goes from submitted to resolving, without an operator editing infrastructure. + +- The domain completes a DNS-01 challenge against an ACME staging directory and reaches + `Active`. +- `learnstack.hub.custom-domain.activated` and + `PUT /api/internal/tenants/{id}/host-mappings` land a `platform_host_to_tenant` row. +- A request carrying that `Host` header resolves to that tenant, and a request carrying a + different tenant's host resolves to the other one — both asserted, because a resolver + that always returns the same tenant passes a single-host test. +- **The resolver does not call the Hub.** The Hub is stopped for this assertion; host + resolution keeps working. An anonymous page load must not depend on the control plane. +- The entitlement payload for that tenant contains **no** certificate material and **no** + host fields, per [P02c-5](p02c-5-custom-domain-lifecycle.md). + +### Gate 4 — The internal API rejects a request missing any leg of the auth chain + +Both directions, three negative tests each. + +The chain is mTLS + RS256 JWT (`aud=learnstack-internal`, five-minute expiry, `jti` replay +protection) + HMAC body signature, applied to every endpoint in +[ADR-0034's set](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md). + +| Removed | Expected | +|---|---| +| Client certificate | Connection refused at the TLS layer; no application handler runs | +| JWT (absent, expired, wrong audience, or replayed `jti`) | `401`, request not processed | +| HMAC body signature (absent or over a tampered body) | `401`, request not processed | + +Run against **both** internal surfaces: LearnStack's `/api/internal/*` (Hub → LearnStack) +and the Hub's `/api/v1/internal/*` (LearnStack → Hub). Each rejection is logged with the +correlation id and the failed check; the response body says nothing about which leg +failed, because an attacker probing the chain should learn nothing from the shape of the +refusal. + +`Internal_API_Endpoints_AreNot_Public` stays green: neither internal surface is bound to +an internet-facing listener. + +### Gate 5 — The realm boundary holds in both directions + +- A `learnstack` (tenant) realm token presented to the operator portal or to a Hub + operator-facing endpoint is **rejected**. +- A `learnstack-hub` (operator) realm token presented to a LearnStack tenant-facing + endpoint is **rejected**. + +Both directions, both asserted at runtime and both backed by +`Hub_Operator_JWT_NeverAccepted_On_LearnStack_Routes` and its Hub-side mirror. A token +that is valid, unexpired and correctly signed by the wrong realm is the interesting case — +signature validity is not authorisation, and an issuer check that is absent looks +identical to one that passes until the day it matters. + +### Gate 6 — Both architecture suites green + +`LearnStack.Hub.Tests.Architecture`: + +- `Hub_NeverStores_TenantData` +- `Hub_Modules_DoNotReference_LearnStack_Internals` +- `Internal_API_Endpoints_AreNot_Public` +- `Frontend_Has_Only_The_OperatorPortal_App` +- `No_Source_Folder_Named_Verticals` +- `Meta_NetArchTest_DetectsAPlantedViolation` — the suite proves it can still fail + +`LearnStack.Tests.Architecture`, on the boundary rules: + +- `LearnStack_Modules_DoNotReference_Hub` +- `Hub_Client_Referenced_Only_By_Named_Adapters` — no type outside + `IEntitlementProvider`, `IUsageReporter` and `IHubTenantSync` holds a Hub client +- `IEntitlementProvider_Implementations_Are_Three` +- `NullEntitlementProvider_NotRegistered_OutsideDevelopment` +- `Modules_Do_Not_Read_Entitlement_Cache_Directly` +- `Modules_Do_Not_Reference_DeploymentMode` +- `CustomDomain_TenantId_NeverReadFrom_RequestBody` and + `Cert_PrivateKey_NeverLeavesVault_To_Logs` from + [ADR-0022](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0022-custom-domain-tls.md) +- `LicenseKey_Validation_Is_Pinned_RSA2048` from + [ADR-0020](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0020-triple-deployment-hybrid-license.md) + +Names follow the reconciled canonical identifiers in +[Standards 21](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/21-architecture-tests-catalogue.md); +Hub-side rules are registered there rather than existing only in this repository. + +### Gate 7 — One contract, two repositories + +- `entitlement-v1.schema.json` is byte-identical in both repositories, and each side's + snapshot test asserts its own serialiser against it. `additionalProperties: false`, so a + field cannot be added on one side alone. +- `license-payload-v1.schema.json` likewise, with `license_id` required + ([P02c-6](p02c-6-license-key.md)). +- The endpoint set implemented on both sides matches + [ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md) + exactly — no extra path, no missing one. +- The `FeatureKey` / `LimitKey` registries agree. The two repositories each keep their own + copy ([plans.md § Registry sync](../modules/plans.md)), so a reconciliation check runs + here and fails on drift rather than discovering it in a customer's projection. + +### What this packet does not do + +No new aggregates, no new endpoints, no new screens. Anything the gates expose is fixed in +the packet that owns it, and that packet's status is reopened. A defect found here is not +"P02c-7 work" — it is evidence that an earlier packet exited early. + +## Deliverables + +- A cross-repository end-to-end suite that boots both stacks (Testcontainers for Postgres, + the ACME staging directory, and both Keycloak realms) and runs Gates 1–5 as executable + scenarios. +- Negative-path tests for the auth chain, both directions, all three legs. +- The registry-reconciliation check for `FeatureKey` / `LimitKey`. +- A short runbook for running the gate locally, in + [docs/operations/](../operations/README.md). +- Hub-side architecture test registrations added to + [Standards 21](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/21-architecture-tests-catalogue.md) + under canonical names. +- A dated Phase 02c status entry in [the Hub roadmap index](README.md), and the matching + entry on the LearnStack side in + [Phase 02c](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-02c-hub-foundation.md). + +## Completion Criteria + +Phase 02c is done when all of the following are observable, in this order, against a +freshly provisioned environment: + +1. An operator creates a tenant in the Hub, and it appears in LearnStack with its default + organization and an entitlement projection at generation 1, under the same UUID. +2. Flipping a feature on that tenant's plan propagates to a LearnStack feature check + within seconds, with `generation` incremented and stale-overwrite protection proven by + replaying an older generation and watching it be rejected. +3. A custom domain completes its DNS challenge, reaches `Active`, and a request carrying + that host resolves to that tenant — with the Hub stopped. +4. A request to either internal API missing mTLS, missing or invalid JWT, or missing or + invalid HMAC is rejected, and the rejection reveals nothing about which leg failed. +5. The operator portal is unreachable with a `learnstack` realm token, and LearnStack's + tenant surface is unreachable with a `learnstack-hub` realm token. +6. Both architecture suites are green, including the meta-test that proves the Hub suite + can still detect a planted violation. +7. Both schema snapshot tests pass against identical schema files, and the feature-key + registries reconcile. + +## Risks + +- **Gate theatre.** A suite that only walks the happy path certifies nothing; the negative + cases in Gates 3, 4 and 5 are the ones that carry the weight. Mitigated by making each + negative case an explicit, separately named test, so deleting one is visible in the diff. +- **The gate becoming the only place the boundary is exercised.** If the auth chain is + tested nowhere else, a change breaks it and nothing notices until the full suite runs. + Mitigated by keeping the per-packet integration tests that cover their own half, with + this suite covering only the seam. +- **Two-repository drift after the gate.** The gate is a point-in-time proof; the schemas + and the endpoint set can diverge the next week. Mitigated by the snapshot tests and the + registry reconciliation running in **both** CI pipelines, not only in this suite. +- **Environment-dependent green.** A gate that passes because a developer's Keycloak still + holds yesterday's client configuration proves nothing. Mitigated by running against a + clean environment built from the checked-in realm export and compose files, twice in a + row. +- **Reopening an earlier packet feels like regression.** It is not — a gate finding is the + system working as designed. The alternative is closing Phase 02c over a known break. + +## Phase Exit Decision + +**Phase 02c is complete when the seven gates pass in CI, against a clean environment, +twice in succession, and the two repositories' status records agree on what shipped.** + +That is the whole claim. Restated in one sentence for anyone deciding whether to depend on +it: *the Hub can provision, entitle, domain-name and licence a LearnStack tenant across a +mutually authenticated boundary that refuses every request missing any leg of its auth +chain, refuses each realm's token on the other realm's surface, and never carries tenant +content or private keys across.* + +What Phase 02c explicitly does **not** deliver, with owners: + +| Not delivered | Owner | +|---|---| +| Invoicing, payment capture, dunning, usage aggregation, plan editor | [hub-billing.md](hub-billing.md) | +| Marketplace listings and installs | [hub-marketplace.md](hub-marketplace.md) | +| Self-Hosted as a *supported* deployment mode — rotation, revocation distribution, hot-reload runbook | [LearnStack Phase 11](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-11-production-hardening.md) | +| Custom-domain TLS termination at the LearnStack edge | [LearnStack Phase 11](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-11-production-hardening.md) | +| Hub production deployment, HA, backup and restore | Hub Operations — unscheduled; see [the post-MVP tracks table](README.md#post-mvp-tracks). LearnStack Phase 11 scopes itself to LearnStack and does not cover the Hub's own database, realm or certificate chain. | + +LearnStack does not wait on any of it. Per +[ADR-0035](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0035-demand-gated-infrastructure.md) the +core runs on `NullEntitlementProvider` until a tenant must be billed or plan-gated, which +is the trigger that makes this phase urgent rather than the calendar. diff --git a/infra/compose/README.md b/infra/compose/README.md index 7125f45..faf39c1 100644 --- a/infra/compose/README.md +++ b/infra/compose/README.md @@ -15,16 +15,16 @@ Total: ~4 services. By contrast, LearnStack core's compose stack runs 15 service ```bash # 1. LearnStack core compose (shared backends) -cd ../learnstack && make dev +cd ../LearnStack && make dev # 2. Hub-side compose -cd ../learnstack-hub && make dev +cd ../LearnStack-Hub && make dev # 3. LearnStack core API -cd ../learnstack/backend && dotnet run --project src/LearnStack.Api +cd ../LearnStack/backend && dotnet run --project src/LearnStack.Api # 4. Hub API -cd ../learnstack-hub/backend && dotnet run --project src/LearnStack.Hub.Api +cd ../LearnStack-Hub/backend && dotnet run --project src/LearnStack.Hub.Api ``` The boot-order dependency is dev-only. Production deploys the two stacks independently. @@ -44,7 +44,7 @@ curl -s http://localhost:8080/realms/learnstack-hub/.well-known/openid-configura ## Demo operator credentials -Loaded from `../learnstack/infra/keycloak/realms/learnstack-hub.json` at LearnStack core's Keycloak first boot: +Loaded from `../LearnStack/infra/keycloak/realms/learnstack-hub.json` at LearnStack core's Keycloak first boot: - Username: `demo-operator@learnstack.test` - Password: `demo-dev-secret` diff --git a/infra/compose/dev.yml b/infra/compose/dev.yml index 6f35403..1ad2df5 100644 --- a/infra/compose/dev.yml +++ b/infra/compose/dev.yml @@ -8,7 +8,7 @@ # via `host.docker.internal`. # # PRE-REQUISITE: LearnStack core compose MUST be running before this stack -# starts. Run `cd ../learnstack && make dev` first. +# starts. Run `cd ../LearnStack && make dev` first. # # Env vars (HUB_*, POSTGRES_*, VAULT_*) come from the repo-root `.env` file # (template: `.env.example`). Every `${VAR:-default}` reference falls back @@ -53,7 +53,7 @@ services: attempts=$((attempts + 1)) if [ $attempts -ge 60 ]; then echo "ERROR: shared Postgres not reachable on host.docker.internal:5432 after 120s." >&2 - echo " Start LearnStack core compose first: cd ../learnstack && make dev" >&2 + echo " Start LearnStack core compose first: cd ../LearnStack && make dev" >&2 exit 1 fi sleep 2 diff --git a/infra/keycloak/README.md b/infra/keycloak/README.md index 3d03712..2aced08 100644 --- a/infra/keycloak/README.md +++ b/infra/keycloak/README.md @@ -3,13 +3,13 @@ Hub does **not** run its own Keycloak instance. The `learnstack-hub` realm is imported by LearnStack core's compose stack at first boot from: ``` -../learnstack/infra/keycloak/realms/learnstack-hub.json +../LearnStack/infra/keycloak/realms/learnstack-hub.json ``` -This shared-instance + two-realm topology is the dev-time convenience documented in [ADR-0004 Amendment 1](../../../learnstack/docs/decisions/0004-authentication-strategy.md): +This shared-instance + two-realm topology is the dev-time convenience documented in [ADR-0004 Amendment 1](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0004-authentication-strategy.md): -- **`learnstack` realm** — tenant users (admins, instructors, learners). Lives in `../learnstack/infra/keycloak/realms/learnstack.json`. -- **`learnstack-hub` realm** — LearnStack operators. Lives in `../learnstack/infra/keycloak/realms/learnstack-hub.json`. MFA (TOTP) required for every operator account. +- **`learnstack` realm** — tenant users (admins, instructors, learners). Lives in `../LearnStack/infra/keycloak/realms/learnstack.json`. +- **`learnstack-hub` realm** — LearnStack operators. Lives in `../LearnStack/infra/keycloak/realms/learnstack-hub.json`. MFA (TOTP) required for every operator account. The realm boundary is **non-negotiable**: @@ -42,4 +42,4 @@ Phase 11 revisits whether Hub deploys its own Keycloak instance or continues to ## Ownership of the realm JSON -The realm JSON (`learnstack-hub.json`) physically lives in the LearnStack core repo because LearnStack's compose stack imports both realms at first boot. Phase 11 may move authoritative ownership to this repo (with LearnStack's compose mounting from `../learnstack-hub/infra/keycloak/realms/`) — but as long as the dev-time shared Keycloak holds both realms, single-source-of-truth keeps the file in LearnStack core. +The realm JSON (`learnstack-hub.json`) physically lives in the LearnStack core repo because LearnStack's compose stack imports both realms at first boot. Phase 11 may move authoritative ownership to this repo (with LearnStack's compose mounting from `../LearnStack-Hub/infra/keycloak/realms/`) — but as long as the dev-time shared Keycloak holds both realms, single-source-of-truth keeps the file in LearnStack core.