Last write wins by losing
A deterministic timestamp merge made X8B6 converge by silently erasing one legitimate inventory action, proving that conflict resolution needed domain verbs.
X8B6's first conflict rule was easy to explain: keep the record with the latest updatedAt.
Two devices could edit the same item offline, reconnect in either order, and eventually display one identical value. The replicas converged. The test passed.
One device had recorded that two lamps were removed. Another had recorded that one lamp was removed. Last-write-wins kept one quantity and erased the other movement.
The algorithm had solved disagreement between records by discarding disagreement between intentions.
Deterministic was not the same as correct
The appeal of last-write-wins was strong. It required one timestamp, produced one result, and avoided asking a person to review conflicts. Every device could apply the same comparison.
Its determinism hid a policy:
When two states conflict, the action associated with the later chosen timestamp completely replaces the other action.
That policy was inappropriate for inventory movements. Removing two units and removing one unit were independent facts that could both matter. A final quantity was not a preference where the newest selection naturally superseded the old one.
The system had been storing whole records, so replacement looked like the only available operation. Once X8B6 preserved domain actions, it could ask whether those actions composed, superseded, or required review.
Convergence remained necessary. It stopped being the sole definition of success.
The clock decided whose work disappeared
Even if replacement had been acceptable, updatedAt was a weak judge. Device clocks could drift. A phone set five minutes ahead could win every conflict regardless of actual action order. Receipt time favored whichever device reconnected later, not whichever intent deserved precedence.
I created a simulator case:
server quantity: 12 at revision 83
device A clock: correct
A records adjustment −2 at 10:02
device B clock: +6 minutes
B records adjustment −1 at apparent 10:07
A reconnects after BSorting by device time kept B. Sorting by server receipt kept A. Neither result preserved both removals. Debating the better clock distracted from the incorrect replacement policy.
Timestamps were evidence for chronology and explanation. They did not automatically confer authority. A domain rule needed to say why one operation could supersede another.
The project retained device creation time and server confirmation revision without turning either into a universal winner.
Adjustments composed
Two adjust operations on the same item could generally apply in confirmed order:
base quantity 12
adjust −2
adjust −1
result 9The result was the same whichever adjustment arrived first because addition was commutative in this simple case. Each operation kept its identity and receipt. No work disappeared.
Constraints complicated the rule. If inventory could not go below zero, two individually valid removals might not both be valid together. The server applied operations against current state and could accept one while marking the other needs-review. It did not pre-merge payloads into one mysterious −3 operation, because authorship and correction links still mattered.
The key was that composition came from the verb, not the record shape. A generic numeric field merge could not know that one quantity represented an adjustment and another represented a recount.
For operations that composed, arrival order determined server revisions but not which intent survived.
Recounts did not compose the same way
A recount said, “I physically observed quantity ten at this location.” It carried observation time and a base revision showing which confirmed operations the device knew.
If another adjustment was confirmed after that base, setting quantity to ten could erase a real movement that occurred after the count. Automatically adding the adjustment might be correct if its physical event definitely occurred after the recount, but device and receipt times could not always prove that order.
The server returned a review case:
Your recount: 10 observed at 14:20 from revision 83
Confirmed since then: removed 1 at revision 84
Current shared quantity: 11The reviewer could apply the recount as the new authoritative count, reinterpret it with the intervening movement, or discard it if the physical observation was no longer useful.
This was more work than last-write-wins. It happened only where the system lacked enough evidence to preserve both intentions safely.
Making the difficult case visible was preferable to choosing a clock and calling the lost movement resolved.
After rejecting whole-record replacement, I tried merging different fields independently. If one device changed location and another changed quantity, keep both.
This worked in examples and failed when fields were coupled. Moving an item changed quantity at a source and destination. A status such as archived affected whether later adjustments were valid. A note could refer to the location before the move.
Field independence was another hidden domain claim. Two fields could be merged safely only when their meanings did not constrain each other.
Explicit operations made the coupling visible. move contained source, destination, and quantity and committed them atomically. archive checked for pending work and current balance. annotate attached context without mutating quantity.
I retained field-level updates for genuinely independent metadata, such as a display label, with their own revision and policy. I did not make “merge every non-overlapping key” the global conflict strategy.
A schema tells which values are stored together. It does not prove which changes are independent.
Conflicts belonged to an operation scope
The first sync engine stopped the entire outbox when any record conflicted. One ambiguous recount prevented unrelated adjustments from reaching the server.
I scoped review to the affected item and dependency chain. Operations for other items continued. Later operations that depended on the conflicting projection paused behind it.
lamp-14 recount — needs review
lamp-14 move — waiting for recount decision
chair-08 adjust — confirmedThis required the outbox to know dependencies, not merely sort all requests by creation time. A conflict became a local obstruction with visible downstream consequences.
The UI summarized “one item needs review” rather than “sync failed.” Network health and conflict policy remained separate.
The narrow scope reduced pressure to automate unsafe resolution. Asking for one decision was tolerable; freezing an entire offline session made last-write-wins look attractive merely as an escape from bad queue design.
Good conflict UX starts by containing the conflict.
The interface named both pieces of work
The earliest error said “This record was changed elsewhere.” It identified no work and offered Reload or Overwrite.
Reload discarded the local action. Overwrite discarded the shared action. The buttons were concise because the interface had removed the context needed for judgment.
The revised review showed domain sentences:
You counted 10 lamps on shelf A while this device was offline.
Since your last confirmed view:
— 1 lamp was removed from shelf A.
Shared quantity is now 11.Actions described consequences: use the physical recount as the new quantity, apply the later removal after the count, or keep the shared quantity and discard the recount. When chronology was uncertain, the interface said so.
Revision numbers and operation identities remained available in detail for diagnosis. They did not substitute for explaining the work.
The goal was not to make conflict resolution effortless. It was to make the uncertainty and cost of each choice legible.
The phrase “mine” could refer to the value displayed on this device, the local operation, or the current optimistic projection after several pending actions. Overwriting the server with that projection might resend multiple intentions as one record.
X8B6 presented operations individually. A person could keep the recount while discarding a dependent move, or correct one adjustment without replacing unrelated confirmed fields.
The interface also avoided “theirs.” Shared state was not owned by an anonymous opponent; it contained accepted operations with authorship and times where appropriate. Adversarial language made cooperation feel like a contest.
I used “this device's pending work” and “confirmed shared work.” That terminology reflected protocol state and remained understandable when the same person used two devices.
Precise nouns improved choices. “Apply recount” said which intent would survive. “Keep mine” asked the user to trust an unspecified merge.
Automatic policy needed an explanation
Some conflicts could resolve automatically. Two independent annotations could coexist. Adjustments could compose within constraints. A duplicate operation identity returned its existing receipt. A label edit might use a declared latest-confirmed policy.
I recorded the applied rule in the receipt or history:
Adjustment combined with revision 84
Label change superseded earlier label at revision 90
Duplicate operation returned existing receipt 72The main interface did not show a technical merge log after every action. Detail could explain why the current projection included or excluded an operation.
This mattered for corrections. If a rule produced an unexpected quantity, the history needed to distinguish a lost request from an intentionally composed action.
Automatic does not have to mean invisible. The system should be able to name the policy it used, especially when that policy changes the fate of work.
Last-write-wins had been difficult to question precisely because it left only the winning record.
Deletion exposed another failure of timestamp replacement. One offline device could delete an item while another edited its old cached copy. If the edit arrived later, a last-write rule might recreate the item unintentionally.
I represented archival or deletion as an operation and retained a tombstone identity through the synchronization window. Later actions against that item received a result explaining that the subject had been archived and naming the intervening operation.
The review could restore deliberately, attach the note elsewhere, or discard the pending action. An old record could not resurrect itself merely by carrying a later device timestamp.
Permanent hard deletion had separate retention and privacy implications. For X8B6's working inventory, archive was the safer operational action. Tombstones could eventually compact after every supported offline device window and receipt policy made replay safe.
Again, a final record was insufficient. Absence looked the same whether an item never existed, had been deleted, or had not loaded. The operation history carried the distinction needed for reconciliation.
Rejection was not a merge
The server could reject an operation because the item no longer existed, the quantity violated a constraint, or the actor lacked permission. I had been grouping all non-success results under “conflict.”
Conflict meant multiple legitimate intentions needed policy. Validation failure meant the declared operation could not be accepted under current rules. Authentication failure meant identity needed repair before policy could even be evaluated. Network failure meant no result was known.
The outbox used separate states and actions:
needs-review competing work requires a decision
rejected operation is invalid as declared
waiting-auth identity must be renewed
outcome-unknown retry same identity or query receiptThis prevented a retry button from appearing where retry could never help. It also kept last-write-wins from being offered as a generic way through every server refusal.
Clear failure categories protected the conflict model from becoming a junk drawer.
The old synchronization test created two records, merged them in both orders, and asserted equal final output. Last-write-wins passed.
The new tests also asserted what happened to each operation identity:
- Accepted with a receipt and represented in the projection.
- Waiting for review with both intentions available.
- Rejected with a durable reason.
- Superseded by an explicit operation and visible in history.
For two adjustments, the final quantity and both receipts needed to match in either arrival order when constraints allowed. For a recount plus an intervening adjustment, the expected result was review, not arbitrary convergence.
I added property-like generated sequences within each operation policy, but the most valuable cases remained readable timelines. They made the lost intent obvious.
Convergence stayed in the assertions. It was joined by preservation and explainability.
The tests began evaluating the product consequence of a merge rather than the tidiness of its data structure.
Sometimes one operation truly superseded another
Rejecting last-write-wins did not mean every value needed permanent coexistence. A person changing a display label from “North shelf” to “Front shelf” might reasonably intend the newer accepted edit to replace the older label.
The policy still needed scope and authority. A confirmed label edit could supersede an earlier label edit for the same field. Its receipt named the prior revision. A device with an old pending label change could see the current label and choose whether its edit remained relevant.
The difference from generic last-write-wins was that the domain declared label edits replaceable. The rule did not spill into quantity movements or archival state.
Where practical, the operation could say supersedesOperationId, making intent explicit instead of inferred only from time. This was especially useful for corrections.
A selective supersession rule was not philosophically purer than last-write-wins. It was accountable. The system could explain which kinds of work it considered replaceable and why.
An offline person might continue counting other items after one recount conflicted. The interface could not demand an immediate modal decision during reconnection.
Review-required operations stayed durable in an inbox. Their affected dependency chains paused, while other confirmed and pending work remained available. The item projection showed confirmed state and a separate unresolved local observation.
A review action itself became an operation or receipt-linked decision. If another change arrived before review completed, the screen refreshed the context and invalidated stale choices. The person did not confirm against a snapshot that had already changed.
This required decision identity: the review request named the conflicting operations and shared revision it described. Submitting a resolution against a newer revision could reopen review rather than apply blindly.
Conflict resolution was therefore another concurrency-sensitive workflow, not a one-time dialog over static data.
Keeping it asynchronous reduced pressure to choose the fastest-looking policy.
The policy table lived beside the protocol
I wrote a compact conflict table and kept it with the operation contracts rather than burying rules inside sync callbacks:
| Local operation | Intervening operation | Default result |
|---|---|---|
| Adjust | Adjust | Compose if constraints permit |
| Recount | Adjust | Review chronology and physical observation |
| Move | Archive item | Reject or review restoration |
| Annotate | Adjust | Preserve both |
| Label edit | Label edit | Offer current label; allow explicit supersession |
| Correction | Target already corrected | Review duplicate correction |
The table was not executable truth by itself. Tests covered each row, and server code remained authoritative. Its value was reviewability. A product decision could be challenged without tracing branches through transport code.
Whenever a new operation kind appeared, it needed entries against the relevant existing verbs. “Use default merge” was not an acceptable blank. Some pairs were impossible by validation, and the table said so.
The interface language was reviewed from the same matrix. A compose result needed a history explanation; a review result needed both pieces of work; a rejection needed the violated condition. Keeping policy and presentation adjacent prevented the server from inventing a technically precise state the UI could only call “sync error.”
This modest artifact made domain conflict policy something the project could own deliberately rather than rediscover after each edge case.
The breakthrough was preserving verbs
Last-write-wins offered a clean database story: compare timestamps, keep one record, and let every replica converge. Its cleanliness came from deleting information the product needed.
X8B6 improved when adjust, recount, move, annotate, archive, and correct carried their own policies. Some composed. Some superseded within a narrow scope. Some required review. Every accepted operation received a receipt, and every unresolved one remained visible with its intent.
The system became more complicated than a timestamp comparison and less mysterious to use. A conflict could name the two pieces of work, show what had happened since the offline base, and offer decisions with consequences.
There was no universal merge that made all concurrency disappear. There was a small set of domain rules and an interface for the evidence those rules could not settle.
Last-write-wins had produced one stable quantity by losing a legitimate action. The better system judged success by whether it could converge without pretending the losing work had never existed.