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
| Surface | Direction | Protocol | Purpose |
|---|---|---|---|
| Sync Registration | Client → sync-service | REST | Subscribe a device to a draft collection |
| Sync Pull | Client → sync-service | SSE | Receive authoritative state updates |
| Sync Push | Client → sync-service | REST | Submit local mutations |
| Conflict Resolution | Client ↔ authoring | REST | User-guided conflict resolution |
| Yjs WebSocket (online only) | Client ↔ authoring | WS | Real-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
| Operation | Limit |
|---|---|
| Push batch size | Max 100 mutations per call |
| Push rate | 10 calls/sec per device |
| Snapshot request | 1 per minute per draft per device |
| SSE concurrent streams | 10 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:
- Authoring-service anonymizes/deletes server-side data
- Emits
sync.purge.requested.v1for the user's devices - Sync-service instructs clients to wipe local copies on next connection
- Client confirms purge, emits
sync.purge.acknowledged.v1
14. Failure Modes
| Failure | Behavior |
|---|---|
| Client loses all local data | Re-register; full snapshot re-sent |
| Server version ahead by > 500 | Force full snapshot (delta too expensive) |
| Mutation rejected for schema violation | Mutation moved to rejected; UI surfaces error |
| Conflict unresolved for 7 days | Auto-resolve to server_wins; user notified |
| Yjs state corruption | Reconstruct from event log + last snapshot |
15. Testing
| Test type | Coverage |
|---|---|
| Unit (client) | Local mutation queue, retry logic, conflict detection |
| Unit (server) | Sync projection correctness |
| Integration | Push + pull + conflict round-trip |
| Chaos | Network partition for 1h; ensure convergence |
| E2E | Author edits offline for 1h, reconnects, all changes sync without data loss |
16. Freeze Points
| ID | Artifact | When |
|---|---|---|
| F17 (shared) | Block registry / BlockBase shape | M2 — directly affects sync schema |
| F-SYNC-2 | LocalMutation kinds enum | S5 start |
| F-SYNC-3 | ConflictRecord shape | S5 start |