Skip to main content

SYNC_CONTRACT — staff-service

Sibling: DATA_MODEL · APPLICATION_LOGIC · DOMAIN_MODEL

Strategic anchors: 02 §8 Sync & Offline · 05 §10 Sync API · ADR-0003 Electron Offline-First

The Electron front-desk desktop is the most critical consumer of staff-service data outside the cloud: every clock-in, capacity check, and hand-off note is read or written from it, and clock-in is the single most-likely write to happen during a connectivity outage. This document declares per-aggregate replication scope, conflict policies, and push/pull semantics that sync-service enforces on our behalf.

The desktop never talks to staff-service directly through the sync surface — it talks to bff-backoffice-service, which delegates to sync-service, which calls our REST and consumes our events. We declare what is replicable and how conflicts resolve; the sync engine implements the transport.


1. Replicated Aggregates and Scope

AggregateReplicated?Scope (per device)
Staffyes (subset)All active, on_leave, pending_invite for property; PII fields (email, phoneE164, emergencyContact) omitted unless device entitlement carries staff.read_pii
PositionyesAll tenant positions; small catalog
DepartmentyesAll departments at the device's property
ShiftPatternyesAll active patterns at the device's property
ShiftyesAll scheduled and in_progress for property; plus completed from last 14 days
ShiftAssignmentyesAlways with parent Shift
ClockEntryyes (append-only)All entries for last 14 days at the device's property
LeaveRequestyesAll requested and approved whose windowLocal.to ≥ today − 7 d
StaffCertificationyesIndex only (type, expires_at, staffId); document_ref opened on demand
HandoffNoteyes (append-only)Last 30 days at device's property/position
ShiftSuggestionyes (advisory)Last 14 days at device's property
outbox / inbox_processednoServer-internal

A walk-in clock-in (PIN-mode, no scheduled shift) does not require a pre-existing replicated Shift; the desktop pushes a fresh ClockEntry with shiftId=null and the server materializes the unscheduled-clock-in flow.


2. Conflict Policy per Field-Class

The sync engine evaluates one policy per logical field-class, not per database column.

Field classPolicyRationale
Staff.employmentStatus and any state-machine field (employmentEndedAt, clockInPinLockedUntil)server_authoritativeStatus transitions are saga/cascade-driven
Staff.clockInPinHmac, clockInPinSetAtserver_authoritativeCrypto material is centrally managed
Staff.spokenLanguages, Staff.skillIds, displayName, preferredLocale, phoneE164lww by updatedAtProfile attributes — last writer wins
Staff.propertyAccess (set membership)set_union with server arbitration on removeRemovals require server confirm to avoid lost grants
Position.label, Department.label (i18n bundles)lww+merge per locale keyDifferent translators editing different locales should not collide
ShiftPattern.*server_authoritativeScheduling templates are tenant-admin only; desktop reads only
Shift.status, Shift.startedAt, Shift.endedAt, Shift.cancelledAtserver_authoritativeState machine; the cloud is the only authority
Shift.noteslww+diff by updatedAtFree-text
ShiftAssignment (creation, role, unassign)server_authoritativeConflict detection (ConflictEvaluator) cannot be replicated
ClockEntry (any kind)append_only (CRDT-G-Set keyed by (staff_id, occurred_at_utc, kind))Audit; never lost, never rewritten
LeaveRequest.status, decidedBy, decidedAtserver_authoritativeApproval is a manager+ action; desktop reads decisions only
LeaveRequest (creation by staff)server_authoritative after first server-confirmed saveDesktop may submit pre-sync; server assigns ID + canonical timestamps
HandoffNote (creation)append_onlyNotes never deleted from the desktop
HandoffNote.acknowledgedBy[]set_unionMultiple staff may ack from different devices
StaffCertification.expiredEmittedAtserver_authoritativeTTL job is server-only
ShiftSuggestion (any field)server_authoritativeAdvisory, AI-produced, never client-mutable

There is no LWW for status transitions. Any push that attempts to advance a state machine in conflict with the server's view is sent to server arbitration: the server reloads the aggregate, runs the requested command through the use case (which re-checks the state machine + invariants), and either accepts or rejects with a typed error.


