Skip to main content

housekeeping-service — SYNC_CONTRACT

Replication target: Electron desktop (offline-first backoffice). Local store: SQLite (encrypted at rest with OS keychain). Protocol: /sync/v1/pull + /sync/v1/push with cursor + per-aggregate conflict policy. Aligned with the global pattern in docs/02-enterprise-architecture.md §8.

The desktop housekeeping board is the primary surface for housekeeping. Cleaners and supervisors update tasks on the shared desktop; the wire is intermittent (laundry rooms, basements). Sync must be:

  • Eventually consistent, with declared per-field conflict semantics (no surprise overrides).
  • Forward-progressing — even if the network is down for hours, status flips and task completions accumulate locally and replay.
  • Auditable — every server-side conflict resolution writes a row to audit_events with cause = "sync_conflict".

1. Replicated aggregates

AggregateScope replicatedVolume targetReasoning
HousekeepingTasklast 30 days for the user's properties (status ≠ archived)~3k–10k rowsBoard + recent history for trend & rework
RoomStatusall rooms for the user's properties~50–500 rowsBoard paint
RoomBlockopen blocks for the user's propertieslowVisual block markers on board
CleaningChecklistlatest version per (tenant, kind)lowBind at task assignment when offline
Inspectionlast 30 dayslowBoard history
LinenInventoryper propertylowIssuance during offline cleaning
LostAndFoundopen items + items closed in the last 30 dayslowCapture/match offline
StaffShiftAssignmentactive + next-24h shiftslowRouter + drag-drop validation

Not replicated: outbox, inbox, idempotency_keys, audit_events. Not replicated cross-property: tasks/blocks/etc. for properties the user has no role on.

2. Conflict resolution policy

Per-aggregate, per-field. Categories from the architecture standard:

  • server_authoritative — server value wins on conflict; client local edit discarded with a toast.
  • lww+diff — last-write-wins by updated_at; we additionally diff and audit when client write is older but contains a non-conflicting field.
  • append_only — both sides keep their writes (additive collections).
  • max-of — we take the higher value (e.g., priority bumps).
  • client_wins_if_newer — client write wins if its monotonic logical clock > server's (used only for non-canonical fields).

2.1 HousekeepingTask

FieldPolicyNotes
statusserver_authoritativeLifecycle is canonical; conflicting transitions resolve via the server's state machine.
assigneeStaffIdlww+diffDrag-drop reassignments race; later wins. Audit row recorded.
prioritymax-ofTwo supervisors bumping concurrently → urgent wins.
pause_reason, paused_atlww+diff
outcome_results (checklist)append_only on itemKeyTwo cleaners checking different items both kept; same itemKey → server's value wins, client's discarded.
linen_issued, linen_returnedserver_authoritativeRecomputed from linen_movements.
noteclient_wins_if_newerFree-text; rare conflicts.
versionserver-controlledOptimistic concurrency on push.

2.2 RoomStatus

FieldPolicy
statusserver_authoritative — applied via the state machine, not blindly overwritten.
last_flipped_at, last_flipped_by, last_causeserver_authoritative
versionserver-controlled

If client pushes a flip the server can't accept (illegal transition), the server returns 409 with MELMASTOON.HOUSEKEEPING.ROOM_STATE_CONFLICT; the client surfaces a toast and reverts to the server's current row.

2.3 RoomBlock

append_only for new blocks. Clearing a block is lww+diff on cleared_at.

2.4 CleaningChecklist

Read-only on the desktop. Pushes for new versions reject with MELMASTOON.HOUSEKEEPING.CHECKLIST_FROZEN.

2.5 Inspection

append_only. New inspections always added; once recorded, never updated.

2.6 LinenInventory / linen_movements

  • LinenInventory.on_hand is derived server-side. Client never pushes on_hand directly.
  • linen_movements is append_only; the server replays them in occurred_at order to recompute on_hand.

2.7 LostAndFound

Per field — description, photoMediaIds, storageLocationlww+diff. status transitions → server_authoritative via the state machine.

2.8 StaffShiftAssignment

Read-only on the desktop. Server-projected from staff-service.

3. Pull contract

