Engineering research / system design

Agents and Lost Updates: Version Checks, Re-Planning on Conflict and Field Merges When Think Time Is Seconds

Why an agent that reads a record, reasons for twenty seconds and writes it back overwrites other people's changes, and how to fix it with version checks at the store, a conflict result the agent can re-plan from, field-level merges, a migration path, and when none of it is needed.

By Sandeep Belgavi · Published 2026-10-26

Agent-era architecture · Practical technical guide · 26 October 2026

Why an agent that reads a record, reasons for twenty seconds and writes it back overwrites other people's changes, and how to fix it with version checks at the store, a conflict result the agent can re-plan from, field-level merges, a migration path, and when none of it is needed.

Why agents turn a rare race into a routine one

A lost update happens when two writers read the same record, each changes it, and the second write silently replaces the first. Web applications have always had the race, but a human editing a form and a backend doing a read-modify-write in a few milliseconds rarely collide. An agent changes the timing. It reads the record through a tool, sends the record and the conversation to a model, waits several seconds or more for a reply, possibly calls other tools, and only then writes. The window between read and write is now as long as the model takes to think.

The other writers do not stop during that window. A person updates the same ticket in the help desk, a batch job recalculates a field, a second agent works on a related request. If the agent's tool writes the whole record back as it read it, with only its own changes applied, every change made in the window disappears. Nothing errors and the record looks valid. The fix is old: make writes conditional on the version that was read. What is new is what the agent should do when the condition fails.

What stays deterministic

The model proposes a new value. Everything that decides whether that value may be written, and what happens if the record moved, belongs to ordinary code at the store.

ConcernOwnerDeterministic?
Which fields to change and to whatAgentNo; this is the judgement
Which version the agent readTool wrapper records itYes
Whether the record changed sinceStore: version column, ETag or resourceVersionYes
Whether the two changes can mergeMerge rule per fieldYes
What the agent sees on a conflictTool result contractYes
How many re-plans before a person decidesRe-plan budgetYes

Keeping these in code means you can test them without a model. The agent's only job after a conflict is to decide whether its intended change still makes sense.

A worked example: a ticket edited by an agent and a person

A support ticket is at version 7: status open, priority P3, no internal note. An agent reads it to draft a resolution. Eight seconds later, while the model is still working, a support engineer raises the priority to P1 and adds a note that the customer is a hospital; the ticket becomes version 8. At twenty seconds the agent writes status resolved with its summary.

Write strategyWhat the agent sendsFinal ticket
Whole record, unconditionalThe version 7 record with status and summary changedResolved, priority back to P3, note gone: two changes lost
Whole record with If-Match: version 7Same, but conditionalRejected with 412; the agent sees version 8 and re-plans
Changed fields only, three-way mergestatus and summary, based on version 7Merged: resolved, P1, note kept
Changed fields only, merge plus a coupling rulestatus and summaryRejected: resolving a P1 needs a second look

The third row is the tempting one, and it shows why a merge needs rules. The fields are disjoint, so a mechanical merge succeeds, yet resolving a ticket that was just escalated to P1 for a hospital is probably wrong. Fields that are coupled in meaning, such as status with priority, or amount with currency, should be declared as a group: a change to any field in the group since the agent read it is a conflict, even if the agent did not touch that field.

Illustrative Python with sqlite3-style calls; the UPDATE is the real guard, the merge check only decides whether to try.

import json

COUPLED = [{"status", "priority"}, {"amount", "currency"}]

def write_ticket(db, ticket_id, read_version, base, changes):
    """Apply an agent's field changes if they merge with what changed since it read."""
    row = db.execute("SELECT version, data FROM tickets WHERE id = ?",
                     (ticket_id,)).fetchone()
    version, current = row[0], json.loads(row[1])
    if version != read_version:
        theirs = {k for k in current if current.get(k) != base.get(k)}
        ours = set(changes)
        touched = ours | {f for g in COUPLED if g & ours for f in g}
        if theirs & touched:
            return {"status": "conflict", "current_version": version,
                    "changed_since_read": sorted(theirs), "current": current}
    merged = {**current, **changes}
    cur = db.execute(
        "UPDATE tickets SET data = ?, version = version + 1 "
        "WHERE id = ? AND version = ?",
        (json.dumps(merged), ticket_id, version))
    if cur.rowcount == 0:          # someone wrote between our SELECT and UPDATE
        return {"status": "conflict", "current_version": None}
    return {"status": "ok", "version": version + 1}

