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