Skip to main content

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_stale error 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/closed event 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:

EventAction
tenant.org.settings_updated.v1Pull 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.v1Pull role delta
tenant.role.created.v1 / .deleted.v1Pull role delta
tenant.org_unit.created/moved/deleted.v1Pull org_unit_tree delta
tenant.feature_flag.changed.v1Pull feature_flag delta
tenant.data_residency.changed.v1Full resync triggered
tenant.org.suspended.v1Wipe 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

SignalStaleness thresholdUser-visible indicator
Last successful sync > 15 minYellow banner: "Working with cached permissions"
Last successful sync > 1 hourOrange: "Permissions may be outdated"
Last successful sync > 24 hoursRed: "Offline mode — actions restricted to cached allowances"
Tenant suspended event receivedBlock: force online mode

9. Conflict Policy

AggregatePolicyReasoning
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:

  1. At bundle mount: LicenseEnvelope signature verified; feature gates applied locally.
  2. At sync: tenant.profile sync ensures gates match the current server state; mismatches trigger bundle re-issue.

11. Capacity & Performance

MetricTarget
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