Skip to main content

Sync Contract

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

Companion: 03 sync-service · SECURITY_MODEL · EVENT_SCHEMAS

Offline bundle sync is core to content-service. This document is the authoritative contract between content-service (producer) and sync-service (broker) / Player runtime (consumer).

1. Sync Role Summary

ActorRole
content-serviceOwner of PlayPackage and Bundle aggregates. Produces sync events.
sync-serviceProtocol broker. Maintains SyncRegistration, per-device cursors, conflict log.
Player runtime (client)Consumer. Pulls deltas, downloads bundles, verifies signatures, reports tamper.

2. Sync Registration

Content-service registers two entity types with sync-service on startup:

2.1 PlayPackage Registration

{
service: 'content',
entityType: 'PlayPackage',
conflictPolicy: 'server_authoritative', // packages are immutable; server wins always
deltaProjector: 'content.sync.play_package.delta',
pushHandler: null, // clients never push PlayPackage changes
versionField: 'hash', // SHA-256 acts as version
schemaRef: 'schemas://content/play_package/sync/v1'
}

2.2 Bundle Registration

{
service: 'content',
entityType: 'PlayPackageBundle',
conflictPolicy: 'server_authoritative', // server owns bundle state
deltaProjector: 'content.sync.bundle.delta',
pushHandler: 'content.sync.bundle.tamper_push', // only tamper reports flow up
versionField: 'status+revoked_at',
schemaRef: 'schemas://content/bundle/sync/v1'
}

3. Delta Pull Protocol

3.1 Request (Client → sync-service)

POST /api/v1/sync/pull HTTP/1.1
Authorization: Bearer {jwt}
X-Tenant-Id: ten_01HXYZ…
X-Device-Id: dev_01HXYZ…
Content-Type: application/json
{
"cursor": {
"scope": "content",
"lamport": 12345
},
"entityTypes": ["PlayPackage", "PlayPackageBundle"],
"limit": 50
}

3.2 Response (sync-service → Client)

{
"cursor": { "scope": "content", "lamport": 12367 },
"entities": [
{
"type": "PlayPackage",
"id": "ppk_01HXYZ…",
"data": {
"id": "ppk_01HXYZ…",
"courseVersionId": "cv_01HXYZ…",
"locale": "en-US",
"manifestUrl": "https://cdn.ghasi.io/…/manifest.json",
"manifestHash": "sha256:…",
"status": "built"
},
"version": 12350
},
{
"type": "PlayPackageBundle",
"id": "bun_01HXYZ…",
"data": {
"id": "bun_01HXYZ…",
"playPackageId": "ppk_01HXYZ…",
"downloadUrl": "https://cdn.ghasi.io/…/bun_….bin?sig=…&exp=…",
"sha256": "sha256:…",
"sizeBytes": 480000000,
"signature": "eyJhbGciOi…",
"license": {
"bundleId": "bun_01HXYZ…",
"userId": "usr_01HXYZ…",
"deviceId": "dev_01HXYZ…",
"expiresAt": "2026-10-15T00:00:00Z",
"features": { "aiTutor": true, "assessments": true, "certificate": true, "copyDownloadable": false },
"signature": "eyJhbGciOi…"
},
"encryption": { "alg": "AES-256-GCM", "kid": "tenant-key-v3" },
"status": "available"
},
"version": 12367
}
],
"deletes": [
{ "type": "PlayPackageBundle", "id": "bun_01HXYZ…OLD" }
],
"hasMore": false
}

3.3 Delta Projection (content-service → sync-service)

Content-service projects every relevant event to sync-scoped subjects consumed by sync-service:

Source EventProjection Subject
content.play_package.built.v1content.sync.play_package.delta (upsert)
content.play_package.revoked.v1content.sync.play_package.delta (delete)
content.play_package.bundle.published.v1content.sync.bundle.delta (upsert, user-scoped)
content.play_package.bundle.revoked.v1content.sync.bundle.delta (delete, user-scoped)

3.4 Scoping Rules

Critical: A device must only receive bundles licensed to it. Sync-service enforces this at delta projection time:

