Skip to main content

SYNC_CONTRACT — reservation-service

Sibling: DATA_MODEL · APPLICATION_LOGIC · DOMAIN_MODEL

Strategic anchors: 02 §8 Sync & Offline · 05 §10 Sync API · ADR-0003 Electron Offline-First

The Electron desktop is the front-desk's primary surface and must keep working through extended connectivity loss. reservation-service is one of the most heavily replicated services because every check-in, walk-in, modification, and check-out happens through the desktop. This document declares the per-aggregate replication scope, conflict policies, and push/pull semantics that the sync-service enforces on our behalf.

The client never talks to reservation-service directly through the sync surface — it talks to bff-backoffice-service, which delegates to sync-service, which calls our REST and consumes our events. We declare what is replicable and how conflicts resolve; the sync engine implements the transport.


1. Replicated aggregates and scope

AggregateReplicated?Scope (per device)
ReservationyesAll confirmed, check_in_started, checked_in, checkout_started for assigned property; plus confirmed arrivals in next 30 days; plus terminal states (checked_out, cancelled, no_show) from last 60 days
ReservationItemyesAlways with parent Reservation
Guest (primary)yes (subset)Only guests referenced by replicated reservations; PII fields decrypted at the bff layer per device entitlement
AdditionalGuestyesAlways with parent Reservation
SpecialRequestyesAlways with parent Reservation
ReservationModificationyes (append-only)Last 30 modifications per replicated reservation
ReservationHoldnoHolds are short-lived and saga-coupled; desktop reads them through ephemeral REST only
outbox / inbox_processednoServer-internal

A walk-in flow does not require a pre-existing replicated reservation; the desktop pushes a fresh reservation in walk_in channel and the server creates it.


2. Conflict policy per field-class

The sync engine evaluates one policy per logical field-class, not per database column.

Field classPolicyRationale
Reservation.status and any state-machine field (pending_saga_step, confirmed_at, checked_in_at, checked_out_at, cancelled_at)server_authoritativeStatus transitions are saga-driven and emit events; the cloud is the only authority
Reservation.totals.*, payment.*, fxSnapshot, payment_intent_ids, key_credential_ids, folio_idserver_authoritativeMoney and external-system references are owned by their authoritative services
Reservation.notes, Reservation.specialRequests[*].freeText, Guest.preferences (additive tags)lww+diff by updatedAtFree-text rarely conflicts; on conflict, the side that wrote later wins, but additive tag union is preserved
Reservation.modifications[*]append_only (CRDT-G-Set keyed by id)Audit rows must never be lost or rewritten
Reservation.specialRequests[*] (existence)append_onlyAdding a request must not conflict; updates to fulfilment use server_authoritative
Reservation.specialRequests[*].fulfilled and fulfilled_atmax-of by (fulfilled, fulfilled_at)Once a staff marks fulfilled, it stays fulfilled even if another device later writes false
AdditionalGuest.* (during walk-in flow)server_authoritative after first server-confirmed saveThe desktop may add guests pre-sync; once the server assigns IDs and persists, the server wins
Reservation.requiresManualKeymax-of (true wins)If any device flagged manual key, treat as manual until cleared by a staff action

There is no LWW for status transitions. Any push that attempts to advance a state machine in conflict with the server's view is sent to server arbitration: the server reloads the aggregate, runs the requested command through the use case (which re-checks the state machine), and either accepts or rejects with a typed error.


3. Pull contract

The desktop calls the sync surface (proxied through bff-backoffice-service):

POST /sync/v1/pull HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_…
X-Property-Id: ppt_…
X-Device-Id: dev_…
Content-Type: application/json

{
"since": "<opaque cursor or null on first sync>",
"aggregates": ["reservation","reservation_item","guest","additional_guest","special_request","reservation_modification"],
"maxBatch": 500,
"scopes": {
"reservation": { "windowDaysFuture": 30, "windowDaysPast": 60, "statuses": ["confirmed","check_in_started","checked_in","checkout_started","checked_out","cancelled","no_show"] }
}
}

Response (per 05 §10):

{
"cursor": "<next opaque cursor>",
"hasMore": false,
"changes": {
"reservation": [ { "op": "upsert", "id": "rsv_…", "version": 9, "data": { /* full Reservation DTO */ } } ],
"reservation_modification": [ { "op": "append", "id": "mod_…", "data": { /* mod row */ } } ]
}
}
  • The cursor encodes (updated_at, id) watermarks per aggregate; replays of the same cursor are idempotent.
  • The server includes version on every record; the client persists it for the next push.
  • Server may insert synthetic op=delete for reservations that fell out of the device's window (e.g., a reservation aged past the 60-day past window) — this is a local cleanup, not a real deletion.

