Skip to main content

maintenance-service · SYNC_CONTRACT

Contract between the cloud maintenance-service and the Electron desktop app's local SQLite store, mediated by sync-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:

AggregateScope replicated to device
WorkOrderAll open / assigned / in_progress / blocked for bound properties + last 30 days closed (resolved / verified / cancelled)
MaintenanceTaskAll for replicated WOs
PartUsageAll for replicated WOs
PreventiveScheduleAll active=true for bound properties
AssetAll active=true for bound properties
PartAll active=true for bound properties
VendorAll active=true tenant-wide (small enough — typically < 200 per tenant)
MaintenanceCategoryAll tenant-wide
outbox / inbox_processed / preventive_fires / idempotency_keysNot 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.

AggregateField classPolicy
WorkOrderstatus (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
WorkOrderdescription, title, severity, categorylww+diff with last-writer-wins on field, plus a structured diff stored in conflict log
WorkOrdertasks[], partsUsed[], costLines[]append_only — both sides' new entries merge; deletions on append-only fields require a server-acknowledged tombstone
WorkOrdervendorAcknowledgementlww (single struct)
WorkOrdervendorInvoiceserver_authoritative — desktop can submit; server posts to billing; canonical state comes back from server
WorkOrderreopenCount, versionserver_authoritative
AssetrunHoursmax-of (monotonic counter; meter readings only ever go up)
AssethealthIndexserver_authoritative (AI-derived; desktop reads only)
Assetother fieldslww+diff
PartonHandserver_authoritative with optimistic local decrement on PartUsage record
Partother fieldslww+diff
PreventiveSchedulenextDueAt, lastFiredAtserver_authoritative
PreventiveScheduleother fieldslww+diff
Vendorall fieldslww+diff
MaintenanceCategoryall fieldsserver_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 cursor stops advancing.
  • Bandwidth: payload is gzip-compressed; per-aggregate diff (only changed columns since last pull) is supported via Prefer: return=diff header.

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

KindServer effect
WorkOrder.CreateCalls CreateWorkOrderUseCase
WorkOrder.UpdatePATCH narrow fields
WorkOrder.AssignAssignWorkOrderUseCase
WorkOrder.StartStartWorkOrderUseCase
WorkOrder.BlockBlockWorkOrderUseCase
WorkOrder.ResumeResumeWorkOrderUseCase
WorkOrder.ResolveResolveWorkOrderUseCase
WorkOrder.CancelCancelWorkOrderUseCase
WorkOrder.RecordVendorAckRecordVendorAcknowledgementUseCase
WorkOrder.RecordPartUsageRecordPartUsageStandaloneUseCase
Asset.RecordHealthUpdateRecordAssetHealthUpdateUseCase (run-hours monotonic)
Vendor.Create / Vendor.UpdateVendor CRUD
Part.Create / Part.UpdatePart CRUD
PreventiveSchedule.TriggerNowTriggerScheduleNowUseCase

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 returns MELMASTOON.SYS.OCC_CONFLICT with 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 in X-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 workOrderId only; other work orders' commands continue.
  • Client must re-fetch the affected WO and re-sequence.

6. Offline behaviour expectations

OperationOnlineOffline
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 serverNow are exchanged on every pull; the client computes a clockSkewMs and warns if > 5 min.
  • All persisted timestamps are UTC ISO-8601. Display layer renders in tenant timeZone.
  • cadence.tz on PreventiveSchedule always wins for next-due computation.

8. Deletion and tombstones

  • Hard-delete is rare (only Vendor, Part, Asset deactivation; 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_ms
  • sync.maintenance.pull.bytes_compressed / _uncompressed
  • sync.maintenance.push.commands_total / _failed_total (labels: error_code)
  • sync.maintenance.conflict.detected_total (labels: aggregate, field_class)
  • sync.maintenance.clock_skew_seconds histogram

Sent to SigNoz via OTel; alerted on push failure rate > 2% over 5 min.