For device D owned by user U in tenant T:
Deliver bundle B only if:
B.tenant_id = T
AND B.user_id = U
AND B.device_id = D
AND B.status = 'available' (unless it was previously 'available' for D, then send deletion)

4. Push Protocol (Tamper Report)

The only push from client to content-service via sync is the tamper report.

4.1 Client Push (via sync-service)

{
"clientMutationId": "01HXYZ…CLIENT",
"tenantId": "ten_01HXYZ…",
"userId": "usr_01HXYZ…",
"deviceId": "dev_01HXYZ…",
"service": "content",
"entityType": "TamperReport",
"entityId": "bun_01HXYZ…", // bundle being reported
"op": "create",
"payload": {
"bundleId": "bun_01HXYZ…",
"expectedHash": "sha256:a1b2…",
"actualHash": "sha256:a1b3…",
"detectedAt": "2026-04-15T09:15:00Z",
"context": {
"locationInBundle": "assets/video_001.mp4",
"deviceFingerprint": "fp_abc123",
"playerVersion": "1.4.2"
}
},
"occurredAt": "2026-04-15T09:15:00Z"
}

4.2 Acknowledgment

Sync-service forwards to content-service's content.sync.bundle.tamper_push handler, which:

  1. Idempotently creates tamper_reports row (keyed by clientMutationId)
  2. Emits content.bundle.tamper_detected.v1
  3. Returns ack to sync-service

Client receives success ack via sync delta with acknowledgment for clientMutationId.

4.3 Offline Queue Behavior

If device is offline when tamper detected:

  • Push queued in client-side outbox
  • On next connectivity, pushed through sync-service
  • Ordering preserved per (deviceId, clientMutationId)

5. Bundle Download Flow

5.1 Sync-Service as URL Relay

Sync-service does not proxy bundle bytes. It relays signed download URLs:

Client sync-service content-service S3/R2
│ │ │ │
│─ pull delta ───────────►│ │ │
│ │─ compute delta ──────►│ │
│ │ │─ mint signed URL ───────►│
│ │ │◄── signed URL ───────────│
│ │◄── delta + URL ───────│ │
│◄─ delta + signed URL ───│ │ │
│ │
│────── GET signed URL (HTTPS, direct to CDN/S3) ────────────────────────────►│
│◄───── bundle blob ──────────────────────────────────────────────────────────│

5.2 URL Freshness

  • Signed URLs have 15-minute TTL
  • If URL expires before download completes, client calls GET /api/v1/bundles/{id}/download to mint a fresh URL
  • Sync-service may embed multiple URLs (primary + fallback regions) for resilient download

5.3 Resumable Download

  • Clients support HTTP range requests for partial download (resume after network drop)
  • S3/CDN honors Range header
  • Client verifies SHA-256 after full download before decryption

6. Bundle Verification on Mount

Before a device plays content from a bundle:

1. Download bundle blob to local storage
2. Verify SHA-256 matches metadata (if mismatch → report_tamper, abort)
3. Verify bundle signature (JWS) against tenant public key (bundled JWKS)
4. Derive decryption key: HKDF(devicePrivKey-equivalent, bundleId) — uses device key escrow
5. Decrypt AES-256-GCM; verify GCM auth tag (if fail → report_tamper, abort)
6. Verify LicenseEnvelope:
a. Signature verifies against tenant key
b. deviceId matches this device
c. expiresAt > now()
d. features present for requested action
7. Mount manifest; begin playback

If any step fails, the bundle is not mounted and the failure is reported via sync-push (tamper report for hash/signature failure; license check failure logged locally).

7. Revocation Propagation

When a bundle is revoked server-side:

content-service sync-service Player client
│ │ │
│─ emit bundle.revoked ─►│ │
│ │─ project to user deltas │
│ │ │
│ │◄── pull delta ──────────│
│ │─── delta (delete) ─────►│
│ │
│ deletes local bundle
│ invalidates license
│ blocks further playback

7.1 Offline Revocation Enforcement

A critical question: what if a device is offline when its bundle is revoked?

