SYNC_CONTRACT — pricing-service
Sibling: DATA_MODEL · API_CONTRACTS · APPLICATION_LOGIC
Strategic anchors: ADR-0003 Electron offline-first desktop · 02 §10 Desktop sync model
The Ghasi Melmastoon Electron desktop is offline-first. Front-desk staff must be able to quote a walk-in price even when the WAN link to the GCP region is degraded. This contract specifies what slice of pricing data is replicated to the desktop, in which direction, and how conflicts are resolved when reconnecting.
Reminder: the desktop is Electron (Chromium + Node renderer), packaged with
electron-forge. We never use Tauri; do not propose Rust-side replication.
1. Replication scope
Every desktop installation belongs to exactly one tenant + one property (a hotel deployment), so all replicated rows are filtered by (tenant_id, property_id) server-side before transmission.
| Table | Direction | Conflict policy | Rationale |
|---|---|---|---|
pricing.rate_plans (status = published) | server → client | server_authoritative | Rate definitions are authored centrally |
pricing.rate_plan_room_types | server → client | server_authoritative | Linkage follows the plan |
pricing.rate_rules (rate_plan_id ∈ replicated plans, retired_at IS NULL) | server → client | server_authoritative | Same as plan |
pricing.discounts (rate_plan_id ∈ replicated plans, enabled = true) | server → client | server_authoritative | Same as plan |
pricing.promotions (status = active, valid_to >= today) | server → client | server_authoritative | Codes and caps are server-managed |
pricing.promotion_redemptions | client → server | append_only | Desktop may pre-record a redemption while offline; server reconciles cap on push |
pricing.tax_rules (jurisdiction matches property) | server → client | server_authoritative | Government rate; never offline-edited |
pricing.fee_rules (property matches) | server → client | server_authoritative | Property-scoped, GM-edited centrally |
pricing.fx_rate_snapshots (latest per pair, plus tenant pin) | server → client | max-of(captured_at) | Always pick the most recent capture; cached for offline fallback |
pricing_quote.price_quotes (created on desktop) | client → server | append_only (id is client-generated ULID) | Quotes are immutable once pinned |
pricing_quote.quote_locks | server → client (read-only mirror) | lww+diff (server wins) | Lock state is authoritative on the server |
pricing.dynamic_suggestions | not replicated | — | HITL workflow lives only in backoffice web UI |
pricing.tax_rules history (superseded rows) | server → client (last 90 days only) | server_authoritative | Needed for retroactive quote reads |
What is not replicated to the desktop, by design:
- The full historical FX series (only the latest snapshot per pair is replicated; history is centralised).
- Outbox/inbox tables (server-internal).
- Idempotency keys (server-internal).
- Other tenants' rows (filtered by RLS at the sync gateway).
2. Replication channel
The desktop talks to desktop-sync-service (not directly to pricing-service). The sync service exposes a long-poll/streaming endpoint and proxies into pricing-service's internal sync APIs:
desktop ──HTTPS/2──▶ desktop-sync-service ──gRPC──▶ pricing-service /internal/v1/sync/*
└── Pub/Sub fan-out for push notifications
Cursor format: opaque <schema_version>:<watermark_ms>:<row_pk>. Watermarks are taken from updated_at for mutable tables and created_at for append-only tables.
GET /internal/v1/sync/rate-plans?since=01H8Z4M%3A1745312049000%3Arate_…&propertyId=pty_…
Authorization: Bearer <service-mesh-jwt>
X-Tenant-Id: tnt_…
200 OK
Content-Type: application/x-ndjson
{"op":"upsert","table":"rate_plans","row": { … }}
{"op":"upsert","table":"rate_rules","row": { … }}
{"op":"delete","table":"rate_rules","id":"rru_…"}
{"meta":{"nextCursor":"01H8Z4M:1745312102000:rule_…","hasMore":false}}
Push notifications via Firebase Cloud Messaging tell the desktop "new pricing data available, pull now"; if FCM is unreachable, the desktop falls back to a 60 s polling interval.
3. Conflict policies
3.1 server_authoritative
Used for every rate definition. The desktop NEVER edits these locally; if a manager modifies a rate plan from the desktop, the request is funnelled through the standard admin REST API (online required). On reconnect, the local copy is overwritten with the server row.
3.2 append_only
Used for price_quotes and promotion_redemptions.
- IDs are client-generated ULIDs with the standard prefixes.
- The desktop persists locally to a SQLite mirror with the same schema.
- On reconnect, the sync service POSTs each unsynced row to
/internal/v1/sync/price-quotes:push. Server-side validation re-runs the derivation (deterministically) using the embeddedrequestsnapshot and rejects the row if the recomputedtotals.grandTotalMicrodiffers by more than the per-currency rounding floor; rejected rows are stored in the desktop's localsync_rejectstable for operator review. - For
promotion_redemptions, the server re-applies the race-safe redemption SQL (see DATA_MODEL §6). If the cap has been hit since the offline redemption, the redemption row is rejected withMELMASTOON.PRICING.PROMO_OVEROBLIGATIONand the desktop displays a deferred warning ("This promo is now unavailable; the booking was honoured at standard rate").
3.3 max-of(captured_at)
Used for fx_rate_snapshots. Both sides always keep the most recent capture per (base, quote). The desktop checks staleAfter and hardExpireAt before using a snapshot; quotes derived against a stale snapshot are flagged with fxSnapshot.stale = true and the booking page shows a "rate may be re-confirmed at booking" banner.
3.4 lww+diff
Used for quote_locks (mirror only). Server timestamp wins. The desktop never writes to this table.
4. Offline quote derivation
While offline, the desktop runs the same TypeScript derivation pipeline as the server (shipped via the shared @melmastoon/pricing-engine package — it has no infra dependencies):
- Resolve candidate
RatePlanfrom local mirror. - Run
deriveNightlyover localRateRule,Discount,FeeRule,TaxRule,FxRateSnapshot. - Emit a local
PriceQuotewithsource: "desktop_offline"recorded inderivation.steps[*].notes. - Persist to local SQLite; mark
sync_status = "pending_push".
A local quote is valid for the standard TTL (30 m). When the desktop comes online and the push is rejected, the front-desk session displays the rejection inline; the guest is re-quoted at the now-authoritative server price.
5. Watermarks and back-pressure
- Each replicated table has a
pricing.sync_watermarksserver-side row per(tenant_id, property_id, table)recording the last successful pull timestamp; surfaced via/internal/v1/sync/health. - A pull request that would exceed 5 000 rows or 5 MB is paginated; the desktop continues until
hasMore=false. - The sync gateway enforces a per-property concurrency cap of 4 in-flight pull streams.
- Push throttling: max 200 quotes/minute per desktop; excess is buffered locally with exponential back-off.
6. Schema evolution
Every replicated row carries a _schemaVersion field (matches version in DATA_MODEL migrations). The desktop refuses to apply a row whose _schemaVersion exceeds its bundled engine version and prompts the operator to update the desktop. Forward-incompatible changes (renames, removals) require a coordinated server + desktop release per ADR-0003 §6.
7. Security
- The desktop holds a long-lived OAuth refresh token bound to the property's service identity; access tokens are minted with
aud=desktop-sync-serviceandtenant_id/property_idclaims. - All replication payloads are encrypted in transit (TLS 1.3) and at rest on the desktop (SQLCipher with a key wrapped by the OS keychain; see SECURITY_MODEL §6).
- The desktop CANNOT read rows from another property even if the same tenant; the sync service enforces
propertyIdscoping in addition to RLS. - AI dynamic-pricing suggestions are never replicated; the HITL workflow lives only in the central backoffice.
8. Observability
The desktop emits OTel spans for desktop.sync.pull, desktop.sync.push, and desktop.pricing.derive_offline, batched and shipped to Cloud Logging via the central collector when online. Required attributes: tenant_id, property_id, table, rows, cursor.before, cursor.after, lag_ms. The lag_ms SLI tracks "how far behind authoritative pricing is the desktop"; alarm at p95 > 60 s sustained 10 m.