POST /sync/v1/pull

{
"since": "cur_01J0ABC…", // opaque cursor; null on first pull
"scope": {
"tenantId": "tnt_…",
"propertyIds": ["prp_…","prp_…"]
},
"aggregates": ["HousekeepingTask","RoomStatus","RoomBlock","CleaningChecklist",
"Inspection","LinenInventory","LostAndFound","StaffShiftAssignment"],
"limit": 1000
}

Response:

{
"cursor": "cur_01J0ABD…",
"hasMore": false,
"snapshots": {
"HousekeepingTask": [ /* full or delta rows */ ],
"RoomStatus": [],
"…": []
},
"tombstones": {
"HousekeepingTask": ["hkt_…"]
},
"serverTimeUtc": "2026-04-22T12:00:00Z"
}

Cursors are opaque ULIDs encoding (tenantId, max(updated_at)_per_aggregate). Delta-encoded after the first pull. Snapshots include version.

4. Push contract

POST /sync/v1/push

{
"tenantId":"tnt_…",
"operations": [
{
"opId":"op_01J…", // ULID; idempotency
"aggregate":"HousekeepingTask",
"id":"hkt_…",
"baseVersion": 4,
"patch": {
"status":"in_progress",
"started_at":"2026-04-22T11:42:01Z"
},
"intent":"start" // map to use-case
},
{
"opId":"op_01J…",
"aggregate":"LostAndFound",
"id":"laf_…",
"baseVersion": 0, // 0 = create
"patch": { "description":"Black umbrella","roomId":"rom_…",},
"intent":"record"
}
]
}

Response:

{
"results": [
{ "opId":"op_…","outcome":"applied","newVersion":5 },
{ "opId":"op_…","outcome":"conflict","reason":"MELMASTOON.HOUSEKEEPING.INVALID_TRANSITION",
"serverState": { "id":"hkt_…","status":"completed","version":7 } }
],
"serverTimeUtc":"2026-04-22T12:00:01Z"
}

Outcomes: applied, conflict, rejected (auth, validation), deferred (server busy; client retries with backoff).

5. Operation → use-case mapping

intentUse caseNotes
createCreateTaskUseCase / RecordLostItemUseCase / BlockRoomUseCaseaggregate-dependent
assignAssignTaskUseCaseenforces shift gating
start, pause, resume, complete, fail, cancel, escalate, bumpPriority, requireMaintenancematching task use case
flipRoomFlipRoomStatusUseCase (manual override)requires elevated role
clearBlockUnblockRoomUseCase
match, disposeMatchLostItemUseCase, DisposeLostItemUseCase
linen_issue, linen_returnIssueLinenUseCase, ReturnLinenUseCase

The push pipeline routes each operation to its use case under the same auth, audit, outbox, and idempotency rules as the REST API. There is no "raw write" sync path.

6. Local SQLite schema (renderer-side, illustrative)

Mirrors the server tables minus outbox, inbox, idempotency_keys, audit_events. Adds:

  • _sync_cursor (aggregate TEXT, cursor TEXT)
  • _pending_ops (op_id TEXT PRIMARY KEY, aggregate TEXT, id TEXT, intent TEXT, patch JSON, created_at INTEGER, attempts INTEGER, last_error TEXT)
  • _replay_log (...) for forensic debugging.

7. Failure modes

ScenarioBehaviour
Network outageLocal writes accumulate in _pending_ops; push retries with exponential backoff (1s → 60s; capped).
Long outage (> 24 h)Pull window may exceed snapshot validity; client falls back to full re-sync if since cursor is invalid (server returns 410 SYNC_CURSOR_EXPIRED).
Conflicting reassignment by two supervisorslww+diff on assigneeStaffId; both writes audited.
Conflicting start on already-completed task409 INVALID_TRANSITION; renderer reverts.
Duplicate op_id (replay)Server detects via idempotency_keys and returns the original outcome.
Stale baseVersion409 CONCURRENCY_CONFLICT; renderer pulls latest, presents 3-way diff for human resolution if unresolvable.
Linen overdraw422 LINEN_OUT_OF_STOCK; renderer shows blocking modal.