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
- 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.
- The key is born with the action. The app assigns the
Idempotency-Keywhen the delegate taps, not when it sends; the direct attempt and every replay share it, so a lost response never duplicates anything. - Two times, both kept.
occurredAtis when the delegate acted (device clock, corrected by the server offset);receivedAtis when the server got it. Business facts useoccurredAt; plausibility bounds it. - Records born offline have client ids. The server upserts on them, so a creation replayed after a lost response is a no-op.
- 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). - No silent resolution. The server never merges or overwrites to make a conflict disappear. It reports
CONFLICTwith 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.
| Operation | Offline | How and why |
|---|---|---|
delegate.tours.start, suspend, resume | yes | Queued 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.reorder | yes | Queued with baseVersion; the planner's concurrent change comes back as VERSION_CONFLICT. |
delegate.tours.addVisit | yes | Client-generated visit id; may depend on a prospect created offline (dependsOn). |
delegate.visits.arrive, start, checklist, note, addPhoto, report, end, notDone | yes | The 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, addPhoto | yes | Field sign-up is offline-capable by requirement (FLD-11); duplicates are caught at replay. |
delegate.invitations.markShared | yes | A QR shown or an SMS sent offline is recorded. |
delegate.tasks.update, complete | yes | Queued with version (update) and occurredAt. |
| Location samples | yes, own channel | Stored on the device, uploaded in chunks through sync.locationPings (below). |
| Photos and attachments (bytes) | upload when online | Upload queue: files.createUpload → PUT → files.completeUpload, then the attach action enters the outbox (below). |
delegate.tours.close | no | Closing 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 only | The 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.create | no | The 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) | cached | The 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 server | Outcome when it fails |
|---|---|
| A version-7 UUID whose embedded time is not more than 5 minutes ahead of the server | 400 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 record | idempotent; a replay is answered from the idempotency record anyway |
| Known id used for another record, parent or creator | 409 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.
IdempotencyRecordis unique on (scopeKey=user:<userId>,key) and stores the fingerprint ofmethod+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 withreplayed: 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 itemRETRY(IDEMPOTENCY_IN_PROGRESS).
Time: occurredAt and receivedAt
| Rule | Server behaviour |
|---|---|
| Clock correction | The 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 agree | The 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 future | More than 5 minutes after receivedAt → 422 OCCURRED_AT_OUT_OF_RANGE → REJECTED. |
| Not too old | Older than 72 hours → same code. The app warns from 48 hours (« À synchroniser avant … ») so this stays exceptional. |
| Not before the previous step | A visit cannot start before its check-in or end before its start: same code with details.reason = "BEFORE_PREVIOUS_STEP". |
| Business-day guards | Use the Africa/Algiers date of occurredAt, not of receivedAt (a tour can only be started on its date: 409 TOUR_NOT_TODAY). |
| Storage | Milestone 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 facts | Events 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.
| Kind | Examples | Evaluated |
|---|---|---|
| Invariants | State machines (a cancelled visit stays cancelled), the delegate scope, the assigned delegate, uniqueness (phone), version, plausibility of occurredAt | At execution time. A violation is a CONFLICT or REJECTED |
| Policy settings | field.gpsToleranceMeters, field.gpsMaxAccuracyMeters, field.allowOutOfToleranceCheckIn, field.reportRequiredToEndVisit, the visit type's photo and report requirements, the checklist template | As 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 (everyadmin.settings.updateis 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:
- Resolve
method+pathagainst the registry of batchable operations; anything else →REJECTED/NOT_BATCHABLE. - Check dependencies: a
CONFLICTorREJECTEDdependency →REJECTED,424 DEPENDENCY_FAILED; aRETRYdependency →RETRY,DEPENDENCY_PENDING; an unknown key →DEPENDENCY_FAILEDwithdependencyResult: "UNKNOWN". - Look up the item's idempotency record.
- Inject
occurredAtinto the body. - 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).
- 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 procedure | Result | Notes |
|---|---|---|
2xx, first execution | APPLIED | Events emitted exactly as for a direct call |
| Stored success | REPLAYED | Nothing executed; no event |
409 state conflicts, 403 OUT_OF_SCOPE, 403 NOT_ASSIGNED_DELEGATE | CONFLICT | Never retried automatically; the stored refusal is replayed with replayed: true |
400, 404, 422, 424 | REJECTED | Never retried automatically; a fix is a new action with a new key |
5xx, 429, IDEMPOTENCY_IN_PROGRESS, DEPENDENCY_PENDING, BATCH_TIME_BUDGET | RETRY | Same 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.
| Situation | Server response | Expected client handling |
|---|---|---|
| Tour cancelled by the back-office while the delegate worked | Tour actions: CONFLICT, 409 TOUR_TRANSITION_NOT_ALLOWED (details.status: "CANCELLED"). Visit actions: CONFLICT, 409 VISIT_LOCKED, cause: "TOUR_CANCELLED", with who, when and why | Pull 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_ALLOWED | Ask 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) expired | CONFLICT, 409 VISIT_LOCKED, cause: "VISIT_ENDED" or "UNLOCK_EXPIRED", details.status | If 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 delegate | CONFLICT, 403 NOT_ASSIGNED_DELEGATE | The 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 scope | Delta: tombstone RESELLER / OUT_OF_SCOPE. Queued actions on it: CONFLICT, 403 OUT_OF_SCOPE | Remove 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 first | CONFLICT, 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 offline | None: the check-in is judged with the settings valid at occurredAt → APPLIED | Nothing 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-tour | Sessions 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 outbox | Sign-in impossible: the queued data cannot be replayed under that identity | Operational 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 planner | CONFLICT, 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 open | CONFLICT, 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 tour | CONFLICT, 409 VISIT_IN_PROGRESS | Usually an ordering problem on the device; end or mark not done the open visit, then redo |
| Start on the wrong day | CONFLICT, 409 TOUR_NOT_TODAY | Show the tour's date; nothing to redo |
| Action too old or in the future | REJECTED, 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 used | CONFLICT, 409 CLIENT_ID_CONFLICT | A client bug: report to Sentry, show as error |
| Operation not batchable | REJECTED, NOT_BATCHABLE | A 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".
| Scope | Membership (evaluated at each pull) | Watermark source |
|---|---|---|
tours | The caller's tours dated from today − 7 to today + 30 (Africa/Algiers) | Tour.updatedAt, plus the date window |
visits | The visits of those tours | Visit.updatedAt |
resellers | Every point of sale in the delegate scope: assigned, in the delegate's zones, or a stop of one of the delegate's tours | Reseller.updatedAt, plus ResellerAssignment for exits |
tasks | Tasks assigned to the caller: OPEN, plus those closed in the last 7 days | CrmTask.updatedAt, plus reassignments from the audit trail |
referenceLists | Reference items of the delegate lists, visit types, checklist templates | their updatedAt |
announcements | Announcements targeted to the caller and not expired | Announcement.updatedAt, plus expiry times |
Cursor
The cursor is opaque to the app: base64url JSON signed with HMAC-SHA-256 (SYNC_CURSOR_SECRET). It holds:
| Field | Meaning |
|---|---|
v | Format version; a change forces a full resync |
uid | The delegate; another user's cursor is refused (400 VALIDATION_FAILED) |
iat | Issue time; older than 7 days → fullResyncRequired |
anchor | The Africa/Algiers date of issue, which positions the tour window and the time-based exits |
wm | One watermark per scope and per history source: the (updatedAt, id) of the last row delivered |
digest | Hash 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:
- 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 theanchor: tours whose date enters the window (anchor + 30totoday + 30) are delivered whatever theirupdatedAt. - 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:
| Exit | Source | Tombstone |
|---|---|---|
| Point of sale reassigned to another delegate | ResellerAssignment rows with fromDelegateId = the caller, created after the watermark, for stores no longer in scope | RESELLER / OUT_OF_SCOPE |
| Delegate's zones changed | membership digest | none — fullResyncRequired instead (the change can move hundreds of stores) |
| Tour reassigned | AuditLog updates of Tour whose previous delegateId is the caller | TOUR / OUT_OF_SCOPE (the app drops its visits with it) |
| Tour leaves the date window | dates between anchor − 7 and today − 7 | TOUR / OUT_OF_SCOPE |
| Task reassigned | AuditLog updates of CrmTask whose previous assignee is the caller | TASK / OUT_OF_SCOPE |
| Task closed more than 7 days ago | closedAt between anchor − 7 and today − 7 | TASK / OUT_OF_SCOPE |
| Announcement expired or withdrawn | expiresAt passed since the cursor; audited withdrawal | ANNOUNCEMENT / OUT_OF_SCOPE |
| Rare hard deletion of a reference row | AuditLog DELETE entries | REFERENCE_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
limitrecords 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 = trueuntil the backlog is drained; the app applies each page in one local transaction and loops at once. fullResyncRequiredwhen 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 adeltaCursortaken 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.
| Rule | Server behaviour |
|---|---|
| Chunks | 1–200 samples per call, with an Idempotency-Key per chunk; more → 413 BATCH_TOO_LARGE. Rate limit 60 calls per minute per delegate. |
| Deduplication | LocationPing is unique on (delegateId, occurredAt) — the sample's recordedAt; inserts use ON CONFLICT DO NOTHING, and the response counts duplicates. |
| Only during the tour | A 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). |
| Plausibility | More than 5 minutes ahead or older than 72 hours → dropped with OCCURRED_AT_OUT_OF_RANGE. |
| Consent | No current consent → 403 LOCATION_CONSENT_REQUIRED. |
| Mock locations | Stored with their flag and shown to supervisors, never used to refuse a ping (they matter for check-ins). |
| Live map | The 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. |
| Kilometres | Computed at closing from the stored samples per BR-54; after closing, late samples are refused, so the closing report is final. |
| Policy echo | Each 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). |
| Retention | 90 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 anownerUserIdfield toSyncBatchRequestso 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
| Metric | Labels | Alert |
|---|---|---|
sync_items_total | operation, result, code | CONFLICT above 5 % of items over 1 h; any rise of REJECTED by OCCURRED_AT_OUT_OF_RANGE (it means data loss) |
sync_item_lag_seconds | operation | histogram 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_total | scope | p95 above 2 s |
sync_full_resync_total | reason | spike after a deploy → check the format version |
sync_tombstones_total | entity, reason | — |
location_pings_total | result (accepted, duplicate, dropped reason) | OUTSIDE_TOUR_INTERVAL above 10 % (app sampling outside tours) |
idempotency_replays_total | operation | — |
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).
| # | Scenario | Expected |
|---|---|---|
| 1 | A full visit (arrive, checklist, photo, report, end) recorded offline, then replayed | All APPLIED in order; one visit.checked_in and one visit.completed event; times equal to occurredAt |
| 2 | The check-in was applied but its response lost; the same item is replayed in a new batch | REPLAYED with the original body; no second event |
| 3 | The exact same batch is retried with its key | Whole stored response, Idempotent-Replayed: true |
| 4 | Same item key, different body | REJECTED, 409 IDEMPOTENCY_KEY_REUSED |
| 5 | A direct call is in flight when the batch replays its key | That item RETRY, IDEMPOTENCY_IN_PROGRESS; next attempt REPLAYED |
| 6 | Tour cancelled by the back-office before the replay | VISIT_LOCKED (TOUR_CANCELLED) on the first visit item, DEPENDENCY_FAILED on its dependents |
| 7 | Prospect created offline with a phone created meanwhile elsewhere | PHONE_ALREADY_EXISTS with the existing store; corrected create with the same client id succeeds; dependents re-sent succeed |
| 8 | Reseller reassigned before the replay | Delta tombstone RESELLER/OUT_OF_SCOPE; queued note CONFLICT, 403 OUT_OF_SCOPE |
| 9 | Tolerance tightened while the delegate is offline | Check-in recorded before the change is APPLIED (rules at occurredAt) |
| 10 | occurredAt 6 minutes ahead; 73 hours old; end before start | REJECTED, OCCURRED_AT_OUT_OF_RANGE with the right reason |
| 11 | 51 items, or a body over 1 MB | 413 BATCH_TOO_LARGE |
| 12 | dependsOn pointing to a later item | 400 VALIDATION_FAILED |
| 13 | Database deadlock on one item | That item RETRY; its dependents DEPENDENCY_PENDING; independent items APPLIED |
| 14 | Batch of 50 slow items | Items after 20 s RETRY, BATCH_TIME_BUDGET; re-sent and applied |
| 15 | Session revoked while offline; delegate signs in again | 401, outbox kept, replay with the same keys succeeds |
| 16 | Two devices of the same delegate replay at the same time | Batches serialised by the user lock; no interleaving; the second device's duplicate actions come back VISIT_LOCKED (VISIT_ENDED) and auto-resolve |
| 17 | A row committed during a delta pull (transaction slower than the read) | Delivered by the next pull thanks to the settle window |
| 18 | Delegate's zones changed | fullResyncRequired; reload without cursor; outbox replayed afterwards |
| 19 | Cursor older than 7 days, or signed for another user | fullResyncRequired; another user's cursor 400 VALIDATION_FAILED |
| 20 | Photo upload URL expired before the upload | New files.createUpload for the same file id gives a fresh URL; the attach succeeds after completion |
| 21 | Location samples before the start, during a suspension, after the close | Dropped OUTSIDE_TOUR_INTERVAL; chunk replayed → duplicates only |
| 22 | Close attempted with items still in the outbox | 409 TOUR_CLOSING_BLOCKED with UNSYNCED_DATA |
Jobs and scheduler
The worker process, the BullMQ queues with their retry and dead-letter policies, the transactional outbox dispatcher, and every scheduled job with its cron in Africa/Algiers, idempotency, failure handling, monitoring, owner and release.
Integrations
Every external service and platform the ecosystem depends on — maps, device location, push, real-time streams, SMS, email, WhatsApp, file storage, carriers, ERP and accounting, the stock system and the app stores — with purpose, release, data exchanged, configuration, failure modes, costs to validate and the decision each depends on.