This document describes the first domain shape. It is intentionally conceptual and evolves as implementation begins. For naming, see the Glossary.
2026-05-18 update. Per ADR-0017 and ADR-0018, the domain model adds:
Organizationaggregate in the Tenancy module (ADR-0017 Amendment 2 (2026-08-10)) — sub-unit within a tenant; carries optionalCustomSubdomain,BrandingOverride,Status. See 28-platform-tenant-organization.md for the full conceptual diagram. Org-scoped entities (Course,Enrollment,LiveSession, ...) gain a nullableOrganizationId; tenant-wide entities leave it null.- Customization aggregates:
TenantContentType,TenantPageBlock,TenantLessonItemType,TenantLevelTaxonomy,TenantScoringRule,TenantCompletionRule,TenantCustomFieldDef,TenantTemplateLibrary. These hold JSON Schema definitions and DSL expressions that LearnStack runtime + frontend compose into per-tenant content shapes without code changes. See 32-tenant-customization-model.md for the data model.AuditEntryaggregate inLearnStack.Modules.Auditper ADR-0033, which supersedes ADR-0016, with the write path decided by ADR-0044. InheritsEntity<TId>(notAuditableEntity<T>) — append-only by design. See 31-audit-subsystem.md.- Hub-side aggregates (
Plan,HubSubscription,Entitlement,HubInvoice,LicenseKey,CustomDomain,CompliancePolicy) live in thelearnstack-hubrepository, not in this domain model. See 24-learnstack-hub.md. The LearnStack core mirrors only a minimalTenantrow +platform_entitlement_cache.User,Course,Enrollment,LiveSessionetc. extend their schema withcustom_fields jsonbcolumn populated fromTenantCustomFieldDefdefinitions.
Each entity lives inside exactly one module and is owned by exactly one aggregate. Cross-aggregate references use ids only — never EF navigation properties.
flowchart LR
subgraph tenancy["Tenancy"]
Tenant
Organization
TenantDomain
TenantFeatureFlag
TenantLocale
TenantSetting
end
subgraph identity["Identity"]
User
UserProfile
Membership
Role
Permission
Invitation
end
subgraph customization["Tenant Customization"]
TenantContentType
TenantPageBlock
TenantLessonItemType
TenantLevelTaxonomy
TenantScoringRule
TenantCompletionRule
TenantCustomFieldDef
TenantTemplateLibrary
end
subgraph audit["Audit"]
AuditEntry
AuditConfig
end
subgraph hubext["External: LearnStack Hub (separate codebase)"]
HubPlan["Plan"]
HubSubscription["HubSubscription"]
HubEntitlement["Entitlement"]
HubInvoice["HubInvoice"]
HubLicenseKey["LicenseKey"]
HubCustomDomain["CustomDomain"]
HubCompliancePolicy["CompliancePolicy"]
end
subgraph mirror["LearnStack mirrors (read-only cache)"]
PlatformEntitlementCache["platform_entitlement_cache"]
PlatformHostToTenant["platform_host_to_tenant"]
end
subgraph content["Content"]
ContentType
ContentEntry
Page
PageVersion
PageBlock
NavigationMenu
Redirect
end
subgraph media["Media"]
MediaAsset
MediaFolder
MediaVariant
StorageObject
end
subgraph catalog["Education Catalog"]
Program
Course
CourseVersion
Category
Level
Tag
InstructorProfile
end
subgraph learning["Learning Content"]
Module
Lesson
LessonItem
LearningPath
CompletionRule
end
subgraph assessment["Assessment"]
Assessment
QuestionBank
Question
Attempt
AttemptAnswer
Score
end
subgraph enrollment["Enrollment"]
Enrollment
CourseAccess
Cohort
Progress
end
subgraph scheduling["Scheduling"]
InstructorAvailability
LiveSession
LiveBooking
LiveAttendance
LiveSessionMaterial
end
subgraph classroom["Live Classroom"]
LiveRoom
LiveRoomToken
LiveRoomProvider
LiveRecording
LiveSessionEvent
end
subgraph billing["Billing"]
Product
Plan
Price
Subscription
Order
InvoiceReference
PaymentProviderAccount
end
subgraph analytics["Analytics"]
LearningEvent
ContentEvent
CommerceEvent
AdminEvent
ClassroomEvent
end
Organization -.referencesId.-> Tenant
Membership -.referencesId.-> Tenant
Membership -.referencesId.-> Organization
Membership -.referencesId.-> User
Page -.referencesId.-> Tenant
Course -.referencesId.-> Tenant
Course -.optionalRef.-> Organization
CourseVersion -.belongsTo.-> Course
Module -.belongsTo.-> CourseVersion
Lesson -.belongsTo.-> Module
LessonItem -.belongsTo.-> Lesson
Enrollment -.referencesId.-> CourseVersion
Enrollment -.referencesId.-> User
Enrollment -.optionalRef.-> Organization
LiveSession -.referencesId.-> CourseVersion
LiveSession -.optionalRef.-> Organization
LiveBooking -.referencesId.-> LiveSession
LiveBooking -.referencesId.-> User
LiveRoom -.referencesId.-> LiveSession
TenantContentType -.referencesId.-> Tenant
TenantPageBlock -.referencesId.-> Tenant
TenantLessonItemType -.referencesId.-> Tenant
TenantLevelTaxonomy -.referencesId.-> Tenant
TenantScoringRule -.referencesId.-> Tenant
TenantCustomFieldDef -.referencesId.-> Tenant
AuditEntry -.referencesId.-> Tenant
AuditEntry -.optionalRef.-> Organization
AuditEntry -.optionalRef.-> User
HubSubscription -.referencesId.-> HubPlan
HubSubscription -.referencesId.-> Tenant
HubEntitlement -.referencesId.-> Tenant
HubInvoice -.belongsTo.-> HubSubscription
HubLicenseKey -.referencesId.-> Tenant
HubCustomDomain -.referencesId.-> Tenant
HubCompliancePolicy -.referencesId.-> Tenant
PlatformEntitlementCache -.mirrorOf.-> HubEntitlement
PlatformHostToTenant -.mirrorOf.-> HubCustomDomain
| Entity | Aggregate root? | Notes |
|---|---|---|
Tenant |
Yes | Tenant-owned, self-keyed: no tenant_id column, because its id is the tenant id and its RLS policy keys on id. Status: Trial / Active / Suspended / Archived. |
Organization |
Yes | Sub-unit within a tenant (branch, studio, campus, department, cohort). Two-level hierarchy strict (ADR-0017). Every tenant has at least one default org. |
TenantDomain |
Yes | Subdomain on {slug}.learnstack.app (always available) or custom domain (Hub-managed; see 27-custom-domain-tls.md). |
TenantBranding |
No — not an entity; the values are TenantSetting rows (Frontend Architecture Standards § Tenant Branding) |
Logo, colors, typography tokens. May be overridden per-organization via OrganizationBranding. |
OrganizationBranding |
Inside Organization | Phase 06 target; not implemented by P02d-2/3. Optional partial design-token override (logo / colors / typography) merged on top of TenantBranding at render time. When the resolved request carries an organization id and a row exists, the merged token set is injected as CSS variables on the SSR'd HTML root; missing fields fall through to the tenant default. See Glossary § Branding. |
TenantFeatureFlag |
Inside Tenant | Experimental / gradual-rollout flags. Plan-level features are surfaced via the entitlement projection (ADR-0021), not stored here. See 21-feature-flags.md. |
TenantLocale |
Inside Tenant | The locales a tenant publishes in (ADR-0008). Composite key (tenant_id, locale), no surrogate id; exactly one row is the default. |
TenantSetting |
Yes | Timezone, default notification sender, content settings. |
Phase 02a Packet 7 settles the boundary
Packet 6 left open, and the two halves do not resolve the same way: TenantDomain and
TenantSetting are already root-shaped — surrogate Vogen id, AuditableEntity,
row_version, their own RLS policy — while TenantLocale and TenantFeatureFlag
carry a composite natural key and no id at all, and therefore cannot be
IAggregateRoot<TId> under any reading.
| Entity | Aggregate root? | Notes |
|---|---|---|
User |
Yes | Global; identifies a person across tenants. Keycloak sub is the stable id. |
UserProfile |
Inside User | Personal details. Tenant-specific profile fields live on Membership. |
Membership |
Yes | Per-tenant + per-org relationship. Triple key (user_id, tenant_id, organization_id). Carries role assignments. A user can have memberships in multiple tenants and multiple orgs within one tenant. |
Role |
Yes | Tenant-scoped (most) or platform-scoped (Hub realm only). May be Tenant-scoped or Organization-scoped (org-scoped roles apply only within one org). |
Permission |
Inside Role | Fine-grained capability key {module}.{resource}.{action}. Permission scope: Platform / Tenant / Organization (ADR-0017). |
Invitation |
Yes | Pending membership offer; bound to email; expires; revocable. |
AuditLog previously listed here moved to its own Audit module section per ADR-0016.
Tenant-customization data (CEFR levels, custom content types, scoring rules) — previously
called "vertical extensions" — now lives in Tenant Customization aggregates
per ADR-0018, not on Membership extension tables.
| Entity | Aggregate root? | Notes |
|---|---|---|
ContentType |
Yes | Schema for structured content; tenant-scoped. |
ContentEntry |
Yes | Instance of a content type. Has draft/published state. |
Page |
Yes | Owns versions and blocks. |
PageVersion |
Inside Page | Draft or published snapshot. |
PageBlock |
Inside PageVersion | Typed unit; schema-versioned. |
NavigationMenu |
Yes | Named tree of links. |
Redirect |
Yes | URL redirect, tenant-scoped. |
| Entity | Aggregate root? | Notes |
|---|---|---|
MediaAsset |
Yes | The logical asset; references a StorageObject. |
MediaFolder |
Yes | Organizational hierarchy. |
MediaVariant |
Inside MediaAsset | Resized / transcoded derivative. |
StorageObject |
Inside MediaAsset | Physical object metadata (bucket, key, content hash). |
| Entity | Aggregate root? | Notes |
|---|---|---|
Program |
Yes | Higher-level learning product grouping. |
Course |
Yes | Catalog-level entity; never mutated structurally after publish. |
CourseVersion |
Yes | Versioned course structure. Enrollments target a CourseVersion. |
Category |
Yes | Catalog grouping. |
Level |
Yes | Generic. Tenants define their own taxonomy (CEFR, yoga difficulty, kyu/dan, coding-difficulty, …) in Tenant Customization — TenantLevelTaxonomy. The Level table holds the items declared by a tenant's taxonomy, looked up by (tenant_id, taxonomy_key, key). |
Tag |
Yes | Search/discovery metadata. |
InstructorProfile |
Yes | Tenant-scoped public instructor information. |
Course vs. CourseVersion.
Coursecarries identity, catalog metadata, SEO, public visibility.CourseVersioncarries the structure (modules, lessons, items) and is what enrollments and progress bind to. Editing a course never breaks a learner currently progressing through a published version.
Phase 02d interim model — Accepted 2026-09-14.
CourseandLessonare independent roots; noCourseVersionorModuleexists in the walking skeleton. ADR-0048 gives each root its owndraft → publishedlifecycle. Course publication does not freeze or snapshot its independently published lesson set. The catalog/version split above is Phase 05's target model; the Education spec records the interim schema, including the exact taxonomy revision pin the futureLevelprojection must preserve.
| Entity | Aggregate root? | Notes |
|---|---|---|
Module |
Inside CourseVersion | Ordered grouping of lessons. |
Lesson |
Inside CourseVersion | Unit of consumption. |
LessonItem |
Inside Lesson | Polymorphic: rich text, video, file, quiz reference, live-session reference, embedded tool. |
LearningPath |
Yes | Optional cross-course traversal. |
CompletionRule |
Inside CourseVersion | Determines when a lesson / module / course is complete. |
Phase 02d interim model — Accepted 2026-09-14.
Lessonis an independent root referencingCourseby typed id, with its own concurrency token and audit subject. Each root contains its translation entities. The tables above remain Phase 05's target; its migration must preserve course and lesson ids, published localized URLs, lesson order, organization scope, translated bodies and exact customization bindings. The Education spec owns the interim model and Phase 05 owns the migration and its verification. P02d-1 accepts this design before its implementation.
P02d-2 Step 1 — 2026-10-02.
ADR-0050 defines
Course-level content-access policy inherited by independent Lessons; it is neither
a grant nor a price. The additive courses.content_access migration applies
enrollment_required to legacy rows and as the storage default. The current
Education diagram
includes the column; P02d-4 supplies enforcing public consumers. Phase 05 preserves and
locates that policy in its versioned model before its writers.
| Entity | Aggregate root? | Notes |
|---|---|---|
Assessment |
Yes | Quiz / exam / placement test / survey. |
QuestionBank |
Yes | Reusable question collection. |
Question |
Inside QuestionBank | Prompt and answer definition. |
Attempt |
Yes | Learner attempt with lifecycle. |
AttemptAnswer |
Inside Attempt | Submitted answer per question. |
Score |
Inside Attempt | Computed result. |
| Entity | Aggregate root? | Notes |
|---|---|---|
Enrollment |
Yes | Grant of access to a CourseVersion (optionally bound to a Cohort). Has status (active, suspended, completed, cancelled) and source (manual, billing, invitation, integration). |
CourseAccess |
Yes | A learner's right to open a specific course. Sources: manual grant, invitation, cohort membership, billing. Not an Entitlement — that term is reserved for the Hub-owned, tenant-subject projection (see glossary). |
Cohort |
Yes | Group of learners progressing on a shared timeline through a CourseVersion. Has its own lifecycle (open → in-progress → completed → archived). |
Progress |
Inside Enrollment | Learner advancement record. |
| Relation | Cardinality | Notes |
|---|---|---|
Cohort ↔ CourseVersion |
many-to-one | A cohort is always bound to exactly one CourseVersion; many cohorts may share the same CourseVersion (different cohorts on different timelines). |
Cohort ↔ Enrollment |
one-to-many | Every Enrollment is optionally bound to one Cohort (Enrollment.CohortId nullable). One-off enrollments leave it null. |
Cohort ↔ LiveSession |
many-to-many through LiveBooking |
A cohort can be associated with many live sessions; a live session can host learners from many cohorts (mixed-cohort sessions are allowed). LiveBooking is the join. |
LiveSession ↔ LiveBooking |
one-to-many | A session may have many bookings: each booking references either a single learner (LiveBooking.UserId) or a cohort (LiveBooking.CohortId); exactly one is non-null. |
LiveBooking ↔ User |
many-to-one (when learner-booked) | One-on-one speaking sessions use direct user bookings with no cohort. |
LiveSession ↔ LiveRoom |
one-to-one (when opened) | The runtime room only exists while the session is in lifecycle states opened → ended. Before / after, LiveRoom is absent. |
Cohort.Progress |
derived | Per-cohort progress is a projection over the Progress records of the cohort's enrollments. Not its own aggregate. |
Lifecycle interactions:
- Cancelling a
Cohortdoes not auto-cancel the cohort's enrollments — that is an operator-chosen action with its own audit entry. Cancelling enrollments individually is the safer default. - A
LiveBookingcancelled before the session opens reduces the participant count; cancelling after the session opens is a no-op (attendance carries the truth). LiveAttendanceis computed fromLiveSessionEventstreams, not fromLiveBooking. A booked learner who never joined and an unbooked learner who joined via a public token both appear correctly.
Naming hygiene. "Cohort" is the grouping; "LiveSession" is the scheduled event; "LiveRoom" is the runtime room. Earlier drafts used "Classroom" for grouping — that term is removed.
| Entity | Aggregate root? | Notes |
|---|---|---|
InstructorAvailability |
Yes | Available teaching windows. |
LiveSession |
Yes | Scheduled live event. Owns time, role mix, and lifecycle (scheduled → opened → in-progress → ended → archived). |
LiveBooking |
Yes | Reservation that ties a learner or cohort to a LiveSession. |
LiveAttendance |
Inside LiveSession | Per-participant attendance record computed from classroom events. |
LiveSessionMaterial |
Inside LiveSession | Files, links, or content entries attached to the session. |
These entities cover the runtime of a Live Session: actual rooms, tokens, recording, events.
| Entity | Aggregate root? | Notes |
|---|---|---|
LiveRoom |
Yes | Runtime room created via ILiveClassProvider. Lives for the duration of a LiveSession. |
LiveRoomToken |
Inside LiveRoom | Short-lived join token; scoped to user + room + role. |
LiveRoomProvider |
Reference | The provider implementation that owns this room. |
LiveRecording |
Yes | Recording metadata, consent state, retention. File lives in SeaweedFS/S3. |
LiveSessionEvent |
Append-only | join / leave / screen-share / recording started / network drop. Feeds ClassroomEvent analytics. |
See In-App Live Classroom for the provider abstraction.
| Entity | Aggregate root? | Notes |
|---|---|---|
Product |
Yes | Sellable item. |
Plan |
Yes | Package / subscription definition. |
Price |
Inside Plan | Currency / interval / amount. |
Subscription |
Yes | Recurring access. |
Order |
Yes | Purchase intent and lifecycle. |
InvoiceReference |
Inside Order | Pointer to external invoice/payment record. |
PaymentProviderAccount |
Yes | Per-tenant provider configuration. |
Billing produces CourseAccess grants; the Enrollment module creates them on consuming OrderPaidV1.
| Event | Source |
|---|---|
LearningEvent |
Learner behavior (lesson viewed, lesson completed). |
ContentEvent |
Content interactions (page view, block engagement). |
CommerceEvent |
Funnel and payment events. |
AdminEvent |
Operational events. |
ClassroomEvent |
Derived from LiveSessionEvent streams; provider-agnostic. |
Events are append-only, schema-versioned, and suitable for reporting, automation, and future learning analytics.
Per ADR-0018, the platform is 100% domain-agnostic. Tenants declare their domain shape as data. The customization module owns these aggregates:
| Entity | Aggregate root? | Notes |
|---|---|---|
TenantContentType |
Yes | JSON Schema for a content type (e.g. vocabulary-card, asana-pose, code-challenge). Tenant-scoped. The key shape is UNIQUE (tenant_id, key, schema_version) plus a partial UNIQUE (tenant_id, key) WHERE status = 'Active' AND deleted_at IS NULL — UNIQUE (tenant_id, key) alone would reject the second revision of any key and make the first breaking change ADR-0013 requires impossible. Phase 04 § Customization Key Shape is the authority. |
TenantPageBlock |
Yes | JSON Schema for a page block + composite renderer key (e.g. default-card, content-list). |
TenantLessonItemType |
Yes | JSON Schema for a custom lesson item type + player composite key. |
TenantLevelTaxonomy |
Yes | Flat list of level items with metadata (sort, color, …). Examples: CEFR, yoga-difficulty, coding-difficulty. |
TenantScoringRule |
Yes | DSL expression for assessment scoring (sandboxed; engine choice pending ADR). |
TenantCompletionRule |
Yes | Boolean DSL expression for lesson/module/course completion. |
TenantCustomFieldDef |
Yes | Field definition added to built-in entities (User, Course, Enrollment, …). Stored in the entity's custom_fields jsonb column. |
TenantTemplateLibrary |
Yes | Notification templates (email, SMS, WhatsApp, in-app) authored as Liquid / Handlebars, per locale. |
Org-scoping: most customization aggregates are tenant-wide, but some
(e.g. TenantTemplateLibrary for org-branded notifications, TenantCustomFieldDef for
org-specific user fields) MAY carry a nullable organization_id. The architecture test
Every_OrgScoped_Entity_HasOrgIdAndFilter checks the marker is consistent.
Full data model, sandbox-engine choices, schema versioning rules, and worked tenant examples live in 32-tenant-customization-model.md.
Per ADR-0033, which supersedes ADR-0016, the Audit module owns the append-only platform-level audit trail and MUST-class rows commit with the state change they describe. ADR-0044 decides the write path: row identity, intent multiplicity, what the capture sees, and how an operation is classified.
| Entity | Aggregate root? | Notes |
|---|---|---|
AuditEntry |
Yes (append-only — inherits Entity<TId> NOT AuditableEntity<T>) |
One row per audited (resource, operation): a command, sensitive query or security event may declare several on one transaction (ProvisionTenantCommand declares two). Tenant-owned and organization-scoped — it implements ITenantOwned, through IOrganizationScoped since the row carries an organization — and takes that class's canonical policy from Database Standards § Table classes unmodified. Fields: id (AuditEntryId, minted app-side by AuditLogBehavior so a commit-in-doubt pair shares one identity — ADR-0023 Amendment 9), tenant_id (TenantId; the tenant the ambient transaction announced, or TenantId.PlatformSentinel for a platform-scope row — and no foreign key to tenants, because the sentinel has no row there and the record outlives the tenant), organization_id? (OrganizationId?), actor_user_id? (UserId?), actor_email?, module, operation (the dotted slug {module}.{resource}.{verb}), operation_type, operation_class, entity_type?, entity_id?, outcome (success | denied | failed | indeterminate), error_key?, before_state (jsonb), after_state (jsonb), changes (jsonb), correlation_id?, ip_address?, user_agent?, reason?, timestamp, metadata?. Stored in the audit_log table — a single plain table with the composite key (id, timestamp) in Phase 02a; partitioned by month from Phase 11 per ADR-0035. |
AuditConfig |
Yes | Per-tenant override of per-(module, operation) audit enablement, keyed (tenant_id, module, operation). Tenant-owned and tenant-wide — it carries no organization_id, so it takes the tenant term only and none of the restrictive write guards (ADR-0044 Amendment 1); audit_log is the org-scoped half of the pair. Tenant-overridable within MUST/SHOULD/MAY classification. Classification overrides only: it carries no retention column, and retention arrives with the purge job in Phase 11. Packet 9 ships the aggregate, the table and the read path, but no writer: learnstack_app and learnstack_platform hold SELECT and nothing else, so the table stays empty until the Studio tenant-settings editor in Phase 06 lands it together with the DML grant, gated by a permission key the audit registry Phase 03 brings from the audit permission registry Phase 03 owns. |
AuditEntry, AuditConfig and AuditDbContext live in the Audit module, in a
migration chain of their own. The capture pipeline is split by layer:
LearnStack.SharedKernel.Audit holds the ports (IAuditStore, IAuditStateCapture,
AuditEntryDraft, AuditIntent, AuditEntryId) and the value types those ports
carry — OperationType, OperationClass, AuditOutcome, AuditClassification,
AuditIntentState and CapturedEntityChange. The module consumes them and declares
none of them: a SharedKernel record naming a type declared in the module's Domain
would be a project cycle (ADR-0044 Amendment 3).
AuditChangeTrackerInterceptor, AuditStateCapture and PostgresAuditStore live in
LearnStack.Infrastructure.Audit, and AuditLogBehavior in
LearnStack.Application's pipeline. Full deep
dive: 31-audit-subsystem.md.
LearnStack Hub (ADR-0019) is a separate codebase with its own aggregates. The LearnStack core's domain model does not own these; it mirrors only a minimal subset for runtime entitlement reads.
| Hub aggregate (external) | Where it lives | LearnStack mirror? |
|---|---|---|
Plan |
learnstack-hub repo |
No |
HubSubscription |
learnstack-hub repo |
No |
Entitlement |
learnstack-hub repo |
Yes — projected into platform_entitlement_cache (15-min cache TTL; eager-invalidated on learnstack.hub.entitlement Dapr pub/sub event). |
HubInvoice / HubInvoiceLine |
learnstack-hub repo |
No |
WebhookLedger |
learnstack-hub repo |
No |
LicenseKey |
learnstack-hub repo |
Air-gapped: signed .lic file on disk (read by SignedLicenseKeyEntitlementProvider). |
CustomDomain |
learnstack-hub repo |
Yes — projected into platform_host_to_tenant mapping (host → tenant_id), used by IHostToTenantResolver at the LearnStack edge. |
CompliancePolicy |
learnstack-hub repo |
Embedded in entitlement projection's compliance.caps field. |
UsageAggregate |
learnstack-hub repo |
No (LearnStack reports usage to Hub via POST /api/v1/usage/report; Hub aggregates). |
The LearnStack ↔ Hub contract surface is narrow:
POST /api/v1/internal/license/verify, POST /api/v1/usage/report,
PUT /api/internal/tenants/{id}/entitlements (Hub→LearnStack),
POST /api/internal/tenants (Hub→LearnStack tenant create). See
24-learnstack-hub.md §3.
Modules reference each other by id only. See Cross-Module Contracts for how Page blocks reference Courses, how Billing notifies Enrollment, and how the Classroom module notifies Scheduling.
All ids are strongly typed (TenantId, UserId, CourseId, LessonItemId, etc.). See Backend Coding Standards for the value-converter pattern.