Skip to main content

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

AggregateDirectionConflict policyNotes
Tenant (own row)pull-onlyserver_authoritativeStatus changes (suspended, closed) gate local writes elsewhere
TenantConfigpull-onlyserver_authoritativeDrives offline defaults (currency, check-in time, cancellation policy)
OrganizationUnit (tree for current tenant)pull-onlyserver_authoritativeProperty switcher, scope display
Membership (caller's own only)pull-onlyserver_authoritativeDetermines what the desktop renders
Role catalog (system + tenant custom)pull-onlyserver_authoritativeUsed for offline ABAC evaluation
RoleAssignment (caller's own only)pull-onlyserver_authoritativeOffline permission checks
FeatureFlagOverridepull-onlyserver_authoritativeToggle local feature gates
Invitationnever syncedn/aToken material; web-only acceptance
BillingContactnever syncedn/aPII / 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 TenantConfig fields (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 return 409 MELMASTOON.SYNC.ONLINE_REQUIRED if 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.v1
  • melmastoon.tenant.membership.role_changed.v1 (where userId matches device user)
  • melmastoon.tenant.membership.removed.v1
  • melmastoon.tenant.feature_flag.toggled.v1
  • melmastoon.tenant.organization_unit.created|moved|archived.v1
  • melmastoon.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 Membership row.
  • That membership's RoleAssignment[].
  • The Role catalog with permissions.
  • The OrganizationUnit tree to resolve propertyScope.

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:

  1. Server wins unconditionally on every field.
  2. The desktop never sends a write that depends on stale state for these aggregates.
  3. 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_INVALIDATED and 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:

  1. iam-service issues device pair (cert) and JWT with device_id claim.
  2. Desktop calls GET /api/v1/me/tenants → picks active tenant.
  3. Desktop calls POST /sync/v1/bootstrap?scope=tenant&tenantId=… → server returns full snapshot stream + an opaque cursor.
  4. Subsequent runs use GET /sync/v1/pull?cursor=….
  5. SQLite tenant_snapshot row exists; subsequent app starts read from it before pulling.

9. Failure Modes

FailureBehavior
Pull 401 (token expired)Sync engine triggers iam refresh; retries once
Pull 429Exponential backoff with jitter, max 5 min
Pull 5xxBackoff; surface "Catalog last updated …" indicator
tenant.deleted receivedSync engine purges the local DB and signs the user out of that tenant context
Local DB tampering / signature mismatchDrop 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.