Mitigations:

  1. License expiry — all licenses have a hard expiresAt; offline devices cannot play beyond it. Default 90 days, tenant-configurable.
  2. Revocation list cached on connection — on every sync pull, device receives the revocation list and applies immediately.
  3. Grace period policy — tenant can configure a "soft revocation" with grace period (e.g., 24h) vs "hard revocation" (immediate).
  4. Player-side integrity checks — Player periodically re-verifies license on every playback session start.

8. Multi-Device Consistency

A user with two devices (laptop + tablet) receives independent bundles per device. Each bundle is cryptographically isolated.

User U, enrolled in Course C (version V, locale en-US):
Device A: bun_A (encrypted with HKDF(tenantKey, A.pubKey, bun_A))
Device B: bun_B (encrypted with HKDF(tenantKey, B.pubKey, bun_B))

Both bundles reference ppk_X (same PlayPackage).
Compromising Device A's key cannot decrypt Device B's bundle.

Progress within the course is synced separately via progress-service (not content-service).

9. Sync Event Projection Subjects

Content-service publishes to these sync-scoped subjects:

SubjectPartitionScope
content.sync.play_package.deltaplayPackageIdTenant-wide (all users in tenant see it)
content.sync.bundle.deltauserId+deviceIdUser-device-scoped

Sync-service consumes these and fans out per device cursor.

10. Cursor Semantics

  • Lamport clock maintained per (tenant, scope='content').
  • Monotonically increasing; each event increments.
  • Client sends last-seen Lamport in pull; server returns all events with Lamport > cursor.
  • Cursor tamper resistance: the Lamport value is a server-side concept, so clients cannot fabricate deltas.

11. Error Handling

11.1 Stale Cursor

If client's cursor is older than sync-service retention (default 180 days):

  • Sync-service returns 410 Gone with resync_required: true
  • Client performs full bootstrap: pull all active bundles for the device
  • Client deletes local bundles not in bootstrap response

11.2 Cross-Tenant Attempt

If a client's JWT tid does not match the X-Tenant-Id header or the cursor's tenant:

  • 403 Forbidden
  • Security event emitted
  • Device binding reviewed

11.3 Device Fingerprint Mismatch

If the device's cached fingerprint diverges from identity-service's registered fingerprint (> threshold):

  • Sync returns 401 Unauthorized with reauth_required: true
  • Device must re-bind before sync resumes
  • All local bundles invalidated (encryption keys no longer derivable)

12. Performance Targets

Operationp50p95p99
Delta pull (sync → content projector)50ms200ms500ms
Signed URL mint20ms100ms300ms
Bundle download throughput50 MB/s20 MB/s5 MB/s (mobile)
End-to-end bundle availability (build → device downloadable)60s180s600s

13. Storage Quotas (Per Device)

Per-device quota enforced by sync-service in cooperation with content-service:

TierQuotaBehavior on Exceed
Free2 GBReject new bundles; notify user
Pro20 GBReject new bundles; notify user
Enterprise100 GBConfigurable per tenant

Quota is evaluated before bundle creation; creation fails with 422 quota_exceeded if it would push device over limit.

14. Device Re-Bind Flow

If a user replaces a device:

1. Old device is unbound in identity-service
2. identity emits 'identity.device.unbound_for_offline.v1'
3. content-service consumes → revokes all bundles for old device
4. New device is bound in identity-service
5. identity emits 'identity.device.bound_for_offline.v1'
6. content-service consumes → creates new bundles for all active enrollments of user
7. Sync propagates new bundles to new device

Timing: new device is typically playable within 5 minutes of binding (depends on bundle count and size).

15. Contract Testing

15.1 Producer Side (content-service)

  • Content-service publishes Pact contracts for sync projection subjects.
  • Fixture events validated against schema in CI.
  • Contract versioning aligned with event schema version.

15.2 Consumer Side (sync-service)

  • Sync-service verifies content-service's Pact contracts before every deploy.
  • Drift blocks merge.

15.3 End-to-End

  • E2E suite: author publish → content build → bundle create → sync deliver → device download → playback
  • Run nightly; blocks release if p95 > 180s.