This file is read first by Claude Code (and any other agent following the convention). It tells you what this project is, what state it is in, and the conventions you must follow when contributing.
LearnStack Hub is the control plane companion to LearnStack core — 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 and ADR-0034.
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.
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
(audit durability),
ADR-0034
(the real endpoint set, plus host-mapping and TLS key delivery), and
ADR-0035
(which makes the Hub a demand-gated integration rather than a Phase-02a prerequisite).
P02c-2 unfreezes when both hold:
- The ADR-0035
trigger for
IEntitlementProviderfires — a tenant must be billed or plan-gated. Until then LearnStack runs onNullEntitlementProviderand needs nothing from the Hub. - The contract surface is built against ADR-0034's two invariants, not against an
endpoint count — the enumerated set, the
host-mappingspath, 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: 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.
This repository owns the Hub plan. docs/roadmap/ 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 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 | ✅ Shipped |
P02c-1 — Hub Domain Core (LearnStackTenant, Plan, HubSubscription, Entitlement) |
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 | ⏸ Frozen — ADR-0035 trigger |
P02c-3 — LearnStack-side integration (HubEntitlementProvider, IUsageReporter, internal-API handlers) |
p02c-3-learnstack-integration.md | ⏸ |
| P02c-4 — Operator portal MVP | p02c-4-operator-portal.md | ⏸ |
| P02c-5 — Custom domain lifecycle | p02c-5-custom-domain-lifecycle.md | ⏸ |
| P02c-6 — License key skeleton | p02c-6-license-key.md | ⏸ |
| P02c-7 — End-to-end exit gate | p02c-7-exit-gate.md | ⏸ |
Post-02c Hub tracks — hub-billing.md and 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.
For any task in this repo, read in this order:
- README.md — direction at a glance.
- ADR-0019 LearnStack Hub — why the Hub is a separate repository.
- ADR-0034 Hub Contract Surface Invariant — the two invariants and the real endpoint set.
- Architecture 24 LearnStack Hub — the deep dive.
- Standards 20 § Hub HTTPS Contract Surface — the rule as written for reviewers.
- docs/roadmap/README.md — the Hub plan, owned here.
- docs/architecture/contract-with-learnstack.md — pointers to LearnStack-side authority.
- docs/glossary.md — Hub-specific terms.
Then pick an entry-point skill from this repo's catalogue at .claude/skills/. 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; for scoping-only use start-task; for review run standards-check then code-review.
Hub skills are project-local: an agent running from the
LearnStack-Hubroot loads them from.claude/skills/. They cite LearnStack core's standards / ADRs by absolute GitHub URL (https://github.com/HodeTech/LearnStack/blob/main/docs/...) for the cross-cutting authority and carry only the Hub-specific workflow on top — they do not duplicate the standards.
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 testHub_NeverStores_TenantDataenforces. This is invariant 1 of ADR-0034. -
No imports from LearnStack core internals. Hub modules may reference only LearnStack
Application.ContractsDTOs (and even those are kept as local copies in this repo until a shared NuGet package is introduced — Phase 11). LearnStackDomain/Infrastructure/Modules.*types are off-limits. Architecture testHub_Modules_DoNotReference_LearnStack_Internalsenforces. -
The contract surface is governed by two invariants, not by a count (ADR-0034). 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:
- The Hub stores no tenant content.
- 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's step 3 is superseded by ADR-0034. -
/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 testInternal_API_Endpoints_AreNot_Publicenforces. -
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 exists to protect, and the one part of it ADR-0034 leaves unchanged.
-
Two-realm Keycloak boundary. Hub authenticates against the
learnstack-hubrealm only. Thelearnstackrealm is rejected on Hub endpoints; thelearnstack-hubrealm 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.
-
Mermaid for diagrams. Fenced
```mermaidblocks; 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 indocs/decisions/here with theHUB-NNNNnumbering series. The Hub roadmap is the exception that runs the other way: it is owned here, and LearnStack links to it.
- Import
LearnStack.Domain,LearnStack.Infrastructure, orLearnStack.Modules.*types into this repo. Use local DTO copies ofLearnStack.Application.Contractsuntil 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. 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/.Iyzicoadapter projects (those don't exist yet — hub-billing.md creates them). - Import
Sentry.SentrySdkfrom a module assembly. Error capture goes throughIErrorTrackingProvider(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.
IHostToTenantResolveron the LearnStack side readsplatform_host_to_tenantand 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 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
WebhookLedgerfor Stripe / Iyzico webhook processing. Idempotency unique constraint enforces. - Throw
DomainExceptionfor expected business-rule violations — returnResult.Fail(business_rule_violation, ...)instead (mirrors LearnStack ADR-0032 § Sub-decision 4). - Edit an Accepted ADR's decision section in either repo. Write a new ADR that supersedes it.
- Reuse an ADR number.
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 (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. 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).
-
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 a
Co-Authored-By:trailer — one per agent that materially contributed, each naming that agent's own runtime-specific identity, never a fixed string standing in for whichever agent actually ran:- Claude Code sessions:
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> - OpenAI Codex sessions:
Co-Authored-By: Codex Opus 4.7 (1M context) <noreply@anthropic.com>
LearnStack's Git Workflow Standards § Trailers is the authority for the strings; if this list and that section disagree, that section wins.
- Claude Code sessions:
When a packet changes both repos (e.g., P02c-3 lands the LearnStack-side internal-API handlers + Hub-side outbound client), the two PRs must be coordinated:
- Hub-side PR opens first; it carries the canonical contract shape.
- LearnStack-side PR references the Hub-side PR's commit hash.
- Both PRs merge in the same session; either-side merge alone leaves the contract dangling.
The wire shape is pinned by a checked-in entitlement-v1.schema.json and a snapshot test in each repository, per ADR-0034. 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.