Skip to main content

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

TermDefinition
PlaySessionA bounded, stateful instance of a learner interacting with a course version on a specific device. The primary aggregate root.
NavigationCursorA value object representing the learner's current position within the course tree (module, lesson, block, branch path).
AssistantTurnA single request-response cycle between the learner and the AI tutor, scoped to the current lesson context.
OfflineMountA record that a PlayPackage bundle has been mounted for offline use on a specific device, with license validation and tamper state.
PlayPackageA read-only projection of course content optimized for runtime delivery. Owned by content-service; consumed here as a reference.
LicenseEnvelopeA cryptographic wrapper (JWS + AES-GCM) around a PlayPackage bundle that binds it to a tenant, device, and time window.
BundleTamperDetection that the integrity of a locally stored PlayPackage bundle has been compromised.
CourseTreeThe 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

FromToGuardSide Effect
initactiveEnrollment valid; PlayPackage manifest loaded; device authorizedEmit play_session.started.v1
activeactiveNavigation target is reachable in course tree; prerequisites metEmit play_session.navigated.v1; update cursor
activepausedSession is activeEmit play_session.paused.v1; snapshot cursor
pausedactiveSession exists and is pausedEmit play_session.resumed.v1
activecompletedAll required modules/lessons visited; completion rules satisfiedEmit play_session.completed.v1; set endedAt
activeabandonedExplicit abandon or inactivity timeout (configurable, default 24h)Emit play_session.abandoned.v1; set endedAt
pausedabandonedPause duration exceeds abandonment thresholdEmit play_session.abandoned.v1; set endedAt
completed(terminal)No further transitions
abandoned(terminal)No further transitions

4.2 Invariants

  1. 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 paused state.
  2. Enrollment validity: A session cannot be started or resumed if the associated enrollment is revoked or expired.
  3. Cursor integrity: The cursor must always point to a valid node in the PlayPackage manifest's course tree.
  4. Offline session ownership: An offline session's offlineMountId must reference a valid, non-expired, non-tampered OfflineMount for the same device and user.
  5. Attempt monotonicity: attemptNumber for a given (user, enrollmentId) must be strictly increasing.
  6. Tenant isolation: A session's tenantId must match the requesting user's JWT tid claim. Cross-tenant operations are always rejected.

5. Domain Events

EventTriggerKey Payload Fields
PlaySessionStartedinit -> activesessionId, enrollmentId, courseVersionId, deviceId, attemptNumber, isOffline
PlaySessionNavigatednavigate()sessionId, fromCursor, toCursor, navigationType
PlaySessionPausedpause()sessionId, cursor, durationSoFar
PlaySessionResumedresume()sessionId, cursor
PlaySessionCompletedcomplete()sessionId, enrollmentId, attemptNumber, durationSeconds
PlaySessionAbandonedabandon()sessionId, enrollmentId, attemptNumber, reason
AssistantTurnCompletedAI tutor finishessessionId, turnId, lessonId, aiProvenance
OfflineMountedmount-offlinemountId, bundleId, deviceId, expiresAt
OfflineUnmountedunmount-offlinemountId, 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.