Skip to main content

Sync Contract

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

Companion: sync-service · 10 Authoring Tool · 06 Data Model


1. Offline-First Goal

Authors must be able to continue editing drafts when offline (poor connectivity, field work, air travel). The sync-service provides the transport; the authoring-service provides the domain-aware projection and conflict resolution.

Delivery milestone: S5 (slice 5). Prior slices (S2, S4) are online-only.

2. Surface Summary

SurfaceDirectionProtocolPurpose
Sync RegistrationClient → sync-serviceRESTSubscribe a device to a draft collection
Sync PullClient → sync-serviceSSEReceive authoritative state updates
Sync PushClient → sync-serviceRESTSubmit local mutations
Conflict ResolutionClient ↔ authoringRESTUser-guided conflict resolution
Yjs WebSocket (online only)Client ↔ authoringWSReal-time collab

3. Sync Projection

The authoring-service maintains a sync projection — a flattened, event-sourced view of each draft optimized for delta sync.

interface DraftSyncProjection {
draftId: CourseDraftId;
tenantId: TenantId;
version: number; // server sync-projection version (not draftVersion)
parts: SyncPart[];
}

interface SyncPart {
partId: string; // e.g. "draft_meta", "module:xxx", "lesson:xxx", "block:xxx"
lastModifiedAt: ISODate;
deleted: boolean;
data: JSONValue; // serialized part
etag: string;
}

Parts are delivered as a stream of deltas, keyed by partId. A client can request all parts since a given version.

4. Data Model on the Client (Dexie / IndexedDB)

// Dexie schema
db.drafts.schema = {
primaryKey: 'id',
indexes: ['tenantId', 'state', 'updatedAt'],
};
db.blocks.schema = {
primaryKey: 'id',
indexes: ['[draftId+sortOrder]', 'lessonId', 'status'],
};
db.outbox.schema = {
primaryKey: '++id',
indexes: ['status', 'createdAt'],
};
db.conflicts.schema = {
primaryKey: 'id',
indexes: ['draftId', 'resolvedAt'],
};
db.yjsState.schema = {
primaryKey: 'draftId',
};

5. Mutation Model

Every offline edit is recorded as a LocalMutation before being applied optimistically to local state.

interface LocalMutation {
id: string; // clientMutationId (ULID)
draftId: CourseDraftId;
tenantId: TenantId;
kind: LocalMutationKind;
payload: JSONValue;
createdAt: ISODate;
appliedLocallyAt: ISODate;
pushedAt?: ISODate;
acknowledgedAt?: ISODate;
status: 'pending' | 'pushed' | 'acknowledged' | 'rejected' | 'conflicted';
rejectionReason?: string;
baseVersion: number; // projection version at time of edit
yjsUpdate?: Uint8Array; // when Yjs is active
}

type LocalMutationKind =
| 'draft_update'
| 'module_add' | 'module_update' | 'module_remove'
| 'lesson_add' | 'lesson_update' | 'lesson_remove'
| 'block_add' | 'block_update' | 'block_remove' | 'block_reorder' | 'block_move'
| 'accept_ai_block' | 'reject_ai_block'
| 'submit_for_review' | 'approve' | 'reject';

6. Push Protocol

6.1 Request

POST /api/v1/sync/push HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_01H...
X-Client-Mutation-Id: 01HW5Q...
Content-Type: application/json

{
"draftId": "drf_01H...",
"baseVersion": 42,
"mutations": [
{
"id": "lmn_01H...",
"kind": "block_update",
"payload": {
"blockId": "blk_01H...",
"changes": { "markdown": "# Updated heading" }
},
"createdAt": "2026-04-15T09:10:00.000Z",
"baseVersion": 42
}
],
"yjsUpdate": "base64-encoded-yjs-update"
}

6.2 Response

{
"data": {
"accepted": ["lmn_01H..."],
"rejected": [
{
"mutationId": "lmn_01H...",
"reason": "version_conflict",
"detail": "Base version 42 but server is at 47"
}
],
"conflicts": [
{
"conflictId": "cfl_01H...",
"mutationId": "lmn_01H...",
"partId": "block:blk_01H...",
"serverState": { /* ... */ },
"clientState": { /* ... */ }
}
],
"newVersion": 48
}
}

7. Conflict Resolution

7.1 CRDT First

When both client and server updates can be merged via Yjs CRDT (text, positional data), the merge is automatic. Yjs update vectors are applied in order; conflict-free by construction.

