Conflict resolution for an offline-first mobile client.
The requirements are not release-ready: conflict detection lacks an atomic causal-concurrency protocol, safety classification fails open, and client-supplied HLCs can be abused or poisoned. The board agrees safety, auditability, sync reliability, and measurable release gates need explicit server-enforced requirements.
HLC orders writes but cannot establish concurrency without a client base version and atomic server compare-and-apply. Races can still silently overwrite critical edits.
Raised by Product Delivery Lead
New fields default non-critical through a mutable/manual allowlist. Criticality must come from a server-owned schema registry and block resolution when unknown.
Raised by AI Governance and Evaluation Reviewer
A malicious or badly skewed future client timestamp can advance ordering and make legitimate writes lose until wall time catches up. Client HLCs need server bounds and validation.
Raised by Security and Reliability Reviewer
No idempotent batch, partial-ack, deduplication, pruning, or outbox-cap requirements are defined. Multi-day offline retries can duplicate operations or exhaust device storage.
Raised by API and Integration Architect
Tombstones, immutable resolution history, retention/export, reviewer authority, SLA/escalation, and downstream behavior during safety holds are unspecified.
Raised by Product Delivery Lead
Majority view: Retain automated resolution only with governed classification, auditable outcomes, and QA/compliance visibility; contextual fields can be safety-relevant.
The decision is whether to approve these product requirements for release; they should not be approved in their current form. The requirements leave critical safety, concurrency, security, auditability, and offline-sync behaviors unspecified, creating a material risk of silently lost critical edits, unsafe automatic resolution, manipulated write ordering, and unreliable recovery from offline operation.
A score of 35/100 indicates serious deficiencies in the document itself, not merely implementation detail to be resolved later. In practice, the requirements do not define the server-enforced controls or measurable release conditions needed to demonstrate safe and reliable operation.
The only substantive disagreement concerns whether non-critical conflicts may resolve silently to reduce technician triage burden. Automated resolution is supportable only after server-governed classification, auditable outcomes, and quality-assurance and compliance visibility are defined, because contextual fields may be safety-relevant.
Field · Value
Status · Proposed — targeting implementation in Q4
Author · Staff Engineer, Mobile Platform
Reviewers · Backend Lead, QA/Compliance Lead, Product Manager
Related systems · fieldnote-mobile (iOS/Android, React
Native), fieldnote-api (sync backend)
Last updated · 2026-08-20
Fieldnote is used by field technicians performing utility and telecom
infrastructure inspections — pole inspections, cabinet checks, right-of-way
clearance surveys. A large share of our inspection volume happens in rural
or industrial sites with no or intermittent cellular coverage: our own
telemetry from the last two quarters shows that 34% of inspection sessions
have at least one 15-minute stretch with zero connectivity, and roughly 6%
of sessions are conducted entirely offline, syncing only once the
technician returns to a vehicle or depot with signal.
The current app (v2.x) handles this by queuing writes locally and
replaying them to the server on reconnect, in submission order, with the
server accepting whichever write arrives last for a given record. This has
worked acceptably for single-author records, but it breaks down in a
specific and increasingly common scenario: a technician completes an
inspection offline, and *before their device reconnects*, a QA supervisor
reviewing a related, already-synced inspection in the web console makes a
correction to the same record (for example, re-classifying a defect
severity after a phone call with the technician). When the technician's
device finally reconnects, its queued write — built from a now-stale local
copy of the record — overwrites the supervisor's correction with no
warning to either party. We have three documented incidents in the last
two quarters where this caused a real inspection record to silently revert
to an incorrect state, one of which was only caught because a compliance
auditor cross-referenced a paper photo log against the app record and
noticed the mismatch.
This document proposes a conflict-aware sync design to replace the current
last-submission-wins behavior.
We need a sync design that:
cases) without data loss.
or user without silently discarding either party's work.
frequently working one-handed, in gloves, in poor lighting or bright
sun — abandon the app or route around it.
(specifically: hazard classification and pass/fail disposition) cannot
be silently overwritten without a record of the fact that a conflict
occurred.
Goals
majority of low-stakes fields (notes, photo attachments, timestamps of
sub-steps).
fields.
without a network time sync and device clocks in the field fleet have
been observed drifting by as much as 40 minutes.
engineer within a quarter.
Non-Goals
concurrent cursors). Inspections are not edited concurrently character
by character; conflicts arise from two separate offline-then-sync
events, not live co-editing.
this (Section 8) and are explicitly not building it for v1.
fieldnote-api server; devices never sync directly with
each other.
Each inspection record is represented locally as a set of fields, not a
single opaque blob. Writes are captured as an append-only local operation
log (an "outbox") rather than as in-place mutations to a local mirror of
server state. On reconnect, the client ships its outbox to the server,
which merges incoming operations against the current server state on a
per-field basis using a Hybrid Logical Clock (HLC) for ordering, not
raw device wall-clock time. Most fields resolve automatically
(last-write-wins by HLC order). A small set of fields designated
"critical" never auto-resolve on conflict — a true concurrent edit to one
of those fields produces a conflict record that blocks final submission
until a human — currently, always a QA reviewer, not the technician —
resolves it explicitly.
```
┌──────────────┐ outbox ops (HLC-stamped) ┌──────────────┐
│ Mobile app │ ───────────────────────────────────────▶│ Sync API │
│ (SQLite + │ │ (per-field │
│ outbox log) │◀─────────────────────────────────────────│ merge) │
└──────────────┘ accepted ops + any CONFLICT markers └──────────────┘
```
Local storage is SQLite. Each inspection record's mutable fields are
stored in an EAV-style (entity-attribute-value) table rather than fixed
columns, which is what makes per-field conflict detection and HLC
stamping tractable without a schema migration every time a new field type
is added.
```sql
-- local (SQLite) and server (Postgres) share this shape
CREATE TABLE inspection_field_values (
record_id TEXT NOT NULL, -- inspection record UUID
field_key TEXT NOT NULL, -- e.g. 'hazard_classification'
value TEXT NOT NULL, -- JSON-encoded scalar, array, or object
hlc_timestamp TEXT NOT NULL, -- hybrid logical clock, e.g. '2026-08-20T14:03:11.482Z-0007-devABC'
author_id TEXT NOT NULL,
author_device TEXT NOT NULL,
is_critical INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (record_id, field_key)
);
CREATE TABLE outbox (
op_id TEXT PRIMARY KEY,
record_id TEXT NOT NULL,
field_key TEXT NOT NULL,
value TEXT NOT NULL,
hlc_timestamp TEXT NOT NULL,
synced INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE conflicts (
conflict_id TEXT PRIMARY KEY,
record_id TEXT NOT NULL,
field_key TEXT NOT NULL,
server_value TEXT NOT NULL,
incoming_value TEXT NOT NULL,
server_hlc TEXT NOT NULL,
incoming_hlc TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open' -- open | resolved
);
```
A Hybrid Logical Clock combines a physical timestamp with a logical
counter and a device ID, so that ordering remains consistent even when two
devices' physical clocks disagree — the logical component only ever moves
forward, and the device ID breaks ties deterministically. This directly
addresses the clock-drift problem noted in Section 3: with raw wall-clock
LWW, a device with a fast clock can always "win" regardless of which edit
actually happened later in real terms, which is exactly the failure mode
implicated in at least one of the three incidents referenced above.
inspection_field_values mirror immediately (optimistic,
for
responsive UI) and appended to outbox with a freshly
minted HLC
timestamp.
state for every record it has pending outbox entries for.
field is causally *before* the client's last known HLC for that field
(i.e., the client had the latest version when it made its edit), no
conflict exists — the client's write is simply newer and is queued for
normal apply.
*concurrent with or after* the client's baseline for that field — i.e.,
someone else wrote to that same field after the point the client's copy
was last known-current — a conflict exists.
is_critical = 0: resolve automatically, server
value wins if its
HLC is greater, else client value wins and is applied. No user is
interrupted.
is_critical = 1: no automatic resolution. A row is
written to
conflicts with status = 'open',
the client's write is held (not
discarded — both versions are retained), and the record is flagged
needs_review in the QA queue. The technician's app
shows a
non-blocking banner ("This inspection has a pending review — your
entries are saved") rather than a resolution dialog; **resolution
UI is only shown to the QA reviewer role**, not in the field.
defects_found, a list of
independently-addable defect entries) are treated specially: rather
than LWW on the whole list, each list item is its own addressable
entity with its own HLC (effectively an OR-Set), so two people adding
different defects concurrently both survive, and only an edit to the
*same* defect entry triggers field-level conflict logic.
```
CRITICAL_FIELDS = {
"hazard_classification",
"pass_fail_disposition",
"safety_hold_flag",
}
```
This is the section most likely to be second-guessed, and it should be,
because it embeds a judgment call rather than a provably correct answer.
An earlier draft of this design proposed representing the entire
inspection record as a CRDT document (evaluated: Automerge). This would
give mathematically guaranteed convergence for every field type, including
nested structures, without hand-maintaining a critical-fields list. It was
set aside for this iteration for three reasons: the storage and CPU
overhead of CRDT metadata on our lowest-spec supported devices (a
meaningful fraction of the field fleet is 4-year-old Android hardware) was
measured at roughly 2.3x the local storage footprint per record in a
spike; the team's collective CRDT experience is limited, which is a real
implementation-risk cost, not just a preference; and — most importantly —
CRDT convergence guarantees *eventual consistency of the data structure*,
not correctness of the safety judgment it contains. A CRDT would happily
and correctly converge to a merged state for
hazard_classification; it
would not tell anyone that two people disagreed about whether a hazard
exists, which is the actual thing QA needs to know about. We still need
explicit conflict surfacing for critical fields regardless of the merge
mechanism underneath, which reduces the marginal benefit of full CRDT
semantics for exactly the fields where correctness matters most.
Backend/mobile engineering's position (reflected in the current
design): a small, explicit, hand-maintained allowlist of critical fields
is the right mechanism. It's auditable — anyone can read the
CRITICAL_FIELDS set and know exactly what's
protected — and it keeps
the common case (the other ~40 fields on an inspection record) fast and
frictionless. Expanding it is a one-line code change with review, which
is an acceptable cost for a decision that shouldn't change often.
QA/Compliance's position, raised in review and not fully resolved,
is that a hand-maintained allowlist will drift out of date as the
inspection form schema evolves — new field types get added by Product
roughly every other release, and nothing in the proposed design forces a
reviewer to consciously decide whether a new field belongs on the
critical list; the default, silently, is that it doesn't. Ilse's
specific counter-proposal was to invert the default: *all* fields
conflict-block unless explicitly marked safe-to-auto-resolve, on the
theory that a false positive (an unnecessary review prompt for a
low-stakes field) costs a QA reviewer a few seconds, while a false
negative (a safety field silently auto-resolved because someone forgot to
add it to the list) has already caused a real incident. This is a
legitimate position that the current draft does not adopt, primarily
because Product's concern (Section 7.3) is that a default-blocking
posture would make review-queue volume unpredictable and potentially large
as the form schema grows, and no one has data yet on what fraction of
fields would reasonably need to be non-critical. This is recorded as an
open disagreement, not a resolved one; see Section 11.
A second, related disagreement is about the non-critical path. The
current design auto-resolves and does not notify anyone that a conflict
occurred, even after the fact — the "losing" write is simply discarded
(though its HLC and value are retained in an audit log table, so it's
recoverable by an engineer, just not surfaced to any human in the normal
workflow).
Product's position: this is correct and is a hard requirement, not a
nice-to-have. Field technicians are the primary users, they are not going
to triage a conflict resolution inbox between site visits, and any
UI that asks them to adjudicate a merge is UI they will learn to dismiss
without reading, which is worse than not showing it at all. Silent
auto-resolution for non-critical fields (notes, sub-step timestamps, photo
captions) is the right trade because the stakes of a wrong auto-resolution
on those fields are genuinely low.