HOCO B2B
SRS

Offline sync design

The server side of offline sync for the delegate app: what works offline, client ids, idempotency, occurredAt rules, batch replay, the conflict catalogue, the delta feed and its tombstones, photos, location pings, security, observability and test scenarios.

The delegate app works offline-first (ADR-010): cahier §7.3 requires it to keep « une tournée, des visites, notes et photos en cas de réseau faible », with a sync queue that handles « les reprises, doublons, erreurs et conflits » and a visible state synchronisé, en attente or en erreur (SYN-03–SYN-06). This page is the server side: how the API makes replay safe, how it detects and reports conflicts, and how it feeds the device with changes.

  • The contract — request and response shapes, result classes, examples — is on Offline sync & location (sync.batch, sync.delta, sync.locationPings).
  • The client side — local database, outbox states, retry scheduler, sync centre UX — is on mobile offline sync.
  • Built in SRV-23 (server) and MOB-09 (client); proven in the field test MOB-13.

Principles

  1. One implementation per action. A replayed item runs exactly the procedure the direct call runs — same permission and scope checks, validation, guards, transaction and events. The batch is a dispatcher, never a second code path.
  2. The key is born with the action. The app assigns the Idempotency-Key when the delegate taps, not when it sends; the direct attempt and every replay share it, so a lost response never duplicates anything.
  3. Two times, both kept. occurredAt is when the delegate acted (device clock, corrected by the server offset); receivedAt is when the server got it. Business facts use occurredAt; plausibility bounds it.
  4. Records born offline have client ids. The server upserts on them, so a creation replayed after a lost response is a no-op.
  5. The server's verdict is final, judged fairly. State, scope and ownership are evaluated at execution time; policy settings (GPS tolerance, mandatory report…) as they were at occurredAt (policy rules versus invariants).
  6. No silent resolution. The server never merges or overwrites to make a conflict disappear. It reports CONFLICT with the current server state, and the delegate decides in the sync centre (DA-27).

What works offline

Decided per operation on the delegate API pages; summarised here with the reason.

