Skip to main content
ThunderLang
← All articles
ai-engineering

A conflict response does not prove a stale write was rejected

5 min read · 2026-11-05 · Allen Codewell

Imagine two editors opening the same document at version 7, each preparing a valid title change. The first saves “Approved budget,” producing version 8; accepting the second editor's “Revised budget” submission against version 7 would overwrite a revision they never saw.

Accept the update implementation only if the stale submission is rejected without changing stored data. Separately, establish that version comparison and mutation are atomic.

A valid payload can still be a stale write.

Exercise the real update handler and persistence layer to check this behavior. This acceptance standard applies equally to human-written updates: AI authorship does not change the concurrency mechanism.

Define permission to replace a particular version

“Prevent lost updates” leaves too much room for interpretation. Specify what must happen at the update boundary.

In this example, a document has an id, a title, and an integer version. The caller supplies the version they read as expected_version. The server owns the next version number.

A stale-write contract

Condition Required result
The payload is valid and expected_version matches the stored version Apply the title change, increment the stored version once, and return success.
The payload is valid but expected_version differs from the stored version Return a conflict and leave the stored document unchanged.
The caller omits expected_version Reject the request rather than silently treating it as an unconditional overwrite.
Competing requests reach persistence Version comparison and mutation must form one indivisible decision.

The version is a precondition for this write. It does not replace authentication or permission checks: an authorized editor can still submit an outdated revision.

I would require this policy for full-record saves or replacements that must preserve intervening edits. I would not apply it blindly to an operation explicitly designed to combine changes, such as adding an item to a set. That operation needs its own conflict semantics.

Make the counterexample an acceptance test

The important sequence is two reads followed by two writes. If the second editor reads again after the first save, the test no longer represents a stale submission.

Adapt the Python template's route, HTTP 200 success and 409 conflict responses, expected record fields, and fixture implementations to your project. Define the missing-version response in your API as well. The fixtures must meet these requirements:

  • client sends requests through the application's real update handler.
  • store seeds and reads an isolated test database using real persistence, not a mocked repository.
  • read_committed performs a fresh database read, avoiding a cached object that could conceal what was actually stored.
def test_stale_update_leaves_document_unchanged(client, store):
    store.seed_committed({
        "id": "doc-1",
        "title": "Draft budget",
        "version": 7,
    })

    # Both editors take their snapshots before either writes.
    editor_a = store.read_committed("doc-1")
    editor_b = store.read_committed("doc-1")
    assert editor_a["version"] == editor_b["version"] == 7

    first = client.patch("/documents/doc-1", json={
        "title": "Approved budget",
        "expected_version": editor_a["version"],
    })
    assert first.status_code == 200

    after_first = store.read_committed("doc-1")
    assert after_first == {
        "id": "doc-1",
        "title": "Approved budget",
        "version": 8,
    }

    stale = client.patch("/documents/doc-1", json={
        "title": "Revised budget",
        "expected_version": editor_b["version"],
    })
    assert stale.status_code == 409
    assert store.read_committed("doc-1") == after_first

Use disposable test resources, never live customer records.

The final assertion matters as much as the conflict response. A handler could return 409 after accidentally writing the stale title. Checking the persisted record catches that failure, including an unintended version increment.

A conflict response alone does not establish that nothing changed.

This test covers the document record. If rejection must also avoid an outbox entry or another persistent side effect, add assertions for those resources to the contract.

A sequential pass still leaves a race to examine

The acceptance test establishes a narrow, useful result: a request arriving after version 8 is committed cannot replace it using version 7.

It does not establish concurrent-write safety.

Consider an implementation that reads the version, compares it in application code, and then performs an unconditional update:

  1. Request A reads version 7 and passes the comparison.
  2. Request B reads version 7 and passes the comparison.
  3. A writes its title.
  4. B writes its title over A's change.

That implementation can pass the sequential test while still losing an update under this interleaving.

Inspect where the precondition is enforced. One implementation option is a conditional update with the version predicate and increment in the same statement:

UPDATE documents
SET title = :title,
    version = version + 1
WHERE id = :document_id
  AND version = :expected_version;

The handler must check the affected-row count before reporting success:

if affected_rows == 0:
    # For an existing document, the version precondition did not match.
    return conflict_response()
if affected_rows != 1:
    raise RuntimeError("Expected exactly one updated document")

Here, affected_rows comes from the database driver, and conflict_response() represents the application's response helper. Adapt parameter syntax and row-count handling to your database and driver. A missing document also produces zero affected rows; define that response separately if your API distinguishes it from a stale version.

This is an implementation option, not a database-independent guarantee. Check the database's conditional-update behavior and the surrounding transaction boundaries. A locking design can also work, but its correctness likewise depends on transaction boundaries and database behavior.

Application-level comparison followed by an unconditional write is insufficient by itself. A transaction label alone does not explain how competing writers are excluded.

The next test should deliberately coordinate competing requests around the critical operation. Sending two requests at roughly the same time is weaker: they might execute sequentially. Even a coordinated test exercises selected schedules rather than proving correctness under every execution.

Stale-write checklist

Before accepting the change, require these four items:

  • Matching version: A valid update succeeds, persists the requested title, and increments the version exactly once.
  • Stale version: Two editors read version 7; the first save succeeds, the stale save is rejected, and a fresh persistence read matches the first saved record.
  • Missing version: Omitting expected_version rejects the request rather than allowing an unconditional overwrite.
  • Competing requests: Identify what makes comparison and mutation atomic, and distinguish that implementation evidence from the sequential acceptance test.

In five minutes, write down the expected stored record after each request and add the final unchanged-state assertion to one existing update test. That gives you a checkable stale-request contract for that update path.