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
| Entity | Replication Direction | Conflict Strategy |
|---|---|---|
PlaySession | Bidirectional | LWW with field-level merge (see §5) |
AssistantTurn | Client -> Server only | Append-only; server accepts all non-duplicate turns |
OfflineMount | Client -> Server only | Server is authoritative for mount lifecycle |
NavigationEvent | Client -> Server only | Append-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)
| Field | Strategy |
|---|---|
state | Strictly 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. |
cursor | Take the cursor with the highest sequenceIndex. If equal, prefer the cursor that references more-completed prerequisites. |
lastActivityAt | MAX of both sides |
startedAt | MIN of both sides (earliest start wins) |
endedAt | If both set, MIN (first termination wins) |
attemptNumber | Server-authoritative; clients cannot set this |
offlineMountId | Client-authoritative if session was offline-initiated |
version | Server 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
- No cross-tenant sync: A client's sync is strictly scoped to its tenant. Cross-tenant mutations rejected.
- Device binding: All offline-originated mutations must include the device ID; server verifies device is registered for the user.
- Attempt number server-authoritative: Clients cannot pick attempt numbers; server allocates on session start.
- Terminal state immutability: Once a session is
completedorabandonedon server, further mutations are rejected. - Tutor cost accounting: Local tutor turns (AI inference on device) are recorded with
aiProvenance.local = trueand 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 Conflictfor concurrent updates; client fetches latest state and retries.
12. Telemetry
| Metric | Description |
|---|---|
delivery_sync_push_latency_seconds | End-to-end latency for client -> server mutation |
delivery_sync_conflicts_total | Count of conflicts by type |
delivery_sync_rejected_total | Mutations rejected (by reason) |
delivery_sync_offline_mount_pending_seconds | Time between offline mount and server registration |