Reproduce the overwrite first
Give two clients the same document revision. Client A changes its title and saves. Client B saves a different title from the old view. If both updates identify the document without checking its revision at the write boundary, the later write can erase the earlier edit.
You can reproduce that sequence with the SQLite example. Save it as experiment.py and run python3 experiment.py. It needs Python with its standard SQLite module. It creates an in-memory database and writes results.json beside the script. No server, credentials or external API is involved.
The unguarded example lets both clients read revision 1 before either writes. The final title is B at revision 3. A's edit is gone even though both clients passed an earlier application-level comparison. This is the failure you need the negative test to reveal.
The repaired example returns an HTTP-like 412 for the stale second edit and leaves A's title at revision 2. These are local function outcomes mapped to an intended endpoint contract, not responses captured from a running HTTP service. The saved run records the actual SQLite version and every resulting state.
Put the comparison in the write
Replace the unconditional update with one statement that identifies both the resource and the revision the client read. Increment the revision in the same write. Check the affected-row count before reporting success.
UPDATE document
SET title = ?, revision = revision + 1
WHERE id = ? AND revision = ?;When A submits revision 1, one row changes and becomes revision 2. B's later update still asks for revision 1, so it changes zero rows. There is no gap between a separate revision comparison and the corresponding mutation for another writer to exploit.
Keep the predicate when you move this into an ORM or repository layer. Reading a row, comparing its version in application code and then calling an ordinary save recreates the original gap unless an appropriate transaction or lock supplies the missing guarantee. Inspect the actual write behavior, not only the presence of a version property on an object.
Zero affected rows is evidence that the guarded update did not apply. In a real endpoint, resource absence, authorization and validation still require their own treatment. Do not expose a different resource's existence merely to explain a precondition result.
Give the validator one clear meaning
An ETag is a validator for a representation. In this fixture, the document has one stable representation and every change increments a revision, so a generated tag such as "v2" can represent that state. The design stops being valid if another code path changes represented data without advancing the revision.
RFC 9110 defines If-Match using strong comparison. A weak tag cannot satisfy that comparison. The wildcard * tests whether a current representation exists; it does not assert that the representation still equals the version a client read. For an edit that must preserve a user's observed state, send its concrete validator.
Avoid reusing a revision after deletion and recreation of the same resource identifier. An old tag could then appear current for a different resource incarnation. Use a generation component or another validator design whose identity cannot collide across that lifecycle. The miniature database never recreates a row, so this requirement remains an integration check. For a separate projection of the resource, the search-index deletion model shows how version guards prevent a delayed write from recreating a deleted document.
If responses vary by language or representation format, decide which representation the validator describes. A revision counter that ignores a representation-changing dependency cannot serve as a strong validator merely because it is wrapped in quotes.
Run the negative checks
The example performs seven guarded calls after resetting the unguarded fixture. Three mutate the row and four do not. The point is the resulting state for each input, not the number of checks.
| Input case | Outcome | State to inspect |
|---|---|---|
| A supplies current v1 | 204 | Title A, revision 2 |
| B supplies stale v1 | 412 | Title A remains |
| Weak current tag | 412 | Revision stays 2 |
| Missing precondition | 428 | Revision stays 2 |
| Wildcard, existing row | 204 | Wildcard title, revision 3 |
| Wildcard, absent row | 412 | Existing d1 is unchanged |
| Reread v3 and merge | 204 | Reviewed merge, revision 4 |
The missing-header result is the chosen policy for this example. RFC 6585's 428 status lets a server require a conditional request. That requirement must be documented for clients; adding an ETag to reads alone does not force writes to use it.
The fixture accepts one generated tag or the wildcard. It intentionally does not parse the full HTTP entity-tag list grammar, validate request bodies or model every ordering rule for HTTP preconditions. Use a standards-aware HTTP layer for those tasks. Treat this download as an executable check of the persistence boundary, not a paste-ready endpoint.
Return a conflict the client can resolve
After a stale edit, keep the user's pending changes in the interface. Fetch the current representation, compare it with the view the user edited and let the user reconcile the difference. Submit the reviewed result with the newly read validator.
Do not automatically replace the stale tag with the latest tag while resending the same full document. That can make the write succeed by discarding the protection the user needed. A blind retry changes neither the intended edit nor the fact that it was based on an old view.
Some domain operations can be safely reapplied after a fresh read. That requires a domain-specific merge rule, such as preserving a separately addressed field or validating an independent operation. A general document editor cannot assume such a rule exists. Make the conflict message preserve the local draft and identify the changed server version.
The API idempotency article deals with repeated attempts to perform the same intent. A version precondition deals with competing edits. A system may need both, and one key should not silently stand in for the other.
Carry the guard into the real endpoint
Repeat the two-client scenario through the deployed endpoint before accepting the change. Capture both initial validators, submit A's edit and then B's stale edit, and inspect the persisted record. A failed response with B's values nevertheless stored is a failed guard.
Add a second writer path, such as an administrative update or background task, to that integration check. It must advance the same representation version. Include resource recreation and representation variants if the product supports them. Finally, verify that the client retains its unsaved draft after the precondition fails and sends a new validator only after reconciliation.
Sources
Documentation checked .