3. Pull Contract

The desktop calls the sync surface (proxied through bff-backoffice-service):

POST /sync/v1/pull HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: ten_…
X-Property-Id: ppt_…
X-Device-Id: dev_…
Content-Type: application/json

{
"since": "<opaque cursor or null on first sync>",
"aggregates": [
"staff", "position", "department", "shift_pattern",
"shift", "shift_assignment", "clock_entry",
"leave_request", "staff_certification", "handoff_note", "shift_suggestion"
],
"maxBatch": 500,
"scopes": {
"shift": { "windowDaysFuture": 30, "windowDaysPast": 14 },
"clock_entry": { "windowDaysPast": 14 },
"handoff_note": { "windowDaysPast": 30 }
}
}

Response (per 05 §10):

{
"cursor": "<next opaque cursor>",
"hasMore": false,
"changes": {
"staff": [
{ "op": "upsert", "id": "stf_01HZ…", "version": 7, "data": { /* Staff DTO sans PII */ } }
],
"shift": [
{ "op": "upsert", "id": "shf_01HZ…", "version": 3, "data": { /* Shift DTO */ } }
],
"clock_entry": [
{ "op": "append", "id": "clk_01HZ…", "data": { /* ClockEntry */ } }
],
"handoff_note": [
{ "op": "append", "id": "hno_01HZ…", "data": { /* HandoffNote */ } }
]
}
}
  • The cursor encodes (updated_at, id) watermarks per aggregate; replays of the same cursor are idempotent.
  • The server includes version on every record; the client persists it for the next push.
  • Server may insert synthetic op=delete for shifts that fell out of the device's window — this is local cleanup, not a real deletion.
  • PII-omission is performed at the BFF layer based on device entitlement and is not recoverable client-side.

4. Push Contract

The desktop pushes batched mutations whenever it has connectivity:

POST /sync/v1/push HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: ten_…
X-Property-Id: ppt_…
X-Device-Id: dev_…
Idempotency-Key: <ULID>
Content-Type: application/json

{
"ops": [
{
"kind": "append",
"aggregate": "clock_entry",
"clientId": "clk_local_01HZQ…",
"data": {
"tenantId": "ten_01HZ…",
"staffId": "stf_01HZ…",
"propertyId": "ppt_01HZ…",
"shiftId": "shf_01HZ…",
"kind": "in",
"occurredAtUtc": "2026-04-23T05:58:11Z",
"source": "offline_replay",
"deviceId": "dev_01HZ…",
"offlineQueueAgeSeconds": 11842,
"pinHmacChallenge": "<opaque base64; verified at server>"
}
},
{
"kind": "create",
"aggregate": "leave_request",
"clientId": "lvr_local_01HZQ…",
"data": { "staffId": "stf_01HZ…", "type": "sick", "windowLocal": { "from": "2026-04-23", "to": "2026-04-24" }, "reason": "Fever" }
},
{
"kind": "create",
"aggregate": "handoff_note",
"clientId": "hno_local_01HZQ…",
"data": { "propertyId": "ppt_01HZ…", "positionId": "pos_01HZ…", "fromStaffId": "stf_01HZ…", "fromShiftId": "shf_01HZ…", "body": "Room 304 AC noisy" }
}
]
}

Response 200

{
"results": [
{ "clientId": "clk_local_01HZQ…", "status": "accepted", "serverId": "clk_01HZ…", "version": 1 },
{ "clientId": "lvr_local_01HZQ…", "status": "accepted", "serverId": "lvr_01HZ…", "version": 1 },
{ "clientId": "hno_local_01HZQ…", "status": "accepted", "serverId": "hno_01HZ…", "version": 1 }
]
}

If any op is rejected, its result entry is { status: "rejected", code: "MELMASTOON.STAFF.<...>", title: "...", retryable: false }. Rejected ops are not retried by the device; they are surfaced in the Electron review queue for the operator.


5. Per-Aggregate Push Semantics

