---
key: concurrency.etag-rotation-completion
title: 2026-07-12 — Cross-editor ETag rotation completed for Collection/Playout config siblings (#269)
status: active
since: '2026-07-12'
supersedes: none
superseded-by: none
rule: Every handler that mutates a versioned root's editor-visible config state must bump `Version` (rotating the ETag) with no per-aggregate carve-outs, short-circuiting on a genuine no-op before the bump so idempotent re-submits don't fire spurious rebuild fan-out; `SaveChangesForcingVersion` rebases the retry (stored + pending delta), never adopts the stored token verbatim.
signals: 'ETag rotation, no-op idempotence, SaveChangesForcingVersion rebase · paths: `CollectionEtagRotationTests`, `PlayoutScheduleFileEtagRotationTests` · issues: #269, #253, #197, #308'
mechanics: '`docs/api-conventions.md` §7a; `CollectionEtagRotationTests`, `PlayoutScheduleFileEtagRotationTests`'
---

The #253 optimistic-concurrency contract (§7a) had a documented tail: the non-If-Match config-sibling
writers of a versioned root mutated editor-visible state **without** bumping `Version`, so editing through
them did not rotate an open editor's ETag (a cross-editor invalidation gap — never a lost-update or a 500,
which the primary endpoints' bump+guard already cover). #269's first slice (PR #302) removed the 500 exposure
by routing those writers through `SaveChangesForcingVersion`; this slice completes the **rotation**.

Handlers now bumping `Version` (all via `SaveChangesForcingVersion`, since they take no `If-Match` → a
concurrent replace-all bump force-writes, never 412/500): the Collection `Add*ToCollection` family (11
handlers) and `RemoveItemsFromCollectionHandler` bump `Collection.Version`; `UpdateCollectionHandler`
(name/flag), `UpdatePlayoutHandler` (`DailyRebuildTime`), and the three `ScheduleFile` writers
(`UpdateSequential`/`UpdateScripted`/`UpdateExternalJsonPlayout`) — which already force-wrote — now also bump.

Decisions frozen (ratified with Fable before implementation, feeding the #197 contract freeze):

- **Rotate on every editor-visible config change, no per-aggregate carve-outs.** §7a's config-only boundary
  ("every mutating handler of a root's editor-visible config bumps `Version`") already held for Playlist
  `Add*`/schedule item writers; the Collection/Playout siblings were an inconsistency, not a judgment call. A
  membership add rotating an open custom-order editor's ETag (→ 412 → reload) is correct: its list is genuinely
  stale. Blast radius of the aggressive-but-safe rotation is a reload, never data loss.
- **No-op idempotence — the trap Fable caught.** These handlers gate their reindex/`BuildPlayout` fan-out on
  `SaveChanges() > 0`. An *unconditional* bump makes that gate always-true, so an idempotent re-add / same-value
  re-submit would fire spurious rebuilds across every playout using the aggregate. Fix: short-circuit a genuine
  no-op **before** the bump — the Add handlers by an explicit membership check (which also fixes the latent
  duplicate-`CollectionItem` insert on a *sequential* re-add; two *concurrent* same-item adds can still both
  pass the check and the loser 500s on the composite-PK unique violation — `SaveChangesForcingVersion` catches
  only `DbUpdateConcurrencyException`, not `DbUpdateException`. That race is narrow and pre-existing, deferred
  to #308), the scalar writers (`UpdateCollection`, `UpdatePlayout`, the
  three `ScheduleFile` writers) by `ChangeTracker.HasChanges()`. A no-op neither bumps nor rebuilds nor rotates
  the ETag — which is itself correct (nothing changed).
- **The `Add*ToCollection` family is not repository-mediated.** #269's original framing ("repository-mediated,
  shared with the scanner hot path") was wrong: `IMediaCollectionRepository` is read-only; each handler loads
  the `Collection` into its own `dbContext` and writes directly. So the rotation bump is a pure API-layer
  concern and the scanner's separate membership-write path is untouched — a background scan does **not** rotate
  the editor ETag (correct: background indexing is not an editor action).
- **Force-write rebases the bump, never adopts the stored token verbatim (Codex review of this PR).**
  `SaveChangesForcingVersion` originally resolved a conflict by setting current=original=stored — which
  silently *discarded* a sibling's pending `Version++` when a versioned writer committed in its load→save
  window (sibling loads 1, bumps to pending 2, concurrent PUT commits 2 → retry wrote 2, so the concurrent
  writer's ETag "2" stayed valid and the rotation was lost under exactly the race it exists for). Fixed in
  this PR (it affects all 25 bumpers routed through the helper, including the pre-existing playlist/schedule
  ones): the retry now rebases — original = stored, current = stored + (pending current − pending original) —
  so a bumper lands at stored+1 and a non-bumper (delta 0, e.g. `ErasePlayoutHistory`) adopts stored unchanged.
  The race tests assert the post-race Version (3, not 2) and fail against the verbatim-adopt implementation.
- **No new status codes.** These endpoints take no `If-Match` and force-write, so they never 412; no
  `[ProducesResponseType(...412...)]` and no OpenAPI regen (response types unchanged). Only §7a prose changes.

Tests: `CollectionEtagRotationTests` (rotation + no-op-without-bump-or-rebuild + force-write-past-concurrent-bump
for Add/Remove/Update) and `PlayoutScheduleFileEtagRotationTests` (ScheduleFile rotation + no-op-without-refresh),
the no-op guard proven non-vacuous by inverting the membership check. The `#265` RFC-7232 If-Match parser
refinement (valid-but-non-matching/weak/list → 412 not 400) is a **separate** PR (disjoint surface: the shared
parser + `CheckVersion`, not the handler saves). Refs #253 #269 #197 · `api-conventions.md` §7a.
