Skip to main content

Domain Model

:::info Source Sourced from services/authoring-service/02-DOMAIN_MODEL.md in the documentation repo. :::

Companion: 01 Service Overview · 12 Data Models · 02 DDD & Bounded Contexts


1. Aggregate Map

CourseDraft (aggregate root)
├── tenantId: TenantId
├── publishedCourseId?: CourseId
├── title: I18nString
├── defaultLocale: Locale
├── state: DraftState
├── collaborators: UserId[]
├── draftVersion: number
├── modules: ModuleDraft[]
│ ├── id: string
│ ├── title: I18nString
│ └── lessons: LessonDraft[]
│ ├── id: LessonId
│ ├── title: I18nString
│ ├── estimatedMinutes?: number
│ └── blocks: Block[]
│ ├── id: BlockId
│ ├── kind: BlockKind
│ ├── status: BlockStatus
│ ├── aiProvenance?: AIProvenance
│ ├── reviewedBy?: UserId
│ ├── reviewedAt?: ISODate
│ └── ...kind-specific data
├── createdAt: ISODate
└── updatedAt: ISODate

CollaborationSession (aggregate root)
├── id: string
├── courseDraftId: CourseDraftId
├── participants: UserId[]
└── yDocUpdateLog: Buffer[]

2. Branded Types

type CourseDraftId = Branded<string, 'CourseDraftId'>;
type LessonId = Branded<string, 'LessonId'>;
type BlockId = Branded<string, 'BlockId'>;
type ModuleId = Branded<string, 'ModuleId'>;

3. Value Objects

3.1 I18nString

// Record<Locale, string> — at least one key matching defaultLocale
type I18nString = Record<Locale, string>;

Invariant: The defaultLocale key must always be present and non-empty.

3.2 AIProvenance

interface AIProvenance {
model: string; // e.g. "claude-sonnet-4-20250514"
version?: string;
promptId?: string; // e.g. "authoring/block_from_intent"
promptVersion?: SemVer; // e.g. "1.0.0"
traceId: string; // links to OTel trace
decisionId?: string; // links to HITL acceptance record
local: boolean; // true if generated by on-device model
generatedAt: ISODate;
reviewedBy?: UserId;
reviewedAt?: ISODate;
cost?: {
microUSD: number;
tokens: { in: number; out: number };
};
}

3.3 DraftState (state machine)

type DraftState = 'editing' | 'in_review' | 'approved' | 'publishing' | 'published_idle';

3.4 BlockStatus

type BlockStatus = 'draft' | 'draft_ai' | 'reviewed' | 'published';

3.5 BlockKind

type BlockKind =
| 'text' | 'heading' | 'list' | 'callout' | 'divider'
| 'image' | 'image_grid' | 'video' | 'audio'
| 'embed' | 'code_snippet'
| 'quiz' | 'branching'
| 'hotspot' | 'drag_drop' | 'sortable' | 'click_reveal' | 'flashcards'
| 'accordion' | 'tabs' | 'timeline' | 'gallery'
| 'button' | 'downloadable_attachment'
| 'interaction'
| 'ai';

4. Entity Definitions

4.1 CourseDraft (Aggregate Root)

interface CourseDraft {
readonly id: CourseDraftId;
readonly tenantId: TenantId;
publishedCourseId?: CourseId; // null until first publish
title: I18nString;
defaultLocale: Locale;
modules: readonly ModuleDraft[];
state: DraftState;
collaborators: readonly UserId[];
draftVersion: number; // monotonically increasing
createdAt: ISODate;
updatedAt: ISODate;
}

4.2 ModuleDraft (Entity)

interface ModuleDraft {
readonly id: ModuleId;
title: I18nString;
lessons: readonly LessonDraft[];
sortOrder: number;
}

4.3 LessonDraft (Entity)

interface LessonDraft {
readonly id: LessonId;
title: I18nString;
blocks: readonly Block[];
estimatedMinutes?: number;
sortOrder: number;
}