AggregateAllowed push opsServer-arbitration on conflict
staffupdate (lww fields)OCC mismatch → server fetches latest, re-applies lww-diff if non-overlapping; else rejects with OCC_CONFLICT
positionnoneRead-only on device
departmentnoneRead-only on device
shift_patternnoneRead-only on device
shiftcancel (admin)Re-evaluate Shift.status; reject if in_progress
shift_assignmentcreate, unassignRe-run ConflictEvaluator; reject if double-shift / leave-collision
clock_entryappendDedup on (staff_id, occurred_at_utc, kind); reject duplicates as accepted (idempotent); rerun multi-property invariant; if violated → reject MULTI_PROPERTY_ACTIVE (rare since device is property-scoped, but possible across devices)
leave_requestcreateValidate window; assign server ID
handoff_notecreateAppend-only; no conflict possible
shift_suggestionnoneRead-only on device

6. Offline-Specific Rules for Clock-In

Clock-in is the most-likely-during-outage write and warrants explicit treatment:

  1. PIN verification when offline. The Electron app caches a per-staff pinHmacChallenge derivation: HMAC(deviceKey, staffId || pinHmac) — this is computed at last sync and stored locally. On offline punch, the device verifies the entered PIN against this challenge. The original PIN HMAC pepper is never shipped to the device. On push, the server re-verifies using the canonical pepper; mismatch → reject with PIN_INCORRECT.
  2. Out-of-window punch. If now − occurredAtUtc > 24 h at the time of push, the punch is accepted but flagged metadata.flagged_late_replay = true for operator review.
  3. Multi-device collision. Two devices at the same property could each punch-in the same staff while offline. Both punches are accepted on push, but the second creates an out auto-emitted by the server at occurredAtUtc + 1ms and emits a MELMASTOON.STAFF.MULTI_DEVICE_PUNCH_DETECTED audit event. Operators reconcile manually.
  4. Pre-shift punch (no scheduled shift). Allowed (since the unscheduled-clock-in flow exists). The server emits a staff.shift.staffing_gap_detected.v1 retroactively for the implied shift window.
  5. Time skew. The Electron app embeds its monotonic clock plus the last NTP-synced offset; the server tolerates ±5 minutes skew on occurredAtUtc against recordedAtUtc. Skew > 5 min → punch accepted with metadata.skew_seconds.

7. Per-Aggregate Versioning

Every replicable aggregate carries a monotonically increasing version integer. The push includes expectedVersion for update ops; the server compares to the persisted version, and on mismatch:

  • Server-authoritative fields touched → reject with OCC_CONFLICT and ship the latest version so the client can rebase.
  • Only LWW fields touched → server merges with lww by updatedAt and accepts; the response includes the new version.

For append-only aggregates (clock_entry, handoff_note), version is not used.


8. Retention on Device

AggregateLocal TTL after pullEviction trigger
staffindefinite while in scopeRemoved on op=delete from server (e.g., terminated past retention)
shift14 d past + 30 d futureServer op=delete for out-of-window
clock_entry14 dLocal prune nightly
handoff_note30 dLocal prune nightly
leave_requestuntil terminal + 7 dLocal prune
shift_suggestion14 dLocal prune

The Electron app uses SQLite WAL mode and runs VACUUM weekly. Local DB is encrypted at rest with a per-device DEK wrapped by OS keystore (per ADR-0003).


9. Compliance Gates

The device pull surface enforces:

  • X-Device-Id is a known, non-revoked device of the staff/manager session (revocation cascades from iam-service).
  • X-Property-Id is among the staff/manager's propertyAccess.
  • PII fields are scrubbed when device entitlement does not include staff.read_pii (most front-desk PIN clocks are PII-scrubbed).
  • A device DSAR or revocation triggers a server-issued op=delete for every aggregate at next pull, and the device wipes local state.

10. Reconciliation Job

A nightly reconciliation job (pnpm -F staff-service sync:reconcile) compares server version and last-known-pulled version per device for staff, shift, and shift_assignment. Devices > 24 h behind get a notification-service push to encourage a sync; > 7 d behind get an alert to the property GM. The job is idempotent and bounded.