# Conditional API requests that stop agent overwrites

An agent can produce a perfectly valid API request and still do real damage. The failure happens when it reads a record, another actor changes that record, and the agent later writes its old copy back over the newer state. Authentication does nothing to stop this. Authorization does nothing either. The request came from an allowed principal, but it carried an obsolete view of reality.

Conditional API requests fix that specific failure. The client says, in effect, "apply this change only if the resource remains the version I observed." The server tests that statement as part of the write. If it is false, the server refuses the action before it changes anything.

That contract matters more with coding agents than with a person clicking through a form. Agents can read many resources, pause to inspect code or run tests, then issue a batch of writes after the world has moved on. Treat every consequential update as a read-modify-write operation unless the API can prove it is an append or a commutative command.

## Lost updates happen in ordinary read-modify-write flows

A lost update occurs when two writers begin from the same old state and the later write erases the earlier one. It does not require a database outage, a malicious user, or a broken network. It requires only a server that accepts an unconditional replacement.

Consider a deployment configuration exposed as JSON:

```json
{
  "name": "billing-worker",
  "replicas": 3,
  "image": "registry.example/billing:2.4.0",
  "maintenanceMode": false
}
```

An agent reads it to raise `replicas` from 3 to 5 before a load test. While it works, an operator changes `maintenanceMode` to `true` to investigate a queue problem. If the agent later sends a full `PUT` using its saved document, it may put `maintenanceMode` back to `false`. The request did change replicas as intended. It also undid a safety decision the agent never saw.

A partial update reduces the blast radius, but it does not erase the race. If an agent sends a PATCH to replace `/replicas`, that field can still have changed since the read. More importantly, the decision to set replicas to 5 may depend on fields that have changed elsewhere. PATCH describes the shape of the request body. It does not state what version of the resource the body assumes.

This is why "our UI only changes one field" is not a concurrency design. A UI may hide the issue for a while because people act slowly and see fresh pages. An autonomous process has none of those accidental safeguards.

## An ETag identifies the representation a client saw

An `ETag` response header is an HTTP validator. When a server returns a representation, it can attach a token that identifies that version of the representation:

```http
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "deploy-8f31c2"

{
  "name": "billing-worker",
  "replicas": 3,
  "image": "registry.example/billing:2.4.0",
  "maintenanceMode": false
}
```

The string has no required internal format. It might encode a database revision, a content hash, or a generated opaque value. Clients must treat it as opaque. Do not parse a tag to discover a revision number, and do not construct one from a JSON body. The server owns its meaning.

RFC 9110 defines entity tags and distinguishes strong tags from weak tags. A strong ETag has ordinary quoted syntax, such as `"deploy-8f31c2"`. It says that the representations match byte for byte under the server's chosen representation semantics. A weak tag begins with `W/`, such as `W/"deploy-8f31c2"`, and says only that two representations are semantically similar enough for cache validation.

That distinction gets blurred constantly. Weak validators are fine for many GET cache checks. They are the wrong tool for protecting a write because two "close enough" representations may still differ in a field the write would destroy. RFC 9110 requires `If-Match` to use strong comparison. If your API publishes only weak ETags, it has not provided an ETag suitable for optimistic concurrency.

A resource can have different ETags for different representations. Pretty-printed JSON, compact JSON, or content negotiated formats may each get their own validator. That is legitimate HTTP behavior, but it is unpleasant for API clients. Keep a stable canonical representation for write endpoints when possible. Then the ETag a client received on GET remains meaningful on PUT, PATCH, and DELETE.

## If-Match turns a version check into a server obligation

`If-Match` puts the expected ETag on an unsafe request. The server performs the method only when the current representation strongly matches a supplied tag.

An agent can read a record and retain the received header:

```bash
curl -i \
  -H 'Authorization: Bearer $TOKEN' \
  https://api.example.test/v1/deployments/billing-worker
```

The response includes:

```http
ETag: "deploy-8f31c2"
```

It can then send the smallest change it intends, with the validator from that read:

```bash
curl -i -X PATCH \
  -H 'Authorization: Bearer $TOKEN' \
  -H 'Content-Type: application/json-patch+json' \
  -H 'If-Match: "deploy-8f31c2"' \
  --data '[{"op":"replace","path":"/replicas","value":5}]' \
  https://api.example.test/v1/deployments/billing-worker
```

If the resource remains at that version, the server applies the patch and returns a fresh ETag:

```http
HTTP/1.1 200 OK
ETag: "deploy-a19d77"
Content-Type: application/json

{
  "name": "billing-worker",
  "replicas": 5,
  "image": "registry.example/billing:2.4.0",
  "maintenanceMode": false
}
```

If the operator's edit changed the current version first, the server returns:

```http
HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json

{
  "type": "https://api.example.test/problems/precondition-failed",
  "title": "The deployment changed after it was read",
  "status": 412,
  "detail": "Fetch the current deployment before retrying this update."
}
```

