Skip to content

Latest commit

 

History

History
321 lines (284 loc) · 23.8 KB

File metadata and controls

321 lines (284 loc) · 23.8 KB

Working in this repository

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.

What this is

LearnStack is a white-label platform for multi-branch education businesses that teach live — not a single LMS, and not an education product of its own. One binary, one schema, and one set of container images serve a language school, a yoga studio, a music school, or a coding bootcamp. What differs between them is tenant customization data loaded at provisioning, not code (ADR-0018).

That claim has a stated edge, and the edge 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. Link to that section; do not restate it.

LearnStack ships in three production deployment modes — SaaS, Dedicated, Self-Hosted — backed by the companion LearnStack Hub control plane (separate repository, see ADR-0019). On developer workstations the Hub repo is the sibling directory ../LearnStack-Hub; GitHub: https://github.com/HodeTech/LearnStack-Hub. The Hub repository owns its own roadmap at ../LearnStack-Hub/docs/roadmap/; this repository holds only LearnStack's side of the boundary, in Phase 02c.

What state this is in

Phase 01 complete. Phase 02a in progress — packets 0–3 shipped; packets 3b–10 were re-scoped on 2026-08-08 after a four-report audit of the corpus.

Phase 01 shipped the .NET 10 solution scaffold under backend/ (core + 7 modules × 4 projects + 4 test projects including the non-skippable LearnStack.Tests.Architecture), the pnpm frontend monorepo under frontend/ (apps/web Next.js App Router + packages/{config,ui,sdk}), the local-dev compose stack at infra/compose/dev.yml, and the DX + CI surround (repo-root Makefile, .env.example single source of truth, .githooks/pre-commit formatter + Leakwatch, infra/compose/e2e.yml ephemeral overlay, .github/workflows/ci.yml with backend + frontend + meta + secret-scan required checks, scripts/seed.sh).

Phase 02a packets 0–3 shipped the decision set (Vogen, API versioning, audit partition management), the shared kernel core (Result<T> + LocalizedMessage, Entity<TId> / AuditableEntity<TId>, domain events, cursor pagination, IClock / IRandom / IGuidFactory), and the ADR-0032 cross-cutting foundation (L1 IExceptionHandler, the eight-step MediatR pipeline, Serilog → OTLP, TenantContextSpanProcessor, IErrorTrackingProvider, IProviderResilience<TPort>, the LS0001 analyzer). Those records are frozen delivery history.

The 2026-08-08 restructure re-scoped packets 3b–10 along three lines, all recorded in the Phase 02a Status block:

  • Correctness moved earlier. The Row Level Security template that four documents carried produced two permissive policies, which PostgreSQL combines with OR — leaking every tenant-wide row across tenants. ADR-0003 Amendment 3 corrects it, and ADR-0033 makes MUST-class audit a durable intent inside the business transaction — which is also what stops the corrected policy from rejecting every audit insert.
  • Additive infrastructure moved later. Per ADR-0035, Packet 5 ships the foundation ports and their default implementations; the Dapr, Kafka, APISIX and Vault adapters land in Phase 11 against written triggers.
  • Proof moved earlier. Two seed tenants in unrelated domains land in Packet 7, and Phase 02d renders both of them in a browser.

Phase 02d: Two-Tenant Walking Skeleton is the next user-visible milestone — the first phase whose output someone who does not read C# can evaluate: two hosts, two tenants, two education sites, one binary and one database.

Every module assembly is still empty of domain code. Module-level references in the docs (e.g. LearnStack.Modules.Education.Application, ILiveClassProvider, ITenantSearch) describe intended shape that the corpus anchors against; Phase 02a packets 6–9 and Phase 02d are where the first of those types actually land.

Where to start

For any task, read in this order:

  1. README.md — direction at a glance.
  2. docs/architecture/01-platform-vision.md — what we build and why.
  3. docs/architecture/05-mvp-scope.md — what is in / out / deferred.
  4. docs/roadmap/README.md — phased plan with explicit dependencies and the one-way-door sequencing principle.
  5. docs/standards/00-principles.md — the beliefs every other standard descends from.
  6. docs/glossary.md — terminology; the single source of truth for project-specific terms.

Then read the two phases that are live:

Once the high-level reading is done, pick exactly one skill entry point based on the user's intent. The entry point dispatches the rest internally; do not chain entry points yourself.

