All posts26 May 2026

Capturing forms and photos offline, then proving it later

Offline evidence survives an audit when the record keeps capture time, sync time, provenance and a change history. What an auditor asks, and what to store.

W. Akram8 min readengineering · industry

Evidence captured offline survives an audit when the record keeps two timestamps rather than one: when the device recorded it, and when the server first received it. Add the identity that captured it, the order the work happened in, and a per-field history of every change since, and you can answer the questions an auditor asks about one record on one date.

A capture timestamp says when evidence was recorded on the device; a sync timestamp says when the server first saw it. In office software those are the same number and nobody thinks about them. In field work they sit six hours apart on a normal day, occasionally six days, and the distance between them is itself something you will be asked about.

Why the two timestamps are two different facts

The capture timestamp comes from the device, which makes it the only honest answer to "when was this taken" and a poor answer to "prove it". Tablets sit flat in vans and come back with the clock wherever the last person left it. The sync timestamp comes from your server, so you can stand behind it, but it describes when the paperwork caught up rather than when the work happened.

Keep both, in separate fields, and never let one write over the other. The temptation is to backdate the server's timestamp to the capture time so a report reads tidily in one column. Resist it, because a system caught rewriting its own timestamps stops being believed about anything else.

The pharmaceutical sector wrote the principle down more plainly than most. The MHRA's guidance on GxP data integrity (2018) expects records to be contemporaneously recorded, and lists access to controlled and synchronised clocks at the point of use among the things a compliant system provides. Different regime, same problem: a record has to reflect when the activity happened, not when somebody got round to transcribing it. Offline capture meets that expectation rather than dodging it, since the engineer records at the point of work instead of scribbling on a paper form in the van and typing it up at six.

Worth a field of storage: the difference between the device clock and the server clock at the moment of sync. When a capture time looks wrong eighteen months later, you can say how far out that device was.

What an auditor asks, and what the system has to hold

Auditors do not ask about your sync architecture. They ask about one record, on one date, usually a record nobody on your team remembers. Show me what this looked like on the twelfth of August. Who entered this reading. Why does the number in the report you sent us differ from the number on screen now.

What the auditor asksWhat the system has to hold
When was this taken?A capture timestamp from the device, stored apart from receipt
When did you receive it?A receipt timestamp written by the server, never by the client
Who took it?Identity from the authenticated session, not a name typed into a field
What did this look like on a date?A per-field change history you can replay backwards
Has it changed since?The same history, with an author against each change
Why is there a gap here?Sync state per item, including what is still queued and what failed

The design consequence is per-record reconstruction rather than a searchable pile of application logs. Nobody wants your logs. They want this row, on that date, with the history that produced it.

The bottom row is the one teams leave out and then regret. A missing record is a finding until you can show it was never captured, as opposed to captured and then lost somewhere between a basement and your database.

What travels with a photo

A photo is harder than a form field because it is a file, and files go missing in ways numbers do not. Four things have to ride with it: the visit and question it belongs to, the identity that captured it, the capture time, and a stable local id assigned the instant the shutter closes, so a form can reference the photo before any server has heard of it.

Store the bytes as bytes. We keep photos in the browser's local database as binary blobs, never base64, because base64 adds a third to the size and a day of evidence hits the storage quota sooner than anyone expects. The upload is its own queued event with its own state, which is what lets the app say "four photos still to send" instead of calling the visit complete.

One thing we will not claim on a client's behalf: camera metadata is not proof of location. Location services get switched off, a picture chosen from the library carries whatever it carried, and no auditor has yet accepted an EXIF tag as evidence that somebody stood where they said they did.

Order is part of the evidence

A day's work arriving in one burst at four in the afternoon is not a chronology. Receipt order tells you where the signal came back, and a report built from it will put the closing signature before the readings it signs off.

So capture order gets stored as a fact of its own, stamped on the device at the moment of the change, and the record replays from that rather than from whatever order the server happened to be handed things in.

The awkward part is that some of those changes cannot be sent in the order they were made. A completed form addresses a visit the server has not heard of yet, and points at photos that may still be uploading over one bar. Send it anyway and the server files a form referencing an attachment it never received. To an auditor that is a missing attachment, not a race condition in somebody's queue, and the distinction will not save you. The queue mechanics that keep those pieces together, and the separate argument about genuinely conflicting edits, are in last write wins, field merge and CRDTs.

The audit log we got as a side effect of a dirty check

Compliance software needs per-record change history, and the obvious designs both run on developer discipline: every mutation remembers to append to a history field, or every handler writes to a separate audit entity. Both are one forgotten call away from a hole in the record, and the hole stays invisible until somebody external goes looking.

On a field operations platform we built for a regulated compliance industry, the history came from somewhere else entirely. The update path locks the row, deserialises the stored record twice (one pristine copy, one to mutate), runs the caller's change function, then compares the round-tripped JSON of the two, ordinally. Identical strings mean nothing changed: no update, no search index write, no audit entry. Different strings mean the update appends a per-property delta, the old value and the new one, to a JSON array on the same row in the same transaction. Eleven server-controlled fields are excluded so the history is not buried under "the updated timestamp updated".

Nobody has to remember to log anything, because there is no logging call to forget.

The change history was the point. The rest was unplanned. An update that changes nothing now costs a read and stops. Better still, incoming webhooks became idempotent for free: a redelivered payment event hits a status guard, changes no properties, produces no diff and therefore no write.

The answer to "what did this record look like in August" is then unglamorous. Read the current state and walk the deltas backwards, reverse-applying until you cross the date. No separate reporting system to keep in step.

Two honest costs. It rests on the serialiser emitting properties in a stable order for a given type; change that and every record looks dirty on its next write, so the dependency is written down where the next person will hit it. And the exclusion list is a judgement call: every field you leave out is a change somebody might one day want to see.

What a change log is not

The requirement we were building to was reconstruction: show what a record looked like on a date, and who changed it since. That is not the same requirement as tamper evidence. A per-property change history records what changed, while a regime wanting cryptographic proof of what did not change is asking for an append-only store or a hash chain. Work out which one your regulator means before you build one and describe it as the other.

The other half of proving it later is making sure the evidence arrives at all, because evidence that never syncs does not exist as far as an audit is concerned. Local work is never deleted by a server-side change. The app carries a visible unsent-changes count for as long as anything is queued. A sync failure files itself the moment it dead-letters, one capture per failure, named by event type so each kind groups as its own issue instead of piling into a single anonymous error bucket. Image data is stripped from those payloads before they leave the device.

There was a "send diagnostics to support" button before that. It depended on an engineer at the end of a long day deciding this was worth reporting, which is not a mechanism, and it was deleted a few weeks after it shipped.

The audit does not happen on the day you build any of this. It happens a year and a half later, to somebody who was not there, about a record nobody remembers making. All of the above is an attempt to make that conversation short.

Let's talk

Ready to build the thing?

Book a free 30-minute call. We'll dig into your idea, your stack and your timeline, and give you an honest read on what it will take to build and launch. You'll leave with a clearer plan whether or not you hire us.

Free 30-min call. No pitch.