---
key: api.playout-build-lock-409
title: 2026-07-10 — Playout API mutations return 409 while the build lock is held (#215)
status: active
since: '2026-07-10'
supersedes: none
superseded-by: none
rule: Every id-keyed playout/channel mutation endpoint checks `IEntityLocker.IsPlayoutLocked(id)` and returns 409 Conflict while a build is in-flight, mirroring Blazor's disabled-buttons behavior; reset-all stays 202 and silently skips locked playouts.
signals: '`ApiResults.ConflictProblem`, `PlayoutListItemResponseModel.IsLocked`, advisory check-then-act · paths: `PlayoutController.cs`, `ChannelController.cs` · issues: #215, adversarial-reviewer#18'
mechanics: '`ApiResults.ConflictProblem`'
---

Blazor disabled per-playout Reset/Erase/Delete/Edit while a `BuildPlayout` was in flight
(`EntityLocker.IsPlayoutLocked`, `Playouts.razor` + per-kind editors); the REST API had no
equivalent, so a client could race an in-flight build with a destructive `ExecuteDelete` and leave
a half-built playout. Adversarial-reviewer#18 promoted this to a #91-phase-(b) removal gate: after
Blazor is deleted the invariant would vanish entirely.

Decision: enforce the invariant **server-side** on the API rather than re-implementing a live push
channel. `PlayoutController` and `ChannelController` inject `IEntityLocker`; every id-keyed mutation
— `PUT /api/playouts/{id}`, `.../deco`, `.../alternate-schedules`, `.../templates`,
`POST .../erase-items`, `.../erase-items-and-history`, `DELETE /api/playouts/{id}`, and
`POST /api/channels/{channelNumber}/playout/reset` — checks `IsPlayoutLocked(id)` first and returns
**409 Conflict** (`ApiResults.ConflictProblem`, new shared helper mirroring `NotFoundProblem`) while
the build lock is held. The PUTs are gated too (not just the destructive ops): the target invariant
is "no mutation during a build", matching Blazor's edit-disable.

- **The guard is advisory check-then-act, not mutual exclusion** — same posture as Blazor's disabled
  buttons. A `BuildPlayout` already sitting in the worker queue can take the lock a few milliseconds
  after the check passes, so the original race is *narrowed*, not eliminated; consequences remain
  self-healing (the next rebuild corrects a half-mutated playout). True prevention — having each
  mutation acquire the playout lock for its duration — was deliberately not taken: `LockPlayout`
  publishes `PlayoutUpdatedNotification` (UI churn per mutation) and would make mutations block
  builds, a semantics change out of scope for restoring Blazor parity.

- **`reset-all` is deliberately NOT gated** — it stays 202. `ResetAllPlayoutsHandler` already
  *silently skips* locked playouts, which matches Blazor and the handler semantics; a fire-and-forget
  bulk enqueue always accepts.
- **SPA mirrors the lock via data, not a push channel** — `PlayoutListItemResponseModel` gains an
  `IsLocked` bool (set from `IsPlayoutLocked` in the controller's list projection). The playouts
  screen disables Reset/Erase/Erase-and-history/Delete for a locked row and shows a "Building…"
  Badge; on a 409 from any mutation it surfaces the error and calls `query.refresh()` so the row
  picks up the flag. No new polling was added (the existing 30s channel-state poll is unchanged).

Precedent for the 409 shape: `TraktController` (left as-is with its own private `ConflictProblem()`
to keep the diff small). Convention recorded in `api-conventions.md` §3a.
