tenant-service — SYNC_CONTRACT
Companion: APPLICATION_LOGIC · SECURITY_MODEL · Platform: 02 Enterprise Architecture §6 Sync · ADR-0003 Electron offline-first
The Melmastoon Electron desktop runs offline-first. tenant-service participates in the platform sync protocol primarily as a read-only projection source for the operator's tenant identity, configuration, org tree, and the operator's own membership / role catalog. RBAC mutations (role assignment, role creation, membership removal) always require online.
This contract is normative. The desktop sync engine (@melmastoon/desktop-sync, owned by sync-service) implements a generic protocol; this file specifies the per-aggregate registration that tenant-service contributes.
1. Sync Surfaces
| Aggregate | Direction | Conflict policy | Notes |
|---|---|---|---|
Tenant (own row) | pull-only | server_authoritative | Status changes (suspended, closed) gate local writes elsewhere |
TenantConfig | pull-only | server_authoritative | Drives offline defaults (currency, check-in time, cancellation policy) |
OrganizationUnit (tree for current tenant) | pull-only | server_authoritative | Property switcher, scope display |
Membership (caller's own only) | pull-only | server_authoritative | Determines what the desktop renders |
Role catalog (system + tenant custom) | pull-only | server_authoritative | Used for offline ABAC evaluation |
RoleAssignment (caller's own only) | pull-only | server_authoritative | Offline permission checks |
FeatureFlagOverride | pull-only | server_authoritative | Toggle local feature gates |
Invitation | never synced | n/a | Token material; web-only acceptance |
BillingContact | never synced | n/a | PII / financial; backoffice online-only screen |
Push from desktop is forbidden for every aggregate above. The single exception is a soft "request" to update non-RBAC
TenantConfigfields (e.g. opening hours), enqueued as a regular online API call once connectivity returns — handled by the offline command queue, not the sync push channel. Role / membership / invitation mutations always return409 MELMASTOON.SYNC.ONLINE_REQUIREDif attempted offline.
last_writer_wins is explicitly disallowed for any tenant aggregate (per ADR-0003 §5). The lww+diff policy is permitted only for FeatureFlagOverride reads (the per-key projection ships with a server-emitted patch versus the previous snapshot for incremental updates).
2. Protocol Registration
The sync engine boots with a manifest. Tenant-service's contribution:
// packages/sync-manifest/tenant.ts
export const tenantManifest: SyncManifestEntry[] = [
{ resource: 'tenant', collection: 'tenant.tenants', policy: 'server_authoritative', pull: true, push: false },
{ resource: 'tenantConfig', collection: 'tenant.tenant_configs', policy: 'server_authoritative', pull: true, push: false },
{ resource: 'orgUnit', collection: 'tenant.organization_units', policy: 'server_authoritative', pull: true, push: false },
{ resource: 'membership', collection: 'tenant.memberships', policy: 'server_authoritative', pull: true, push: false, scope: 'self' },
{ resource: 'role', collection: 'tenant.roles', policy: 'server_authoritative', pull: true, push: false },
{ resource: 'roleAssignment', collection: 'tenant.role_assignments', policy: 'server_authoritative', pull: true, push: false, scope: 'self' },
{ resource: 'featureFlag', collection: 'tenant.feature_flag_overrides', policy: 'server_authoritative', pull: true, push: false, diff: 'patch' },
];
scope: 'self' means the cursor query is rewritten as WHERE user_id = :callerUserId (memberships) or via join (role assignments).
3. Pull Cursor
Per the platform sync protocol:
GET /sync/v1/pull?scope=tenant&cursor=<opaque>&since=<iso>
Authorization: Bearer <jwt>
X-Device-Id: dvc_<ulid>
Server-side cursor encodes:
type TenantSyncCursor = {
v: 1;
tenantId: TenantId;
userId: UserId;
perCollectionWatermark: Record<string, { updatedAt: ISO; lastId: string; }>;
};
Response 200:
{
"data": {
"items": [
{ "kind": "tenantConfig", "op": "upsert", "doc": { "id": "tcg_...", "version": 13, "...": "..." } },
{ "kind": "membership", "op": "upsert", "doc": { "id": "mbr_...", "version": 5, "...": "..." } },
{ "kind": "role", "op": "upsert", "doc": { "id": "rol_...", "permissions": ["..."] } },
{ "kind": "featureFlag", "op": "patch", "doc": { "key": "aiEnabled", "patch": { "enabled": true } } },
{ "kind": "orgUnit", "op": "delete", "id": "org_..." }
],
"nextCursor": "...",
"hasMore": false,
"serverTime": "2026-04-22T08:00:00Z"
},
"meta": { "snapshotAt": "2026-04-22T08:00:00Z" }
}
Initial sync does a full bootstrap (cursor empty); subsequent pulls are deltas. Heartbeat pull cadence: every 60 s when foregrounded, 5 min when backgrounded.
4. Triggers for Client Pull
A delta pull is requested out-of-band when the desktop receives any of these events through the sync notification channel (long-polling fan-out from sync-service):
melmastoon.tenant.config_updated.v1melmastoon.tenant.membership.role_changed.v1(whereuserIdmatches device user)melmastoon.tenant.membership.removed.v1melmastoon.tenant.feature_flag.toggled.v1melmastoon.tenant.organization_unit.created|moved|archived.v1melmastoon.tenant.suspended.v1/…reactivated.v1/…deleted.v1
On tenant.suspended.v1 the desktop also pauses all outbound write enqueues until tenant.reactivated.v1 arrives.
5. Client-Side Schema (SQLite)
The desktop persists the projection in SQLite (tenant.db):
CREATE TABLE tenant_snapshot (
tenant_id TEXT PRIMARY KEY,
legal_name TEXT NOT NULL,
status TEXT NOT NULL,
residency TEXT NOT NULL,
config_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
version INTEGER NOT NULL
);
CREATE TABLE membership_snapshot (
membership_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
user_id TEXT NOT NULL,
status TEXT NOT NULL,
display_name TEXT NOT NULL,
property_scope TEXT NOT NULL, -- json array
version INTEGER NOT NULL
);
CREATE TABLE role_snapshot (
role_id TEXT PRIMARY KEY,
tenant_id TEXT,
code TEXT NOT NULL,
permissions TEXT NOT NULL -- json array of "resource:action"
);
CREATE TABLE role_assignment_snapshot (
assignment_id TEXT PRIMARY KEY,
membership_id TEXT NOT NULL,
role_id TEXT NOT NULL,
property_scope TEXT NOT NULL -- json array
);
CREATE TABLE feature_flag_snapshot (
tenant_id TEXT NOT NULL,
flag_key TEXT NOT NULL,
enabled INTEGER NOT NULL,
rollout_bp INTEGER NOT NULL,
PRIMARY KEY (tenant_id, flag_key)
);
CREATE INDEX ix_ms_user_tenant ON membership_snapshot(user_id, tenant_id);
The SQLite database is encrypted with SQLCipher, key derived per-device from the device-bound key (see iam-service device pairing).
6. Offline ABAC
The desktop carries enough state to make local read decisions:
- The signed-in
Membershiprow. - That membership's
RoleAssignment[]. - The
Rolecatalog with permissions. - The
OrganizationUnittree to resolvepropertyScope.
The local PolicyEngine (same package as the server, pure TS) returns identical decisions to the server PDP for a given input. Writes are still queued offline only for endpoints that were tagged offlineQueueable: true in API_CONTRACTS — none of tenant-service's mutation endpoints carry that tag.
7. Conflict Behavior (server-authoritative)
When the desktop's snapshot diverges from server:
- Server wins unconditionally on every field.
- The desktop never sends a write that depends on stale state for these aggregates.
- If the desktop attempted an offline RBAC action while the role definition changed server-side, the queued action is rejected on flush with
409 MELMASTOON.SYNC.PRECONDITION_INVALIDATEDand surfaced in the Activity Center for manual retry.
There is no merge UI for tenant aggregates; the operator only sees "your local copy was outdated, refreshed".
8. Bootstrapping Flow
After first login on a new device:
- iam-service issues device pair (cert) and JWT with
device_idclaim. - Desktop calls
GET /api/v1/me/tenants→ picks active tenant. - Desktop calls
POST /sync/v1/bootstrap?scope=tenant&tenantId=…→ server returns full snapshot stream + an opaque cursor. - Subsequent runs use
GET /sync/v1/pull?cursor=…. - SQLite
tenant_snapshotrow exists; subsequent app starts read from it before pulling.
9. Failure Modes
| Failure | Behavior |
|---|---|
| Pull 401 (token expired) | Sync engine triggers iam refresh; retries once |
| Pull 429 | Exponential backoff with jitter, max 5 min |
| Pull 5xx | Backoff; surface "Catalog last updated …" indicator |
tenant.deleted received | Sync engine purges the local DB and signs the user out of that tenant context |
| Local DB tampering / signature mismatch | Drop SQLite cache and re-bootstrap |
10. Telemetry
Sync emits OTel spans sync.tenant.pull with attributes device.id, tenant.id, cursor.kind, items.count, bytes.out, pull.deltaSeconds. Metric sync_tenant_pull_lag_seconds SLO p95 ≤ 30 s under normal load. Alerts: > 5 min for 10 minutes pages on-call.