Optimistic concurrency — ETag / If-Match
Last updated: September 2026
The problem
Every long-form editor saved with "read the row, then write it back by id"
and no precondition of any kind. Two people editing the same risk, policy,
report template or custom maturity model produced a silent last-write-wins:
the second save overwrote the first, with no error, nothing in the audit
trail to distinguish it, and nothing in the UI to notice. Before this
contract, neither ETag nor If-Match was supported anywhere in the API.
The contract
Plain RFC 9110 (§8.8.3, §13.1.1). Nothing CMDash-specific:
| Read a single protected entity | response carries a strong ETag, e.g. ETag: "3f9c…" |
| Write it | send If-Match: "3f9c…" (a list and * are both accepted) |
| Entity has moved on | 412 Precondition Failed, details.code = PRECONDITION_FAILED, nothing is written |
| Write succeeds | response carries the NEW ETag, so edits can be chained without a re-read |
No If-Match sent | the write proceeds unconditionally, exactly as before |
412, not 409. 409 means "the request conflicts with the current state of the resource" and says nothing about headers; 412 means specifically "a precondition you supplied in a request header evaluated to false", which is what happened. Every HTTP client library already understands it.
An absent If-Match is not a failed precondition. RFC 9110 defines it as
no precondition, and that is what the API does: the save behaves exactly as
it did before this item. Making the header mandatory would have been a
breaking change — including to Public API v1, which carries a real Sunset
header and must not change shape — in order to fix a problem single-writer
callers do not have. Opting in is the client's choice; an editor that wants
stale-write detection sends the header, a script that wants to force a value
does not.
Where it applies
Read (returns ETag) | Write (honours If-Match) |
|---|---|
GET /risks/:id | PATCH /risks/:id |
GET /policies/:idOrSlug | PATCH /policies/:id |
GET /report-builder/templates/:id | PATCH /report-builder/templates/:id |
GET /maturity/custom-models/:slug | PUT /maturity/custom-models/:slug |
The ETag is emitted only on routes whose write actually enforces the
precondition. Advertising a tag that the write path ignores would be worse
than advertising none — the client would send If-Match, the server would
drop it, the save would succeed, and the client would believe it had been
protected from the overwrite it just performed.
Not yet covered: POST /policies/:id/publish (the content-versioning save,
which creates a new PolicyVersion rather than editing in place) and the
Public API's own PUT /v1/risks/:id and /v2/risks/:id, which have no
single-risk GET for a client to take a tag from in the first place.
How the token is derived
For the three entities with an @updatedAt column, the tag is a hash of
id + updatedAt. It is opaque deliberately — equality is the whole
contract, and a bare timestamp would invite clients to start parsing it.
MaturityModel has no updatedAt at all (its version column is the pack
author's semver, which the builder itself rewrites), so its tag is a hash of
the entire hydrated pack: the model row plus every level, dimension and
control mapping. That is also the more faithful token for that resource,
since a save replaces all of those child rows and a token tracking only the
parent would miss the edits two authors are most likely to collide on.
What is not used: the version Int column on StatementTemplate,
ControlStatement, ProgramCharter and RemediationPlaybook. Those are
supersession chains — version numbers the published revisions of a
document and deliberately does not move when a draft is edited in place.
Keying concurrency off it would mean two writers to the same draft both see
version 1 and both succeed: the bug this item exists to fix, shipped inert.
Why it is atomic, not just checked
Comparing the tag in application code and then writing is not enough — a
competing transaction that commits in between still wins silently. So the
enforcing write repeats the token inside its own where clause
(update({ where: { id, updatedAt } })), making the check and the write one
statement; Prisma raises P2025 when the row has moved, and that is
translated into the same 412. The maturity builder, which cannot express its
tag as a column, takes a SELECT … FOR UPDATE on the model row at the top of
its transaction and re-derives the tag under that lock, because Prisma's
interactive transactions run at READ COMMITTED and an unlocked in-transaction
comparison would let two simultaneous saves both pass.
Tenant safety
The precondition is evaluated only after the tenant-scoped existence
check has passed. A caller from a different tenant never reaches it: RLS and
the tenantId filter make the row missing, which is a 404 — the same 404
an id that exists nowhere produces. So a mismatched tag and a nonexistent row
are indistinguishable from outside the tenant, and 412 reveals only that a
row the caller can already read has changed. Evaluating the precondition
first would turn the status code into a cross-tenant existence oracle.
CORS
Both halves are explicitly named in the API's CORS
configuration: ETag in Access-Control-Expose-Headers
and If-Match in Access-Control-Allow-Headers. Neither is CORS-safelisted, and each
missing entry disables the feature silently in a browser — without the
exposure res.headers.get('ETag') is null, and without the allowance the
preflight refuses the request header so the save arrives unconditional. Both
are asserted together in http-security-headers.spec.ts.
Client pattern
const res = await fetch(`/risks/${id}`);
const etag = res.headers.get('ETag');
const risk = await res.json();
// …user edits…
const save = await fetch(`/risks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...(etag ? { 'If-Match': etag } : {}) },
body: JSON.stringify(patch),
});
if (save.status === 412) {
// Someone else saved first. Re-read, reapply the edit on top of the
// current content, and retry with the new tag. Do NOT retry the same
// body blindly — that reinstates the overwrite the 412 just prevented.
// The body's `details.currentEtag` is the tag the entity holds now.
}