Intent Entry point
"Implement / geliştir / yap / ekle / refactor X" (substantive work) implement-task — the default. Dispatches start-task in Step 1, then the workflow-specific add-* skill(s), then runs linter + tests, updates docs, commits, and emits a review-agent prompt.
"Plan / scope / orient / araştır / kapsamı çıkar" (no implementation yet) start-task — standalone scoping pass. Stops at the plan.
"Review this diff / PR" standards-check first (5-min mechanical gate), then code-review (security + bugs + optimisation + refactor + LearnStack-specific lenses).
"Explain / what is X" (informational) No skill — answer directly.
One-line typo / comment fix No skill — edit directly.

The full skills catalogue lists every workflow skill (add-tenant-owned-entity, wire-dapr-pubsub, add-tenant-scoring-rule, …) the entry points dispatch to. You almost never invoke a workflow skill directly — let the entry point pick it.

Documentation layout

Directory Purpose Mutability
docs/architecture/ Conceptual descriptions of what we are building. Numbered NN-topic.md linearly. Editable as the system evolves.
docs/decisions/ ADRs — one-time decisions with status, context, decision, consequences. Redirect / superseded ADRs live under _redirects/. Accepted ADRs are immutable except for dated Amendments.
docs/standards/ Engineering rules (NN-topic.md, 00 – 21). Each anchored standard carries a **Derives from:** ADR-NNNN header. Editable as the team learns; standard changes cite an ADR.
docs/roadmap/ Phased plan (phase-NN-topic.md, 00 – 12 with 02a/02b/02c/02d, 08a/08b/08c, and 09/09b splits). Every phase doc carries the same six sections — Goal, Scope, Deliverables, Completion Criteria, Risks, Phase Exit Decision — with three declared exceptions listed in the roadmap index: Phase 09b and Phase 12 are pointer documents into the Hub repository, and Phase 01 predates the convention. Editable per phase; the Status block of a shipped packet is a dated delivery record and is not rewritten.
docs/glossary.md Terminology source of truth. Editable; new term goes here first, then used.

docs/analysis/ exists locally but is gitignored — it is a private scratchpad for exploratory research, prior-art studies, and redesign drafts. Never reference paths under docs/analysis/ from committed files (Markdown, code comments, commit messages, PR descriptions). See Documentation Standards § Local-Only Directories.

