maintenance-service · SYNC_CONTRACT
Contract between the cloud
maintenance-serviceand the Electron desktop app's local SQLite store, mediated bysync-service. Goal: technicians and front-office staff at properties with intermittent connectivity can read and act on work orders without the cloud being reachable, and reconcile cleanly when the link is restored.
1. Replication scope per device
A device is bound to one tenant (mandatory) and one or more properties (selected at install time). Replication scope:
| Aggregate | Scope replicated to device |
|---|---|
WorkOrder | All open / assigned / in_progress / blocked for bound properties + last 30 days closed (resolved / verified / cancelled) |
MaintenanceTask | All for replicated WOs |
PartUsage | All for replicated WOs |
PreventiveSchedule | All active=true for bound properties |
Asset | All active=true for bound properties |
Part | All active=true for bound properties |
Vendor | All active=true tenant-wide (small enough — typically < 200 per tenant) |
MaintenanceCategory | All tenant-wide |
outbox / inbox_processed / preventive_fires / idempotency_keys | Not replicated — server-side only |
Closed WOs older than 30 days are read-only via cloud fetch when the device is online; not held locally.
2. Per-aggregate conflict resolution policy
Following platform contract in
docs/02-enterprise-architecture.md§8.2.
| Aggregate | Field class | Policy |
|---|---|---|
WorkOrder | status (state machine) | server_authoritative — desktop transitions translate to commands on push; if server's current status + version make the command invalid, the local change is rejected and surfaced as a conflict for the technician |
WorkOrder | description, title, severity, category | lww+diff with last-writer-wins on field, plus a structured diff stored in conflict log |
WorkOrder | tasks[], partsUsed[], costLines[] | append_only — both sides' new entries merge; deletions on append-only fields require a server-acknowledged tombstone |
WorkOrder | vendorAcknowledgement | lww (single struct) |
WorkOrder | vendorInvoice | server_authoritative — desktop can submit; server posts to billing; canonical state comes back from server |
WorkOrder | reopenCount, version | server_authoritative |
Asset | runHours | max-of (monotonic counter; meter readings only ever go up) |
Asset | healthIndex | server_authoritative (AI-derived; desktop reads only) |
Asset | other fields | lww+diff |
Part | onHand | server_authoritative with optimistic local decrement on PartUsage record |
Part | other fields | lww+diff |
PreventiveSchedule | nextDueAt, lastFiredAt | server_authoritative |
PreventiveSchedule | other fields | lww+diff |
Vendor | all fields | lww+diff |
MaintenanceCategory | all fields | server_authoritative |
Conflict log entries are surfaced in a dedicated "Sync issues" view in the desktop app and emit a melmastoon.sync.conflict.detected.v1 event for telemetry.
3. Pull contract
GET /api/v1/sync/maintenance/pull (served by sync-service, reading from maintenance-service projection):
{
"cursor": "01HXYZ...", // opaque per-device cursor
"scope": { "tenantId": "tnt_x", "propertyIds": ["prop_a","prop_b"] },
"since": "2026-04-22T13:00:00Z", // last successful pull's serverNow
"include": ["work_orders","tasks","preventive_schedules","assets","parts","vendors","categories","part_usages"]
}
Response:
{
"cursor": "01HXYY...",
"serverNow": "2026-04-22T14:05:00Z",
"items": {
"work_orders": [ { "...": "..." } ],
"tasks": [ ... ],
"part_usages": [ ... ],
"preventive_schedules": [ ... ],
"assets": [ ... ],
"parts": [ ... ],
"vendors": [ ... ],
"categories": [ ... ]
},
"tombstones": {
"work_orders": ["mnt_old_..."], // out-of-scope (e.g. > 30 days closed)
"vendors": ["vnd_deactivated_..."]
},
"nextHintMinutes": 5
}
- Cursor is monotonic per-device, encoded as
(serverNow, lastSeenIdSet). - Pagination: each call returns at most 5,000 items per aggregate; client keeps polling until
cursorstops advancing. - Bandwidth: payload is gzip-compressed; per-aggregate diff (only changed columns since last pull) is supported via
Prefer: return=diffheader.
4. Push contract
POST /api/v1/sync/maintenance/push:
{
"deviceId": "dev_01HXY...",
"actor": { "userId": "usr_01HXY...", "rolesAtCommandTime": ["staff_supervisor"] },
"commands": [
{
"kind": "WorkOrder.Start",
"workOrderId": "mnt_01HXY...",
"expectedVersion": 4,
"occurredAtIso": "2026-04-22T14:02:11.000Z",
"deviceClock": "2026-04-22T14:02:11.000Z"
},
{
"kind": "WorkOrder.Resolve",
"workOrderId": "mnt_01HXY...",
"expectedVersion": 5,
"occurredAtIso": "2026-04-22T14:50:00.000Z",
"resolutionNote": "replaced filter",
"costLines": [ { "kind": "labor", "description": "1 staff × 30 min", "amount": { "currency": "AFN", "amountMicro": "50000000" }, "minutes": 30 } ],
"partsUsed": [ { "partId": "prt_01HXP...", "quantity": 1 } ]
}
]
}
Response:
{
"results": [
{ "ok": true, "workOrderId": "mnt_01HXY...", "newVersion": 5 },
{ "ok": false, "workOrderId": "mnt_01HXY...", "error": "MELMASTOON.SYS.OCC_CONFLICT", "currentVersion": 6, "currentStatus": "blocked" }
]
}
4.1 Accepted push commands
| Kind | Server effect |
|---|---|
WorkOrder.Create | Calls CreateWorkOrderUseCase |
WorkOrder.Update | PATCH narrow fields |
WorkOrder.Assign | AssignWorkOrderUseCase |
WorkOrder.Start | StartWorkOrderUseCase |
WorkOrder.Block | BlockWorkOrderUseCase |
WorkOrder.Resume | ResumeWorkOrderUseCase |
WorkOrder.Resolve | ResolveWorkOrderUseCase |
WorkOrder.Cancel | CancelWorkOrderUseCase |
WorkOrder.RecordVendorAck | RecordVendorAcknowledgementUseCase |
WorkOrder.RecordPartUsage | RecordPartUsageStandaloneUseCase |
Asset.RecordHealthUpdate | RecordAssetHealthUpdateUseCase (run-hours monotonic) |
Vendor.Create / Vendor.Update | Vendor CRUD |
Part.Create / Part.Update | Part CRUD |
PreventiveSchedule.TriggerNow | TriggerScheduleNowUseCase |
Commands Verify and RecordVendorInvoice are not pushable from desktop in v1 — they require GM/owner role and the file-upload pipeline; they always go through cloud REST.
4.2 OCC and conflicts
- Each command carries
expectedVersion. Server compares against current; on mismatch returnsMELMASTOON.SYS.OCC_CONFLICTwith the current version and status. - The desktop client logs the conflict, presents the latest server state, and asks the technician to re-attempt or discard.
- For append_only fields (tasks, partsUsed, costLines), conflicts never block: appended rows merge regardless of version.
4.3 Idempotency
- Each command carries a client-generated
commandId(ULID) embedded inX-Idempotency-Key. - Server stores
(deviceId, commandId)for 7 days; replays return the original result.
5. Push ordering and partial failure
- Server processes commands in array order per work_order but may parallelise across different work orders.
- On any failure, server stops processing for that
workOrderIdonly; other work orders' commands continue. - Client must re-fetch the affected WO and re-sequence.
6. Offline behaviour expectations
| Operation | Online | Offline |
|---|---|---|
| Create WO from staff | ✅ | ✅ (queued; server creates on push, may collapse with auto-created WO) |
| Assign to internal staff | ✅ | ✅ (queued) |
| Assign to vendor | ✅ | ⚠️ queued; vendor notification sent after sync |
| Start / Block / Resume | ✅ | ✅ (queued) |
| Resolve | ✅ | ✅ (queued; parts decrement applied optimistically locally; server reconciles Part.onHand) |
| Verify | ✅ | ❌ (cloud-only; surfaced as "needs sync") |
| Record vendor invoice | ✅ | ❌ (cloud-only; file upload requires connectivity) |
| Read open WO list | ✅ | ✅ |
| Read closed WOs > 30 days old | ✅ | ❌ |
| Trigger preventive now | ✅ | ⚠️ queued; server may dedupe with cron firing |
7. Time and timezone
- Device clock and
serverNoware exchanged on every pull; the client computes aclockSkewMsand warns if > 5 min. - All persisted timestamps are UTC ISO-8601. Display layer renders in tenant
timeZone. cadence.tzonPreventiveSchedulealways wins for next-due computation.
8. Deletion and tombstones
- Hard-delete is rare (only
Vendor,Part,Assetdeactivation; tasks are cancelled, not deleted). - Tombstones are emitted in the pull response for items that left scope (closed > 30 days, deactivated entities). Client removes them from local store.
9. Scope changes
When a property is added/removed from a device's bound properties, the next pull returns:
{
"scopeChanged": true,
"removedPropertyIds": ["prop_x"],
"addedPropertyIds": ["prop_y"]
}
Client wipes local rows belonging to removed properties and triggers a full backfill for added properties.
10. Telemetry
Each pull/push records:
sync.maintenance.pull.duration_mssync.maintenance.pull.bytes_compressed/_uncompressedsync.maintenance.push.commands_total/_failed_total(labels:error_code)sync.maintenance.conflict.detected_total(labels:aggregate,field_class)sync.maintenance.clock_skew_secondshistogram
Sent to SigNoz via OTel; alerted on push failure rate > 2% over 5 min.