Skip to main content
CMDashCybersecurity Maturity Dashboard
DocsDevelopersOptimistic concurrency — ETag / If-Match

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 entityresponse carries a strong ETag, e.g. ETag: "3f9c…"
Write itsend If-Match: "3f9c…" (a list and * are both accepted)
Entity has moved on412 Precondition Failed, details.code = PRECONDITION_FAILED, nothing is written
Write succeedsresponse carries the NEW ETag, so edits can be chained without a re-read
No If-Match sentthe 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/:idPATCH /risks/:id
GET /policies/:idOrSlugPATCH /policies/:id
GET /report-builder/templates/:idPATCH /report-builder/templates/:id
GET /maturity/custom-models/:slugPUT /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

typescript
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.
}

This page is the product manual, published from the repository at docs/developers/optimistic-concurrency.md. Customers read the same text inside CMDash under Help. Last updated October 9, 2026.

Optimistic concurrency — ETag / If-Match — CMDash Docs