OperationOfflineHow and why
delegate.tours.start, suspend, resumeyesQueued with occurredAt and position; a basement or a road without signal must not stop the day. Resuming on a later day is allowed (a tour suspended overnight is resumed to be closed).
delegate.tours.reorderyesQueued with baseVersion; the planner's concurrent change comes back as VERSION_CONFLICT.
delegate.tours.addVisityesClient-generated visit id; may depend on a prospect created offline (dependsOn).
delegate.visits.arrive, start, checklist, note, addPhoto, report, end, notDoneyesThe heart of the field day. The app runs the same tolerance algorithm locally; checklist, note and report saves are coalesced while unsent.
delegate.resellers.create, update, addNote, addPhotoyesField sign-up is offline-capable by requirement (FLD-11); duplicates are caught at replay.
delegate.invitations.markSharedyesA QR shown or an SMS sent offline is recorded.
delegate.tasks.update, completeyesQueued with version (update) and occurredAt.
Location samplesyes, own channelStored on the device, uploaded in chunks through sync.locationPings (below).
Photos and attachments (bytes)upload when onlineUpload queue: files.createUpload → PUT → files.completeUpload, then the attach action enters the outbox (below).
delegate.tours.closenoClosing is definitive and needs every action of the day on the server (cahier §6.9 « Les données hors ligne doivent être synchronisées »). The app sends it once its outbox for the tour is empty; the closing preview lists UNSYNCED_DATA otherwise. A delegate without signal closes later; the end-of-day auto-suspension stops location sampling meanwhile (jobs).
delegate.assisted.* (first-order assistance)drafts onlyThe basket can be prepared offline as a local draft with indicative prices, but creating, editing and submitting the server draft is online: the reseller's customer-type price, promotions, stock and credit must be evaluated live, and the reseller confirms prices they will be held to (ORD-08).
delegate.invitations.createnoThe code and the signed link are generated by the server; offline the screen says « Connexion requise pour générer l'invitation ».
Reads (delegate.home, objectives, performance, reports, orders, sheets)cachedThe app shows the last value or builds the view from its local database (the delta scopes and today's snapshot).

Client-generated ids

Records that can be created offline carry a UUIDv7 generated by the app: Reseller (field prospects), Visit (unplanned), VisitPhoto, VisitReport, Interaction (notes), ResellerPhoto and their File rows (database design).

Check at the serverOutcome when it fails
A version-7 UUID whose embedded time is not more than 5 minutes ahead of the server400 VALIDATION_FAILED → REJECTED
Unknown id → the record is created with it—
Known id, same creator, same parent (tour, reseller, visit) → the operation is an upsert of the same recordidempotent; a replay is answered from the idempotency record anyway
Known id used for another record, parent or creator409 CLIENT_ID_CONFLICT → CONFLICT (a client bug: reported to Sentry by the app)

Client ids are identifiers, never credentials: every operation still checks that the caller may act on the parent record. When a creation fails (for example a duplicate phone), the id stays free, so the corrected creation can reuse it and the queued actions that point to it stay valid.

Idempotency

The server-wide rules are on Architecture and data; what matters for offline replay:

  • Scope and fingerprint. IdempotencyRecord is unique on (scopeKey = user:<userId>, key) and stores the fingerprint of method + path + body (model). A direct call and a batch item with the same key are the same action.
  • The batch has its own key for the batch content. Retrying the exact same batch returns the whole stored response; any change of the items needs a new batch key, and items already processed then answer from their own records (REPLAYED, or the stored refusal with replayed: true).
  • Stored outcomes. Successes and business refusals are stored for 30 days; transient outcomes (5xx, 401, 429, IDEMPOTENCY_IN_PROGRESS) are not, so the same key retries.
  • Coalescing on the device. Checklist, note and report saves of a visit are coalesced while they have never been sent: the outbox keeps only the latest body under the newest key. An item that has been sent once — even if its response was lost — is frozen: its body never changes, or the next replay would be IDEMPOTENCY_KEY_REUSED.
  • Concurrency. Batches of the same user are serialised by a Redis lock sync:user:<userId> (wait up to 5 s), so two devices or an overlapping retry never interleave. A direct call still in flight with an item's key makes that item RETRY (IDEMPOTENCY_IN_PROGRESS).

Time: occurredAt and receivedAt

RuleServer behaviour
Clock correctionThe app stamps occurredAt with its device clock corrected by the offset derived from serverTime (in every sync response and in today's snapshot). The server never rewrites occurredAt.
Item and body agreeThe batch injects the item's occurredAt into a body that has none; a body value different from the item value → VALIDATION_FAILED.
Not in the futureMore than 5 minutes after receivedAt → 422 OCCURRED_AT_OUT_OF_RANGE → REJECTED.
Not too oldOlder than 72 hours → same code. The app warns from 48 hours (« À synchroniser avant … ») so this stays exceptional.
Not before the previous stepA visit cannot start before its check-in or end before its start: same code with details.reason = "BEFORE_PREVIOUS_STEP".
Business-day guardsUse the Africa/Algiers date of occurredAt, not of receivedAt (a tour can only be started on its date: 409 TOUR_NOT_TODAY).
StorageMilestone columns (startedAt, checkedInAt, endedAt…) hold occurredAt; append-only rows record the receipt as createdAt; the check-in has its own checkInReceivedAt; the AuditLog entry is dated at occurredAt with the receipt as createdAt (field operations model).
Late factsEvents of a replayed item carry occurredAt; the back-office shows late syncs (BO-25); tour summaries and daily aggregates are recomputed for the day of occurredAt (three days stay provisional — analytics).

Policy rules versus invariants

A replayed action is judged by two kinds of rules, and the difference is what keeps offline work fair without making the server lenient.

KindExamplesEvaluated
InvariantsState machines (a cancelled visit stays cancelled), the delegate scope, the assigned delegate, uniqueness (phone), version, plausibility of occurredAtAt execution time. A violation is a CONFLICT or REJECTED
Policy settingsfield.gpsToleranceMeters, field.gpsMaxAccuracyMeters, field.allowOutOfToleranceCheckIn, field.reportRequiredToEndVisit, the visit type's photo and report requirements, the checklist templateAs in force at occurredAt

How "as in force at occurredAt" is computed:

  • Settings. settings.valueAt(key, instant) reads the value valid at that instant from the setting's change history (every admin.settings.update is audited with before and after).
  • Visit type and checklist template. Frozen on the visit at check-in.
  • Reference position. The store position used for the tolerance is the one valid at occurredAt (position changes are in the reseller's history).

The app evaluated the same rule with its snapshot, so in the normal case both verdicts agree. What this prevents is a setting published while the delegate was offline — tolerance tightened from 150 m to 100 m at 10:00 — turning a 120 m check-in made at 09:40 into a refusal. A check-in that was out of tolerance even under the rules of the moment is refused (422 GPS_OUT_OF_TOLERANCE), as cahier §6.3 requires.

Batch replay

POST /v1/sync/batch — contract on sync.batch. Inside the server:

Envelope. The usual chain: session, app-version gate, rate limit (30 batches per minute per user), body size. The batch Idempotency-Key is checked, and a stored response is replayed whole. Then the shape: 1–50 items, unique item keys, every dependsOn pointing to an earlier item or a known key. More than 50 items or more than 1 MB → 413 BATCH_TOO_LARGE; other shape errors → 400 VALIDATION_FAILED.

Serialise. Take the per-user lock sync:user:<userId>.

For each item, in order:

  1. Resolve method + path against the registry of batchable operations; anything else → REJECTED / NOT_BATCHABLE.
  2. Check dependencies: a CONFLICT or REJECTED dependency → REJECTED, 424 DEPENDENCY_FAILED; a RETRY dependency → RETRY, DEPENDENCY_PENDING; an unknown key → DEPENDENCY_FAILED with dependencyResult: "UNKNOWN".
  3. Look up the item's idempotency record.
  4. Inject occurredAt into the body.
  5. Invoke the operation's procedure in-process through the server-side oRPC client, with the batch's context (user, session, app headers) and the item's own key — the same middleware runs (permission, scope, validation, idempotency completion inside the transaction).
  6. Classify the outcome (table below) and store it.

Budget. No new item starts after 20 s; the rest come back RETRY / BATCH_TIME_BUDGET. Release the lock, store the batch response under the batch key, answer 200 with one outcome per item and serverTime.

Outcome of the procedureResultNotes
2xx, first executionAPPLIEDEvents emitted exactly as for a direct call
Stored successREPLAYEDNothing executed; no event
409 state conflicts, 403 OUT_OF_SCOPE, 403 NOT_ASSIGNED_DELEGATECONFLICTNever retried automatically; the stored refusal is replayed with replayed: true
400, 404, 422, 424REJECTEDNever retried automatically; a fix is a new action with a new key
5xx, 429, IDEMPOTENCY_IN_PROGRESS, DEPENDENCY_PENDING, BATCH_TIME_BUDGETRETRYSame key, backoff 2 s, 4 s, 8 s… capped at 5 minutes

A transient failure of one item does not abort the batch: each item is its own transaction, and items that depend on it come back DEPENDENCY_PENDING. If the database itself is unreachable, the whole request fails (500/503) and the app retries the batch with the same key.

How the app chains items (so that dependency rules do their job): each visit action depends on the previous queued action of the same visit; an unplanned visit or a note on a prospect created offline depends on its creation; a visit end depends on the photo attachments the visit type requires; markShared depends on nothing (the invitation was created online).

Normal replay

Idempotent replay after a timeout

Conflict

Conflict catalogue

What can go wrong between the moment the delegate acts offline and the moment the server sees it. "Dependents" are the queued items that declared the conflicted item in dependsOn; they come back REJECTED / 424 DEPENDENCY_FAILED and are resolved together with it.

SituationServer responseExpected client handling
Tour cancelled by the back-office while the delegate workedTour actions: CONFLICT, 409 TOUR_TRANSITION_NOT_ALLOWED (details.status: "CANCELLED"). Visit actions: CONFLICT, 409 VISIT_LOCKED, cause: "TOUR_CANCELLED", with who, when and whyPull the delta (tour and visits CANCELLED); group the items per visit; offer « Conserver comme note » — one new delegate.resellers.addNote per store with the checklist and report summary — or « Abandonner ». Never retried
Tour already closed (closed from another device, or reopening window over)CONFLICT, 409 VISIT_LOCKED, cause: "TOUR_CLOSED"; tour actions TOUR_TRANSITION_NOT_ALLOWEDAsk the back-office to reopen (admin.tours.reopen, within field.reopenMaxDays), then redo the actions as new ones; or keep as note
Visit already done or locked — ended on another device, or the unlock window (field.visitUnlockHours) expiredCONFLICT, 409 VISIT_LOCKED, cause: "VISIT_ENDED" or "UNLOCK_EXPIRED", details.statusIf the server state already contains the delegate's own end (same visit, same outcome), mark the items resolved silently; otherwise show both versions and offer note or abandon
Visit removed from the tour (stop removed by the planner)CONFLICT, 409 VISIT_LOCKED, cause: "VISIT_CANCELLED"If the delegate really visited: « Enregistrer comme visite hors programme » — a new delegate.tours.addVisit (new client id) on the same store, then the visit's actions as new items depending on it; else note or abandon
Tour or visit reassigned to another delegateCONFLICT, 403 NOT_ASSIGNED_DELEGATEThe work cannot be recorded on that visit; keep it as a note on the store if the store is still in scope; otherwise show « Tournée réattribuée » and keep the content readable for the end-of-day comment
Point of sale reassigned or out of scopeDelta: tombstone RESELLER / OUT_OF_SCOPE. Queued actions on it: CONFLICT, 403 OUT_OF_SCOPERemove the card from local lists; show the queued content in the sync centre (it cannot be saved on that store in V1); the delegate tells the supervisor if it matters
Phone now duplicated — another delegate, the back-office or a self sign-up created the same phone firstCONFLICT, 409 PHONE_ALREADY_EXISTS; details.existing gives the store name, number, commune and whether it is in the caller's scope (no other personal data)« C'est le même magasin » (in scope) → re-send the dependent notes, photos and visit on the existing store as new actions; « Corriger le numéro » → a new create with the corrected phone, reusing the same client id, then the dependents again; or abandon
Tolerance rules changed while offlineNone: the check-in is judged with the settings valid at occurredAt → APPLIEDNothing to do
Check-in out of tolerance even under the rules of the moment (stale store position on the device, GPS drift)REJECTED, 422 GPS_OUT_OF_TOLERANCE (or GPS_ACCURACY_TOO_LOW, GPS_MOCK_LOCATION)The visit is not checked in: mark it not done with a reason, or redo the check-in with a justification when field.allowOutOfToleranceCheckIn is on
Account suspended mid-tourSessions are revoked: the batch answers 401 UNAUTHENTICATED, then 403 ACCOUNT_SUSPENDED at sign-in. The account.suspended handler has already suspended the tour in progress (reason ACCOUNT_SUSPENDED)Show the explicit message (DA-02), stop location sampling, keep the outbox encrypted on the device. After reactivation and sign-in the outbox replays; visit actions on the suspended tour come back TOUR_NOT_IN_PROGRESS, so the delegate resumes the tour and the app re-sends them as new actions
Account disabled with a non-empty outboxSign-in impossible: the queued data cannot be replayed under that identityOperational rule: a leaving delegate is suspended, not disabled, until their device's outbox is empty (the back-office sees each delegate's last sync time)
Stale version — reseller edited in the back-office, task changed, tour reordered by the plannerCONFLICT, 409 VERSION_CONFLICT with details.current (or currentVersion and the new stop order for reorder)Three-way merge on the device: base = the values the item was made from, theirs = details.current, mine = the item. Fields only the delegate changed are re-sent as a new action on the new version; a field changed on both sides is shown for the delegate to choose
Task no longer openCONFLICT, 409 TASK_NOT_OPEN (details.status)Resolved silently if the task was completed with the same outcome; otherwise shown
Another visit still open in the tourCONFLICT, 409 VISIT_IN_PROGRESSUsually an ordering problem on the device; end or mark not done the open visit, then redo
Start on the wrong dayCONFLICT, 409 TOUR_NOT_TODAYShow the tour's date; nothing to redo
Action too old or in the futureREJECTED, 422 OCCURRED_AT_OUT_OF_RANGE (details.reason)Content kept readable in the sync centre; the delegate can re-enter what matters as a note (new action, current time)
Client id already usedCONFLICT, 409 CLIENT_ID_CONFLICTA client bug: report to Sentry, show as error
Operation not batchableREJECTED, NOT_BATCHABLEA client bug

Proposal (not in the V1 contract): a grace period for reassignments. Scope is evaluated at execution time, so notes and photos taken on a store just before it was reassigned are refused. Accepting append-only facts whose occurredAt precedes the reassignment (from ResellerAssignment), within the 72-hour window, would keep that history. To decide with HOCO if the pilot shows the case matters.

Delta feed

GET /v1/sync/delta — contract on sync.delta: six scopes (tours, visits, resellers, tasks, referenceLists, announcements), changes in their latest state, tombstones, nextCursor, hasMore, fullResyncRequired, serverTime.

Scopes

The same scope resolvers as the online operations (the access module) — one definition of "what this delegate may see".

ScopeMembership (evaluated at each pull)Watermark source
toursThe caller's tours dated from today − 7 to today + 30 (Africa/Algiers)Tour.updatedAt, plus the date window
visitsThe visits of those toursVisit.updatedAt
resellersEvery point of sale in the delegate scope: assigned, in the delegate's zones, or a stop of one of the delegate's toursReseller.updatedAt, plus ResellerAssignment for exits
tasksTasks assigned to the caller: OPEN, plus those closed in the last 7 daysCrmTask.updatedAt, plus reassignments from the audit trail
referenceListsReference items of the delegate lists, visit types, checklist templatestheir updatedAt
announcementsAnnouncements targeted to the caller and not expiredAnnouncement.updatedAt, plus expiry times

Cursor

The cursor is opaque to the app: base64url JSON signed with HMAC-SHA-256 (SYNC_CURSOR_SECRET). It holds:

FieldMeaning
vFormat version; a change forces a full resync
uidThe delegate; another user's cursor is refused (400 VALIDATION_FAILED)
iatIssue time; older than 7 days → fullResyncRequired
anchorThe Africa/Algiers date of issue, which positions the tour window and the time-based exits
wmOne watermark per scope and per history source: the (updatedAt, id) of the last row delivered
digestHash of the delegate's zone set and assignment basis; a mismatch (zones changed) → fullResyncRequired

Detecting changes

Per scope, the server selects the rows of the membership with (updatedAt, id) > watermark, ordered by (updatedAt, id) — the monotonic change sequence of the contract — in one REPEATABLE READ transaction, so every page is a consistent snapshot. Each record appears once, in its latest state.

Settle window. A transaction can commit after rows with a later updatedAt were already served. To never miss it, the final cursor of a pull (when hasMore is false) is set back to now − 30 s for every scope. The next pull re-reads that short window, and the app ignores records whose version or updatedAt it already has. Transactions are bounded at 10 s (transactions), so 30 s is a safe margin. Pages within one pull use the exact keyset, without the overlap.

Two invariants keep the watermark sufficient:

  1. A record that enters a scope always gets a new updatedAt (assignment, tour planning, task reassignment write the row) — except through time windows, which the server handles from the anchor: tours whose date enters the window (anchor + 30 to today + 30) are delivered whatever their updatedAt.
  2. A visit never changes tour: moving a stop means cancelling it in one tour and creating a new visit in the other.

Tombstones

There is no tombstone table: records are never deleted (database design), and every way out of a scope is already recorded somewhere. The server derives tombstones since the watermarks:

ExitSourceTombstone
Point of sale reassigned to another delegateResellerAssignment rows with fromDelegateId = the caller, created after the watermark, for stores no longer in scopeRESELLER / OUT_OF_SCOPE
Delegate's zones changedmembership digestnone — fullResyncRequired instead (the change can move hundreds of stores)
Tour reassignedAuditLog updates of Tour whose previous delegateId is the callerTOUR / OUT_OF_SCOPE (the app drops its visits with it)
Tour leaves the date windowdates between anchor − 7 and today − 7TOUR / OUT_OF_SCOPE
Task reassignedAuditLog updates of CrmTask whose previous assignee is the callerTASK / OUT_OF_SCOPE
Task closed more than 7 days agoclosedAt between anchor − 7 and today − 7TASK / OUT_OF_SCOPE
Announcement expired or withdrawnexpiresAt passed since the cursor; audited withdrawalANNOUNCEMENT / OUT_OF_SCOPE
Rare hard deletion of a reference rowAuditLog DELETE entriesREFERENCE_ITEM, VISIT_TYPE, CHECKLIST_TEMPLATE / DELETED

Cancelled visits, deactivated reference items and archived templates are not tombstones: they come back as changes with their status (CANCELLED, active: false), because the device needs to show them.

Paging and full resync

  • Paging. Up to limit records per page across scopes (default 500, maximum 1 000), scopes served in a fixed order — referenceLists, tours, visits, resellers, tasks, announcements — so parents arrive before children. hasMore = true until the backlog is drained; the app applies each page in one local transaction and loops at once.
  • fullResyncRequired when the cursor is older than 7 days, the format version changed, or the zone digest no longer matches. The app reloads every scope without a cursor and swaps the cached data only once the reload completes, so the app stays usable offline mid-resync. It never drops its outbox, and replays it afterwards.
  • The today snapshot (delegate.tours.today) returns a deltaCursor taken in the same database snapshot as its payload.

Sync hints

When the back-office changes something in a delegate's scope, the sync module's handlers (on tour.updated, tour.cancelled, tour.reopened, visit.added, visit.unlocked, reseller.assigned, task.assigned, delegate.zones_changed, announcement.published, field-policy settings.changed) ask notifications for a silent push (data message { "type": "SYNC" }, collapse key sync, at most one per delegate per minute). The app pulls the delta within seconds when online. An app in the foreground also sees notification.created on its live stream (events.stream).

Indexes that keep pulls cheap: (delegateId, updatedAt) on tours; (tourId, updatedAt) on visits; (assigneeId, updatedAt) on tasks; (fromDelegateId, createdAt) on assignments; (entityType, createdAt) on the audit log.

Photos offline

Capture. The app stores the compressed, EXIF-stripped image locally with two client ids — the VisitPhoto (or ResellerPhoto) and its File — and the capture time and position.

Upload when online. files.createUpload with the file id, purpose VISIT_PHOTO (or STORE_PHOTO), type and size → pre-signed PUT valid 10 minutes → PUT the bytes → files.completeUpload. The server checks the object's size (at most 5 MB) and type, then the worker strips metadata and makes thumbnails. A URL that expired while offline is simply requested again for the same file id (file flows).

Attach. Only after completion does the attach action (delegate.visits.addPhoto / delegate.resellers.addPhoto with the photo id, fileId, occurredAt, position) enter the outbox, so the server never sees an attachment whose bytes are missing. The server checks that the file is the caller's, completed and of the right purpose (422 FILE_NOT_UPLOADED, 400 VALIDATION_FAILED otherwise — both indicate a client bug once the gate above is respected).

Mandatory photos. When the visit type requires photos, the visit's end item depends on the attach items, so the end is judged after the photos are on the server.

Uploaded but never attached files are purged after 7 days and pending uploads after 24 hours — longer than the 72-hour offline window for the first, while a pending upload is re-created on the next attempt anyway.

Location pings

sync.locationPings, outside the batch because it is high-volume, append-only and loss-tolerant.

RuleServer behaviour
Chunks1–200 samples per call, with an Idempotency-Key per chunk; more → 413 BATCH_TOO_LARGE. Rate limit 60 calls per minute per delegate.
DeduplicationLocationPing is unique on (delegateId, occurredAt) — the sample's recordedAt; inserts use ON CONFLICT DO NOTHING, and the response counts duplicates.
Only during the tourA chunk is accepted while the tour is IN_PROGRESS or SUSPENDED; each sample is stored only if its time falls inside an in-progress interval rebuilt from TourStatusChange (start, suspensions, resumptions, close). Others are dropped with OUTSIDE_TOUR_INTERVAL — the technical guarantee behind "no position outside a tour" (ADR-016).
PlausibilityMore than 5 minutes ahead or older than 72 hours → dropped with OCCURRED_AT_OUT_OF_RANGE.
ConsentNo current consent → 403 LOCATION_CONSENT_REQUIRED.
Mock locationsStored with their flag and shown to supervisors, never used to refuse a ping (they matter for check-ins).
Live mapThe newest accepted sample, if recorded in the last 10 minutes, is published as delegate.position on the back-office stream (at most one per delegate every 30 s). A backlog arriving late updates the trail, not the live marker.
KilometresComputed at closing from the stored samples per BR-54; after closing, late samples are refused, so the closing report is final.
Policy echoEach response returns the current sampling policy (sampling, intervalSeconds, distanceMeters, maxBatchSize); the app applies it at once and stops sampling when sampling is false (end-of-day auto-suspension, tour closed).
Retention90 days (field.purgeLocationPings).

Security

  • Same authorisation as online. Every item runs the direct operation's permission and scope checks with the scope at execution time; a record that left the scope is a conflict, never applied.
  • Session expiry or revocation while offline. The batch answers 401; the app keeps its outbox untouched, asks the delegate to sign in, and resumes with the same keys. Sessions last 30 days sliding, longer than the 72-hour window of replayable actions.
  • One outbox per user. The app partitions its outbox by user: if another person signs in on the device, the previous user's queue stays encrypted on the device and is not replayed until that user signs in again. Idempotency scope user:<userId> would otherwise apply the actions under the wrong identity. Proposal: add an ownerUserId field to SyncBatchRequest so the server refuses a mismatch (403 SYNC_OWNER_MISMATCH) as a second line of defence.
  • Traceability. Every applied item is audited like a direct call (actorApp = DELEGATE_APP, user, device id, occurredAt, receipt time, the batch's request id).
  • Limits. 50 items and 1 MB per batch; 200 pings per chunk; per-user rate limits; cursors signed and bound to the user; the delta never returns data outside the current scope — tombstones carry ids only.
  • Data at rest on the device (encrypted local database, outbox and photos) is specified on the mobile side.

Observability

MetricLabelsAlert
sync_items_totaloperation, result, codeCONFLICT above 5 % of items over 1 h; any rise of REJECTED by OCCURRED_AT_OUT_OF_RANGE (it means data loss)
sync_item_lag_secondsoperationhistogram of receivedAt − occurredAt; p95 above 6 h during working hours (delegates syncing too late)
sync_batch_duration_seconds—p95 above 10 s
sync_batch_items—items per batch
sync_delta_duration_seconds · sync_delta_records_totalscopep95 above 2 s
sync_full_resync_totalreasonspike after a deploy → check the format version
sync_tombstones_totalentity, reason—
location_pings_totalresult (accepted, duplicate, dropped reason)OUTSIDE_TOUR_INTERVAL above 10 % (app sampling outside tours)
idempotency_replays_totaloperation—

The back-office shows each delegate's last sync time and pending-item count reported by the closing preview (delegates list BO-16, tour live detail BO-23), so a supervisor sees who has not synced. Logs carry the batch's requestId and each item's key, so a delegate's support call can be traced item by item.

Test scenarios

Automated against a real PostgreSQL and Redis, plus the field test of MOB-13 (airplane-mode visits, sync recovery, photo queue, battery).

#ScenarioExpected
1A full visit (arrive, checklist, photo, report, end) recorded offline, then replayedAll APPLIED in order; one visit.checked_in and one visit.completed event; times equal to occurredAt
2The check-in was applied but its response lost; the same item is replayed in a new batchREPLAYED with the original body; no second event
3The exact same batch is retried with its keyWhole stored response, Idempotent-Replayed: true
4Same item key, different bodyREJECTED, 409 IDEMPOTENCY_KEY_REUSED
5A direct call is in flight when the batch replays its keyThat item RETRY, IDEMPOTENCY_IN_PROGRESS; next attempt REPLAYED
6Tour cancelled by the back-office before the replayVISIT_LOCKED (TOUR_CANCELLED) on the first visit item, DEPENDENCY_FAILED on its dependents
7Prospect created offline with a phone created meanwhile elsewherePHONE_ALREADY_EXISTS with the existing store; corrected create with the same client id succeeds; dependents re-sent succeed
8Reseller reassigned before the replayDelta tombstone RESELLER/OUT_OF_SCOPE; queued note CONFLICT, 403 OUT_OF_SCOPE
9Tolerance tightened while the delegate is offlineCheck-in recorded before the change is APPLIED (rules at occurredAt)
10occurredAt 6 minutes ahead; 73 hours old; end before startREJECTED, OCCURRED_AT_OUT_OF_RANGE with the right reason
1151 items, or a body over 1 MB413 BATCH_TOO_LARGE
12dependsOn pointing to a later item400 VALIDATION_FAILED
13Database deadlock on one itemThat item RETRY; its dependents DEPENDENCY_PENDING; independent items APPLIED
14Batch of 50 slow itemsItems after 20 s RETRY, BATCH_TIME_BUDGET; re-sent and applied
15Session revoked while offline; delegate signs in again401, outbox kept, replay with the same keys succeeds
16Two devices of the same delegate replay at the same timeBatches serialised by the user lock; no interleaving; the second device's duplicate actions come back VISIT_LOCKED (VISIT_ENDED) and auto-resolve
17A row committed during a delta pull (transaction slower than the read)Delivered by the next pull thanks to the settle window
18Delegate's zones changedfullResyncRequired; reload without cursor; outbox replayed afterwards
19Cursor older than 7 days, or signed for another userfullResyncRequired; another user's cursor 400 VALIDATION_FAILED
20Photo upload URL expired before the uploadNew files.createUpload for the same file id gives a fresh URL; the attach succeeds after completion
21Location samples before the start, during a suspension, after the closeDropped OUTSIDE_TOUR_INTERVAL; chunk replayed → duplicates only
22Close attempted with items still in the outbox409 TOUR_CLOSING_BLOCKED with UNSYNCED_DATA

On this page