Domain Model
:::info Source
Sourced from services/delivery-service/DOMAIN_MODEL.md in the documentation repo.
:::
Companion: 02 DDD · 12 Data Models · SERVICE_OVERVIEW
1. Ubiquitous Language
| Term | Definition |
|---|---|
| PlaySession | A bounded, stateful instance of a learner interacting with a course version on a specific device. The primary aggregate root. |
| NavigationCursor | A value object representing the learner's current position within the course tree (module, lesson, block, branch path). |
| AssistantTurn | A single request-response cycle between the learner and the AI tutor, scoped to the current lesson context. |
| OfflineMount | A record that a PlayPackage bundle has been mounted for offline use on a specific device, with license validation and tamper state. |
| PlayPackage | A read-only projection of course content optimized for runtime delivery. Owned by content-service; consumed here as a reference. |
| LicenseEnvelope | A cryptographic wrapper (JWS + AES-GCM) around a PlayPackage bundle that binds it to a tenant, device, and time window. |
| BundleTamper | Detection that the integrity of a locally stored PlayPackage bundle has been compromised. |
| CourseTree | The hierarchical structure of modules, lessons, and blocks within a PlayPackage manifest. Used for navigation. |
2. Aggregate Roots
2.1 PlaySession (primary aggregate)
type PlaySessionId = Branded<string, 'PlaySessionId'>;
interface PlaySession {
id: PlaySessionId;
tenantId: TenantId;
userId: UserId;
enrollmentId: EnrollmentId;
courseVersionId: CourseVersionId;
deviceId: DeviceId;
attemptNumber: number;
state: PlaySessionState;
cursor: NavigationCursor;
startedAt: ISODate;
lastActivityAt: ISODate;
endedAt?: ISODate;
offlineMountId?: string;
version: number; // optimistic concurrency
}
type PlaySessionState = 'init' | 'active' | 'paused' | 'completed' | 'abandoned';
Clustered entities under PlaySession:
interface AssistantTurn {
id: string; // ULID
sessionId: PlaySessionId;
turnId: string;
prompt: string;
contextRefs: TutorContextRefs;
response?: string;
toolCalls?: ToolCallTrace[];
aiProvenance: AIProvenance;
startedAt: ISODate;
finishedAt?: ISODate;
rating?: 'helpful' | 'unhelpful';
}
interface TutorContextRefs {
lessonId: string;
blockIds: string[];
additionalContext?: string[];
}
interface ToolCallTrace {
tool: string;
input: JSONValue;
output: JSONValue;
durationMs: number;
}
2.2 OfflineMount (secondary aggregate, referenced by PlaySession)
interface OfflineMount {
id: string; // ULID
tenantId: TenantId;
userId: UserId;
deviceId: DeviceId;
bundleId: BundleId;
courseVersionId: CourseVersionId;
mountedAt: ISODate;
expiresAt: ISODate;
signatureValid: boolean;
unmountedAt?: ISODate;
unmountReason?: 'user_initiated' | 'expired' | 'tamper_detected' | 'license_revoked';
}
3. Value Objects
interface NavigationCursor {
moduleId: string;
lessonId: string;
blockId?: string;
branchPath?: string[]; // for branching scenarios
sequenceIndex?: number; // linear position in flattened tree
}
interface NavigationTarget {
type: 'next' | 'prev' | 'jump' | 'branch';
targetModuleId?: string;
targetLessonId?: string;
targetBlockId?: string;
branchChoice?: string;
}
interface SessionSummary {
sessionId: PlaySessionId;
state: PlaySessionState;
cursor: NavigationCursor;
startedAt: ISODate;
lastActivityAt: ISODate;
endedAt?: ISODate;
durationSeconds: number;
assistantTurnsCount: number;
isOffline: boolean;
}
4. State Machine — PlaySession
┌───────┐
│ init │
└───┬───┘
│ start()
┌───▼───┐
┌─────│active │─────┐
│ └───┬───┘ │
│ │ │
pause() navigate() complete()
│ │ │
┌────▼───┐ │ ┌───▼──────┐
│paused │ │ │completed │
└────┬───┘ │ └──────────┘
│ │
resume() │
│ │
└────►active
│
abandon()
│
┌────▼─────┐
│abandoned │
└──────────┘
4.1 Transition Rules
| From | To | Guard | Side Effect |
|---|---|---|---|
init | active | Enrollment valid; PlayPackage manifest loaded; device authorized | Emit play_session.started.v1 |
active | active | Navigation target is reachable in course tree; prerequisites met | Emit play_session.navigated.v1; update cursor |
active | paused | Session is active | Emit play_session.paused.v1; snapshot cursor |
paused | active | Session exists and is paused | Emit play_session.resumed.v1 |
active | completed | All required modules/lessons visited; completion rules satisfied | Emit play_session.completed.v1; set endedAt |
active | abandoned | Explicit abandon or inactivity timeout (configurable, default 24h) | Emit play_session.abandoned.v1; set endedAt |
paused | abandoned | Pause duration exceeds abandonment threshold | Emit play_session.abandoned.v1; set endedAt |
completed | (terminal) | — | No further transitions |
abandoned | (terminal) | — | No further transitions |
4.2 Invariants
- Single active session per (user, courseVersion, device): A learner cannot have two active sessions for the same course version on the same device. Starting a new session when one exists forces the old session to
pausedstate. - Enrollment validity: A session cannot be started or resumed if the associated enrollment is revoked or expired.
- Cursor integrity: The cursor must always point to a valid node in the PlayPackage manifest's course tree.
- Offline session ownership: An offline session's
offlineMountIdmust reference a valid, non-expired, non-tampered OfflineMount for the same device and user. - Attempt monotonicity:
attemptNumberfor a given (user, enrollmentId) must be strictly increasing. - Tenant isolation: A session's
tenantIdmust match the requesting user's JWTtidclaim. Cross-tenant operations are always rejected.
5. Domain Events
| Event | Trigger | Key Payload Fields |
|---|---|---|
PlaySessionStarted | init -> active | sessionId, enrollmentId, courseVersionId, deviceId, attemptNumber, isOffline |
PlaySessionNavigated | navigate() | sessionId, fromCursor, toCursor, navigationType |
PlaySessionPaused | pause() | sessionId, cursor, durationSoFar |
PlaySessionResumed | resume() | sessionId, cursor |
PlaySessionCompleted | complete() | sessionId, enrollmentId, attemptNumber, durationSeconds |
PlaySessionAbandoned | abandon() | sessionId, enrollmentId, attemptNumber, reason |
AssistantTurnCompleted | AI tutor finishes | sessionId, turnId, lessonId, aiProvenance |
OfflineMounted | mount-offline | mountId, bundleId, deviceId, expiresAt |
OfflineUnmounted | unmount-offline | mountId, reason |
6. Domain Services
6.1 NavigationService
Resolves the next valid cursor position given a NavigationTarget and the current cursor state. Consults the PlayPackage manifest's course tree. Enforces prerequisite gates (delegates to assessment-service for quiz-gated checks).
6.2 SessionLifecycleService
Manages the PlaySession state machine transitions. Enforces invariants (single active session, enrollment validity). Coordinates with OfflineMount for offline sessions.
6.3 TutorOrchestrationService
Prepares RAG context from the current lesson's content blocks, constructs the prompt with conversation history, and streams the AI tutor response via the AIClient port. Handles both online (cloud model) and offline (local model) paths.
7. Ports (Application Layer Interfaces)
interface PlaySessionRepository {
findById(id: PlaySessionId): Promise<PlaySession | null>;
findActiveByUserAndCourse(userId: UserId, courseVersionId: CourseVersionId, deviceId: DeviceId): Promise<PlaySession | null>;
save(session: PlaySession): Promise<void>;
}
interface OfflineMountRepository {
findById(id: string): Promise<OfflineMount | null>;
findActiveByDevice(deviceId: DeviceId, userId: UserId): Promise<OfflineMount[]>;
save(mount: OfflineMount): Promise<void>;
}
interface AssistantTurnRepository {
findBySession(sessionId: PlaySessionId, cursor?: string, limit?: number): Promise<AssistantTurn[]>;
save(turn: AssistantTurn): Promise<void>;
}
interface EnrollmentClient {
validateEnrollment(enrollmentId: EnrollmentId, tenantId: TenantId): Promise<EnrollmentStatus>;
}
interface ContentClient {
getPlayPackageManifest(courseVersionId: CourseVersionId): Promise<PlayPackageManifest>;
validateBundleIntegrity(bundleId: BundleId, checksum: SHA256): Promise<boolean>;
}
interface AIClient {
streamTutorResponse(request: TutorRequest): AsyncIterable<TutorChunk>;
}
interface EventPublisher {
publish(event: DomainEvent): Promise<void>;
}
8. Anti-Corruption Layer
8.1 Content-Packaging -> Delivery
The PlayPackage manifest from content-service is translated into the delivery context's internal CourseTree representation. The ACL strips authoring-specific metadata and normalizes the navigation structure.
8.2 Assessment -> Delivery
Assessment scoring results (assessment.attempt_result.scored.v1) are translated into a simple GateResult value object (passed/failed/pending) that the NavigationService uses for prerequisite checks.
8.3 AI Gateway -> Delivery
AI responses are wrapped in the delivery context's AssistantTurn aggregate with AIProvenance tracking. The ACL handles both cloud and local model response format differences.