SYNC_CONTRACT — theme-config-service
Sibling: DATA_MODEL · APPLICATION_LOGIC · EVENT_SCHEMAS
Platform anchors:
docs/architecture/ADR-0003-electron-offline-first-desktop.md·docs/standards/SERVICE_TEMPLATE.md
This document defines how theme-config-service interacts with the Electron desktop client ("Ghasi Melmastoon Operator Console") that some hotel operators run on-property for offline-tolerant operations. Per ADR-0003, only services where on-property staff perform critical workflows offline are first-class participants in the desktop sync layer.
1. Sync classification
| Question | Answer |
|---|---|
Is theme-config-service desktop-replicable? | No, with one exception. |
| Replication strategy | Read-only mirror of the active published bundle for the property's theme. |
| Authoring offline? | No. All authoring (CRUD, publish, rollback) is cloud-only. |
| Conflict policy | Server wins by definition — desktop never writes back to the service. |
Why: theme authoring is a deliberate, low-frequency activity performed by the brand owner / GM on the cloud backoffice; an offline operator does not need to edit theming. What an offline operator does need is for the booking flow embedded in the Electron operator console (e.g. walk-in reservation entry) to render correctly with the property's brand even when the LAN is partitioned from the WAN.
So we replicate exactly one read-model: the property's currently-published theme bundle.
2. The replicated artefact: local_theme_bundle
2.1 SQLite schema (Electron-side)
CREATE TABLE local_theme_bundle (
id TEXT PRIMARY KEY CHECK (id = 'singleton'),
theme_id TEXT NOT NULL,
theme_version_id TEXT NOT NULL,
publication_id TEXT NOT NULL,
bundle_sha256 TEXT NOT NULL,
bundle_size_bytes INTEGER NOT NULL,
bundle_json BLOB NOT NULL, -- gzipped JSON, identical to CDN payload
email_theme_json BLOB NOT NULL, -- gzipped JSON
default_locale TEXT NOT NULL,
enabled_locales TEXT NOT NULL, -- JSON array
fetched_at TEXT NOT NULL, -- RFC3339
origin TEXT NOT NULL CHECK (origin IN ('cdn','origin','provisioning')),
schema_version INTEGER NOT NULL DEFAULT 1
);
The id = 'singleton' row is overwritten on every successful sync — there is no history on the desktop because there is no offline editing to reconcile.
2.2 Mirror semantics
- Source of truth: the cloud
theme_publicationsrow markedis_active=truefor the property's theme. - Granularity: one bundle per Electron install; chains running multiple properties from one console use
propertyIdselection in the console UI to switch between bundles (each persisted as a row keyed bylocal_theme_bundle.theme_id— the schema above is shown for the single-property case; the multi-property variant usestheme_idas the PK and removes the'singleton'check). - Invalidation: triggered by either (a) push notification through the desktop sync channel carrying
melmastoon.theme.published.v1, or (b) periodic poll every 15 minutes when the channel is up; or (c) on-demand "Refresh branding" menu action.
3. Sync channels
3.1 Push (preferred)
The desktop sync gateway (desktop-sync-service, separate platform service per ADR-0003) maintains a per-device WebSocket. Theme events are routed through it as small notifications:
{
"kind": "theme_publication_changed",
"themeId": "thm_01J...",
"themeVersionId": "thv_01J...",
"publicationId": "thp_01J...",
"bundleSha256": "9f2c8e...",
"bundleUrl": "https://cdn.melmastoon.app/themes/thm_01J.../published.json",
"occurredAt": "2026-04-23T10:14:55.211Z"
}
The desktop client compares bundleSha256 to its local row and pulls only on mismatch.
3.2 Pull (fallback)
When the WebSocket is down or stale, the client polls:
GET /api/v1/desktop-sync/themes/{themeId}/active
If-None-Match: "<localBundleSha256>"
Authorization: Bearer <device-jwt>
Response 200
{
"themeId": "thm_01J...",
"themeVersionId": "thv_01J...",
"publicationId": "thp_01J...",
"bundleSha256": "9f2c8e...",
"bundleUrl": "https://cdn.melmastoon.app/themes/thm_01J.../published.json",
"emailThemeUrl": "https://cdn.melmastoon.app/themes/thm_01J.../email.json",
"publishedAt": "2026-04-23T10:14:55.211Z"
}
Response 304 when the SHA matches.
3.3 First-run / re-install bootstrap
On device pairing, the operator console requests the bundle directly from origin:
POST /api/v1/desktop-sync/themes/bootstrap
Authorization: Bearer <pairing-jwt>
Body: { "themeId": "thm_01J...", "deviceId": "dvc_01J..." }
Returns the same payload as 3.2 plus the gzipped bundle bytes inline (so the device does not need internet routing to the CDN host on first run; useful for hotels where IT first whitelists the API origin).
4. Conflict policy
Because the desktop never writes back to theme-config-service, there are no aggregate-level conflicts. The only conflicts are:
| Scenario | Resolution |
|---|---|
| Local bundle is older than server | Replace local bundle with server bundle. Server wins, always. |
| Local bundle is newer than server (impossible without manual file tampering) | Treat as corruption: wipe local bundle, refetch via 3.2; raise a desktop diagnostic event desktop.bundle.tamper_suspected.v1. |
| Bundle download partial / SHA mismatch | Discard download; retry with exponential backoff (max 5); on persistent failure, keep prior bundle and surface a banner "Branding may be outdated". |
| Theme deleted on server | Server returns 410 Gone; desktop deletes the local row and falls back to the platform default scaffold bundle shipped with the installer. |
There is no conflict resolution UI because there is nothing to merge.
5. Security
- The device JWT used by 3.2 / 3.3 is bound to a
deviceIdand a single tenant + property scope. It carries notheme:authorpermissions; it can only read the active published bundle. - Bundles are public anyway (they are served via CDN to anonymous booking-flow visitors). No PII is in the bundle.
- The
email_theme_jsonis also non-sensitive (it carries presentation tokens, not content). Email rendering still requires a server round-trip per thenotification-servicecontract; the desktop only usesemail_theme_jsonfor in-app preview. - Pairing happens through the platform device-pairing flow (out of scope here); see
iam-servicedocumentation.
6. Bandwidth & freshness budgets
| Metric | Target |
|---|---|
| Bundle size on disk | ≤ 60 KB gzipped (vs the 40 KB CDN budget; the local store keeps a small index header + the email-theme bundle) |
| Sync round-trip (push → SHA check → fetch) | p95 ≤ 3 s on 1 Mbps DSL |
| Time-to-freshness after cloud publish on a healthy device | p95 ≤ 30 s |
| Time-to-freshness after a 1-hour WAN partition | ≤ 1 minute after recovery |
| Polling interval (fallback) | 15 minutes; 5 minutes after a known-pending publish (server pushes a theme_publication_pending advisory) |
7. Observability on the desktop
The Electron client emits the following diagnostics to desktop-sync-service (which forwards to the platform):
| Event | When |
|---|---|
desktop.theme.bundle.installed.v1 | After a successful sync (carries from/to SHA) |
desktop.theme.bundle.refresh_failed.v1 | After exhausting retries |
desktop.theme.bundle.stale.v1 | When fetched_at is older than 24 h and a sync was attempted |
desktop.theme.bundle.tamper_suspected.v1 | When local SHA does not match recomputed SHA of stored bytes |
These show up in the platform observability stack alongside the cloud-side theme.published.v1 and theme.cdn_cache_invalidated.v1 events for end-to-end traceability of "did the property actually receive the rebrand?".
8. Out of scope
- Authoring offline. Trying to author offline is not supported; the backoffice console is web-only and requires connectivity.
- Editing the bundle on the device. The bundle is read-only at runtime.
- Per-device theming. Phase 2 chain branding is property-scoped, not device-scoped.
9. References
- ADR:
docs/architecture/ADR-0003-electron-offline-first-desktop.md - Cloud-side publish flow:
APPLICATION_LOGIC §2.5 - Authoring REST surface:
API_CONTRACTS - Sync gateway contract:
services/desktop-sync-service/SERVICE_OVERVIEW.md(when authored)