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/pushwith cursor + per-aggregate conflict policy. Aligned with the global pattern indocs/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_eventswithcause = "sync_conflict".
1. Replicated aggregates
| Aggregate | Scope replicated | Volume target | Reasoning |
|---|---|---|---|
HousekeepingTask | last 30 days for the user's properties (status ≠ archived) | ~3k–10k rows | Board + recent history for trend & rework |
RoomStatus | all rooms for the user's properties | ~50–500 rows | Board paint |
RoomBlock | open blocks for the user's properties | low | Visual block markers on board |
CleaningChecklist | latest version per (tenant, kind) | low | Bind at task assignment when offline |
Inspection | last 30 days | low | Board history |
LinenInventory | per property | low | Issuance during offline cleaning |
LostAndFound | open items + items closed in the last 30 days | low | Capture/match offline |
StaffShiftAssignment | active + next-24h shifts | low | Router + 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
| Field | Policy | Notes |
|---|---|---|
status | server_authoritative | Lifecycle is canonical; conflicting transitions resolve via the server's state machine. |
assigneeStaffId | lww+diff | Drag-drop reassignments race; later wins. Audit row recorded. |
priority | max-of | Two supervisors bumping concurrently → urgent wins. |
pause_reason, paused_at | lww+diff | |
outcome_results (checklist) | append_only on itemKey | Two cleaners checking different items both kept; same itemKey → server's value wins, client's discarded. |
linen_issued, linen_returned | server_authoritative | Recomputed from linen_movements. |
note | client_wins_if_newer | Free-text; rare conflicts. |
version | server-controlled | Optimistic concurrency on push. |
2.2 RoomStatus
| Field | Policy |
|---|---|
status | server_authoritative — applied via the state machine, not blindly overwritten. |
last_flipped_at, last_flipped_by, last_cause | server_authoritative |
version | server-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_handis derived server-side. Client never pusheson_handdirectly.linen_movementsisappend_only; the server replays them inoccurred_atorder to recomputeon_hand.
2.7 LostAndFound
Per field — description, photoMediaIds, storageLocation → lww+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
intent | Use case | Notes |
|---|---|---|
create | CreateTaskUseCase / RecordLostItemUseCase / BlockRoomUseCase | aggregate-dependent |
assign | AssignTaskUseCase | enforces shift gating |
start, pause, resume, complete, fail, cancel, escalate, bumpPriority, requireMaintenance | matching task use case | |
flipRoom | FlipRoomStatusUseCase (manual override) | requires elevated role |
clearBlock | UnblockRoomUseCase | |
match, dispose | MatchLostItemUseCase, DisposeLostItemUseCase | |
linen_issue, linen_return | IssueLinenUseCase, 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
| Scenario | Behaviour |
|---|---|
| Network outage | Local 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 supervisors | lww+diff on assigneeStaffId; both writes audited. |
Conflicting start on already-completed task | 409 INVALID_TRANSITION; renderer reverts. |
Duplicate op_id (replay) | Server detects via idempotency_keys and returns the original outcome. |
Stale baseVersion | 409 CONCURRENCY_CONFLICT; renderer pulls latest, presents 3-way diff for human resolution if unresolvable. |
| Linen overdraw | 422 LINEN_OUT_OF_STOCK; renderer shows blocking modal. |
8. Cross-link
- Global sync architecture:
docs/02-enterprise-architecture.md. - Conflict-policy rationale:
docs/architecture/ADR-0003-electron-offline-first-desktop.md. - Auth, RLS:
SECURITY_MODEL.md. - Use-case semantics:
APPLICATION_LOGIC.md.