4.4 Block (Discriminated Union)

interface BlockBase {
readonly id: BlockId;
sortOrder: number;
required: boolean; // template-driven; defaults to false
aiProvenance?: AIProvenance;
status: BlockStatus;
reviewedBy?: UserId;
reviewedAt?: ISODate;
}

interface TextBlock extends BlockBase {
readonly kind: 'text';
markdown: string;
}

interface ImageBlock extends BlockBase {
readonly kind: 'image';
assetId: MediaAssetId;
alt: I18nString;
}

interface VideoBlock extends BlockBase {
readonly kind: 'video';
assetId: MediaAssetId;
captions?: CaptionTrackRef[];
transcript?: I18nString;
}

interface AudioBlock extends BlockBase {
readonly kind: 'audio';
assetId: MediaAssetId;
transcript?: I18nString;
}

interface QuizBlockRef extends BlockBase {
readonly kind: 'quiz';
quizBankId: string;
}

interface BranchingBlockRef extends BlockBase {
readonly kind: 'branching';
scenarioId: string;
}

interface EmbedBlock extends BlockBase {
readonly kind: 'embed';
provider: string;
url: string;
sandbox: string;
}

interface InteractionBlock extends BlockBase {
readonly kind: 'interaction';
interactionType: 'hotspot' | 'drag_drop' | 'sortable' | 'click_reveal' | 'flashcards';
config: JSONValue;
}

interface DividerBlock extends BlockBase {
readonly kind: 'divider';
style: 'line' | 'space' | 'ornament';
}

interface AIBlock extends BlockBase {
readonly kind: 'ai';
suggested: Block;
rationale?: string;
}

type Block =
| TextBlock | ImageBlock | VideoBlock | AudioBlock
| QuizBlockRef | BranchingBlockRef | EmbedBlock
| InteractionBlock | DividerBlock | AIBlock;

4.5 CollaborationSession (Aggregate Root)

interface CollaborationSession {
readonly id: string;
readonly courseDraftId: CourseDraftId;
participants: readonly UserId[];
yDocUpdateLog: readonly Buffer[];
}

5. State Machine

┌───────────────────────────┐
│ │
▼ │
┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ │ │ │ │ │
───►│ editing │───►│in_review │───►│ approved │────┘ (reject → editing)
│ │ │ │ │ │
└──────────┘ └──────────┘ └────┬─────┘
▲ │
│ │ publish
│ ▼
│ ┌──────────┐
│ (saga fail/timeout) │ │
│◄─────────────────────────│publishing│
│ │ │
│ └────┬─────┘
│ │
│ │ saga complete
│ ▼
│ ┌───────────────┐
│ (fork) │ │
└───────────────────────│published_idle │
│ │
└───────────────┘

Transition Rules

FromToGuardTrigger
editingin_reviewAt least one block existsSubmitForReview command
in_reviewapprovedReviewer is not the authorApproveReview command
in_revieweditingRejectReview command
approvedpublishingAll required blocks status ∈ {reviewed, published} AND all media refs resolvePublishDraft command
publishingpublished_idleSaga completes successfullySaga completion event
publishingeditingSaga fails or times out (15 min)Saga failure/timeout
published_idleeditingForkDraft command (creates new draft version)

6. Domain Invariants

INV-1: Tenant Consistency

TenantId must be identical across the CourseDraft and every child entity. Any cross-tenant reference throws DomainError.CrossTenant.

// Enforced at construction and every mutation
if (block.tenantId !== draft.tenantId) {
throw new DomainError.CrossTenant(block.tenantId, draft.tenantId);
}

INV-2: Block Ordering

Every block belongs to exactly one lesson. Block sortOrder values must be contiguous integers starting from 0 within each lesson. No gaps, no duplicates.

INV-3: AI Block Provenance

If block.status === 'draft_ai', then block.aiProvenance must be non-null. Conversely, if aiProvenance is present and status !== 'draft_ai', then aiProvenance.reviewedBy and aiProvenance.reviewedAt must also be set.

