---
key: concurrency.replace-all-contract
title: '2026-07-11 — Optimistic-concurrency contract for replace-all PUTs (#253 PR1: infra + Block reference)'
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: Replace-all aggregate PUTs carry a uniform plain `int Version` concurrency token (EF `.IsConcurrencyToken()`), checked pre-save and enforced by the EF UPDATE guard, returning 412 (not 409) on a stale `If-Match`.
signals: 'ETag, If-Match, Version token, 412 Precondition Failed · paths: `api-conventions.md` §7a, `ConcurrencyHeaders`, `ApiResults.ToErrorResult` · issues: #253, #197, #265, #259'
mechanics: '`docs/api-conventions.md` §7a; `SaveChangesWithConcurrencyGuard`'
---

Replace-all aggregate PUTs had **no** optimistic concurrency — a stale second tab silently overwrote a
fresher edit (200, no signal) across ~10 aggregate surfaces. PR1 lands the shared contract on the Block
reference aggregate; PRs 2–4 fan it out. The full ratified design + independent-review hardening is
[#253#issuecomment-8472](http://192.168.1.95:3000/timothy/ersatztv/issues/253#issuecomment-8472);
the mechanics live in `api-conventions.md` §7a. Decisions frozen here:

- **Token = uniform plain `int Version`** on each root implementing `IVersionedAggregate`, EF-mapped
  `.IsConcurrencyToken()`, one dual-provider migration (`AddAggregateVersions`, `defaultValue: 0`). **Not**
  a reused `DateUpdated` (tick-collision, SQLite TEXT precision, couples UI cosmetics to correctness) and
  **not** a MySQL-native rowversion (portability over provider-native).
- **412 Precondition Failed**, not 409 — 409 stays the §3a EntityLocker "build in progress" guard;
  distinct codes → distinct SPA UX. New `PreconditionFailedError : BaseError` → 412 in `ApiResults.ToErrorResult`.
- **Pre-check AND EF token both required.** The handler pre-check (a standalone `Either` introduced AFTER
  the validation pipeline — never via `Apply`, which `Join()`-flattens the subtype to 422) gives a clean
  412; the unconditional `root.Version++` + `IsConcurrencyToken` UPDATE-guard + a `SaveChangesWithConcurrencyGuard`
  backstop closes the residual load→save TOCTOU (`DbUpdateConcurrencyException` → 412).
- **Unconditional bump** (not "only when a child changed"): EF writes the root row only when a scalar
  differs, so a no-op PUT-back must still bump to fire the token and rotate every other client's ETag.
- **Config-only aggregate boundary**: every mutating handler of a root's *editor-visible config state*
  bumps `Version` (incl. bulk `ExecuteUpdate/Delete` writers via `.SetProperty`); regenerated build output
  (playout items/history) is outside the token — neither bumped nor guarded.
- **Header-only ETag**, strong tag of the decimal `Version`; parsed/emitted by `ConcurrencyHeaders`. The
  successful PUT returns the new ETag (else a same-tab second save 412s against its own write).
- **Phasing**: Phase 1 (this arc) = a missing `If-Match` force-writes (zero breakage) while the SPA starts
  echoing; Phase 2 (a later PR) flips missing → **428** after every editor echoes and one release soaks.
  `If-Match: *` stays the scripted force-write escape hatch.
- **Child stable-identity is OUT of #253** (the "moved fill-group item inherits the wrong slot's state"
  concern on the positional reconcile) — root-anchored versioning is orthogonal to it; split to **#259**.
- **If-Match status semantics** (non-canonical/weak/list → 400) are fail-safe; the stricter RFC 7232
  "valid-but-non-matching → 412" refinement is deferred to #197 (**#265**).
