Skip to main content

property-service — SYNC_CONTRACT

Companion: DATA_MODEL · DOMAIN_MODEL · EVENT_SCHEMAS · ../../docs/02-enterprise-architecture.md §Sync & Offline · ../../docs/architecture/ADR-0003-electron-offline-first-desktop.md

This document specifies how property-service data flows to and from the Electron desktop backoffice via bff-backoffice-service /sync/v1/pull and /sync/v1/push. It is the binding contract for SQLite replication, conflict resolution, and idempotent writeback for the property aggregate family.

Desktop is Electron (Node + Chromium). Tauri is explicitly out of scope.


1. Replicated Aggregates

AggregateDirectionConflict PolicyWhy
Property (metadata, translations, contact, geo, status, hero)server → desktopserver_authoritativeEdits funnel through HQ workflows; desktop should never invent property changes
RoomType (incl. translations, bed configs)server → desktopserver_authoritativeCatalog change
Room (creation, archival, lock binding)server → desktopserver_authoritativeInventory-defining changes are headquarters concerns
Room.status (active / out_of_order / out_of_service)bidirectionallww+diff with vector clockFront-desk operators must mark a room OOO immediately when they discover a fault, even offline
Room.notesbidirectionallww+diffOperational annotations from the desk
Photo ordering & is_hero flagbidirectionallww+diffField staff curate galleries; conflicts are rare and resolvable
Photo add/removeserver → desktop onlyserver_authoritativePhoto upload requires media pipeline; desktop initiates via signed-URL flow but the server owns final state
PolicyOverrideserver → desktopserver_authoritativeCompliance-sensitive
RoomGroupserver → desktopserver_authoritative
Amenity (canonical catalog)server → desktopserver_authoritativeRead-only on desktop
PropertyAmenity selectionserver → desktopserver_authoritative

The list above is the complete replication surface; nothing else is mirrored. The desktop store is scoped to the operator's tenant and properties they have read access to (see SECURITY_MODEL §6).


2. Local Store

  • Engine: SQLite via better-sqlite3 inside the Electron main process; renderer access only via the typed IPC bridge.
  • At-rest encryption: SQLCipher with a per-device key wrapped by the OS keychain (Keychain on macOS, DPAPI on Windows, libsecret on Linux). Key rotation handled by the desktop bootstrap; not a property-service concern.
  • Schema parity: A subset of the Postgres schema with the same column names. Tenant column is implicit (single tenant per device session).
  • Sync metadata table: sync_state(table, last_pulled_at, last_pulled_cursor, last_pushed_at).

3. Pull Protocol

The desktop polls GET /sync/v1/pull?since=<cursor>&scope=property on bff-backoffice-service. The BFF fans out to property-service /internal/sync/changes and returns a delta envelope:

{
"service": "property-service",
"cursor": "01H8Z...", // ULID-ordered watermark
"ts": "2026-04-22T10:00:00.000Z",
"changes": [
{
"type": "property.upserted",
"id": "ppt_01H...",
"version": 7,
"vectorClock": { "server": 7 },
"row": { /* full property + translations + amenities + policies.resolved snapshot */ }
},
{
"type": "room.upserted",
"id": "rmu_01H...",
"version": 12,
"vectorClock": { "server": 12, "device:dvc_xyz": 2 },
"row": { /* full room */ }
},
{
"type": "room.tombstoned",
"id": "rmu_01H...",
"tombstoneAt": "2026-04-22T09:55:00.000Z"
}
],
"more": false
}

Cursor. A monotonically increasing ULID computed from the maximum updated_at/status_changed_at observed on property-service's side. The cursor is opaque to the client; the BFF caches it for resume.

Pagination. more: true indicates additional pages; the client immediately re-pulls until more: false. Page size cap: 500 rows.

Latency goals. First catch-up after 24 h offline ≤ 8 s for a property of 200 rooms; steady-state pull every 30 s while online.

Bandwidth. Pull payloads are gzip-compressed and patched with field-level diffs when the desktop already holds the previous version (server compares supplied If-None-Match: <vector_clock> header).


4. Push Protocol

Local writes (only Room.status, Room.notes, Photo.sort_order, Photo.is_hero) are queued in outbox_local and pushed by POST /sync/v1/push in batches.

{
"service": "property-service",
"deviceId": "dvc_01H...",
"submittedAt": "2026-04-22T10:00:00.000Z",
"operations": [
{
"operationId": "opn_01H...", // ULID, idempotency key
"type": "room.status.set",
"id": "rmu_01H...",
"expectedVersion": 12, // optimistic
"vectorClock": { "server": 12, "device:dvc_01H...": 3 },
"payload": {
"status": "out_of_order",
"reason": "broken_window",
"occurredAt": "2026-04-22T09:48:00.000Z"
}
}
]
}

