Two protection officers, one case. A child, unaccompanied, flagged for a possible trafficking risk near a border crossing. Neither officer has a signal. One of them updates the case's risk level. So does the other, a few days later, from a different village, not knowing the first one touched it.
Whichever update reaches the server second just wins. The first one isn't shown anywhere. Nobody gets an error, nobody gets a warning. The only way to know it happened is to go digging through an audit log you had no reason to open.
That's what I found in Primero, the case management system UNICEF uses for child protection and gender-based violence response in conflict zones and refugee camps. Here's how I found it, and where I got it wrong before I got it right.
I build ZamSync.
Primero runs offline in the browser. It queues your edits in IndexedDB and replays them as PATCH requests once you're back online. My first guess was that two people editing the same case offline at the same time would step on each other, full stop. I was wrong about that, and I'd rather say so than quietly bury my first draft.
The PATCH body is a real field-level diff:
export const compactValues = (values, initialValues) => difference(values, initialValues);
Two devices editing different fields on the same case merge fine. One updates a follow-up note, the other updates a risk level, both survive. That's the correct design, and I checked it carefully because I almost reported it as broken.
The gap only opens when two devices touch the same field while both offline. I went looking for the check I expected, something comparing the incoming value against whatever the server currently holds before writing it. There isn't one. Not in the client queue, not in the Rails controller either: the update action loads the record, applies the patch, saves it. No version check in between.
Last write wins. The earlier value isn't gone from the database, Primero logs a RecordHistory row on every save, so someone determined enough could dig it back out. But nothing merges the two edits, nothing flags the conflict, nothing tells either worker to go look. The case just quietly shows the second number, as if the first report never came in.
There's a second, separate problem, QUEUE_ALLOWED_RETRIES is hardcoded to 3. A queued edit that fails to sync three times gets dropped for good, and for anything that isn't an attachment, nothing tells you it happened. A field worker whose connection just won't cooperate for a few days, not a rare situation in the places this runs, can lose an edit with no error and no trace, because it never reached the server in the first place. There's no history row for something that was never saved.
I've read enough writeups where "I read the code and I think there's a bug" turned out to be wrong that I didn't want to stop there. So I built a small proof of concept: zamsync-primero-poc. It takes zamsync-core, unmodified, compiles it to WebAssembly, and runs it against both scenarios next to a faithful copy of Primero's real queue and update logic, built straight from their source. The difference showed up exactly where the code said it would. Same-field conflicts get preserved and ordered instead of overwritten. A sync attempt that fails doesn't get a strike counted against it, it just sits in a durable log until one succeeds, whether that's the fifth try or the five hundredth.
I opened an issue with both findings, the exact files and lines, and a link to the POC. I tried to be careful about two things. I'm not claiming Primero's offline mode is broken in general, only that these two scenarios behave this way, and I said that plainly. And if this turns out to be a known, accepted tradeoff, I said I'd rather understand why than assume I'd found something nobody had thought about. I haven't heard back yet.
The part worth admitting: my first draft of this claimed any two offline edits to the same case would conflict, not just edits to the same field. That's not what the code does. If I'd published that version I'd have been describing a bug next to one that doesn't exist, and muddying the one that does. Rereading the diffing logic narrowed the claim to something smaller and, I think, more useful, a precise report with a file and a line number is a lot harder to wave off than a vague one.
If you work on Primero, or on offline-first systems in general, I'd like to hear where I've got this wrong. And if this kind of thing is useful to you, ZamSync and MenSung are both open.
Mathéo
Top comments (0)