The final UPDATE is what makes this safe: it only succeeds if the version is still the one the merge was computed against, so two concurrent merges cannot both win. The earlier check exists to produce a useful answer for the agent instead of a bare failure.

A conflict result the agent can act on

HTTP already has the vocabulary. A client that wants to avoid overwriting someone else's change sends If-Match with the ETag it read, and the server answers 412 Precondition Failed when the ETag no longer matches; RFC 9110 describes this as preventing the lost update problem. A server that wants to forbid unconditional writes can answer 428 Precondition Required, defined in RFC 6585 for exactly that purpose. Kubernetes uses the same idea with resourceVersion and answers a stale update with 409 Conflict, and Google's AIP-154 asks APIs to reject a mismatched etag with ABORTED, which maps to 409.

For an agent the status code is not enough. The tool result should say that the write did not happen, which fields changed since the read, the current values of those fields, and the new version to base a retry on. That turns a conflict into new evidence: the agent re-reads the situation and either re-applies its change, adapts it, or gives up. Give it a small re-plan budget, two attempts is a reasonable start, and route the case to a person when the budget is spent, because repeated conflicts usually mean another actor is actively working on the same record.

Locks and long transactions fit agents badly

A pessimistic lock looks simpler: lock the row when the agent reads it and release it on write. With an agent in the middle, that lock is held across a model call of unknown length, every other writer queues behind it, and a crashed run leaves the lock until a timeout notices. Holding a database transaction open across the model call has the same problem plus a connection pinned for the duration. Under PostgreSQL's Repeatable Read or Serializable isolation, a concurrent change makes the agent's write fail with a serialization error such as could not serialize access due to concurrent update, which is correct but arrives only after all the waiting.

If an edit genuinely needs exclusivity, for example an agent rewriting a long document section by section, use a lease: a row that names the holder and an expiry, renewed while the agent works, and checked with a fencing token on every write so a holder whose lease expired cannot write late. For the common case of short field edits, optimistic concurrency is cheaper and needs no cleanup.

Migration steps for an existing system

  1. Add a version column, or expose the ETag or resourceVersion the store already has, on every resource an agent can write. Return it from every read tool.
  2. Change agent write tools from whole-record PUT to a patch of changed fields, carrying the version the agent read and the base values of the fields it changed.
  3. Require a precondition for agent principals: reject an agent write without a version, as a 428 would, while leaving existing human clients alone until they migrate.
  4. Implement the conflict result described above and declare coupled field groups for each resource.
  5. Log every conflict with the read-to-write interval. That interval, not the request rate, predicts how often agents collide.
  6. Add a re-plan budget and an escalation path, then test with two synthetic writers and a forced interleaving.

Failure modes to test for

  • The tool retries for the agent. A wrapper that answers 412 by re-reading the version and re-sending the same body has reintroduced the lost update with extra steps. A retry after a conflict must go back to the model or apply a merge rule.
  • Versions on the parent only. If line items or comments live in child tables, a version on the ticket row does not cover them unless every child write also bumps it.
  • Cached reads. A read tool served from a cache returns an old version, so the agent starts every write already in conflict and burns its re-plan budget.
  • Timestamp validators. Last-Modified has one-second resolution, so two writes in the same second share a validator; use a counter or a strong ETag.
  • Silent merges of coupled fields. A mechanical merge that succeeds is not proof the result is sensible; declare the groups.

Verification

Write a test that interleaves two writers deterministically: agent reads, person writes, agent writes. Assert that the final record contains both changes or that the agent received a conflict naming the changed fields. The companion example, agent_optimistic_writes, does exactly this for the ticket above with SQLite. In production, track conflicts per write tool and the share resolved by re-planning versus escalation; a rising conflict rate on one resource is a sign that agents and people are working the same queue and the work should be partitioned instead.

When this is unnecessary

Skip the machinery when an agent is the only writer of a resource, when the agent only creates new records, when the data is append-only such as events or comments, or when the change is commutative, such as incrementing a counter with an atomic add. In those cases a conditional write protects nothing. Everywhere else, the version check costs one column and one WHERE clause, which is much cheaper than discovering weeks later that an agent quietly undid a colleague's escalation.

Sources and scope

Checked against these sources on 26 October 2026. Examples and figures are illustrative, not measurements.

Make every agent write conditional on the version it read, send changed fields rather than whole records, declare coupled fields so a merge cannot hide a semantic conflict, return a conflict the agent can re-plan from with a small budget, and never let the tool wrapper retry a stale write on its own.