Hard rules

  • English is the documentation language (ADR-0007). The Turkish-facing UI of any tenant is separate.
  • Mermaid for diagrams in fenced ```mermaid blocks. Diagrams must remain readable in text form (titles + bullet fallbacks) for renderers that don't support Mermaid.
  • Single source of truth. Each piece of knowledge lives in exactly one place. The glossary holds terms. ADRs hold decisions. Standards hold ongoing rules. Architecture docs hold conceptual descriptions. Roadmap holds phases. Do not duplicate.
  • ADR numbers are sequential and never reused. Superseded ADRs become redirect stubs under decisions/_redirects/. Adding a new ADR uses the next free number.
  • Standards changes cite an ADR. A new standard rule or a change to an existing one is paired with an ADR when the rule is non-trivial.
  • Modular monolith with four cross-module mechanisms (ADR-0010): application contract, intra-module domain event, integration event via outbox (dispatched through IEventBusInProcessEventBus today, the Dapr/Kafka adapter on its trigger), read-model projection. No fifth.
  • Tenant + organization isolation is defense-in-depth from day one (ADR-0003 Amendment 1, ADR-0017): tenant + organization context + EF query filters + PostgreSQL RLS + architecture tests.
  • One canonical RLS template, in one file. The corrected policy shape — one AND-ed policy per table, ENABLE and FORCE ROW LEVEL SECURITY, an explicit WITH CHECK, and the four-role model (learnstack_migration owns, learnstack_app connects with NOBYPASSRLS, learnstack_platform and learnstack_outbox_admin hold audited bypasses) — is decided in ADR-0003 Amendment 3 and written as SQL in exactly one document: Database Standards. Every other document links there. The superseded template lived in four documents and was wrong in all four — two permissive policies, which PostgreSQL combines with OR, so every tenant-wide row was visible across tenants.
  • Self-hosted infrastructure preferred for Keycloak (auth, with two realms — learnstack + learnstack-hub), LiveKit OSS (live classroom), SeaweedFS (object storage), Meilisearch (search), Kafka (pub/sub backend), Vault (secrets). See ADRs 0004, 0005, 0014. What LearnStack uses is settled; when each arrives is ADR-0035's trigger table.
  • The core platform stays domain-generic. Domain-specific shapes (CEFR levels, English placement-test scoring, kyu/dan ranks, yoga asana catalogs, …) live as tenant customization data (ADR-0018), never as code in any module. There is no Verticals/ folder. ADR-0011 is superseded. The boundary of that claim is in Platform Vision § Genericity boundary.
  • Irreversible now, additive on demand — the one-way-door test (ADR-0035): if I add this six months from now, will I have to touch code that is already written?
    • Yes → ship it now. Tenant + organization isolation, the corrected RLS policies, the outbox_messages table and its ownership, strongly-typed identifiers, the localization schema, MUST-class audit durability, module boundaries and their architecture tests. These touch every query, every migration, and every job payload.
    • No → ship the port now, the adapter on a named trigger. Dapr pub/sub, Kafka, Valkey-backed cache, Vault, APISIX, the Hub entitlement source, signed licence keys, custom-domain TLS automation, audit_log partitioning. Each has a port in LearnStack.SharedKernel (shipped, or landing in Phase 02a Packet 5), a working default implementation (InProcessEventBus, InMemoryCacheService, ConfigurationSecretProvider, NullEntitlementProvider), an owning phase, and a written trigger condition. A building block missing any of those four is not demand-gated — it is missing.
  • Provider adapters everywhere. Payments, auth, storage, search, live classroom, notifications, event bus, cache, secrets, Hub contract, entitlement source, host resolver — all sit behind interfaces. No SaaS lock-in in Domain or Application. See 20-infrastructure-stack.md.
  • The Hub contract is governed by two invariants, not by a count (ADR-0034): (1) the Hub stores no tenant content — courses, lessons, learners, enrollments, sessions and media live only in LearnStack, and the Hub holds tenant metadata only; (2) every LearnStack↔Hub crossing goes through a named adapterIEntitlementProvider, IUsageReporter, IHubTenantSync, and nothing else may hold a Hub client. Adding an endpoint still requires an ADR, because the surface is a cross-repository contract both repositories have to agree on.
  • One binary, five DeploymentMode values, two of them wired. Selection happens at the composition root; module code never branches on the mode (ADR-0020, enforced by Modules_Do_Not_Reference_DeploymentMode). Development and SaaS are wired end to end; Dedicated, SelfHostedOnline and SelfHostedAirGapped are prepared seams, not supported deployments, until Phase 11 builds their adapters and integration suites.

Conventions when editing docs

  • Short and declarative — heading + bullets over essay paragraphs.
  • Present tense decisions ("LearnStack uses ..."), not future tense ("LearnStack will use ...").
  • Cross-link liberally — to glossary, related architecture docs, standards, ADRs. Use relative paths.
  • TODO comments include a date and an owner: // TODO(YYYY-MM-DD, @owner): refactor when X lands.
  • Don't redefine glossary terms in other docs; link to them.

Conventions when editing code (future)

Once application code lands, the engineering standards under docs/standards/ are the authority for every PR. The most load-bearing rules:

  • C# / .NET 10, strongly-typed ids, records, MediatR pipeline, EF Core with per-module DbContext (02, 05).
  • TypeScript strict + Next.js App Router; one frontend app under frontend/apps/web with route segments (03, 07). The operator portal is a separate app, frontend/apps/operator-portal, in the LearnStack-Hub repository.
  • REST + RFC 7807 Problem Details + cursor pagination + idempotency keys + ETag concurrency (04).
  • OpenTelemetry + correlation id end to end (10).
  • WCAG 2.2 AA across all surfaces (16).
  • Audit-coverage matrix required per module (18).
  • Permission keys {module}.{resource}.{action} with closed action set + scope (Platform / Tenant / Organization) (19).
  • Infrastructure-stack rules (foundation ports and their default implementations, the Hub contract surface, outbox + inbox, entitlement projection) in 20.
  • The architecture-test catalogue in 21 — canonical rule names live there; do not invent a second spelling.
  • Zero-tolerance review blockers enumerated in 17.

Commit conventions

  • Conventional Commits style: type(scope): subject.
  • Subject in imperative mood; ≤ 72 chars.
  • For doc-only commits: docs(scope): ... where scope is one of architecture, decisions, standards, roadmap, or omitted for cross-cutting changes.
  • Commits made with AI assistance carry the trailer Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>.

Things to never do

  • Edit an Accepted ADR's decision section. Write a new ADR that supersedes the old one instead.
  • Introduce a fifth cross-module communication mechanism.
  • Add domain-specific code (CEFR, exam, English placement, kyu/dan, asana, code-challenge runner, …) to any module. Such shapes live as tenant customization data per ADR-0018. There is no Verticals/ folder; the architecture test No_Source_Folder_Named_Verticals enforces it.
  • Add an endpoint to the Hub contract surface without an ADR. The count is not the rule — ADR-0034's two invariants are — but the surface is a cross-repository contract, so it changes by decision record, in both repositories, or not at all.
  • Call Hub endpoints from anywhere except the dedicated IEntitlementProvider / IUsageReporter / IHubTenantSync adapters.
  • Resolve a host by calling the Hub. IHostToTenantResolver reads platform_host_to_tenant and nothing else (ADR-0034); an anonymous page load must never depend on a control plane being reachable.
  • Carry TLS certificates or private keys in the entitlement payload. Cert material moves by secret-store replication and is referenced by path from PUT /api/internal/tenants/{id}/host-mappings, never by value through a payload LearnStack caches, logs, audits and mirrors.
  • Copy the RLS template into a second document. It lives only in Database Standards; everywhere else links to it. The last duplication shipped a broken policy into four files at once.
  • Run a tenant- or organization-isolation test as the table owner or as a BYPASSRLS role. Isolation tests connect as learnstack_app — a test that runs as learnstack_migration, learnstack_platform or learnstack_outbox_admin passes even when every policy is inert, and therefore proves nothing.
  • Write a MUST-class audit row outside the business transaction. MUST-class audit is written on the same transaction as the state change it describes (ADR-0033) — AuditLogBehavior classifies and parks the intent, TransactionBehavior writes it immediately before COMMIT — so it commits with that change or not at all, and so it executes while app.tenant_id is set and RLS accepts it. "The same SaveChanges as the business write" was the earlier formulation and ADR-0033 withdraws it: the guarantee is the transaction, which is what a reader of audit_log observes and which needs no cross-DbContext machinery. A tenant AuditConfig may narrow SHOULD/MAY coverage but never removes baseline MUST coverage. Exactly two failures reject the operation: an operation the catalogue does not classify at all, and a MUST-class row that cannot be written durably. A tenant-override read failure does not — it falls back to the in-process catalogue, which carries the same MUST floor, so nothing proceeds unaudited and a cache outage does not deny every request platform-wide.
  • Inject IConnectionMultiplexer / IDistributedCache / KafkaProducer / VaultClient directly — use IEventBus / ICacheService / ISecretProvider.
  • Read DeploymentMode from inside a module — the composition root branches once, modules never.
  • Write audit_log, platform_entitlement_cache, or outbox_messages directly — use IAuditStore, IEntitlementProvider.RefreshAsync, IOutbox.
  • Accept learnstack-hub realm tokens on tenant-facing endpoints, or learnstack realm tokens on /api/internal/*.
  • Reuse an ADR number.
  • Add an architecture or standard document whose existence makes one of the existing documents ambiguous about ownership; if a topic needs more space, expand the existing doc rather than splintering.
  • Mention a feature as "deferred to a later phase" without naming the phase that owns it. For an infrastructure building block the bar is higher: name the port, the default implementation, the owning phase, and the trigger condition (ADR-0035). Three out of four is not demand-gating.
  • Throw DomainException for expected business-rule violations — use Result.Fail(business_rule_violation, ...). DomainException is reserved for programmer errors / aggregate invariant bugs (ADR-0032 § Sub-decision 4). The Roslyn analyzer LearnStackException-DomainExceptionThrow flags violations; full catalogue entry in docs/standards/21-architecture-tests-catalogue.md.
  • Throw FluentValidation.ValidationException from ValidationBehavior — the behavior returns Result.Fail(validation_failed) and never throws.
  • Reference Sentry.SentrySdk directly from any module assembly — error capture goes through IErrorTrackingProvider; the L1 IExceptionHandler is the only sanctioned caller in application code.
  • Add an ExceptionHandlingBehavior to the MediatR pipeline — AuditLogBehavior (catches handler exceptions, audits, rethrows via ExceptionDispatchInfo) plus the L1 IExceptionHandler cover every exception path.
  • Register the OpenTelemetry LoggerProvider (AddOpenTelemetry().WithLogging()) alongside Serilog. Logs flow through Serilog → OTLP sink only; double-export would duplicate every line.
  • Import Serilog.ILogger from a module assembly — modules use Microsoft.Extensions.Logging.ILogger<T>; Serilog is the implementation wired once at the composition root.
  • Import a provider SDK exception type outside the adapter's LearnStack.Infrastructure.<Adapter> namespace — adapters translate SDK exceptions into ProviderException subclasses at the boundary.
  • Tag a span with tenant.id / organization.id / user.id / correlation.id from module code — the TenantContextSpanProcessor enriches every span centrally.

Where to look when stuck

  • Term means what? — docs/glossary.md.
  • Why was this decided? — docs/decisions/. Each ADR carries context.
  • What rule applies to my change? — docs/standards/. The index is in docs/standards/README.md.
  • What's next? — docs/roadmap/README.md.
  • What's the shape of the live classroom / auth / search / etc.? — the corresponding docs/architecture/NN-topic.md.