Skip to main content

Application Logic

:::info Source Sourced from services/delivery-service/APPLICATION_LOGIC.md in the documentation repo. :::

Companion: DOMAIN_MODEL · API_CONTRACTS · EVENT_SCHEMAS

1. Use Cases

1.1 StartPlaySession

Trigger: POST /api/v1/play-sessions

Flow:

  1. Validate JWT claims and extract tenantId, userId, deviceId.
  2. Call EnrollmentClient.validateEnrollment(enrollmentId) — reject if not active.
  3. Check for existing active session for (userId, courseVersionId, deviceId) — if found, force-pause it.
  4. Resolve attemptNumber — query max attempt for (userId, enrollmentId) and increment.
  5. Fetch PlayPackage manifest via ContentClient.getPlayPackageManifest(courseVersionId).
  6. Construct initial NavigationCursor pointing to first module/lesson.
  7. Create PlaySession aggregate in init state, transition to active.
  8. Persist via PlaySessionRepository.save() inside transaction with outbox event.
  9. Emit delivery.play_session.started.v1.
  10. Return session state with cursor and manifest metadata.

Error Conditions:

  • 404 — Enrollment not found
  • 403 — Enrollment revoked or expired
  • 409 — Rate limit on session creation (max 5 sessions per user per minute)
  • 422 — Invalid courseVersionId or missing PlayPackage

1.2 Navigate

Trigger: PATCH /api/v1/play-sessions/{id}/navigate

Flow:

  1. Load PlaySession by id; verify ownership (userId + tenantId match JWT).
  2. Validate session is in active state.
  3. Parse NavigationTarget from request body.
  4. Call NavigationService.resolve(currentCursor, target, manifest).
  5. If target is quiz-gated, check gate status via cached assessment result or event.
  6. Update cursor on PlaySession aggregate.
  7. Update lastActivityAt.
  8. Persist with outbox event.
  9. Emit delivery.play_session.navigated.v1.
  10. Return updated session state.

Error Conditions:

  • 404 — Session not found
  • 403 — Not session owner
  • 409 — Session not in active state
  • 422 — Navigation target unreachable (prerequisite not met, invalid node)

1.3 PausePlaySession

Trigger: POST /api/v1/play-sessions/{id}/pause

Flow:

  1. Load session; verify ownership.
  2. Validate state is active.
  3. Transition to paused.
  4. Persist with outbox event.
  5. Emit delivery.play_session.paused.v1.

1.4 CompletePlaySession

Trigger: POST /api/v1/play-sessions/{id}/complete

Flow:

  1. Load session; verify ownership.
  2. Validate state is active.
  3. Evaluate completion rules from PlayPackage manifest (all required lessons visited, all gates passed).
  4. If rules not satisfied, return 422 with details of unmet requirements.
  5. Transition to completed; set endedAt.
  6. Calculate durationSeconds.
  7. Persist with outbox event.
  8. Emit delivery.play_session.completed.v1.

1.5 AbandonPlaySession

Trigger: POST /api/v1/play-sessions/{id}/abandon or automatic via inactivity timeout

Flow:

  1. Load session; verify ownership.
  2. Validate state is active or paused.
  3. Transition to abandoned; set endedAt and reason.
  4. Persist with outbox event.
  5. Emit delivery.play_session.abandoned.v1.

1.6 GetSessionState

Trigger: GET /api/v1/play-sessions/{id}/state

Flow:

  1. Load session; verify ownership.
  2. Return SessionSummary projection.
  3. Cache result in Redis (TTL 30s) keyed by (tenantId, sessionId).

1.7 StreamTutorResponse (AI Tutor)

Trigger: POST /api/v1/play-sessions/{id}/tutor/turn Response: SSE stream at GET /api/v1/play-sessions/{id}/tutor/stream

Flow:

  1. Load session; verify ownership; validate active state.
  2. Extract current lesson context from cursor.
  3. Call TutorOrchestrationService: a. Gather RAG context: current lesson content blocks, previous 5 turns in conversation. b. Build prompt with system instructions (scope to lesson, avoid curriculum drift). c. Determine inference path: online (AIClient → cloud) or offline (AIClient → local model). d. Stream response chunks via SSE.
  4. On stream completion, create AssistantTurn entity.
  5. Persist turn; emit delivery.play_session.tutor_turn.completed.v1.

Error Conditions:

  • 404 — Session not found
  • 503 — AI service unavailable (graceful degradation message sent to client)
  • 429 — Tutor rate limit (max 30 turns per session per hour)

1.8 RateAssistantTurn

Trigger: POST /api/v1/play-sessions/{id}/tutor/turn/{turnId}/rate (from API spec: handled within tutor turn flow)

