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.
| Concern | Owner | Deterministic? |
|---|---|---|
| Which fields to change and to what | Agent | No; this is the judgement |
| Which version the agent read | Tool wrapper records it | Yes |
| Whether the record changed since | Store: version column, ETag or resourceVersion | Yes |
| Whether the two changes can merge | Merge rule per field | Yes |
| What the agent sees on a conflict | Tool result contract | Yes |
| How many re-plans before a person decides | Re-plan budget | Yes |
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 strategy | What the agent sends | Final ticket |
|---|---|---|
| Whole record, unconditional | The version 7 record with status and summary changed | Resolved, priority back to P3, note gone: two changes lost |
| Whole record with If-Match: version 7 | Same, but conditional | Rejected with 412; the agent sees version 8 and re-plans |
| Changed fields only, three-way merge | status and summary, based on version 7 | Merged: resolved, P1, note kept |
| Changed fields only, merge plus a coupling rule | status and summary | Rejected: 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
- 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.
- 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.
- 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.
- Implement the conflict result described above and declare coupled field groups for each resource.
- Log every conflict with the read-to-write interval. That interval, not the request rate, predicts how often agents collide.
- 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.
- RFC 9110: HTTP Semantics (conditional requests, If-Match, 412, idempotent methods)
- RFC 6585: 428 Precondition Required
- Kubernetes documentation: API concepts (resourceVersion and 409 Conflict)
- Google AIP-154: Resource freshness validation (etags)
- PostgreSQL documentation: Transaction isolation
- Anthropic: Building effective agents
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.