Skip to main content

SYNC_CONTRACT — inventory-service

Sibling: APPLICATION_LOGIC · DATA_MODEL · SECURITY_MODEL · FAILURE_MODES

Strategic anchors: 02 §8 Sync & Offline Architecture · ADR-0003 Electron Offline-First Desktop

The Electron desktop ships at every property and must continue working when the cloud is unreachable. For inventory, that means the front desk needs to (a) see which rooms are sellable today and over the next 30 days even offline, and (b) be able to walk-in-allocate a room with an honest expectation that the cloud will accept the allocation when connectivity returns. Inventory is fundamentally a server-authoritative ledger — only the cloud may finally decide who gets a room — but a constrained client-initiated path is allowed for the walk-in flow with explicit server arbitration.


1. Replication scope

Aggregate / tableDirectionWindowConflict policy
room_type_inventory_daily (per property)pull onlytoday - 7 days to today + 30 daysserver_authoritative
availability_calendars (per property)pull onlysame windowserver_authoritative
inventory_blocks (per property)pull onlysame windowserver_authoritative
room_allocations for the propertypull onlysame windowserver_authoritative
Walk-in room_allocations created offlinepush (one-way, client → server)local creationserver_arbitration (see §3)
overbooking_policiespull onlyfullserver_authoritative

Other inventory aggregates (group holds, import jobs, materialized views) are not replicated.


2. Pull contract

2.1 Endpoint

GET /api/v1/inventory/properties/:propertyId/snapshot
?since=<RFC3339> // server-side high-water mark from sync-service
&windowDays=37 // 30 forward + 7 back; server enforces max 60

Called by sync-service (not the desktop directly). sync-service carries the device's auth and the per-tenant per-device cursor.

2.2 Response shape

{
"propertyId": "ppt_…",
"asOf": "2026-05-04T08:00:00Z",
"window": { "from": "2026-04-27", "to": "2026-06-03" },
"roomTypeInventory": [
{
"roomTypeId": "rty_…",
"perDay": [
{ "stayDate": "2026-05-04", "total": 12, "held": 1, "committed": 4, "oosBlocked": 0, "stopSell": false }
]
}
],
"blocks": [
{ "blockId": "blk_…", "roomId": "rom_…", "stayWindow": { "checkIn": "2026-05-10", "checkOut": "2026-05-13" }, "reason": "ooo", "status": "active" }
],
"allocations": [
{ "allocationId": "inv_…", "reservationId": "rsv_…", "roomTypeId": "rty_…", "roomId": "rom_…", "stayWindow": { "checkIn": "2026-05-04", "checkOut": "2026-05-07" }, "status": "committed" }
],
"overbookingPolicy": {
"enabled": false, "cap": 0, "roomTypeIds": [], "alertRoutes": []
},
"nextSince": "2026-05-04T08:00:00Z"
}

2.3 Cadence

  • Live mode: sync-service polls every 60 s when online; pulls only since nextSince.
  • Re-sync after offline period: full window pulled in one call, capped at 60 days; if the gap exceeds 60 days, two consecutive paginated calls are used.
  • After every cloud-side inventory.allocation.confirmed.v1, …released.v1, …block.created.v1, …block.released.v1 for the property, sync-service schedules an immediate fan-out pull (event-coalesced 250 ms).

2.4 What the desktop stores locally (SQLite)

Tables mirror the server shape but with reduced indexes; primary keys identical so conflict detection is straightforward. Counters (held, committed, etc.) are read-only on the desktop except for the staged offline-walk-in case (§3).


3. Offline walk-in allocation — the special saga

The only client-initiated mutation tolerated for inventory is the walk-in case: a guest is at the desk, the cloud is unreachable, the staff member needs to assign a room now.

