Independent review fix commit (cold fork MERGEABLE-WITH-NITS + Codex BLOCKED, 2 Highs):
- Codex H1: a comma (0x2C) is a valid etagc and can appear INSIDE a quoted opaque-tag
("3,5" is ONE tag). The old Split(',') broke it into two malformed tokens → 400. Replaced
with a quote-aware position scanner that treats a comma as a separator only outside the
quotes; "3,5" is now one valid non-canonical tag → 412.
- Codex H2: RFC 7230 OWS is SP/HTAB only. string.Trim() also strips NBSP and other Unicode
whitespace, letting " * " masquerade as the "*" force-write escape. Trim only
(' ', '\t'); such input is now Malformed → 400.
- Fork nit: corrected the canonical-guard comment (interior-whitespace tags are rejected by
IsEtagc, not NumberStyles.None).
- CI Formatting gate: de-BOM the 8 touched legacy Application .cs (charset=utf-8, #311/#310).
- Tests: added comma-in-tag ("3,5", "x,y","3"), empty-element tolerance, NBSP-not-OWS,
trailing-junk, lowercase-weak, wildcard-in-list cases. Full ErsatzTV.Tests green (1556).
Refs #253#197
The shared optimistic-concurrency parser (ConcurrencyHeaders.ParseIfMatch) classified any
non-canonical/weak/list If-Match value as Malformed → 400. Per RFC 7232 §3.1 a syntactically
-valid entity-tag that simply doesn't strong-match must be 412; 400 is only for a genuine
grammar violation.
- Rewrite ParseIfMatch as a real RFC 7232 entity-tag/list parser: walks the comma-separated
1#entity-tag list, validates each [W/]DQUOTE *etagc DQUOTE member, and collects the strong
members whose opaque text is our canonical decimal. Weak / empty / non-canonical /
out-of-range tags are valid but contribute no version (→ empty set → 412); genuine grammar
violations (unquoted, SP-in-tag, unterminated, garbage) → 400.
- Reshape IfMatchCondition.ExpectedVersion : Option<int> → ExpectedVersions : Option<Seq<int>>
and VersionedAggregateExtensions.CheckVersion → set membership (any strong match proceeds;
empty set always 412). Threads through 10 replace/update commands + handlers + request
mappers + 9 controllers.
- No wire-contract change (400 + 412 already declared on every PUT; the field is header-derived
and internal — no DTO/route/response-type/OpenAPI change).
- Tests: ConcurrencyHeadersTests rewritten for the new classification (lists, weak, empty,
non-canonical → Version/empty-set; grammar violations → Malformed) + new
VersionedAggregateExtensionsTests for CheckVersion membership/empty-set/force-write.
- Docs: api-conventions.md §7a rewritten; decisions.md entry appended.
Refs #253#197fixes#265
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backend mutation-hardening cluster from the 2026-07-11 mutation-safety audit
sweep (adversarial-reviewer #22/#23), the parallel-safe backend-isolated slice.
audit#22 F4 — standardize post-commit enqueues on CancellationToken.None:
20 command handlers under MediaCollections/, ProgramSchedules/, Playouts/,
Channels/ threaded the request cancellationToken into work that runs AFTER
SaveChangesAsync commits (WriteAsync rebuild/refresh enqueues, mediator.Publish,
reindex, cache Refresh, and post-commit lookups that gate an enqueue). A late
client-disconnect then turns an already-durable commit into a thrown request AND
drops the side effect. Generalizes the #251 deco-handler fix. Excludes
BuildPlayoutHandler (worker/background token, not a client-disconnect token),
the config/FFmpeg multi-upsert handlers (partial-commit case, separate
follow-up), and response-projection reloads (correctly keep the request token).
audit#22 F2 — DeleteChannelHandler/DeletePlayoutHandler now delete the channel
guide {number}.xml through IFileSystem.File.Delete (observable under
MockFileSystem) and BEFORE the commit (a post-commit delete orphans the xml on a
crash; the xml is regenerable on demand, so pre-commit delete is the safe order).
audit#23 F4 — ReplacePlayoutAlternateScheduleItemsHandler rejects an empty item
list in the handler (not only the controller pre-guard) so a direct caller can't
trip the Max()-on-empty crash.
Docs: api-conventions.md §7a (post-commit token convention + boundaries),
decisions.md entry (rationale, sweep scope, #253 PR2-4 coordination note).
Tests: guide-cache-delete-through-FS for both delete handlers, empty-list guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RerunCollectionRequestMapping.ResolveSelection builds an id-only VM
with no existence check, so CreateRerunCollectionHandler and
UpdateRerunCollectionHandler blindly copied CollectionId /
MultiCollectionId / SmartCollectionId / MediaItemId from the request
onto the RerunCollection FK columns. A bad id persisted a dangling FK
or 500'd at SaveChanges.
Add RerunCollectionSelectionValidation, a shared per-CollectionType
existence check (Collection/MultiCollection/SmartCollection sets, and
subtype-accurate media-item sets: Shows/Seasons/Artists/Movies/
Episodes/MusicVideos/OtherVideos/Songs/Images/RemoteStreams), composed
into both handlers' Validate via the existing .Apply(...) pattern so a
missing/mismatched id now fails validation (422) with a clear message
instead of persisting or 500ing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>