Agent-era architecture · Practical technical guide · 3 November 2026
Two agent runs each read that a customer has one goodwill credit left and each insert one. Every row-level check passes and the rule is broken. How write skew happens across tool calls, which guards actually stop it, and how to migrate an existing service.
Two runs, one rule, three credits
The help desk at Belgavi's Book Store lets its agent grant goodwill store credit, with one rule from the finance team: at most two goodwill credits per customer per calendar month. Geetha already has one this month. On a Tuesday morning she writes about a damaged atlas by email (CASE-4101) and, ten minutes later, opens a chat about a late delivery (CASE-4102). Each case starts its own agent run.
Both runs call the read tool list_goodwill_credits within a second of each other and both see one credit. Both reason for a few seconds, both decide a credit is fair, and both call grant_goodwill_credit. Each tool call inserts one row, each insert succeeds, and Geetha ends the month with three credits. No row was overwritten, no version check failed, and no error was logged anywhere.
Why this is not a lost update
A lost update is two writers changing the same row, and the usual fix is a version check: write only if the row still has the version you read. That fix protects the rows you write. Here neither run writes a row the other read or wrote. The rule spans a set of rows, the decision depends on a count over that set, and each run adds a new member.
This is write skew: two transactions read an overlapping set, make disjoint writes, and together break a constraint that each would have kept alone. Snapshot isolation does not prevent it. The PostgreSQL documentation lists serialization anomalies as possible at Repeatable Read, and warns that attempts to enforce business rules at that level are not likely to work correctly without careful use of explicit locks to block conflicting transactions. Across two agent tool calls there is not even one transaction: the read commits long before the write starts.
Agents turn a rare race into a routine one. A web form reads and writes within milliseconds. An agent run reads, then spends seconds producing its next step, and the same customer can have runs open from email, chat, a retry of a timed-out run and a sub-agent fanned out by a planner. The window between check and act is thousands of times longer.
What stays deterministic
| Concern | Owner | Why |
|---|---|---|
| Whether a credit is deserved | Agent | Judgement over a messy case; can be wrong without breaking an invariant |
| The monthly limit of two | Write tool, in the same transaction as the insert | An invariant must hold whatever the model decides and however many runs race |
| Which month a credit counts against | Write tool, from the server clock and the store time zone | A run must not choose the key that the limit is checked on |
| What happens when the limit is reached | Write tool returns a typed refusal | The agent can tell the customer or escalate instead of retrying blindly |
The read tool still exists and still helps the agent explain itself, but its answer is advice. The write tool is the only place where the limit is enforced, and it enforces it against data it reads inside its own transaction.
Guards that work and one that does not
| Guard | Stops the race? | Cost |
|---|---|---|
| Read tool, then insert (today) | No | None, until the rule breaks |
| Version check on the rows written | No: inserts have no prior version to compare | Looks safe in review |
| INSERT ... SELECT ... WHERE count < 2 in one statement, Read Committed | No: both statements can see a count of one | Narrows the window to milliseconds, does not close it |
| Quota row updated with UPDATE ... WHERE used < 2 | Yes: the row lock serialises the two runs | One extra row per customer and month |
| SERIALIZABLE transaction with retry | Yes: one transaction fails with a serialization error | Retries, and every write path must use the level |
| Advisory lock on the customer key | Yes, if every writer takes it | Nothing enforces that a new code path takes it |
| Partial unique index | Yes, when the limit is one | Cannot express a limit of two |
The quota row is usually the simplest correct choice because it turns a constraint over a set into a write to one row, which is the case version checks and row locks were built for. The PostgreSQL documentation describes the mechanism: under Read Committed, an UPDATE that finds a row being updated by another transaction waits for it to commit, then re-evaluates its WHERE clause against the updated row. The second run's used < 2 is false after the first commits, so it updates nothing.
The write tool, rewritten
Python with a psycopg-style connection; illustrative, checked for syntax. The quota row and the credit row commit together or not at all.
QUOTA_SQL = """
INSERT INTO goodwill_quota (customer_id, month, used)
SELECT %s, %s, count(*) FROM goodwill_credit WHERE customer_id = %s AND month = %s
ON CONFLICT (customer_id, month) DO NOTHING
"""
CLAIM_SQL = """
UPDATE goodwill_quota SET used = used + 1
WHERE customer_id = %s AND month = %s AND used < %s
RETURNING used
"""
def grant_goodwill_credit(conn, customer_id, case_id, amount, limit=2):
month = store_month_now() # server clock, store time zone
with conn.transaction():
conn.execute(QUOTA_SQL, (customer_id, month, customer_id, month))
row = conn.execute(CLAIM_SQL, (customer_id, month, limit)).fetchone()
if row is None:
return {"status": "limit_reached", "limit": limit, "month": month,
"next_step": "explain the limit or hand the case to a person"}
conn.execute("INSERT INTO goodwill_credit (customer_id, case_id, amount, month) "
"VALUES (%s, %s, %s, %s)", (customer_id, case_id, amount, month))
return {"status": "granted", "used": row[0], "limit": limit}The quota row starts from the credits that already exist, so switching mid-month counts them. Three more details matter. The month is computed by the tool, not passed in by the model, so a run cannot dodge the limit by naming another month. The refusal is a normal result with a next step, not an exception the agent framework might retry. And the transaction contains no model call: the lock on the quota row is held for milliseconds, not for the seconds a model takes to think.
Worked example, with and without the guard
| Time (s) | Run for CASE-4101 | Run for CASE-4102 |
|---|---|---|
| 0.0 | reads 1 credit this month | |
| 0.6 | reads 1 credit this month | |
| 3.2 | grant: quota used 1 to 2, credit inserted, commit | |
| 3.8 | grant: quota row has used = 2, update matches no row | |
| 3.8 | tool returns limit_reached; run tells Geetha a person will review the chat |
Without the quota row, the 3.8 s call inserts a third credit. The companion lab replays this with up to six runs, different think times and each guard in the table, and counts invariant violations, refusals and retries. With SERIALIZABLE and no retry budget, the second run fails with an error instead of a clean refusal, which an agent loop tends to treat as a transient failure and retry with a different tool. That is why the guard should return a domain result.
Failure modes to test
- Two paths, one guard. The agent tool takes the quota row, but the back-office screen inserts credits directly. Put the guard in the database layer both use, or add a constraint trigger.
- Wrong key. The month is computed in UTC while the rule is in store time, so a credit at 00:30 on the first of the month counts against the previous month.
- Retried reasoning. A serialization failure retries the whole agent step, including a new model call that may now decide differently. Retry the transaction, not the reasoning.
- Workaround tools. After
limit_reached, the agent issues a discount code instead. Decide which tools count against the same budget and enforce it on all of them. - Lock held across thinking. A run takes
SELECT ... FOR UPDATEin its read tool and holds the transaction open until its write tool runs. Every other run for that customer waits on the model.
Migration steps for an existing service
- List the rules that count or sum over rows: limits per customer, capacity, one active item per owner, minimum staffing. These are the ones row-level checks miss.
- For each, find every write path, including agent tools, admin screens, batch jobs and imports.
- Pick a guard per rule: a partial unique index for a limit of one, a quota or counter row for small limits, SERIALIZABLE where many rules share a transaction and the team can handle retries.
- Move the check into the write tool and return a typed refusal. Keep the read tool as advice.
- Run a concurrency test that starts several writers behind a barrier for the same key and asserts the invariant afterwards. Keep it in CI.
- Add a periodic query that counts violations in production, so a missed path shows up as a number rather than a finance complaint.
When this is unnecessary
If all work for one customer is serialised already, for example a queue partitioned by customer with one consumer per partition, two runs never overlap and the read-then-write tool pair is safe. If the rule is soft, such as a guideline that is reviewed monthly, an alert on violations may be cheaper than a guard. And if the limit is one, a partial unique index expresses it directly and needs no new table. Adding an agent does not change these cases; it changes how often the unguarded ones fire.
Verification
The companion example runs both tool versions against SQLite with two threads released by a barrier and checks the final count; the unguarded version ends with three credits and the guarded one with two and one limit_reached. In your own system, the same test against the real database is the evidence that matters, together with the violation count staying at zero after release.
Sources and scope
Checked against these sources on 3 November 2026. Timelines and counts are from the deterministic lab model and the companion example, which runs on SQLite with synthetic data; nothing here is a production measurement.
- PostgreSQL documentation: Transaction Isolation (Repeatable Read, Serializable, serialization failures)
- PostgreSQL documentation: Explicit Locking (row-level locks, advisory locks)
- PostgreSQL documentation: UPDATE (WHERE conditions, RETURNING)
- Anthropic: Building effective agents (workflows versus agents, when not to use agents)
A limit over several rows breaks when concurrent runs each read the count and each insert, and per-row version checks never notice. Enforce the limit inside the write tool, in one short transaction, with a quota row, SERIALIZABLE plus retry or a unique index, and return a typed refusal the agent can act on.