Skip to main content

Sync Contract

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

Companion: 03 sync-service · 11 LMS Runtime Player · APPLICATION_LOGIC

1. Purpose

Delivery is one of the most critical replicable contexts. Learners start sessions offline, navigate through courses offline, interact with the on-device AI tutor, and must have their state reconciled when the device returns online — without losing work, creating phantom sessions, or violating domain invariants.

This document specifies the sync protocol for delivery-owned entities: PlaySession, AssistantTurn, and OfflineMount.

2. Replicable Entities

EntityReplication DirectionConflict Strategy
PlaySessionBidirectionalLWW with field-level merge (see §5)
AssistantTurnClient -> Server onlyAppend-only; server accepts all non-duplicate turns
OfflineMountClient -> Server onlyServer is authoritative for mount lifecycle
NavigationEventClient -> Server onlyAppend-only event log, deduplicated

3. Sync Subjects

sync.delivery.play_session.upsert.v1
sync.delivery.assistant_turn.append.v1
sync.delivery.offline_mount.upsert.v1
sync.delivery.navigation_event.append.v1

4. Client-Side Schema (IndexedDB / SQLite)

// Mirror of server tables; same logical fields
interface LocalPlaySession extends PlaySession {
_syncState: 'pending' | 'synced' | 'conflict';
_localMutationIds: string[]; // ULIDs tracked by sync-service
_vectorClock: VectorClock;
_lastSyncedAt?: ISODate;
}

interface LocalAssistantTurn extends AssistantTurn {
_syncState: 'pending' | 'synced';
_localMutationId: string;
}

interface LocalOfflineMount extends OfflineMount {
_syncState: 'pending' | 'synced';
}

5. PlaySession Conflict Resolution

The critical scenario: a learner starts a session online on Device A, goes offline, continues on the same session on Device A, then Device B comes online and the server sees state from both.

5.1 Merge Rules (field-by-field)

FieldStrategy
stateStrictly monotone: init < active < paused < completed/abandoned. If one side has terminal state and the other has active/paused, terminal wins. If both terminal with different outcomes (rare), completed wins over abandoned.
cursorTake the cursor with the highest sequenceIndex. If equal, prefer the cursor that references more-completed prerequisites.
lastActivityAtMAX of both sides
startedAtMIN of both sides (earliest start wins)
endedAtIf both set, MIN (first termination wins)
attemptNumberServer-authoritative; clients cannot set this
offlineMountIdClient-authoritative if session was offline-initiated
versionServer increments after merge

5.2 Merge Algorithm

mergePlaySession(serverState, clientState):
if serverState.state is terminal and clientState.state is terminal:
return preferCompletedOverAbandoned(serverState, clientState)

if serverState.state is terminal:
return serverState // server wins; reject client update

if clientState.state is terminal:
return clientState // apply client termination

// Both non-terminal: field-level merge
merged = { ...serverState }
merged.cursor = pickFurtherCursor(serverState.cursor, clientState.cursor)
merged.lastActivityAt = max(serverState.lastActivityAt, clientState.lastActivityAt)
merged.state = resolveActiveState(serverState.state, clientState.state)
merged.version = serverState.version + 1
return merged

5.3 Conflict Detection

Conflicts are detected by sync-service using vector clocks. If both sides have made concurrent updates not observed by the other, a conflict is raised. Delivery's merge function is invoked via the ConflictResolver port registered with sync-service.

6. Push Flow (Client -> Server)

┌─────────────────────────────────────────────────────────┐
│ 1. Client accumulates mutations in local mutation log │
│ 2. On connectivity, sync-service batches mutations │
│ 3. POST /api/v1/sync/push │
│ Body: { mutations: [ { topic, payload, mutationId } ] }│
│ 4. Delivery validates each mutation: │
│ a. Idempotency check via mutationId │
│ b. Domain invariant check │
│ c. Tenant + ownership check │
│ 5. For each accepted mutation: │
│ a. Merge with server state │
│ b. Emit corresponding domain event (via outbox) │
│ c. Record in inbox to prevent replay │
│ 6. Return per-mutation result: accepted / conflict / rejected │
└─────────────────────────────────────────────────────────┘

7. Pull Flow (Server -> Client)

┌─────────────────────────────────────────────────────────┐
│ 1. Client holds a sync cursor per topic │
│ 2. GET /api/v1/sync/pull?cursor=...&topics=delivery.* │
│ 3. sync-service fetches events since cursor │
│ 4. Returns batched events for client projection │
│ 5. Client applies events to local DB │
│ 6. Client advances sync cursor │
└─────────────────────────────────────────────────────────┘

8. AssistantTurn Sync (Append-Only)

Assistant turns are simpler: they are append-only and never conflict.

1. Client creates AssistantTurn locally with local ULID
2. Sync pushes turn to server via sync.delivery.assistant_turn.append.v1
3. Server validates:
a. Session exists and is owned by user
b. turnId is unique within session
4. Server persists; emits tutor_turn.completed.v1 event
5. If local AI inference was used, aiProvenance.local = true preserved

9. OfflineMount Sync

Offline mounts are tricky because a client can mount a bundle while offline, and the server must record this mount even though it occurred without server approval.

9.1 Client-Initiated Offline Mount (After-the-Fact Registration)

1. Client mounts bundle locally with pre-cached license envelope
2. Client creates local OfflineMount record
3. On connectivity, sync pushes OfflineMount
4. Server validates:
a. License envelope signature (must verify)
b. Device binding (must match JWT)
c. Bundle checksum matches published bundle
5. If all checks pass, server records mount and emits offline_mounted.v1
6. If license or device check fails, server rejects and emits tamper_detected

9.2 Unmount Propagation

Server-initiated unmounts (e.g., due to license revocation, tamper detection) are pushed to clients via pull flow. Clients must unmount immediately on receiving delivery.offline_unmounted.v1 with reason license_revoked or tamper_detected.

10. Sync Invariants

  1. No cross-tenant sync: A client's sync is strictly scoped to its tenant. Cross-tenant mutations rejected.
  2. Device binding: All offline-originated mutations must include the device ID; server verifies device is registered for the user.
  3. Attempt number server-authoritative: Clients cannot pick attempt numbers; server allocates on session start.
  4. Terminal state immutability: Once a session is completed or abandoned on server, further mutations are rejected.
  5. Tutor cost accounting: Local tutor turns (AI inference on device) are recorded with aiProvenance.local = true and no cost; cloud turns include cost data.

11. Retry & Backoff

  • Client retries failed mutations with exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, max 5 min.
  • After 10 failed retries, mutation moves to local dead-letter queue; user notified.
  • Server returns 409 Conflict for concurrent updates; client fetches latest state and retries.

12. Telemetry

MetricDescription
delivery_sync_push_latency_secondsEnd-to-end latency for client -> server mutation
delivery_sync_conflicts_totalCount of conflicts by type
delivery_sync_rejected_totalMutations rejected (by reason)
delivery_sync_offline_mount_pending_secondsTime between offline mount and server registration