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 / table | Direction | Window | Conflict policy |
|---|---|---|---|
room_type_inventory_daily (per property) | pull only | today - 7 days to today + 30 days | server_authoritative |
availability_calendars (per property) | pull only | same window | server_authoritative |
inventory_blocks (per property) | pull only | same window | server_authoritative |
room_allocations for the property | pull only | same window | server_authoritative |
Walk-in room_allocations created offline | push (one-way, client → server) | local creation | server_arbitration (see §3) |
overbooking_policies | pull only | full | server_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-servicepolls every 60 s when online; pulls only sincenextSince. - 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.v1for the property,sync-serviceschedules 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
- Operator opens the walk-in dialog. The desktop computes a candidate set from local
room_type_inventory_daily(snapshot). - Operator picks (or auto-picks via the same
RoomPickerheuristic shipped to the desktop) aroomIdand presses Confirm. - The desktop:
- Inserts a local
room_allocationsrow withstatus='pending_offline', a client-generatedinv_d_…id (the_d_suffix marks "client-issued"), andmode='walk_in'. - Decrements local
room_type_inventory_dailycounters as if the allocation had succeeded; this is the tentative local hold. The localheldcounter for the row is incremented. - Records a
pending_pushrow insync-service's outbound queue with the full request body. - Surfaces the assignment in the UI marked "Pending sync" with a yellow badge.
- Inserts a local
- 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 outcome | Server response | Desktop reconciliation |
|---|---|---|
| Room still free for all nights | 201 Created with canonical inv_… id; allocation.confirmed.v1 (status=committed) emitted | desktop 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 meantime | 409 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_EXPIRED | desktop 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:
- The earliest client-side
createdAtwins (signed by the device's monotonic clock, attested at boot). - Tie → the lower
inv_d_…ULID wins (lexicographic). - 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
committedallocation locally; onlypending_offlineis 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_capeven locally; the local validator enforces the sameCHECKconstraints as the cloud.
4. Conflict policy summary
| Field set | Policy | Rationale |
|---|---|---|
Counters (held, committed, oos_blocked, total) | server_authoritative | Inventory is global truth |
RoomAllocation lifecycle (status, committedAt, releasedAt, releaseReasonCode) | server_authoritative | Only the cloud commits |
RoomAllocation.notes (operator-visible) | lww | Clerical; no money/state implication |
InventoryBlock lifecycle | server_authoritative | Operator action; pull-only on desktop |
Walk-in offline room_allocations (status='pending_offline') | server_arbitration per §3 | The one allowed offline write |
OverbookingPolicy | server_authoritative | Tenant 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_offlineallocation. OverbookingPolicy.enabled=falseand local counters sayavailable=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_totalcounter.inventory_snapshot_pull_latency_mshistogram.
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
- Sync architecture: 02 §8, ADR-0003
- Reservation counterpart (the guest record): reservation-service SYNC_CONTRACT
- Use cases involved: APPLICATION_LOGIC §2.8 Walk-in
- Failure handling on lost arbitration: FAILURE_MODES F18