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
| Aggregate | Direction | Conflict Policy | Why |
|---|---|---|---|
Property (metadata, translations, contact, geo, status, hero) | server → desktop | server_authoritative | Edits funnel through HQ workflows; desktop should never invent property changes |
RoomType (incl. translations, bed configs) | server → desktop | server_authoritative | Catalog change |
Room (creation, archival, lock binding) | server → desktop | server_authoritative | Inventory-defining changes are headquarters concerns |
Room.status (active / out_of_order / out_of_service) | bidirectional | lww+diff with vector clock | Front-desk operators must mark a room OOO immediately when they discover a fault, even offline |
Room.notes | bidirectional | lww+diff | Operational annotations from the desk |
Photo ordering & is_hero flag | bidirectional | lww+diff | Field staff curate galleries; conflicts are rare and resolvable |
Photo add/remove | server → desktop only | server_authoritative | Photo upload requires media pipeline; desktop initiates via signed-URL flow but the server owns final state |
PolicyOverride | server → desktop | server_authoritative | Compliance-sensitive |
RoomGroup | server → desktop | server_authoritative | |
Amenity (canonical catalog) | server → desktop | server_authoritative | Read-only on desktop |
PropertyAmenity selection | server → desktop | server_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-sqlite3inside 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:
- Looks up the operation in
inboxkeyed byoperationId. If present and processed → return cached result. - Resolves conflict per policy (next section).
- Persists, writes outbox, and returns the new server
version+vectorClock. - 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:
- If
incoming.vectorClock.server == row.version: fast-forward apply (no real conflict). Bump server. - If
incoming.vectorClock.server < row.version: a conflict exists. Apply field-level diff:- For
status: compareoccurredAt. Latest wins. If equal, server wins. - For
notes: 3-way merge usingdiff-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 recentoccurredAtwins; the loser is reset tois_hero=false.
- For
- The operation's response carries
result=conflict_resolvedplus the merged row so the desktop can update its cache.
5.3 Tombstones
- Server-side delete or archive triggers a
*.tombstonedchange 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-Afteron429. 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-serviceand 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
aiProvenanceso 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 underinventory-service,pricing-service, andreservation-servicesync contracts respectively. - Housekeeping cleanliness state (
clean/dirty/inspected) is owned byhousekeeping-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.