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
| Actor | Role |
|---|---|
| content-service | Owner of PlayPackage and Bundle aggregates. Produces sync events. |
| sync-service | Protocol 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 Event | Projection Subject |
|---|---|
content.play_package.built.v1 | content.sync.play_package.delta (upsert) |
content.play_package.revoked.v1 | content.sync.play_package.delta (delete) |
content.play_package.bundle.published.v1 | content.sync.bundle.delta (upsert, user-scoped) |
content.play_package.bundle.revoked.v1 | content.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:
- Idempotently creates
tamper_reportsrow (keyed byclientMutationId) - Emits
content.bundle.tamper_detected.v1 - 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}/downloadto 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
Rangeheader - 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:
- License expiry — all licenses have a hard
expiresAt; offline devices cannot play beyond it. Default 90 days, tenant-configurable. - Revocation list cached on connection — on every sync pull, device receives the revocation list and applies immediately.
- Grace period policy — tenant can configure a "soft revocation" with grace period (e.g., 24h) vs "hard revocation" (immediate).
- 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:
| Subject | Partition | Scope |
|---|---|---|
content.sync.play_package.delta | playPackageId | Tenant-wide (all users in tenant see it) |
content.sync.bundle.delta | userId+deviceId | User-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 Gonewithresync_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 Unauthorizedwithreauth_required: true - Device must re-bind before sync resumes
- All local bundles invalidated (encryption keys no longer derivable)
12. Performance Targets
| Operation | p50 | p95 | p99 |
|---|---|---|---|
| Delta pull (sync → content projector) | 50ms | 200ms | 500ms |
| Signed URL mint | 20ms | 100ms | 300ms |
| Bundle download throughput | 50 MB/s | 20 MB/s | 5 MB/s (mobile) |
| End-to-end bundle availability (build → device downloadable) | 60s | 180s | 600s |
13. Storage Quotas (Per Device)
Per-device quota enforced by sync-service in cooperation with content-service:
| Tier | Quota | Behavior on Exceed |
|---|---|---|
| Free | 2 GB | Reject new bundles; notify user |
| Pro | 20 GB | Reject new bundles; notify user |
| Enterprise | 100 GB | Configurable 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.