RFC 9110 says an origin server must not perform the requested method when an `If-Match` condition evaluates false. That is the property you are buying. The check must occur in the same atomic operation as the mutation. A handler that reads the row, compares a revision in application memory, then writes later still has a race between comparison and write.

For a relational database, the implementation often looks like a conditional update:

```sql
UPDATE deployments
SET replicas = :replicas,
    revision = revision + 1
WHERE id = :id
  AND revision = :expected_revision;
```

If the affected row count is zero, the API returns 412. If it is one, the API returns the updated document and derives its next ETag from the new revision. Put the test in the `WHERE` clause, or in an equivalent transactional compare-and-set primitive. Do not split it into two separate queries and call it safe.

## Version fields expose the same contract in application data

A version field is an application-level validator. It gives clients a visible revision that they send back in the request body, query, or a dedicated header. This can be easier to work with when clients use generated SDKs, message queues, or protocols that do not preserve HTTP response headers well.

A GET might return:

```json
{
  "id": "billing-worker",
  "revision": 42,
  "replicas": 3,
  "maintenanceMode": false
}
```

The update can state its expectation explicitly:

```http
PATCH /v1/deployments/billing-worker HTTP/1.1
Content-Type: application/json

{
  "expectedRevision": 42,
  "replicas": 5
}
```

The server compares `expectedRevision` to the stored revision atomically. On success it increments the revision. On a mismatch it rejects the request with a documented response, commonly 412 when the field acts as a precondition.

Do not confuse a version field with a timestamp. A monotonic integer revision makes equality clear. Timestamps create awkward questions: What precision does the server store? Can two writes land in the same precision bucket? Did serialization change the value? Does a replica assign time differently? You can solve some of those problems, but a revision counter costs less explanation.

ETags and version fields are not enemies. An API can expose both, with the ETag carrying standard HTTP semantics and the revision helping application code display or reconcile changes. They must come from the same committed state. If one says version 42 and the other accidentally refers to version 41, clients have no sound way to recover.

Avoid accepting either an ETag or a revision field if they can disagree. Pick one authoritative precondition for a route, or require both to agree. Flexible input contracts feel friendly until a client sends a stale body revision with a fresh copied header and nobody knows which claim the server honored.

## If-None-Match protects creation, not stale replacement

`If-None-Match` reverses the predicate. It says the method may proceed only if the current representation does not match one of the supplied tags. For unsafe methods, a false condition produces 412.

Its most useful mutation form is `If-None-Match: *`, which means "create this only if no current representation exists." A client can safely attempt a named resource creation:

```bash
curl -i -X PUT \
  -H 'Authorization: Bearer $TOKEN' \
  -H 'Content-Type: application/json' \
  -H 'If-None-Match: *' \
  --data '{"name":"nightly-export","schedule":"0 2 * * *"}' \
  https://api.example.test/v1/jobs/nightly-export
```

If another client already created that job, the server rejects the request rather than silently replacing it. This is useful when an agent has derived an identifier and must not take over an existing object with the same name.

Do not send `If-Match: *` for normal optimistic concurrency. It only requires that some current representation exists. It gives an agent permission to overwrite any current version, including one it never read. That is existence protection, not lost-update protection.

For GET and HEAD, `If-None-Match` supports caching. A matching tag generally yields `304 Not Modified`, without a response body. That cache behavior is often why developers first encounter ETags. Do not let it lead you to treat validators as cache-only plumbing. The same mechanism has much sharper consequences on writes.

## A stale-write response needs a disciplined agent policy

A 412 should halt the current mutation plan. The agent's old premise has failed, and sending the same request again does not make it true.

The safe recovery sequence is short:

1. Fetch the current representation and its new validator.
2. Compare the fields or state assumptions behind the intended action, not merely the field named in the patch.
3. Retry with the new validator only if the intent remains correct without reinterpretation.
4. Ask for approval or stop when the current state changes the meaning, scope, or risk of the action.

The second item is where automated clients often cheat. Suppose an agent planned to remove a user from an access group after reading a membership list. A person then changes the user's role from contractor to incident responder. The agent can still make a syntactically legal removal after refetching. It should not automatically do so, because the role change makes the original plan questionable.

Keep the work record modest but complete: resource URI, observed ETag or revision, fields read, intended mutation, and the response. A tool runner can retain this in memory for a short task. A longer autonomous workflow should persist it in its own audited task state. Never ask the model to remember a validator from prose alone; quoted header values are easy to drop, alter, or reuse against the wrong resource.

Sallyport can keep the agent away from the API credential while it executes the HTTP call, but the agent still needs to preserve and send the ETag as ordinary request data. Credential isolation and concurrency control address separate failures, so use both where the action has consequences.

## 412, 409, and 428 describe different failures

Return `412 Precondition Failed` when the client supplied a conditional request header or an equivalent documented precondition, and that condition is false. The response says, precisely, that the resource is no longer in the state the client asserted.

Return `428 Precondition Required` when the server requires a precondition for a route and the client omitted it. RFC 6585 defines this status specifically to prevent lost updates. A response can say that PATCH requires `If-Match`, and include the current ETag if exposing it does not create a disclosure issue.

