Sync Contract
:::info Source
Sourced from services/tenant-service/SYNC_CONTRACT.md in the documentation repo.
:::
Blueprint doc 7 of 17. Companion: 02 DDD §7 | 03 sync-service | SECURITY_MODEL
1. Purpose
The tenant-service participates in the platform's offline-first sync protocol for read-only client-side caching. Memberships, roles, org-units, and feature-flag overrides are projected to the client so that ABAC checks and tenant context resolution work offline (during the lesson player's offline sessions).
The tenant-service is not a write-replicable context — tenant mutations always require network connectivity (critical security posture).
2. Sync Registration
Registered with sync-service at service startup:
const registration: SyncRegistration = {
service: 'tenant-service',
aggregates: [
{
name: 'TenantProfile',
direction: 'server_to_client', // read-only projection
conflictPolicy: 'server_wins', // server is authoritative
partitionKey: 'tenantId',
pullEndpoint: '/sync/v1/pull?aggregate=tenant.profile',
pushEndpoint: null, // no client writes accepted
schemaVersion: 1,
},
{
name: 'Membership',
direction: 'server_to_client',
conflictPolicy: 'server_wins',
partitionKey: 'tenantId',
scopePredicate: 'membership.user_id == device.user_id', // user sees only own membership
pullEndpoint: '/sync/v1/pull?aggregate=tenant.membership',
schemaVersion: 1,
},
{
name: 'Role',
direction: 'server_to_client',
conflictPolicy: 'server_wins',
partitionKey: 'tenantId',
pullEndpoint: '/sync/v1/pull?aggregate=tenant.role',
schemaVersion: 1,
},
{
name: 'OrgUnitTree',
direction: 'server_to_client',
conflictPolicy: 'server_wins',
partitionKey: 'tenantId',
pullEndpoint: '/sync/v1/pull?aggregate=tenant.org_unit_tree',
schemaVersion: 1,
},
{
name: 'FeatureFlagOverride',
direction: 'server_to_client',
conflictPolicy: 'server_wins',
partitionKey: 'tenantId',
pullEndpoint: '/sync/v1/pull?aggregate=tenant.feature_flag',
schemaVersion: 1,
},
],
};
3. Client-Side Projection Schema
Stored in the web client's IndexedDB (Dexie) and mobile/desktop's SQLite with identical logical schema:
// IndexedDB store: tenant_profile
interface ClientTenantProfile {
id: TenantId;
slug: string;
name: string;
type: TenantType;
homeRegion: Region;
status: TenantStatus;
settings: {
defaultLocale: Locale;
allowedLocales: Locale[];
offlineEnabled: boolean;
aiEnabled: boolean;
aiTutorEnabled: boolean;
brandingTheme: BrandingTheme;
mfaRequired: boolean;
sessionTimeout: ISODuration;
};
etag: string; // for If-None-Match on delta pull
lastSyncedAt: ISODate;
}
// IndexedDB store: membership (scoped to own user)
interface ClientMembership {
id: ULID;
tenantId: TenantId;
userId: UserId;
roleIds: RoleId[];
orgUnitIds: OrgUnitId[];
status: 'active' | 'suspended'; // 'invited' not synced; requires online acceptance
etag: string;
lastSyncedAt: ISODate;
}
// IndexedDB store: role
interface ClientRole {
id: RoleId;
tenantId: TenantId | null;
name: string;
permissions: Permission[]; // includes ABAC predicates for offline eval
isSystem: boolean;
etag: string;
lastSyncedAt: ISODate;
}
// IndexedDB store: org_unit
interface ClientOrgUnit {
id: OrgUnitId;
tenantId: TenantId;
parentId?: OrgUnitId;
name: I18nString;
ltreePath: string;
etag: string;
lastSyncedAt: ISODate;
}
// IndexedDB store: feature_flag_override
interface ClientFeatureFlag {
tenantId: TenantId;
flag: string;
value: JSONValue;
etag: string;
lastSyncedAt: ISODate;
}
4. Sync Cursor Semantics
Per platform sync spec (Frozen F06, F07, F08):
interface TenantSyncCursor {
tenantId: TenantId;
aggregate: 'tenant.profile' | 'tenant.membership' | 'tenant.role' | 'tenant.org_unit_tree' | 'tenant.feature_flag';
lastSeq: number; // NATS stream sequence
lastEventId?: ULID;
lastSyncedAt: ISODate;
vectorClock: VectorClock; // merged from delta
}
- Cursor advanced on every successful delta pull.
- Server returns
sync_staleerror if cursor sequence is older than hot retention window (requires full re-sync).
5. Pull Protocol
5.1 Delta Pull
GET /sync/v1/pull?aggregate=tenant.role&tenantId=tnt_01HX&cursor=eyJsIjox...
Response:
{
"data": {
"changes": [
{
"type": "upsert",
"aggregate": "tenant.role",
"entity": { "id": "rol_01HX...", "...": "..." },
"eventId": "01HX...",
"seq": 2047,
"occurredAt": "2026-04-15T10:00:00Z"
},
{
"type": "delete",
"aggregate": "tenant.role",
"entityId": "rol_01HY...",
"eventId": "01HY...",
"seq": 2048,
"occurredAt": "2026-04-15T10:05:00Z"
}
],
"nextCursor": "eyJsIjoyMDQ4fQ",
"complete": true
}
}
5.2 Full Resync
GET /sync/v1/pull?aggregate=tenant.role&tenantId=tnt_01HX&cursor=null&fullSync=true
Returns current authoritative snapshot. Triggered when:
- Client first installs
- Cursor is stale (beyond 180-day regulated retention)
- Data residency migration completes (cursor invalidated)
tenant.org.suspended/closedevent received
6. Trigger Events for Client Pull
The client-side sync worker pulls delta after receiving any of these events via WebSocket/SSE from sync-service:
| Event | Action |
|---|---|
tenant.org.settings_updated.v1 | Pull tenant.profile delta |
tenant.org.membership_activated.v1 (own user) | Pull membership delta |
tenant.org.membership_suspended.v1 (own user) | Pull membership delta; if suspended, wipe local cache |
tenant.role.updated.v1 | Pull role delta |
tenant.role.created.v1 / .deleted.v1 | Pull role delta |
tenant.org_unit.created/moved/deleted.v1 | Pull org_unit_tree delta |
tenant.feature_flag.changed.v1 | Pull feature_flag delta |
tenant.data_residency.changed.v1 | Full resync triggered |
tenant.org.suspended.v1 | Wipe all local tenant data |
Notifications are pushed via sync-service's durable WebSocket channel. Events are queued if client offline; drained on reconnect.
7. Offline ABAC Evaluation
Critical capability: ABAC checks must work offline during learner play sessions.
// Client-side policy engine (mirrors server PolicyEngine)
class ClientPolicyEngine {
async check(resource: string, action: string, attrs: ResourceAttributes): Promise<boolean> {
const tenant = await db.tenant_profile.get(ctx.tenantId);
const membership = await db.membership.where({ tenantId: ctx.tenantId, userId: ctx.userId }).first();
const roles = await db.role.where('id').anyOf(membership.roleIds).toArray();
const orgUnits = await db.org_unit.where({ tenantId: ctx.tenantId }).toArray();
const evalContext = {
ctx: {
tenant_id: tenant.id,
user: {
id: ctx.userId,
role_ids: membership.roleIds,
org_unit_ids: membership.orgUnitIds,
org_unit_paths: orgUnits
.filter(ou => membership.orgUnitIds.includes(ou.id))
.map(ou => ou.ltreePath),
},
time: new Date().toISOString(),
},
resource: attrs,
};
for (const role of roles) {
for (const perm of role.permissions) {
if (perm.resource === resource && perm.action === action) {
if (!perm.condition || evaluatePredicate(perm.condition, evalContext)) {
return true;
}
}
}
}
return false;
}
}
The shared ABACQuery DSL evaluator is compiled once in a dedicated @ghasi/abac-eval package consumed by both server and client — single source of truth.
8. Data Freshness & Staleness
| Signal | Staleness threshold | User-visible indicator |
|---|---|---|
| Last successful sync > 15 min | Yellow banner: "Working with cached permissions" | |
| Last successful sync > 1 hour | Orange: "Permissions may be outdated" | |
| Last successful sync > 24 hours | Red: "Offline mode — actions restricted to cached allowances" | |
| Tenant suspended event received | Block: force online mode |
9. Conflict Policy
| Aggregate | Policy | Reasoning |
|---|---|---|
| All tenant-* | server_wins (no client writes accepted) | Tenant data is security-critical; client cannot be authoritative |
Any attempted write from the client to a tenant-owned aggregate via /sync/v1/push is rejected with 403 READ_ONLY_AGGREGATE.
10. PlayPackage License Envelope Integration
Tenant settings (offlineEnabled, aiTutorEnabled, mfaRequired) are inlined into every PlayPackage Bundle's LicenseEnvelope (signed by tenant key). Enforcement is dual:
- At bundle mount: LicenseEnvelope signature verified; feature gates applied locally.
- At sync: tenant.profile sync ensures gates match the current server state; mismatches trigger bundle re-issue.
11. Capacity & Performance
| Metric | Target |
|---|---|
| Delta pull p95 | ≤ 100ms |
| Full resync p95 (typical tenant) | ≤ 2s |
| Client-side ABAC eval | ≤ 1ms per check |
| IndexedDB footprint (typical tenant) | ≤ 500KB |
| Projection lag server-side | ≤ 2s p95 |