The BFF forwards each operation to property-service /internal/sync/apply. The service:

  1. Looks up the operation in inbox keyed by operationId. If present and processed → return cached result.
  2. Resolves conflict per policy (next section).
  3. Persists, writes outbox, and returns the new server version + vectorClock.
  4. Emits the corresponding domain event (e.g., melmastoon.property.room.taken_out_of_order.v1).

Per-operation responses:

{
"operationId": "opn_01H...",
"result": "applied", // applied | rejected | conflict_resolved
"newVersion": 13,
"vectorClock": { "server": 13, "device:dvc_01H...": 3 },
"appliedAt": "2026-04-22T10:00:01.000Z",
"warnings": []
}

Rejection reasons map to error codes in ERROR_CODES: MELMASTOON.PROPERTY.ROOM_NOT_FOUND, MELMASTOON.PROPERTY.ILLEGAL_STATUS_TRANSITION, MELMASTOON.GENERAL.PRECONDITION_FAILED, MELMASTOON.GENERAL.IDEMPOTENCY_KEY_REPLAYED_DIFFERENT_BODY.


5. Conflict Resolution Detail

5.1 server_authoritative (Property, RoomType, Room create/archive, RoomGroup, Amenity, Photo add/remove, PolicyOverride)

  • Local state for these aggregates is read-only.
  • The desktop UI hides "edit" affordances when the operator is offline for these fields.
  • A pulled change always overwrites the local row.

5.2 lww+diff (Room.status, Room.notes, Photo.sort_order, Photo.is_hero)

Each row carries vector_clock: { server, device:<id> }. On push:

  1. If incoming.vectorClock.server == row.version: fast-forward apply (no real conflict). Bump server.
  2. If incoming.vectorClock.server < row.version: a conflict exists. Apply field-level diff:
    • For status: compare occurredAt. Latest wins. If equal, server wins.
    • For notes: 3-way merge using diff-match-patch; on irreconcilable overlap, append the device note as a new line prefixed with [device dvc_…].
    • For sort_order: server reapplies its current ordering and treats device input as a re-order request that re-sequences only the affected sub-list.
    • For is_hero: only one row may be hero; the most recent occurredAt wins; the loser is reset to is_hero=false.
  3. The operation's response carries result=conflict_resolved plus the merged row so the desktop can update its cache.

5.3 Tombstones

  • Server-side delete or archive triggers a *.tombstoned change in the next pull.
  • Desktop deletes the local row and any pending push operations for that ID (it surfaces a toast: "This room was archived at headquarters; your pending change was discarded.").

6. Status-Transition Constraints

Even with lww+diff for status, illegal transitions are still rejected. The state machine in DOMAIN_MODEL §6 is enforced at apply-time, not at push-time. This means a stale device cannot push archived → active.


7. Backpressure & Throttling

  • Push batch cap: 100 operations or 256 KB per request, whichever first.
  • The service sets Retry-After on 429. The desktop honors it with jitter.
  • Per device, a sliding window of 60 push requests/minute. Excess queued locally.
  • A catch-up window larger than 7 days triggers a "full reset" mode: the desktop drops local state for property-service and replays from cursor 0.

8. Security Posture (sync-specific)

  • All sync calls require an authenticated user JWT scoped to a tenant; the BFF rejects mismatched X-Tenant-Id.
  • The pull endpoint enforces per-property authorization (property:read); a row not visible to the operator is silently elided (not 403'd).
  • The push endpoint enforces per-property authorization (property:room:status:write, property:photo:order:write).
  • AI-suggested rows pulled to desktop carry aiProvenance so the desktop can render an HITL pending state.

9. Observability

  • Span attributes for every sync call: melmastoon.tenant_id, melmastoon.device_id, melmastoon.operation_id, melmastoon.aggregate, melmastoon.conflict_resolution{none, lww, three_way_merge, server_wins}.
  • Counter property_sync_conflicts_total{aggregate,resolution}.
  • Histogram property_sync_apply_seconds{aggregate}.
  • Alert: conflict_rate per device > 5 ops/h sustained 30 min → operations runbook (likely a stale device).

10. Out of Scope

  • Per-night availability, ADR/RevPAR, and reservations are not sync'd by property-service. They flow under inventory-service, pricing-service, and reservation-service sync contracts respectively.
  • Housekeeping cleanliness state (clean/dirty/inspected) is owned by housekeeping-service. Pulled in parallel under its own contract.
  • Lock device events live under lock-integration-service.

Cross-references: pull/push REST shape in API_CONTRACTS §Internal sync; state machine in DOMAIN_MODEL §6; failure modes (e.g. SYNC_CURSOR_REGRESSION) in FAILURE_MODES.