4. Push contract

The desktop pushes batched mutations whenever it has connectivity:

POST /sync/v1/push HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_…
X-Property-Id: ppt_…
X-Device-Id: dev_…
Idempotency-Key: <ULID>
Content-Type: application/json

{
"operations": [
{
"opId": "01H…", // ULID, idempotent
"aggregate": "reservation",
"command": "walk_in",
"expectedVersion": null,
"payload": { /* WalkInBookingUseCase DTO */ }
},
{
"opId": "01H…",
"aggregate": "reservation",
"id": "rsv_…",
"command": "check_in",
"expectedVersion": 7,
"payload": { "issueKey": true, "actorStaffId": "stf_…" }
},
{
"opId": "01H…",
"aggregate": "reservation",
"id": "rsv_…",
"command": "modify",
"expectedVersion": 9,
"payload": { "type": "special_request_added", "request": { "freeText": "...", "locale": "fa-AF" } }
}
]
}

The bff/sync layer translates each command into the matching REST endpoint with If-Match: "v<expectedVersion>". Per-operation result is returned:

{
"results": [
{ "opId": "01H…", "status": "applied", "id": "rsv_…", "newVersion": 1 },
{ "opId": "01H…", "status": "conflict", "code": "MELMASTOON.RESERVATION.STALE_VERSION", "currentVersion": 8 },
{ "opId": "01H…", "status": "applied", "id": "rsv_…", "newVersion": 10 }
]
}

On STALE_VERSION for a status-transition command, the desktop reloads the aggregate via the next pull and surfaces a UI prompt rather than auto-retrying — this prevents accidental double check-ins.


5. Push commands accepted

CommandUse case backed by
walk_inWalkInBookingUseCase
check_inStartCheckInUseCase (sync from desktop)
complete_check_inCompleteCheckInUseCase
check_outStartCheckOutUseCase
complete_check_outCompleteCheckOutUseCase
modify.* (sub-types)ModifyReservationUseCase
cancelCancelReservationUseCase
record_no_showRecordNoShowUseCase
record_early_checkoutRecordEarlyCheckoutUseCase
add_special_requestAddSpecialRequestUseCase

hold, confirm, and quote operations are not accepted from the desktop — those belong to the booking funnel served from the cloud. A walk-in confirms server-side as part of WalkInBookingUseCase.


6. Offline behavior expectations

  • The desktop may queue up to 500 operations locally before applying back-pressure to the staff UI.
  • Walk-ins created while offline carry a temporary client-generated ULID with prefix rsv_d_ (the _d marker tells the server "client-issued"); the server replaces it with the canonical rsv_ ID and returns the mapping in the push result. The desktop SQLite remaps every reference by the opId correlation.
  • A reservation in held state is never pushed offline — holds require live inventory and payment coordination. The desktop UI greys out the booking action until connectivity resumes.
  • Modifications taken offline carry the device clock; the server stamps an authoritative occurredAt on persistence, but preserves the device clock as clientOccurredAt in the modification audit row.

7. Tombstones & device window churn

  • A reservation that ages out of the desktop's 60-day past window is soft-removed from the SQLite store via op=delete. The aggregate is not deleted in the cloud.
  • A reservation that becomes irrelevant (e.g., property reassigned, staff lost access) is removed similarly.
  • Tombstones never delete reservation_modifications from the central audit; the cloud always keeps them.

8. Failure & recovery

ScenarioBehavior
Cursor lost (device wipe)Full sync from since=null; window scope keeps payload bounded
Push partial failurePer-operation results; client retries only conflict/pending; applied ops are removed from the queue
Long offline (> 30 days)Pull issues a fresh window; cleared local state for reservations now outside scope; modifications from that window remain in the cloud audit
Operator on two devicesEach device pushes independently; server arbitration via OCC; the second device sees STALE_VERSION and reconciles
Status drift (offline check-in vs server cancellation)Server arbitration rejects with MELMASTOON.RESERVATION.ILLEGAL_TRANSITION; UI surfaces "Reservation was cancelled while offline" with a guided remediation

9. Cross-references