Skip to content

Latest commit

 

History

History
504 lines (426 loc) · 28.4 KB

02-domain-model.md

File metadata and controls

504 lines (426 loc) · 28.4 KB

Domain Model

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:

  1. Organization aggregate in the Tenancy module (ADR-0017 Amendment 2 (2026-08-10)) — sub-unit within a tenant; carries optional CustomSubdomain, BrandingOverride, Status. See 28-platform-tenant-organization.md for the full conceptual diagram. Org-scoped entities (Course, Enrollment, LiveSession, ...) gain a nullable OrganizationId; tenant-wide entities leave it null.
  2. 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.
  3. AuditEntry aggregate in LearnStack.Modules.Audit per ADR-0033, which supersedes ADR-0016, with the write path decided by ADR-0044. Inherits Entity<TId> (not AuditableEntity<T>) — append-only by design. See 31-audit-subsystem.md.
  4. Hub-side aggregates (Plan, HubSubscription, Entitlement, HubInvoice, LicenseKey, CustomDomain, CompliancePolicy) live in the learnstack-hub repository, not in this domain model. See 24-learnstack-hub.md. The LearnStack core mirrors only a minimal Tenant row + platform_entitlement_cache.
  5. User, Course, Enrollment, LiveSession etc. extend their schema with custom_fields jsonb column populated from TenantCustomFieldDef definitions.

Aggregate Roots and Modules

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
Loading

Tenancy

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.

Identity

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.

Content

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.

Media

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).

Education Catalog

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. Course carries identity, catalog metadata, SEO, public visibility. CourseVersion carries 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. Course and Lesson are independent roots; no CourseVersion or Module exists in the walking skeleton. ADR-0048 gives each root its own draft → published lifecycle. 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 future Level projection must preserve.

Learning Content

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. Lesson is an independent root referencing Course by 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.

Assessment

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.

Enrollment & Access

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.

Cohort × LiveSession × LiveBooking — relations and cardinalities

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 Cohort does 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 LiveBooking cancelled before the session opens reduces the participant count; cancelling after the session opens is a no-op (attendance carries the truth).
  • LiveAttendance is computed from LiveSessionEvent streams, not from LiveBooking. 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.

Scheduling

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.

Live Classroom Runtime

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.

Billing

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.

Analytics

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.

Tenant Customization

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.

Audit

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.

External: LearnStack Hub aggregates (mirrored, not owned)

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.

Cross-module references

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.

Strongly-typed identifiers

All ids are strongly typed (TenantId, UserId, CourseId, LessonItemId, etc.). See Backend Coding Standards for the value-converter pattern.