3.1 Local flow

  1. Operator opens the walk-in dialog. The desktop computes a candidate set from local room_type_inventory_daily (snapshot).
  2. Operator picks (or auto-picks via the same RoomPicker heuristic shipped to the desktop) a roomId and presses Confirm.
  3. The desktop:
    • Inserts a local room_allocations row with status='pending_offline', a client-generated inv_d_… id (the _d_ suffix marks "client-issued"), and mode='walk_in'.
    • Decrements local room_type_inventory_daily counters as if the allocation had succeeded; this is the tentative local hold. The local held counter for the row is incremented.
    • Records a pending_push row in sync-service's outbound queue with the full request body.
    • Surfaces the assignment in the UI marked "Pending sync" with a yellow badge.
  4. The reservation, also created locally with status='held' (reservation-service's offline path), references the pending allocation by client id.

3.2 Reconnect arbitration

When sync-service reconnects, it pushes pending offline walk-in allocations to:

POST /api/v1/inventory/allocations
X-Sync-Mode: offline_arbitration
X-Client-Allocation-Id: inv_d_…

Server-side arbitration:

Server outcomeServer responseDesktop reconciliation
Room still free for all nights201 Created with canonical inv_… id; allocation.confirmed.v1 (status=committed) emitteddesktop maps inv_d_…inv_…; allocation flips to committed; reservation flips from held to pending (awaiting payment confirmation if cash; or auto-confirmed if cash captured); UI badge clears
Room taken by a cloud-side reservation in the meantime409 MELMASTOON.INVENTORY.OFFLINE_ARBITRATION_LOST with body { alternativeRoomIds: [...] }desktop bumps the reservation to status pending_reassignment, clears the local allocation row, prompts operator with alternatives; if operator accepts an alternative, a new allocation push is queued; if the operator picks none, the reservation is canceled with reasonCode='offline_arbitration_lost'
Property has gone over the offline grace window (> 24 h)409 MELMASTOON.INVENTORY.OFFLINE_GRACE_EXPIREDdesktop forces operator review; allocation cannot auto-resolve; ops alert raised
Server already has an allocation with this X-Client-Allocation-Id (replayed push)200 OK with the canonical allocation (idempotent)desktop confirms mapping, no-op

The bump-to-pending behavior keeps the reservation alive (the guest is in front of the desk!) while making it clear that the room assignment must be redone. Front-desk staff resolve it within minutes.

3.3 Conflict ranking

If two desktops at the same property both took the same room offline (rare — same property, same room), the cloud uses a deterministic tiebreaker:

  1. The earliest client-side createdAt wins (signed by the device's monotonic clock, attested at boot).
  2. Tie → the lower inv_d_… ULID wins (lexicographic).
  3. Tie → server picks the device with the longer continuous online history.

The loser receives OFFLINE_ARBITRATION_LOST.

3.4 What the desktop is not allowed to do

  • It must not finalize a committed allocation locally; only pending_offline is permitted.
  • It must not touch room_allocations.released_* fields.
  • It must not modify inventory_blocks (block creation requires cloud).
  • It must not modify the overbooking policy.
  • It must not allocate beyond total + overbooking_cap even locally; the local validator enforces the same CHECK constraints as the cloud.

4. Conflict policy summary

Field setPolicyRationale
Counters (held, committed, oos_blocked, total)server_authoritativeInventory is global truth
RoomAllocation lifecycle (status, committedAt, releasedAt, releaseReasonCode)server_authoritativeOnly the cloud commits
RoomAllocation.notes (operator-visible)lwwClerical; no money/state implication
InventoryBlock lifecycleserver_authoritativeOperator action; pull-only on desktop
Walk-in offline room_allocations (status='pending_offline')server_arbitration per §3The one allowed offline write
OverbookingPolicyserver_authoritativeTenant policy, not local

5. Desktop UX implications

  • The funnel search view shows availability "as of "; if the snapshot is > 5 minutes old, a "Cloud unreachable — figures may be stale" banner shows.
  • The walk-in dialog disables the Confirm button when any of:
    • The local snapshot for that day is > 24 h old.
    • The room is already in a local pending_offline allocation.
    • OverbookingPolicy.enabled=false and local counters say available=0.
  • The reservation card shows "Pending room arbitration" while the desktop awaits the cloud's verdict on a pending offline walk-in.
  • Operators see a counter on the sidebar: "N pending offline allocations" until the queue drains.

6. Telemetry on sync paths

sync-service emits per-pull metrics (latency, payload size, dedupe hit rate) and per-push metrics (success / OFFLINE_ARBITRATION_LOST / OFFLINE_GRACE_EXPIRED). Inventory exposes:

  • inventory_offline_allocations_pushed_total{outcome} counter.
  • inventory_offline_arbitration_lost_total counter.
  • inventory_snapshot_pull_latency_ms histogram.

Alert RESV-201 fires if OFFLINE_ARBITRATION_LOST rate exceeds 1% over 24 h for any tenant — that's a sign that the desktop horizon is misconfigured or that OverbookingPolicy.cap is too low.


7. Cross-references