A white-label platform for education businesses that teach live. One binary, one schema, one set of container images — and a language school, a yoga studio and a music school each get their own site, their own content shapes and their own vocabulary.
What differs between them is tenant customization data loaded at provisioning, not
compiled code (ADR-0018).
There is no Verticals/ folder, and an architecture test fails the build if one appears.
That claim has a stated edge, and it lives in exactly one place: Platform Vision § Genericity boundary. Content shape, presentation and pure rule evaluation are tenant data. Stateful entitlement — credit packs, session quotas — and external capability invocation — running submitted code, scoring speech — are platform features gated by plan. They need a release, not a customization row.
An education business that teaches live needs more than a course list. It needs a public site it can edit, a catalog that reflects what it actually sells, a way to enrol people and track what they finished, a room to teach in, and a schedule behind it. LearnStack is that whole path, once, for every tenant on it.
flowchart LR
visitor[Visitor lands on the tenant's site]
signup[Signs up, takes a placement test]
enroll[Is enrolled in a course]
learn[Works through lessons, progress tracked]
live[Joins a live session in the classroom]
visitor --> signup --> enroll --> learn --> live
Each tenant gets three surfaces, served by one deployment:
- The public site — landing pages, catalog and lesson pages, built from content the tenant authors and rendered from its own branding tokens.
- Admin Studio — where staff write that content, define their own shapes, manage people and watch what happened.
- The learner portal — enrolment, progress, assessments, and the in-app live classroom with attendance and optional consent-aware recording.
Four roles, scoped per tenant and per organization: tenant-admin, editor,
instructor, learner. A tenant may be one school or a chain of branches — an
organization is a sub-unit inside a tenant, and the isolation model treats it as a
first-class boundary rather than a filter someone remembers to apply
(ADR-0017).
This is the part that makes one binary serve unrelated businesses. A tenant declares its own content types, page blocks, lesson item types, level taxonomy, scoring rules, completion rules, custom fields and notification templates — each a JSON Schema or a sandboxed expression, validated before a row is written (ADR-0043).
CEFR levels, a vocabulary card, a placement test that recommends a level, kyu/dan ranks, an asana catalog: all of them are rows. None of them is a branch in any module. When a yoga studio and an English school render different sites from the same deployment, that is the mechanism doing its job — and Phase 10 is the showcase that fills all eight aggregates at once for a single tenant, not the proof that it works.
make install # one-time per clone: dependencies + git hooks
make dev # bring the local stack up (Postgres, Keycloak, SeaweedFS, …)
make migrate # apply the platform + module migration chains
make seed # write the two demo tenants through the real command pathmake help lists every target. make test runs the whole suite — unit, architecture,
contract and the Docker-bound integration tests — and it installs what it is about to run.
The seed writes two tenants in unrelated domains, each reachable on its own host:
| Tenant | Host | Shape |
|---|---|---|
demo-english |
demo-english.learnstack.local |
Online English school |
demo-yoga |
demo-yoga.learnstack.local |
Yoga studio |
Add both to /etc/hosts pointing at 127.0.0.1. They are not a demo fixture: they are
written through ProvisionTenantCommand and the same handlers a request uses, so
make seed exercises the production path rather than a second one. From
Phase 02d they render side by side in a
browser — which is how the genericity claim is tested continuously rather than asserted
once at the end.
The platform kernel is finished; the product layers land on top of it. What runs today is multi-tenancy with real isolation, the customization foundation, the audit trail, and the API conventions everything else is built against. What does not run yet is every learner-facing surface — there are no users, no courses and no classroom in this repository right now, and the documentation says so wherever it describes them.
| Capability | Phase | State |
|---|---|---|
| Multi-tenancy, organizations, isolation to the database row | 02a | Running |
| Tenant customization foundation — content types and level taxonomy | 02a | Running (2 of the 8 aggregates) |
| Audit trail, written inside the business transaction | 02a | Running |
| API conventions, entitlement socket, foundation ports | 02a | Running |
| Two tenants rendering their own sites, side by side | 02d | Next |
| Authentication, sessions, events | 02b | Planned |
| Users, roles, permissions, admin foundation | 03 | Planned |
| Headless CMS, page builder, media library | 04 | Planned |
| Course catalog and learning content | 05 | Planned |
| Public site renderer and Admin Studio | 06 | Planned |
| Enrolment, learner portal, progress | 07 | Planned |
| Assessment, notifications, background jobs | 08a | Planned |
| Scheduling and booking | 08b | Planned |
| In-app live classroom | 08c | Planned |
| Billing, integrations, analytics | 09 | Planned |
| Production hardening and the demand-gated adapters | 11 | Planned |
The order is dependency-driven, not numeric — the roadmap is
authoritative, and it explains why 02d runs before 02b. Every phase document carries
the same six sections, and a shipped one carries a delivery record listing what it built
and the defects it introduced and caught in its own review rounds.
Three modules hold domain code today — Tenancy, Customization and Audit. The other four module assemblies are scaffolded and empty.
Most of the interesting engineering here is not a feature. It is the machinery that stops a plausible change from quietly breaking an invariant.
Tenant isolation is defense in depth, from the first migration. Every tenant-owned
table carries a tenant column, an EF Core query filter, and a PostgreSQL Row Level
Security policy under ENABLE and FORCE — plus an integration test that connects as
learnstack_app, the application's own NOBYPASSRLS role, because a test that runs as
the table owner passes even when every policy is inert. The canonical policy template is
written as SQL in exactly one document,
Database Standards; an earlier copy of it lived in four
and was wrong in all four.
MUST-class audit commits with the change it describes. Not "the same SaveChanges" —
the same transaction, which is what a reader of audit_log actually observes
(ADR-0033). An operation the catalogue
does not classify is rejected rather than silently unaudited.
The rules the documentation states are the rules that run. The architecture-test catalogue is the canonical list, and it is checked against the suite rather than trusted: an entry that reports a rule Implemented must name a test method that exists, no test may be skipped, and the counts the catalogue publishes about itself are recomputed by a test — because the first version of those counts was wrong in the commit that wrote them.
Every rule ships with a companion that proves it can fail. The lesson the review rounds kept repeating is a rule that cannot tell clean from blind: a guard passes because nothing violates it, and nobody has checked that its mechanism works at all. So a scan is fed a planted violation, and a behavioural claim is mutation-checked — break the production code on purpose, watch the test go red, put it back.
backend/
src/ LearnStack.{Api,Domain,Application,Infrastructure*,SharedKernel} + Modules/
tests/ Unit · Architecture · Contract · Integration (Testcontainers)
analyzers/ LearnStack.Analyzers — the LS0001 Roslyn rule
frontend/
apps/web Next.js 15 App Router — the one application
packages/ config · ui · sdk (generated from the API's OpenAPI document)
infra/
compose/ the local dev stack, plus the gated profile and the e2e overlay
keycloak/ … per-service configuration
docs/ architecture · decisions · standards · roadmap · modules · glossary
scripts/ seed.sh and the CI helpers
docs/ is not an afterthought here — it is where the decisions live, and the code is
expected to agree with it. See CLAUDE.md for the working agreement every
contributor (and every agent) follows.
| Layer | Choice | Notes |
|---|---|---|
| Backend | .NET 10 · ASP.NET Core · EF Core · MediatR | Modular monolith, four cross-module mechanisms and no fifth (ADR-0010) |
| Database | PostgreSQL 18 | RLS from day one, four database roles, one canonical policy template (ADR-0003) |
| Frontend | Next.js 15 · TypeScript · React | One app with route segments for public, studio and portal (ADR-0009) |
| Identity | Keycloak, self-hosted | Two realms: learnstack for tenant users, learnstack-hub for operators |
| Live classroom | LiveKit OSS, self-hosted | Cloud available behind the same ILiveClassProvider; a custom SFU is out of scope (ADR-0005) |
| Storage · Search | SeaweedFS · PostgreSQL FTS | S3-compatible in production; Meilisearch behind ITenantSearch when scale requires it |
| Observability | OpenTelemetry · Serilog → OTLP | Spans enriched centrally; module code never tags a tenant id (ADR-0032) |
Vendor adapters are demand-gated. Each has a seam that ships today, an owning phase and
a written trigger condition in
ADR-0035; a building block missing any
of those is not demand-gated, it is missing. Most seams are a port in
LearnStack.SharedKernel with a working default — IEventBus / InProcessEventBus for
Kafka, ICacheService / InMemoryCacheService for Valkey, ISecretProvider /
ConfigurationSecretProvider for Vault, IEntitlementProvider / NullEntitlementProvider
for the Hub. Not all of them are: APISIX's seam is the composition root, and audit_log
partitioning is schema-internal.
Infrastructure Stack Standards § Demand-gated
is the table that maps each one, and it is the authority.
One binary, five DeploymentMode values, two of them wired. Development and SaaS
run end to end. Dedicated, SelfHostedOnline and SelfHostedAirGapped are prepared
seams, not supported deployments, until
Phase 11 builds their adapters and suites.
The three production categories those values serve are SaaS, Dedicated and Self-Hosted
(ADR-0020,
25 — Deployment Models).
SaaS and Dedicated are backed by LearnStack Hub, a separate control plane repository
(ADR-0019,
GitHub). It is expected at
../LearnStack-Hub so the cross-repository links resolve, and it owns its own roadmap.
This repository holds only LearnStack's side of the boundary, in
Phase 02c. The contract is governed by two
invariants — the Hub stores no tenant content, and every crossing goes through a named
adapter (ADR-0034).
| If you want to… | Read |
|---|---|
| Understand what this is and why | Platform Vision → MVP Scope |
| See what is being built next | Roadmap → Phase 02d |
| Contribute code | CLAUDE.md → Engineering principles → Standards index |
| Know why something was decided | ADR index |
| Understand the technical shape | Technical Architecture → Module Boundaries → Cross-Module Contracts |
| Understand tenancy | Tenant Isolation → Platform Tenant + Organization → Tenancy module spec |
| Understand customization | Tenant Customization Model → Customization module spec |
| Understand the audit trail | Audit Subsystem → Audit module spec → ADR-0044 |
| Understand the Hub boundary | LearnStack Hub → Deployment Models → Hybrid License Model |
| Look up a term | Glossary |
Every architecture document — 33 of them, grouped
Strategy and shape · 01 Platform Vision · 02 Domain Model · 03 Module Boundaries · 04 Technical Architecture · 05 MVP Scope · 06 Extension Model · 11 Extension Points · 19 MVP Vertical Slice
Platform substrate · 09 Tenant Isolation · 10 Cross-Module Contracts · 15 Events and Outbox · 28 Platform Tenant + Organization · 31 Audit Subsystem · 32 Tenant Customization Model · 33 Cross-Cutting Concerns
Product surfaces · 12 Localization · 13 Identity and Authentication · 14 Frontend Architecture · 16 Media Pipeline · 17 Page Builder · 20 Search · 21 Feature Flags and Entitlements · 23 Data Protection (KVKK / GDPR)
Live classroom · 07 In-App Live Classroom · 08 Cost Model · 18 WebRTC Build vs Adopt
Hub, deployment and edge · 22 Custom Domains · 24 LearnStack Hub · 25 Deployment Models · 26 Hybrid License Model · 27 Custom Domain + TLS · 29 Dapr Integration · 30 API Gateway (APISIX)
| Directory | Holds | Mutability |
|---|---|---|
docs/architecture/ |
What we are building, conceptually — 33 numbered documents | Editable as the system evolves |
docs/decisions/ |
ADRs: one-time decisions with context and consequences | Accepted ADRs change only by dated Amendment or the two bounded corrections in ADR-0041 |
docs/standards/ |
The rules every PR is held to, 00–21, each labelled Active or Adopted by what actually enforces it |
Editable as the team learns; changes cite an ADR |
docs/roadmap/ |
Phases 00–12, with dependency order that filename order does not imply | Editable per phase; a shipped phase's delivery record is not rewritten |
docs/modules/ |
Per-module specs, with permission and audit matrices | Editable with the module |
- English for all documentation (ADR-0007). A tenant's Turkish-facing UI is a separate concern.
- Mermaid for diagrams, in fenced code blocks, readable as text when unrendered.
- Conventional Commits, imperative subject, ≤ 72 characters — enforced by the
commit-msghook and re-run in CI, which states no grammar of its own. - Single source of truth. The glossary holds terms, ADRs hold decisions, standards hold ongoing rules, the roadmap holds phases. A second copy is a copy that will go stale — the corpus has the scars to prove it.
- Every pull request is reviewed against Code Review Standards, whose zero-tolerance blocker list is short and non-negotiable.