Skip to main content

Sync Contract

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

1. Applicability

Statements and Attempts are replicable for offline. Offline devices:

  • Write statements to local outbox.
  • Read transcripts from local cache.
  • Never update or delete statements (append-only).

2. Sync Registration

{
service: 'progress-service',
entityType: 'Statement',
conflictPolicy: 'append_only', // never conflicts — new ID each time
deltaProjector: 'statements_by_user',
pushHandler: 'IngestStatementBatch',
versionField: 'stored',
schemaRef: 'schemas://progress/statement/v1'
},
{
service: 'progress-service',
entityType: 'Attempt',
conflictPolicy: 'server_authoritative',
deltaProjector: 'attempts_by_user',
pushHandler: 'none (read-only client-side)',
versionField: 'ended_at',
schemaRef: 'schemas://progress/attempt/v1'
},
{
service: 'progress-service',
entityType: 'CompletionRecord',
conflictPolicy: 'server_authoritative',
deltaProjector: 'completions_by_user',
pushHandler: 'none',
versionField: 'completed_at',
schemaRef: 'schemas://progress/completion/v1'
}

3. Delta Format

Server → device delta payload:

{
"cursor": { "lamport": 123456, "scope": "progress:user:u_01H..." },
"upserts": {
"Attempt": [ { "id": "...", "state": "closed", "outcome": "passed", ... } ],
"CompletionRecord": [ { "id": "...", "attemptId": "...", "passed": true, ... } ]
},
"deletes": {
"Statement": [],
"Attempt": [] // per GDPR erasure
}
}

4. Conflict Resolution

  • Statement: append_only. Each statement has unique ULID; duplicates silently deduplicated on statementId.
  • Attempt: server_authoritative. Server computes outcome from statements. Device shows cached state; server version overrides on sync.
  • CompletionRecord: server_authoritative.

5. Cursor Rules

  • Cursor is {lamport, scope}. Scope = progress:user:{userId}.
  • Device maintains cursor per (tenant, user, device, scope).
  • Server responds with delta since cursor; new cursor in response.
  • Cursor opaque to client; format evolution via v field.

6. LocalStore Schema (Client)

statements_outbox: (clientMutationId PK, tenantId, userId, statementId, payload, createdAt, pushedAt?)
attempts: (attemptId PK, tenantId, userId, enrollmentId, state, outcome, score, ...)
completions: (completionRecordId PK, attemptId, ...)
cursor: (tenantId, userId, deviceId, scope PK, lamport, updatedAt)
  • statements_outbox encrypted with per-tenant key (contains potentially PHI).
  • 7-day retention offline before force-sync prompt.

7. Push Flow

Client has N statements in outbox.
POST /sync/v1/push with batch:
{ mutations: [
{ clientMutationId, entityType: "Statement", op: "create", payload: <statement> }, ...
] }
Server validates + forwards to progress.ingestStatements().
Response includes applied/conflicted/rejected per clientMutationId.
On apply, server forwards to /xapi/statements pipeline internally.

8. Pull Flow

GET /sync/v1/pull?scope=progress:user:u_01H...&cursor=lamport:123456
Server returns delta (§3).
Client applies upserts/deletes transactionally in LocalStore.
Client advances cursor.

9. Background Sync Behavior

  • Client syncs on: app foreground, reconnect, 15-min timer when online.
  • Prioritizes revocations and license changes before statement pulls.
  • Statements pushed first (learner's in-flight work) before pulls.

10. Offline Statement Expiry

  • Statements in outbox retained 7 days. After 7 days offline, user prompted to sync.
  • After 14 days, app enters read-only mode until sync.
  • Never silently discarded.

11. Tamper Resistance

  • Statements-outbox encrypted; integrity via HMAC per entry.
  • Server verifies statement signature (if client signed with device key — M5+).
  • Replay protection: clientMutationId unique; stored server-side overwrites device-stored value.