7.2 Non-CRDT Conflicts

Some fields are not CRDT-compatible:

  • Block.kind (cannot change kind)
  • CourseDraft.state (state machine)
  • Block.status (review state)
  • Block.required (template-driven)

For these, a ConflictRecord is created and surfaced to the user:

interface ConflictRecord {
id: string;
draftId: CourseDraftId;
tenantId: TenantId;
partId: string;
field: string;
serverValue: JSONValue;
clientValue: JSONValue;
serverVersion: number;
clientMutationId: string;
detectedAt: ISODate;
resolvedBy?: UserId;
resolvedAt?: ISODate;
resolution?: 'server_wins' | 'client_wins' | 'manual';
resolvedValue?: JSONValue;
}

The UI displays a side-by-side diff and asks the user to pick.

7.3 Conflict Resolution API

POST /api/v1/drafts/{draftId}/conflicts/{conflictId}/resolve
Content-Type: application/json

{
"resolution": "manual",
"resolvedValue": { "markdown": "# Heading — merged version" }
}

8. Offline AI

When offline, AI requests are routed to a LocalAIClient (adapter port) that uses on-device models. The AIProvenance is flagged local: true and model identifies the local model.

interface LocalAIClient {
available(): Promise<boolean>;
generateBlock(params: AIGenerateParams): Promise<AICompletionResult>;
improveBlock(params: AIImproveParams): Promise<AICompletionResult>;
}

On reconnect, local AI blocks are not re-submitted to the cloud AI. Their provenance remains local: true and they are subject to the same HITL acceptance flow.

9. Pull Protocol

9.1 Initial Snapshot

GET /api/v1/sync/snapshot?draftId=drf_01H... HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_01H...

=> 200 OK
{
"data": {
"draftId": "drf_01H...",
"version": 47,
"parts": [ /* all SyncParts */ ],
"yjsSnapshot": "base64-encoded-yjs-snapshot"
}
}

9.2 Delta Stream (SSE)

GET /api/v1/sync/stream?draftId=drf_01H...&sinceVersion=47 HTTP/1.1
Accept: text/event-stream
event: delta
data: {"version":48,"partId":"block:blk_A","data":{...},"deleted":false}

event: delta
data: {"version":49,"partId":"module:mod_B","deleted":true}

event: heartbeat
data: {"version":49,"serverTime":"2026-04-15T10:00:00Z"}

10. Sync Cursor

The client tracks the last-applied version per draft.

interface SyncCursor {
draftId: CourseDraftId;
userId: UserId;
deviceId: DeviceId;
version: number;
lastPulledAt: ISODate;
lastPushedAt: ISODate;
}

Server side, sync.cursor table (owned by sync-service) persists this.

11. Backpressure & Rate Limits

OperationLimit
Push batch sizeMax 100 mutations per call
Push rate10 calls/sec per device
Snapshot request1 per minute per draft per device
SSE concurrent streams10 per user

12. Registration

POST /api/v1/sync/register
{
"deviceId": "dev_01H...",
"draftIds": ["drf_A", "drf_B"],
"localeHint": "en-US"
}

Sync-service creates SyncRegistration rows and subscribes the device to updates.

13. GDPR & Offline Data

When a gdpr.subject_request.received.v1 event arrives:

  1. Authoring-service anonymizes/deletes server-side data
  2. Emits sync.purge.requested.v1 for the user's devices
  3. Sync-service instructs clients to wipe local copies on next connection
  4. Client confirms purge, emits sync.purge.acknowledged.v1

14. Failure Modes

FailureBehavior
Client loses all local dataRe-register; full snapshot re-sent
Server version ahead by > 500Force full snapshot (delta too expensive)
Mutation rejected for schema violationMutation moved to rejected; UI surfaces error
Conflict unresolved for 7 daysAuto-resolve to server_wins; user notified
Yjs state corruptionReconstruct from event log + last snapshot

15. Testing

Test typeCoverage
Unit (client)Local mutation queue, retry logic, conflict detection
Unit (server)Sync projection correctness
IntegrationPush + pull + conflict round-trip
ChaosNetwork partition for 1h; ensure convergence
E2EAuthor edits offline for 1h, reconnects, all changes sync without data loss

16. Freeze Points

IDArtifactWhen
F17 (shared)Block registry / BlockBase shapeM2 — directly affects sync schema
F-SYNC-2LocalMutation kinds enumS5 start
F-SYNC-3ConflictRecord shapeS5 start