diff --git a/.claude/skills/README.md b/.claude/skills/README.md index 4dd6bbe..fcfc6b6 100644 --- a/.claude/skills/README.md +++ b/.claude/skills/README.md @@ -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's `docs/decisions/`](https://github.com/HodeTech/LearnStack/blob/main/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). | @@ -81,10 +81,12 @@ These Hub workflows aren't needed yet; create the skill when the packet that nee | Skill | Owning packet | Mirror of | | -------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------- | -| `add-provider-adapter` | P02c-2 (`LearnStackApiClient`) / P02c-5 (Let's Encrypt) / Phase 09b (Stripe, Iyzico) | LearnStack `add-provider-adapter` | +| `add-provider-adapter` | P02c-2 (`LearnStackApiClient`) / P02c-5 (Let's Encrypt) / Phase 09b (Stripe, Iyzico) | [LearnStack `add-provider-adapter`](https://github.com/HodeTech/LearnStack/blob/main/.claude/skills/add-provider-adapter/SKILL.md) | | `add-hub-permission` | P02c-4 (Operators module) | LearnStack `add-permission` (operator-scope only) | | `add-audit-coverage` | P02c-4 (Audit module) | LearnStack `add-audit-coverage` | | `add-operator-portal-route` / `add-i18n-key` | P02c-4 (operator portal) | LearnStack `add-frontend-route` / `add-i18n-key` | +| `add-recurring-job` | P02c-5 (challenge verification + renewal) / Hub Billing (usage rollup) | **No LearnStack mirror — Hub-originated.** The scheduler choice is an open `HUB-NNNN` ADR | +| `add-contract-test` | P02c-2 (`entitlement-v1.schema.json` + the OpenAPI/SDK contract) | **No LearnStack mirror.** Covers the byte-identical-across-repos rule and how it is checked in a coordinated PR | ## Authoring a new skill @@ -103,5 +105,5 @@ 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](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 decisions.** Decisions live in ADRs ([LearnStack's `docs/decisions/`](https://github.com/HodeTech/LearnStack/blob/main/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-feature-key/SKILL.md b/.claude/skills/add-feature-key/SKILL.md index 6357c4d..c20614d 100644 --- a/.claude/skills/add-feature-key/SKILL.md +++ b/.claude/skills/add-feature-key/SKILL.md @@ -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 exactly. That registry does **not exist yet** — it lands with [LearnStack Phase 02a Packet 9](https://github.com/HodeTech/LearnStack/blob/main/docs/roadmap/phase-02a-kernel-tenancy.md), so until then the default action is to record the pending LearnStack-side addition rather than to cross-check. When it exists it will live under [`https://github.com/HodeTech/LearnStack/blob/main/backend/src/LearnStack.SharedKernel/`](https://github.com/HodeTech/LearnStack/blob/main/backend/src/LearnStack.SharedKernel/) — 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-integration-event/SKILL.md b/.claude/skills/add-integration-event/SKILL.md index 09cd39d..62c95d9 100644 --- a/.claude/skills/add-integration-event/SKILL.md +++ b/.claude/skills/add-integration-event/SKILL.md @@ -8,8 +8,9 @@ description: > learnstack.hub.custom-domain.activated), wiring a module to publish it via IOutbox.EnqueueAsync, or consuming a LearnStack-emitted event (learnstack.tenancy.*). DO NOT USE FOR: intra-module domain events (plain MediatR INotification, in-process), - the four HTTPS contract endpoints (those are request/response, not pub/sub), or - adding a fifth HTTPS contract endpoint (needs an ADR). + the HTTPS contract surface (those are request/response, not pub/sub), or adding an + endpoint to it (needs a LearnStack ADR — the surface is governed by ADR-0034's two + invariants, not by a count). --- # Adding a Hub integration event @@ -29,7 +30,9 @@ 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 belongs to the HTTPS contract surface enumerated in + [ADR-0034 § The endpoint set](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md); + adding one needs a LearnStack ADR. ## Workflow @@ -80,4 +83,7 @@ For a LearnStack-emitted event Hub consumes (e.g. `learnstack.tenancy.tenant.ren - **An unversioned event.** Breaking changes need `V2`. - **Wrong topic prefix.** Hub publishes under `learnstack.hub.*`; never under bare `learnstack.*`. - **A consumer without the inbox guard.** Re-delivery duplicates the side effect. -- **Reaching for a fifth HTTPS endpoint instead of an event.** If it's fire-and-forget cross-boundary state, it's an event; a new request/response endpoint needs an ADR. +- **Reaching for a new HTTPS endpoint instead of an event.** If it's fire-and-forget + cross-boundary state, it's an event; a new request/response endpoint needs a LearnStack + ADR. ADR-0034 retired the "closed at four endpoints" framing — protecting that count is + what caused TLS private keys to be tunnelled through the entitlement payload. diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index 1655ffc..41b614e 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -59,7 +59,7 @@ 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 pattern it's based on. Compare against the Hub's own shipped source first (`backend/src/Core/`, `backend/src/Modules/`); for a LearnStack-only pattern read [`https://github.com/HodeTech/LearnStack/blob/main/backend/src/`](https://github.com/HodeTech/LearnStack/blob/main/backend/src/) — no sibling checkout needed. ### 5. Hub-structural (the delta lens) @@ -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 it derives from, by absolute URL ([`https://github.com/HodeTech/LearnStack/blob/main/docs/`](https://github.com/HodeTech/LearnStack/blob/main/docs/)). - 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 3349b68..fbe4b03 100644 --- a/.claude/skills/commit-and-pr/SKILL.md +++ b/.claude/skills/commit-and-pr/SKILL.md @@ -63,7 +63,14 @@ 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), in this order — the sequence is the point, not the list of artefacts: + +1. **Merge the LearnStack ADR PR first**, if the packet adds or reshapes a contract endpoint. The ADR lands in the `HodeTech/LearnStack` repo under `docs/decisions/` and is **merged** — not merely opened — before either code PR exists. Code written against an unmerged ADR is code written against a proposal. +2. **Open the Hub code PR.** It carries the canonical contract shape; that is why it goes first. +3. **Open the LearnStack code PR**, referencing the Hub PR's commit hash, written against the shape step 2 fixed. +4. **Merge the two code PRs together**, in the same session. Either-side merge alone leaves the contract dangling. + +**Do not** push or merge anything in `../LearnStack` without explicit user permission — including the ADR PR in step 1 — because another agent may be active there. ## Validation @@ -76,5 +83,5 @@ Per [CLAUDE.md § Cross-repo coordination](../../../CLAUDE.md): the Hub-side PR - **Pushing without being asked.** Default is local commit. - **Amending / force-pushing `main`.** Forbidden — new commits only. -- **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). +- **A contract change without an ADR.** Adding or reshaping an endpoint on the contract surface needs an ADR in the `HodeTech/LearnStack` repo under `docs/decisions/`, merged first — it is a cross-repository agreement ([ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md)). - **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 ed6746e..990c1a5 100644 --- a/.claude/skills/implement-task/SKILL.md +++ b/.claude/skills/implement-task/SKILL.md @@ -45,11 +45,14 @@ 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 at [`https://github.com/HodeTech/LearnStack/blob/main/docs/`](https://github.com/HodeTech/LearnStack/blob/main/docs/) — read it on GitHub, no sibling checkout needed), 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:** the Hub's own `backend/src/Core/` and `backend/src/Modules/` are +the first place to look — four modules and the SharedKernel are already on `main`. For a +pattern that exists only in LearnStack, read it at +[`https://github.com/HodeTech/LearnStack/blob/main/backend/src/`](https://github.com/HodeTech/LearnStack/blob/main/backend/src/) rather than assuming a local checkout. ### Step 3 — Plan @@ -69,7 +72,7 @@ Use [run-tests-locally](../run-tests-locally/SKILL.md): `~/.dotnet/dotnet build ### Step 7 — Update every related document -Walk the list, leave nothing stale: `docs/roadmap/README.md` (packet status); the affected `docs/modules/.md` / `docs/architecture/*.md`; `docs/glossary.md` (new term → [update-glossary](../update-glossary/SKILL.md)); a Hub-internal ADR if a new rule emerged → [write-adr](../write-adr/SKILL.md); the sibling-link audit (CI's `meta` job pattern — `(\.\./)+learnstack/` links are validated locally only). +Walk the list, leave nothing stale: `docs/roadmap/README.md` (packet status); the affected `docs/modules/.md` / `docs/architecture/*.md`; `docs/glossary.md` (new term → [update-glossary](../update-glossary/SKILL.md)); a Hub-internal ADR if a new rule emerged → [write-adr](../write-adr/SKILL.md); the link audit (CI's `meta` job **fails** on a sibling-relative `../LearnStack/...` Markdown link; use the absolute GitHub URL). ### Step 8 — Commit diff --git a/.claude/skills/local-dev-setup/SKILL.md b/.claude/skills/local-dev-setup/SKILL.md index 718f255..2ebe8fb 100644 --- a/.claude/skills/local-dev-setup/SKILL.md +++ b/.claude/skills/local-dev-setup/SKILL.md @@ -40,7 +40,7 @@ Get the Hub stack running. Hub's compose is deliberately minimal — it runs onl ### Step 1 — Bootstrap (one-time) ```bash -cd ~/Documents/Projects/learnstack-hub +cd ~/Documents/Projects/LearnStack-Hub make install # restores backend NuGet + frontend pnpm + activates .githooks/pre-commit ``` @@ -63,7 +63,12 @@ Brings up the Hub-only services: Dapr placement (50006), Dapr sidecar (3501/5000 ### Step 4 — Hub API ```bash -cd ../LearnStack-Hub/backend +# The Api host does not read .env — export it the way scripts/seed.sh does, +# or the connection string falls back to defaults and Postgres is unreachable. +cd ../LearnStack-Hub +set -a; . ./.env; set +a +export POSTGRES_HOST=localhost +cd backend ~/.dotnet/dotnet run --project src/Core/LearnStack.Hub.Api # binds 0.0.0.0:5181 ``` diff --git a/.claude/skills/standards-check/SKILL.md b/.claude/skills/standards-check/SKILL.md index f48c651..098726a 100644 --- a/.claude/skills/standards-check/SKILL.md +++ b/.claude/skills/standards-check/SKILL.md @@ -61,7 +61,12 @@ 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/`. +- [ ] The Hub stores no tenant content (`Hub_NeverStores_TenantData`), and every + LearnStack↔Hub crossing goes through `IEntitlementProvider` / `IUsageReporter` / + `IHubTenantSync` — nothing else holds a Hub client. Those are + [ADR-0034](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/0034-hub-contract-surface-invariant.md)'s two invariants; + the surface is **not** governed by an endpoint count. Any endpoint added to the set + needs a merged LearnStack ADR first. - [ ] `/api/internal/*` not internet-exposed (when those endpoints land). - [ ] No Kubernetes-credential / K8s-state writes to LearnStack's cluster. - [ ] `learnstack-hub` realm boundary respected. @@ -71,7 +76,10 @@ Walk these against the diff. Each item is pass/fail; a fail blocks merge until f ### Docs + corpus hygiene - [ ] Adjacent docs updated (module deep dive, glossary, roadmap status, ADR cross-link). -- [ ] Sibling-relative links (`(\.\./)+learnstack/...`) resolve locally. +- [ ] **No** sibling-relative Markdown link to LearnStack (`../LearnStack/...`) anywhere — + cross-repo references are absolute `https://github.com/HodeTech/LearnStack/blob/main/...` URLs, and CI's `meta` job **rejects** the + relative form. `../LearnStack` stays correct for shell paths only. Every other relative + link resolves from its own file's directory. - [ ] English docs; Mermaid diagrams readable as text. ## Output diff --git a/.claude/skills/start-task/SKILL.md b/.claude/skills/start-task/SKILL.md index 94b8075..aa24c58 100644 --- a/.claude/skills/start-task/SKILL.md +++ b/.claude/skills/start-task/SKILL.md @@ -33,7 +33,9 @@ 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) at + [https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/) and standard(s) at + [https://github.com/HodeTech/LearnStack/blob/main/docs/standards/](https://github.com/HodeTech/LearnStack/blob/main/docs/standards/) — read on GitHub; no sibling checkout needed. 4. [docs/roadmap/README.md](../../../docs/roadmap/README.md) — which packet owns this work. 5. [docs/glossary.md](../../../docs/glossary.md) — terms. @@ -41,7 +43,17 @@ Read, don't skim. If `git log` shows recent edits to the surface, read the commi ### Step 2 — Check packet fit -Confirm the task belongs to the current/next packet (`docs/roadmap/README.md`). If it belongs to a later packet, say so and stop — don't pull future work forward. If it requires LearnStack-side changes, flag it as a cross-repo coordinated packet (it's not Hub-only). +**The Hub track is frozen from P02c-2 onward** (owner decision 2026-08-08). At the start of +Step 2 — after Step 1's reading, which is what tells you which packet the task belongs to — +check the task against [CLAUDE.md § What state this is in](../../../CLAUDE.md) +and [the freeze](../../../docs/roadmap/README.md): if it belongs to P02c-2 or any packet +after it, **say so and stop** — the two conditions that resume the track are named there, +and neither has fired. + +If it is not frozen, confirm it belongs to the current packet (`docs/roadmap/README.md`). +If it belongs to a later packet, say so and stop — don't pull future work forward. If it +requires LearnStack-side changes, flag it as a cross-repo coordinated packet (it's not +Hub-only) and follow the two-PR protocol. ### Step 3 — Walk the Hub deltas + hard rules diff --git a/.claude/skills/update-glossary/SKILL.md b/.claude/skills/update-glossary/SKILL.md index e965730..5cbfcbe 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 + https://github.com/HodeTech/LearnStack/blob/main/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's glossary](https://github.com/HodeTech/LearnStack/blob/main/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 0c9013a..3588a9f 100644 --- a/.claude/skills/wire-cross-cutting-foundation/SKILL.md +++ b/.claude/skills/wire-cross-cutting-foundation/SKILL.md @@ -29,14 +29,21 @@ 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. -Surface to reproduce (see [docs/architecture/cross-cutting-foundation.md § 1](../../../docs/architecture/cross-cutting-foundation.md)): `Results/`, `Localization/`, `Domain/`, `Identifiers/`, `Time/`, `Random/`, `Pagination/`, `Persistence/`, `Errors/`, `Secrets/`, `Observability/`, `Resilience/`, `Hosting/` (`DeploymentMode`), `FeatureFlags/` (`FeatureKey`/`LimitKey` — see [add-feature-key](../add-feature-key/SKILL.md)), and `LearnStackHubVogenDefaults.IdMask`. Open the live LearnStack source for the exact public API shapes — reproduce them verbatim. +Surface to reproduce (see [docs/architecture/cross-cutting-foundation.md § 1](../../../docs/architecture/cross-cutting-foundation.md)): `Results/`, `Localization/`, `Domain/`, `Identifiers/`, `Time/`, `Random/`, `Pagination/`, `Persistence/`, `Errors/`, `Secrets/`, `Observability/`, `Resilience/`, `Hosting/` (`DeploymentMode`), `FeatureFlags/` (`FeatureKey`/`LimitKey` — see [add-feature-key](../add-feature-key/SKILL.md)), and `LearnStackHubVogenDefaults.IdMask`. For the exact public API shapes, the Hub's own shipped source is authoritative — it is what the four modules compile against. LearnStack's original is the **reconciliation** reference only, at [`https://github.com/HodeTech/LearnStack/blob/main/backend/src/LearnStack.SharedKernel/`](https://github.com/HodeTech/LearnStack/blob/main/backend/src/LearnStack.SharedKernel/). `.csproj`: `Vogen` (PrivateAssets="all"), `MediatR`, `Microsoft.EntityFrameworkCore`, `Polly`, `Microsoft.Extensions.Configuration.Abstractions` — match LearnStack's SharedKernel.csproj. Add unit tests mirroring LearnStack's (Result, LocalizedMessage prefix invariant, Entity equality, FixedClock, generation-style invariants). @@ -92,4 +99,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.** The Hub's shipped `backend/src/Core/LearnStack.Hub.SharedKernel/`, `LearnStack.Hub.Api/Common/` and `LearnStack.Hub.Application/Pipeline/` are the authority — the four modules compile against them. Reconcile against [LearnStack's originals](https://github.com/HodeTech/LearnStack/blob/main/backend/src/) only when closing a named drift. diff --git a/.claude/skills/write-adr/SKILL.md b/.claude/skills/write-adr/SKILL.md index 9f36916..98b72c2 100644 --- a/.claude/skills/write-adr/SKILL.md +++ b/.claude/skills/write-adr/SKILL.md @@ -7,7 +7,8 @@ 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 — filed in the `HodeTech/LearnStack` repo under + `docs/decisions/`, as a coordinated pull request), 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 +26,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; it is filed in the `HodeTech/LearnStack` repo under `docs/decisions/` as its own pull request, which **merges before** either code PR opens (coordinated, with user permission). Template and numbering: [https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/](https://github.com/HodeTech/LearnStack/blob/main/docs/decisions/). - 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb51a84..fb71852 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ # `apps/operator-portal` + `packages/{config,sdk,ui}`. # - `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 +# - `openapi-diff` is gated on an unset `vars.ENABLE_OPENAPI_DIFF` until P02c-2 produces the first # generated OpenAPI spec. # - No `lighthouse-budget` job — operator portal is internal-only; the # accessibility / performance budget review happens manually in Phase @@ -188,13 +188,17 @@ jobs: openapi-diff: name: openapi diff (deferred to P02c-2) runs-on: ubuntu-latest - if: false # activate when /api/v1/internal/license/verify replaces /healthz as the only documented surface + # Disabled by default: an unset repository variable is the empty string, so this is + # false until it is set to 'true'. Not `if: false` — actionlint rejects a constant + # condition ([if-cond]). Activates when /api/v1/internal/license/verify replaces + # /healthz as the only documented surface. + if: vars.ENABLE_OPENAPI_DIFF == 'true' steps: - run: echo "Placeholder — P02c-2 wires oasdiff against the prior main spec." # ─── Meta (commit-message format, link audit) ───────────────────────── meta: - name: meta (commit hygiene + link audit) + name: meta (markdown link audit) runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 96dc69f..9e26d22 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,12 +4,20 @@ LearnStack Hub follows the same engineering rigour as [LearnStack core](https:// ## Branch protection -Required status checks on `main`: - -- `backend` — `dotnet build` + format verify + unit + architecture + contract tests -- `frontend` — pnpm install + typecheck + lint + build + Vitest -- `meta` — `make lint`-style format verification + Markdown link audit -- `secret-scan` — Leakwatch scan (gates per LearnStack Standards 12 § Secrets Management) +Required status checks on `main`. These are the names GitHub **emits** — a job's `name:` +in [`ci.yml`](.github/workflows/ci.yml), not its job id — so branch protection must be +configured on the left-hand strings exactly. A rule configured on the bare job id (`meta`, +`backend`) never matches and silently protects nothing: + +- `backend (build + unit + arch + contract)` — `dotnet build` + format verify + unit + architecture + contract tests +- `frontend (typecheck + lint + build + test)` — pnpm install + typecheck + lint + build + Vitest +- `meta (markdown link audit)` — Markdown link audit on changed docs. Backend format + verification runs in the `backend` job (`dotnet format --verify-no-changes`); frontend + lint runs in the `frontend` job. +- `secret scan (leakwatch)` — Leakwatch scan (gates per LearnStack Standards 12 § Secrets Management) + +If a job's `name:` changes in `ci.yml`, the branch-protection entry and this list change +with it, in the same pull request. `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. diff --git a/README.md b/README.md index fc4b60e..5c9a610 100644 --- a/README.md +++ b/README.md @@ -55,12 +55,21 @@ cd ../LearnStack-Hub make dev # 3. LearnStack core API +# Both projects pin .NET 10 in global.json, and the system `dotnet` on this +# workstation is .NET 9 — so invoke the SDK explicitly. `export PATH="$HOME/.dotnet:$PATH"` +# once per shell works too; the explicit path is used here so a copy-pasted +# line works on its own. cd ../LearnStack/backend -dotnet run --project src/LearnStack.Api +~/.dotnet/dotnet run --project src/LearnStack.Api # 4. Hub API -cd ../LearnStack-Hub/backend -dotnet run --project src/LearnStack.Hub.Api +# The host does not load .env itself, so export it first — otherwise the +# connection string resolves to defaults and the API cannot reach Postgres. +cd ../LearnStack-Hub +set -a; . ./.env; set +a +export POSTGRES_HOST=localhost +cd backend +~/.dotnet/dotnet run --project src/Core/LearnStack.Hub.Api ``` In production, the two repos deploy independently — the shared compose is a dev-time convenience only. diff --git a/backend/src/Core/LearnStack.Hub.Infrastructure/Composition/HubFoundationRegistration.cs b/backend/src/Core/LearnStack.Hub.Infrastructure/Composition/HubFoundationRegistration.cs index 33611a0..3cfe3f7 100644 --- a/backend/src/Core/LearnStack.Hub.Infrastructure/Composition/HubFoundationRegistration.cs +++ b/backend/src/Core/LearnStack.Hub.Infrastructure/Composition/HubFoundationRegistration.cs @@ -79,7 +79,10 @@ public static string ResolveConnectionString(IConfiguration configuration) var host = configuration["POSTGRES_HOST"] ?? "localhost"; var port = configuration["POSTGRES_PORT"] ?? "5432"; - var database = configuration["POSTGRES_DB_HUB"] ?? "learnstack_hub"; + // Key matches .env.example and infra/compose/dev.yml, which provision + // ${HUB_POSTGRES_DB}. Reading a different key worked only because both + // sides defaulted to the same literal. + var database = configuration["HUB_POSTGRES_DB"] ?? "learnstack_hub"; var username = configuration["POSTGRES_USER"] ?? "learnstack"; var password = configuration["POSTGRES_PASSWORD"]; diff --git a/backend/tests/LearnStack.Hub.Tests.Architecture/HubBoundaryTests.cs b/backend/tests/LearnStack.Hub.Tests.Architecture/HubBoundaryTests.cs index 11165d2..2c84e55 100644 --- a/backend/tests/LearnStack.Hub.Tests.Architecture/HubBoundaryTests.cs +++ b/backend/tests/LearnStack.Hub.Tests.Architecture/HubBoundaryTests.cs @@ -5,14 +5,14 @@ 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 -/// (six tests): +/// tests is Architecture 24 § 10, at +/// https://github.com/HodeTech/LearnStack/blob/main/docs/architecture/24-learnstack-hub.md +/// (seven tests — ADR-0034 added Hub_Client_Referenced_Only_By_Named_Adapters): /// -/// 1. `Hub_NeverStores_TenantContent` (Architecture 24 spelling; ADR-0019 -/// § Architecture tests calls the same rule `Hub_NeverStores_TenantData`) -/// — see below. This repo uses -/// the ADR-0019 name because the ADR is the Accepted decision; the -/// drift is a corpus-side reconciliation belonging to LearnStack core. +/// 1. `Hub_NeverStores_TenantData` — see +/// below. Architecture 24 once spelled this `Hub_NeverStores_TenantContent`; +/// the 2026-08-08 restructure corrected it to the ADR-0019 name, so the two +/// documents now agree and this repo needs no local exception. /// 2. `Hub_Modules_DoNotReference_LearnStack_Internals` — lands in P02c-1 /// once Hub modules exist. /// 3. `Internal_API_Endpoints_AreNot_Public` — integration test; lands in @@ -32,14 +32,6 @@ namespace LearnStack.Hub.Tests.Architecture; /// - `LicenseKey_Validation_Is_Pinned_RSA2048` (ADR-0020) — P02c-6. /// - `Cert_PrivateKey_NeverLeavesVault_To_Logs` (ADR-0022) — P02c-5. /// - `CustomDomain_TenantId_NeverReadFrom_RequestBody` (ADR-0022) — P02c-5. -/// -/// KNOWN CORPUS DRIFT (not a Hub bug; flagged for the LearnStack-side -/// reconciliation packet): ADR-0019 § Architecture tests lists only four -/// rules and uses the name `Hub_NeverStores_TenantData`; Architecture 24 -/// § 10 lists six rules and uses `Hub_NeverStores_TenantContent`. Hub -/// follows ADR-0019's name (the Accepted decision is authority); P02c-3 -/// or a follow-up amendment to ADR-0019 should fold all six tests into the -/// ADR's § Architecture tests section so the two docs agree. /// public sealed class HubBoundaryTests { diff --git a/backend/tests/LearnStack.Hub.Tests.Architecture/RepositoryLayoutTests.cs b/backend/tests/LearnStack.Hub.Tests.Architecture/RepositoryLayoutTests.cs index d85c625..a4c1083 100644 --- a/backend/tests/LearnStack.Hub.Tests.Architecture/RepositoryLayoutTests.cs +++ b/backend/tests/LearnStack.Hub.Tests.Architecture/RepositoryLayoutTests.cs @@ -29,7 +29,8 @@ 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 https://github.com/HodeTech/LearnStack/blob/main/" + + "docs/decisions/0018-tenant-driven-customization-model.md."); } /// diff --git a/docs/architecture/entitlement-projection.md b/docs/architecture/entitlement-projection.md index 9b744e0..6846a5d 100644 --- a/docs/architecture/entitlement-projection.md +++ b/docs/architecture/entitlement-projection.md @@ -37,7 +37,7 @@ This is the JSON the projection serialises to (per [Architecture 24 § 4](https: "compliance": { "caps": { "gdpr.hard_delete.enabled": { "allowed": true, "forced": false }, - "audit.retention.days": { "allowed": true, "forced": true, "value": 365 }, + "audit.retention.days": { "allowed": true, "forced": true, "value": "365" }, "data.residency.region": { "allowed": false, "forced": true, @@ -56,6 +56,14 @@ This is the JSON the projection serialises to (per [Architecture 24 § 4](https: - **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. +- Inside that object, **`value` is always a string on the wire, or null** — never a + number. A numeric cap such as `audit.retention.days` is carried as `"365"` and parsed + by the consumer. `entitlement-v1.schema.json` declares + `"value": { "type": ["string", "null"] }` and the envelope is + `additionalProperties: false`, so a numeric literal is rejected outright — and + `ComplianceCapDto.Value` is `string?`, so the serialiser cannot emit one anyway. An + example here showing `365` unquoted described a payload that would fail its own + contract test. - The `tier` mirrors the `Plan.tier` (`starter | growth | scale | enterprise | custom`). P02c-1 stores `features` / `limits` / `compliance_caps` as **JSONB columns** on the `entitlements` table. The projection serialiser produces exactly the JSON above. diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index fd73032..ebbbe88 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -100,6 +100,8 @@ flowchart TB p2 -- "OpenAPI + generated SDK" --> p4 p2 -- "outbound client for host-mappings push" --> p5 p2 -- "verify endpoint the .lic path calls" --> p6 + p4 -- "portal shell + Operators + Audit modules" --> p5 + p4 -- "portal shell + operator audit" --> p6 p3 --> p7 p4 --> p7 p5 --> p7 @@ -130,11 +132,19 @@ Both repositories block each other in places. Neither table is a wish list — e | 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-4 | [LearnStack `infra/keycloak/realms/learnstack-hub.json`](https://github.com/HodeTech/LearnStack/blob/main/infra/keycloak/realms/learnstack-hub.json) | The operator roles and the MFA-required browser flow, added to the single owning realm export — LearnStack's compose imports both realms at first boot, so the file cannot be duplicated here. A coordinated pull request; the rest of P02c-4 is Hub-only | | 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. +P02c-0 and P02c-1 have shipped. Of what remains, **only P02c-2 is fully Hub-local** — it +touches no LearnStack code and can proceed as soon as the Hub track resumes. **P02c-3, +P02c-4, P02c-5 and P02c-6 each land as two coordinated pull requests**; see the tables +above and [Coordination protocol](#coordination-protocol). P02c-4's cross-repo half is +the smallest of the four — the operator roles and MFA browser flow in the realm export — +but it is not zero, and calling the packet LearnStack-free is what let that edit go +unplanned. P02c-3 is the only packet gated on the LearnStack spine reaching **Phase +02b**, not merely Phase 02a. ### LearnStack waits on Hub diff --git a/docs/roadmap/p02c-4-operator-portal.md b/docs/roadmap/p02c-4-operator-portal.md index a84de16..aeb7639 100644 --- a/docs/roadmap/p02c-4-operator-portal.md +++ b/docs/roadmap/p02c-4-operator-portal.md @@ -1,6 +1,12 @@ # 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. +> **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). **Cross-repo, in one +> narrow place:** the operator roles and the MFA-required browser flow are added to the +> single owning realm export, which lives in the LearnStack repository at +> `infra/keycloak/realms/learnstack-hub.json` because LearnStack's compose imports both +> realms at first boot. That is a coordinated pull request per +> [Coordination protocol](README.md#coordination-protocol); everything else in this +> packet is Hub-only. ## Goal diff --git a/infra/compose/README.md b/infra/compose/README.md index faf39c1..86fa412 100644 --- a/infra/compose/README.md +++ b/infra/compose/README.md @@ -21,12 +21,22 @@ cd ../LearnStack && make dev cd ../LearnStack-Hub && make dev # 3. LearnStack core API -cd ../LearnStack/backend && dotnet run --project src/LearnStack.Api +# `~/.dotnet/dotnet`, not plain `dotnet`: both projects pin .NET 10 in +# global.json and the system dotnet on this workstation is .NET 9. +cd ../LearnStack/backend && ~/.dotnet/dotnet run --project src/LearnStack.Api -# 4. Hub API -cd ../LearnStack-Hub/backend && dotnet run --project src/LearnStack.Hub.Api +# 4. Hub API — see the note below; this line is not the whole step. +cd ../LearnStack-Hub/backend && ~/.dotnet/dotnet run --project src/Core/LearnStack.Hub.Api ``` +**Step 4 needs `.env` exported into the shell first.** The Hub host does not load `.env` +itself, so without it the connection string falls back to defaults and the API cannot +reach Postgres. The full step — the `set -a; . ./.env; set +a` sequence and the +`POSTGRES_HOST=localhost` override — is written once, in +[the repository README's Dev Workflow](../../README.md#dev-workflow). Follow it there +rather than reconstructing it here; a second copy is what let this file drift out of step +in the first place. + The boot-order dependency is dev-only. Production deploys the two stacks independently. ## Health verification