Return `409 Conflict` when the request conflicts with application state even after its version condition passes. For example, a client might send an `If-Match` value that matches a current invoice, yet the server refuses cancellation because payment settlement has started. The version test passed; the business command still conflicts with the invoice state.

Do not collapse these into one generic error. An agent should react differently:

- After 428, fetch the resource and retry with the required condition.
- After 412, refetch and reassess the original intent.
- After 409, inspect the domain conflict and follow the API's business resolution path.

A useful error body names the resource, identifies the failed condition without echoing secrets, and tells the client whether a fresh GET can help. It should not pretend that a retry is harmless. The HTTP status carries the machine-readable category; the body gives the operator enough context to decide what happens next.

## Last-modified dates are a compatibility fallback

`Last-Modified` and `If-Unmodified-Since` can express a related condition: perform the method only if the resource has not changed since the supplied date. They remain useful where an old API already publishes modification times and adding tags will take time.

They are weaker for consequential writes. HTTP dates have one-second precision. Two changes in the same second can produce the same visible date, and a client may not know whether the server's stored timestamp has finer precision than its header. Replication, clocks, and serialization add more ways to surprise yourself.

If a client sends both `If-Match` and `If-Unmodified-Since`, RFC 9110 gives `If-Match` precedence. That is sensible. A strong validator gives an exact version test; a date is an approximation.

Do not build your own `X-If-Version` header unless you have a protocol reason that standard headers cannot meet. Custom headers spread quickly through SDKs and proxies, then become permanent compatibility work. `ETag` and `If-Match` already have clear semantics, known status codes, and support across ordinary HTTP tooling.

## PATCH formats need their own tests as well

Conditional headers guard the version of the resource. They do not validate whether a patch expresses a safe transformation. A JSON Merge Patch that includes a whole nested object can still erase sibling fields, even with a correct ETag. A JSON Patch can target the wrong array position if the API models an ordered list whose membership changed.

Use the patch format that matches the operation. JSON Patch, defined by RFC 6902, expresses operations such as `replace`, `add`, `remove`, and `test` on specific paths. Its `test` operation can assert a value inside the document before later operations execute. JSON Merge Patch, defined by RFC 7396, describes a desired partial document and treats `null` as deletion.

A document-level ETag should remain the outer guard. Add a JSON Patch `test` when the operation has a field-specific assumption worth making explicit:

```json
[
  {"op":"test","path":"/maintenanceMode","value":false},
  {"op":"replace","path":"/replicas","value":5}
]
```

If another writer changed `maintenanceMode` before this request, the request must fail rather than increase capacity during maintenance. The API should document the error it returns for a failed JSON Patch test. Many implementations use 409 because the patch instruction conflicts with the current document, while the outer ETag mismatch remains a 412. The distinction is useful if clients need to know whether they held a stale document or made an invalid state-dependent request.

Do not rely on a patch `test` alone as your general concurrency scheme. It guards only the paths you remembered to test. A strong ETag guards the representation version the agent actually based its plan on.

## Servers must enforce the precondition at the write boundary

An API contract that merely recommends `If-Match` will fail under deadline pressure. One client skips it, another SDK forgets to forward it, and the vulnerable endpoint becomes the one agents discover through examples. Require it for updates where a stale overwrite has a meaningful cost.

The handler should reject missing conditions before it performs side effects. Then it should pass the expected validator into the storage operation that mutates state. For a resource backed by multiple tables or an external control plane, wrap the comparison and state change in one transaction or use the provider's compare-and-set operation. If the provider cannot do that, your API cannot honestly promise lost-update protection for that write.

Test the race deliberately. Seed a resource at revision 7. Have client A and client B both GET it. Let A PATCH with `If-Match: "7"` and verify it receives revision 8. Then let B PATCH with `If-Match: "7"` and verify it receives 412 and that its desired change did not appear. Repeat with DELETE, full PUT, and any bulk action that writes a resource based on a prior read.

Also test the dangerous shortcuts: a missing `If-Match` must receive 428 on protected routes, `If-Match: *` must not be presented as stale-write protection, and a weak ETag must not pass a strong comparison. These tests catch the regressions that occur when a new endpoint bypasses the normal repository method.

## Make the safe path easier than the overwrite path

The API should return ETags on every GET of mutable resources, document required conditions next to each unsafe operation, and make SDK methods carry validators naturally. A client should not need to scrape raw headers from an obscure response object to avoid damaging another writer's work.

For agents, separate planning from execution. Read the target, record its validator, describe the intended mutation, and issue the conditional call. If any observation changes, discard the planned write unless the agent can show that the change is irrelevant. That rule sounds conservative because it is. The alternative is granting an automated process authority to act on facts it knows are old.

Start with the endpoints where an overwritten change would wake someone up: deployment settings, access control, customer records, payment state, and secrets metadata. Add `If-Match`, make a missing precondition fail, and test two writers against the route. Once the server refuses stale writes by default, an agent's speed stops turning ordinary concurrency into silent damage.
