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
| From | To | Guard | Trigger |
|---|---|---|---|
editing | in_review | At least one block exists | SubmitForReview command |
in_review | approved | Reviewer is not the author | ApproveReview command |
in_review | editing | — | RejectReview command |
approved | publishing | All required blocks status ∈ {reviewed, published} AND all media refs resolve | PublishDraft command |
publishing | published_idle | Saga completes successfully | Saga completion event |
publishing | editing | Saga fails or times out (15 min) | Saga failure/timeout |
published_idle | editing | — | ForkDraft 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 === truehasstatus ∈ {'reviewed', 'published'} - Every media ref (
assetId) in image/video/audio blocks resolves to amedia.asset.readystate - No blocks have
status === 'draft_ai'withrequired === 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 Code | Description |
|---|---|
DomainError.CrossTenant | Cross-tenant reference detected |
DomainError.InvalidStateTransition | Draft state machine violation |
DomainError.BlockOrderGap | Block ordering non-contiguous |
DomainError.AIProvenanceMissing | draft_ai block without provenance |
DomainError.PublishNotReady | Publish preconditions not met |
DomainError.VersionConflict | Optimistic concurrency violation |
DomainError.CollaboratorNotMember | Collaborator not in tenant |
DomainError.AIBlockCannotBeRequired | draft_ai block marked required |
DomainError.DuplicateBlockId | Block ID already exists in lesson |
DomainError.LessonNotFound | Referenced lesson does not exist |
DomainError.ModuleNotFound | Referenced module does not exist |
DomainError.BlockNotFound | Referenced block does not exist |