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
| Aggregate | Replicated? | Scope (per device) |
|---|---|---|
Reservation | yes | All 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 |
ReservationItem | yes | Always with parent Reservation |
Guest (primary) | yes (subset) | Only guests referenced by replicated reservations; PII fields decrypted at the bff layer per device entitlement |
AdditionalGuest | yes | Always with parent Reservation |
SpecialRequest | yes | Always with parent Reservation |
ReservationModification | yes (append-only) | Last 30 modifications per replicated reservation |
ReservationHold | no | Holds are short-lived and saga-coupled; desktop reads them through ephemeral REST only |
outbox / inbox_processed | no | Server-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 class | Policy | Rationale |
|---|---|---|
Reservation.status and any state-machine field (pending_saga_step, confirmed_at, checked_in_at, checked_out_at, cancelled_at) | server_authoritative | Status transitions are saga-driven and emit events; the cloud is the only authority |
Reservation.totals.*, payment.*, fxSnapshot, payment_intent_ids, key_credential_ids, folio_id | server_authoritative | Money and external-system references are owned by their authoritative services |
Reservation.notes, Reservation.specialRequests[*].freeText, Guest.preferences (additive tags) | lww+diff by updatedAt | Free-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_only | Adding a request must not conflict; updates to fulfilment use server_authoritative |
Reservation.specialRequests[*].fulfilled and fulfilled_at | max-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 save | The desktop may add guests pre-sync; once the server assigns IDs and persists, the server wins |
Reservation.requiresManualKey | max-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
versionon every record; the client persists it for the next push. - Server may insert synthetic
op=deletefor 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
| Command | Use case backed by |
|---|---|
walk_in | WalkInBookingUseCase |
check_in | StartCheckInUseCase (sync from desktop) |
complete_check_in | CompleteCheckInUseCase |
check_out | StartCheckOutUseCase |
complete_check_out | CompleteCheckOutUseCase |
modify.* (sub-types) | ModifyReservationUseCase |
cancel | CancelReservationUseCase |
record_no_show | RecordNoShowUseCase |
record_early_checkout | RecordEarlyCheckoutUseCase |
add_special_request | AddSpecialRequestUseCase |
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_dmarker tells the server "client-issued"); the server replaces it with the canonicalrsv_ID and returns the mapping in the push result. The desktop SQLite remaps every reference by theopIdcorrelation. - A reservation in
heldstate 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
occurredAton persistence, but preserves the device clock asclientOccurredAtin 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_modificationsfrom the central audit; the cloud always keeps them.
8. Failure & recovery
| Scenario | Behavior |
|---|---|
| Cursor lost (device wipe) | Full sync from since=null; window scope keeps payload bounded |
| Push partial failure | Per-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 devices | Each 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
- Sync API surface: 05 §10
- Conflict policy taxonomy: 02 §8.2
- Desktop spec: 12 Desktop Spec
- ADR: ADR-0003 Electron Offline-First