Flow:

  1. Load session and turn; verify ownership.
  2. Update AssistantTurn.rating.
  3. Persist.

1.9 MountOfflineBundle

Trigger: POST /api/v1/play-sessions/{id}/mount-offline

Flow:

  1. Validate JWT; extract deviceId.
  2. Verify device is registered and trusted (via identity-service device binding).
  3. Validate license envelope: verify JWS signature, check expiry, confirm device binding.
  4. Verify bundle integrity via ContentClient.validateBundleIntegrity().
  5. Create OfflineMount record.
  6. Persist with outbox event.
  7. Emit delivery.offline_mounted.v1.
  8. Return mount metadata (expiresAt, bundleId).

Error Conditions:

  • 403 — Device not trusted or license invalid
  • 409 — Bundle already mounted on this device
  • 422 — Bundle integrity check failed

1.10 UnmountOfflineBundle

Trigger: POST /api/v1/play-sessions/{id}/unmount-offline

Flow:

  1. Load mount; verify ownership.
  2. Set unmountedAt and unmountReason.
  3. Invalidate any active sessions referencing this mount.
  4. Persist with outbox event.
  5. Emit delivery.offline_unmounted.v1.

2. Event Consumers

2.1 OnPlayPackageBuilt

Event: content.play_package.built.v1

Handler:

  1. Extract courseVersionId and manifest metadata.
  2. Cache manifest in Redis for fast runtime access.
  3. Invalidate any stale manifest cache for previous versions.

2.2 OnEnrollmentCreated

Event: enrollment.created.v1

Handler:

  1. Store enrollment reference in local projection table.
  2. Pre-warm PlayPackage manifest cache if not already present.

2.3 OnBundlePublished

Event: content.play_package.bundle.published.v1

Handler:

  1. Update bundle availability projection.
  2. Mark bundle as available for offline mount.

2.4 OnBundleTamperDetected

Event: content.bundle.tamper_detected.v1

Handler:

  1. Find all OfflineMounts referencing the tampered bundle.
  2. Force unmount all affected mounts (set unmountReason: 'tamper_detected').
  3. Pause any active sessions using those mounts.
  4. Emit delivery.offline_unmounted.v1 for each affected mount.
  5. Log security alert.

2.5 OnAssessmentScored

Event: assessment.attempt_result.scored.v1 (consumed for quiz-gated navigation)

Handler:

  1. Extract quiz gate result.
  2. Update local gate-status projection for the session.
  3. If the gate was blocking a navigation request, the next navigate call will now succeed.

3. Command/Query Separation

3.1 Commands (Write Side)

CommandHandlerAggregate
StartPlaySessionCommandStartPlaySessionHandlerPlaySession
NavigateCommandNavigateHandlerPlaySession
PauseSessionCommandPauseSessionHandlerPlaySession
CompleteSessionCommandCompleteSessionHandlerPlaySession
AbandonSessionCommandAbandonSessionHandlerPlaySession
RequestTutorTurnCommandTutorTurnHandlerPlaySession + AssistantTurn
MountOfflineCommandMountOfflineHandlerOfflineMount
UnmountOfflineCommandUnmountOfflineHandlerOfflineMount

3.2 Queries (Read Side)

QueryHandlerSource
GetSessionStateQuerySessionStateHandlerPlaySession (Redis cache + DB fallback)
ListSessionsQueryListSessionsHandlerPlaySession (DB)
GetTutorHistoryQueryTutorHistoryHandlerAssistantTurn (DB)
GetOfflineMountsQueryOfflineMountsHandlerOfflineMount (DB)

4. Saga: Offline-to-Online Resume

When a device comes back online after an offline session:

┌──────────────────────────────────────────────────────────────┐
│ 1. sync-service delivers offline session mutations │
│ 2. Delivery validates each mutation against server state │
│ 3. If session exists online with divergent cursor: │
│ a. Server cursor wins for completed lessons │
│ b. Offline cursor wins for lessons not yet seen online │
│ c. Merge navigation history │
│ 4. Replay offline assistant turns (store only, no re-infer) │
│ 5. Emit reconciled events to progress-service │
│ 6. Mark sync complete │
└──────────────────────────────────────────────────────────────┘

5. Idempotency

  • All write endpoints require Idempotency-Key header (ULID).
  • Keys stored in Redis with 24h TTL.
  • Duplicate requests return the original response without re-executing side effects.
  • Event consumers use inbox table with eventId unique constraint for exactly-once application.

6. Concurrency Control

  • PlaySession uses optimistic concurrency via version field.
  • PATCH /navigate includes If-Match header with current version.
  • Concurrent navigation attempts from the same user on different tabs receive 409 Conflict with retry guidance.