INV-4: Publish Readiness

Cannot transition to publishing unless:

  • Every block with required === true has status ∈ {'reviewed', 'published'}
  • Every media ref (assetId) in image/video/audio blocks resolves to a media.asset.ready state
  • No blocks have status === 'draft_ai' with required === true

INV-5: Monotonic Version

draftVersion must strictly increase on every persisted mutation. Enforced via optimistic concurrency (version check on write).

INV-6: AI Blocks Cannot Be Required

A block with status === 'draft_ai' cannot have required === true. This prevents unreviewed AI content from blocking the publish gate.

INV-7: Collaborator Membership

All collaborators must be members of the same tenantId as the draft.

7. Domain Events

// CourseDraft lifecycle
interface CourseDraftCreated {
draftId: CourseDraftId;
tenantId: TenantId;
title: I18nString;
createdBy: UserId;
}

interface CourseDraftUpdated {
draftId: CourseDraftId;
tenantId: TenantId;
changes: DraftChangeSet;
draftVersion: number;
updatedBy: UserId;
}

interface CourseDraftPublished {
draftId: CourseDraftId;
tenantId: TenantId;
publishedCourseId: CourseId;
draftVersion: number;
publishedBy: UserId;
}

interface CourseDraftForked {
newDraftId: CourseDraftId;
sourceDraftId: CourseDraftId;
tenantId: TenantId;
forkedBy: UserId;
}

// Block lifecycle
interface BlockAdded {
draftId: CourseDraftId;
lessonId: LessonId;
block: Block;
addedBy: UserId;
}

interface BlockUpdated {
draftId: CourseDraftId;
blockId: BlockId;
changes: BlockChangeSet;
updatedBy: UserId;
}

interface BlockRemoved {
draftId: CourseDraftId;
lessonId: LessonId;
blockId: BlockId;
removedBy: UserId;
}

interface BlockAIGenerated {
draftId: CourseDraftId;
lessonId: LessonId;
blockId: BlockId;
aiProvenance: AIProvenance;
}

interface BlockReviewed {
draftId: CourseDraftId;
blockId: BlockId;
reviewedBy: UserId;
decision: 'accepted' | 'rejected';
}

8. Domain Services

8.1 PublishReadinessChecker

Validates all INV-4 conditions and returns a detailed report of any blockers.

interface PublishReadinessReport {
ready: boolean;
blockers: PublishBlocker[];
}

type PublishBlocker =
| { kind: 'unreviewed_required_block'; blockId: BlockId; lessonId: LessonId }
| { kind: 'unresolved_media_ref'; blockId: BlockId; assetId: MediaAssetId }
| { kind: 'ai_block_required'; blockId: BlockId; lessonId: LessonId }
| { kind: 'empty_lesson'; lessonId: LessonId };

8.2 BlockOrderingService

Handles reordering within a lesson and across lessons. Recomputes contiguous sortOrder values.

8.3 AIBlockPromoter

Promotes draft_ai blocks to reviewed status (on acceptance) or removes them (on rejection). Updates aiProvenance with reviewedBy and reviewedAt.

9. Domain Error Catalog

Error CodeDescription
DomainError.CrossTenantCross-tenant reference detected
DomainError.InvalidStateTransitionDraft state machine violation
DomainError.BlockOrderGapBlock ordering non-contiguous
DomainError.AIProvenanceMissingdraft_ai block without provenance
DomainError.PublishNotReadyPublish preconditions not met
DomainError.VersionConflictOptimistic concurrency violation
DomainError.CollaboratorNotMemberCollaborator not in tenant
DomainError.AIBlockCannotBeRequireddraft_ai block marked required
DomainError.DuplicateBlockIdBlock ID already exists in lesson
DomainError.LessonNotFoundReferenced lesson does not exist
DomainError.ModuleNotFoundReferenced module does not exist
DomainError.BlockNotFoundReferenced block does not exist