---
key: api.postcommit-cancellation-none
title: '2026-07-11 — Post-commit side effects run on `CancellationToken.None` (generalized from #251 to #254)'
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: Once a mutation commits, its entire compensating side effect (enqueues, publishes, reindexes, cache refresh, and any post-commit lookup gating one of those) runs on `CancellationToken.None` so a late client disconnect can't half-abort an already-committed change.
signals: '`WriteAsync` enqueue, `mediator.Publish`, search-index reindex · paths: `docs/api-conventions.md` §7b, `MediaCollections/`, `ProgramSchedules/`, `Playouts/`, `Channels/` handlers · issues: #251, #254, adversarial-reviewer#22'
mechanics: '`docs/api-conventions.md` §7b'
---

Audit #22 (adversarial-reviewer) found ~20 command handlers threading the request `cancellationToken`
into work that runs **after** `SaveChangesAsync` commits — the post-commit `WriteAsync` enqueue that
rebuilds/refreshes the affected entity, `mediator.Publish`, search-index reindex, cache refresh. A late
HTTP-client disconnect cancels that token, so the *already-committed* mutation throws on the way out
**and silently drops its side effect** (the playout rebuild is never queued → the persisted edit never
takes visible effect until a manual Reset). #251 fixed this for the deco handlers; #254 generalizes the
policy across the codebase.

**Decision.** Once a mutation has committed, the *entire* compensating side effect — enqueues, publishes,
reindexes, cache refreshes, and any post-commit lookup that **gates** one of those enqueues — runs on
`CancellationToken.None`. The commit is the point of no return; past it the side effect must not be
half-abortable. Full convention + the two boundaries in `docs/api-conventions.md` §7b.

**Scope of the #254 sweep (this PR).** Swept the single-`SaveChanges` handlers under `MediaCollections/`,
`ProgramSchedules/`, `Playouts/`, `Channels/` (20 handlers). Deliberately **excluded**:
- **`BuildPlayoutHandler`** — a background/worker handler; its token is the worker shutdown token, not a
  client-disconnect token, so its downstream enqueues *correctly* honor cancellation.
- **`UpdateFFmpegSettingsHandler` + the two `Configuration/` settings handlers** — they commit via several
  sequential `IConfigElementRepository.Upsert` calls with an interleaved enqueue; "when is it committed"
  is a partial-commit-under-cancellation question broader than the clean single-`SaveChanges` F4 pattern.
  Left for a separate follow-up.
- **Response-projection reloads** (`ReplaceProgramScheduleItemsHandler` / `AddProgramScheduleItemHandler`
  post-commit graph reload that builds the *returned* view model) keep the request token — a cancelled
  response after a durable commit + `None`-enqueue loses nothing.
- Handlers a no-token `WriteAsync()` already makes behaviorally correct (`default` == `None`) were left
  alone (explicit-`None` there is cosmetic).

Also folded in the two other #254 items on the same handlers: the channel-guide `{number}.xml` delete in
`DeleteChannelHandler`/`DeletePlayoutHandler` now routes through `IFileSystem.File.Delete` (observable
under `MockFileSystem`) **before** the commit (a post-commit delete orphans the xml on a crash; the guide
xml is regenerable on demand, so a pre-commit delete is the safe ordering), and
`ReplacePlayoutAlternateScheduleItemsHandler` now rejects an empty item list in the handler (not only at
the controller pre-guard) so a direct caller can't trip the `Max()`-on-empty crash.

**Coordination note for #253 PR2–PR4.** Those PRs add `Version++` (pre-commit) to the mutating handlers of
the versioned aggregates — several of which this sweep also touched (post-commit token, a different line
region). Low git-conflict risk, but merge `main` in and expect to see the `CancellationToken.None`
convention already present on the post-commit enqueues.
