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
| Aggregate | Replicated? | Scope (per device) |
|---|---|---|
Staff | yes (subset) | All active, on_leave, pending_invite for property; PII fields (email, phoneE164, emergencyContact) omitted unless device entitlement carries staff.read_pii |
Position | yes | All tenant positions; small catalog |
Department | yes | All departments at the device's property |
ShiftPattern | yes | All active patterns at the device's property |
Shift | yes | All scheduled and in_progress for property; plus completed from last 14 days |
ShiftAssignment | yes | Always with parent Shift |
ClockEntry | yes (append-only) | All entries for last 14 days at the device's property |
LeaveRequest | yes | All requested and approved whose windowLocal.to ≥ today − 7 d |
StaffCertification | yes | Index only (type, expires_at, staffId); document_ref opened on demand |
HandoffNote | yes (append-only) | Last 30 days at device's property/position |
ShiftSuggestion | yes (advisory) | Last 14 days at device's property |
outbox / inbox_processed | no | Server-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 class | Policy | Rationale |
|---|---|---|
Staff.employmentStatus and any state-machine field (employmentEndedAt, clockInPinLockedUntil) | server_authoritative | Status transitions are saga/cascade-driven |
Staff.clockInPinHmac, clockInPinSetAt | server_authoritative | Crypto material is centrally managed |
Staff.spokenLanguages, Staff.skillIds, displayName, preferredLocale, phoneE164 | lww by updatedAt | Profile attributes — last writer wins |
Staff.propertyAccess (set membership) | set_union with server arbitration on remove | Removals require server confirm to avoid lost grants |
Position.label, Department.label (i18n bundles) | lww+merge per locale key | Different translators editing different locales should not collide |
ShiftPattern.* | server_authoritative | Scheduling templates are tenant-admin only; desktop reads only |
Shift.status, Shift.startedAt, Shift.endedAt, Shift.cancelledAt | server_authoritative | State machine; the cloud is the only authority |
Shift.notes | lww+diff by updatedAt | Free-text |
ShiftAssignment (creation, role, unassign) | server_authoritative | Conflict 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, decidedAt | server_authoritative | Approval is a manager+ action; desktop reads decisions only |
LeaveRequest (creation by staff) | server_authoritative after first server-confirmed save | Desktop may submit pre-sync; server assigns ID + canonical timestamps |
HandoffNote (creation) | append_only | Notes never deleted from the desktop |
HandoffNote.acknowledgedBy[] | set_union | Multiple staff may ack from different devices |
StaffCertification.expiredEmittedAt | server_authoritative | TTL job is server-only |
ShiftSuggestion (any field) | server_authoritative | Advisory, 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
versionon every record; the client persists it for the next push. - Server may insert synthetic
op=deletefor 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
| Aggregate | Allowed push ops | Server-arbitration on conflict |
|---|---|---|
staff | update (lww fields) | OCC mismatch → server fetches latest, re-applies lww-diff if non-overlapping; else rejects with OCC_CONFLICT |
position | none | Read-only on device |
department | none | Read-only on device |
shift_pattern | none | Read-only on device |
shift | cancel (admin) | Re-evaluate Shift.status; reject if in_progress |
shift_assignment | create, unassign | Re-run ConflictEvaluator; reject if double-shift / leave-collision |
clock_entry | append | Dedup 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_request | create | Validate window; assign server ID |
handoff_note | create | Append-only; no conflict possible |
shift_suggestion | none | Read-only on device |
6. Offline-Specific Rules for Clock-In
Clock-in is the most-likely-during-outage write and warrants explicit treatment:
- PIN verification when offline. The Electron app caches a per-staff
pinHmacChallengederivation: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 withPIN_INCORRECT. - Out-of-window punch. If
now − occurredAtUtc > 24 hat the time of push, the punch is accepted but flaggedmetadata.flagged_late_replay = truefor operator review. - 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
outauto-emitted by the server atoccurredAtUtc + 1msand emits aMELMASTOON.STAFF.MULTI_DEVICE_PUNCH_DETECTEDaudit event. Operators reconcile manually. - Pre-shift punch (no scheduled shift). Allowed (since the unscheduled-clock-in flow exists). The server emits a
staff.shift.staffing_gap_detected.v1retroactively for the implied shift window. - Time skew. The Electron app embeds its monotonic clock plus the last NTP-synced offset; the server tolerates ±5 minutes skew on
occurredAtUtcagainstrecordedAtUtc. Skew > 5 min → punch accepted withmetadata.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_CONFLICTand ship the latestversionso the client can rebase. - Only LWW fields touched → server merges with
lwwbyupdatedAtand accepts; the response includes the newversion.
For append-only aggregates (clock_entry, handoff_note), version is not used.
8. Retention on Device
| Aggregate | Local TTL after pull | Eviction trigger |
|---|---|---|
staff | indefinite while in scope | Removed on op=delete from server (e.g., terminated past retention) |
shift | 14 d past + 30 d future | Server op=delete for out-of-window |
clock_entry | 14 d | Local prune nightly |
handoff_note | 30 d | Local prune nightly |
leave_request | until terminal + 7 d | Local prune |
shift_suggestion | 14 d | Local 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-Idis a known, non-revoked device of the staff/manager session (revocation cascades fromiam-service).X-Property-Idis among the staff/manager'spropertyAccess.- 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=deletefor 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.