feat(403): make unsupported PlaybackOrder loud at build time + tripwire #457

Merged
timothy merged 2 commits from feat/403-loud-playbackorder into main 2026-07-18 19:10:36 +02:00
Owner

Fixes #403.

What

Makes the three still-silent build-time PlaybackOrder dispatch sites loud but non-fatal, and adds a by-construction net so the next order added can't slip through.

  • Classic (PlayoutBuilder default:) — was a // TODO silently returning Random; now logs a Warning, keeps the Random fallback (channel doesn't go dark).
  • Playlist (PlaylistEnumerator.Create) — had no default: arm → null enumerator → item silently dropped. Create was static with no logger (that's why it couldn't report); added optional Option<ILogger>, loggered callers pass it, new default: arm warns.
  • Block (BlockPlayoutBuilder) — silent allow-list continue now warns; added an explicit Random arm (previously reached an enumerator only by coincidence) + a loud defensive fallback.
  • PlaybackOrderSupport matrix (per SchedulingEngineKind) + tripwire PlaybackOrderSupportTests: Supported ∪ Unsupported must partition the enum for every engine, so a new order fails the test until classified. Block consumes the matrix for its allow-list (removing a duplicated inline list).

Deliberately out of scope

  • Write-path rejection unchanged — #70 already closed the persistence hole; decisions.md records that this perimeter has been wrong three times, so broadening it here is pure regression risk.
  • Reverse _ => PlaybackOrder.None mappings reviewed and deferred: that path is hit by legit enumerator types (ShuffleInOrder/SeasonEpisode) so making it loud would false-warn. Different axis (enumerator class → order), not #403's 'unsupported order degrades silently'.

Verification

  • ErsatzTV.Core.Tests full: 586 passed (incl. scheduler goldens — no behavior drift). Infra + app scheduling tests green. New behavioral test proves the Playlist drop is now logged.
  • docs/decisions.md updated.

Merge gated on adversarial review (Done-when box 5) + CI green.

Fixes #403. ## What Makes the three still-silent build-time `PlaybackOrder` dispatch sites **loud but non-fatal**, and adds a by-construction net so the next order added can't slip through. - **Classic** (`PlayoutBuilder` `default:`) — was a `// TODO` silently returning Random; now logs a Warning, keeps the Random fallback (channel doesn't go dark). - **Playlist** (`PlaylistEnumerator.Create`) — had **no** `default:` arm → null enumerator → item silently dropped. `Create` was `static` with no logger (that's why it couldn't report); added optional `Option<ILogger>`, loggered callers pass it, new `default:` arm warns. - **Block** (`BlockPlayoutBuilder`) — silent allow-list `continue` now warns; added an explicit `Random` arm (previously reached an enumerator only by coincidence) + a loud defensive fallback. - **`PlaybackOrderSupport`** matrix (per `SchedulingEngineKind`) + **tripwire** `PlaybackOrderSupportTests`: `Supported ∪ Unsupported` must partition the enum for every engine, so a new order **fails the test until classified**. Block consumes the matrix for its allow-list (removing a duplicated inline list). ## Deliberately out of scope - **Write-path rejection unchanged** — #70 already closed the persistence hole; `decisions.md` records that this perimeter has been wrong three times, so broadening it here is pure regression risk. - **Reverse `_ => PlaybackOrder.None` mappings** reviewed and deferred: that path is hit by legit enumerator types (ShuffleInOrder/SeasonEpisode) so making it loud would false-warn. Different axis (enumerator class → order), not #403's 'unsupported order degrades silently'. ## Verification - `ErsatzTV.Core.Tests` full: 586 passed (incl. scheduler **goldens** — no behavior drift). Infra + app scheduling tests green. New behavioral test proves the Playlist drop is now logged. - `docs/decisions.md` updated. Merge gated on adversarial review (Done-when box 5) + CI green.
timothy added 1 commit 2026-07-18 18:23:59 +02:00
feat(403): make unsupported PlaybackOrder loud at build time + tripwire
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m21s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 37m32s
0f34c86afa
Adding a new PlaybackOrder was unsafe by construction: three build-time
dispatch sites turned an unknown value into an enumerator silently.
Classic substituted RandomizedMediaCollectionEnumerator (the // TODO
default arm), PlaylistEnumerator had no default arm so the item was
dropped, and BlockPlayoutBuilder's allow-list continue skipped it.
(#70 already made YAML/Scripted log a warning and MultiCollectionGroup
throws.)

- each silent site now logs a Warning naming the order + engine + the
  fallback taken; the fallback itself is preserved so a live channel
  never goes dark on one misconfigured item and scheduler goldens do
  not move.
- PlaylistEnumerator.Create gained an optional Option<ILogger> (it was
  static with no logger -- why the drop was unreportable); loggered
  callers pass it.
- BlockPlayoutBuilder gained an explicit Random arm (it previously
  reached an enumerator only via the coincidental _ => fallback) and a
  loud defensive fallback.
- new PlaybackOrderSupport matrix (per SchedulingEngineKind) + tripwire
  PlaybackOrderSupportTests: Supported ∪ Unsupported must partition the
  enum for every engine, so a new order fails the test until classified.
  BlockPlayoutBuilder consumes the matrix for its allow-list.
- write-path rejection left unchanged (#70 closed the persistence hole;
  the perimeter has been wrong three times per decisions.md); reverse
  _ => None mappings reviewed and deferred (different axis; making them
  loud would warn on legit enumerator types).

docs/decisions.md updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
timothy added 1 commit 2026-07-18 18:32:39 +02:00
fix(403): accurate Classic fallback message + engine-coverage tripwire
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 8s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m20s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m1s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 14m21s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 14m18s
1d95cbfee6
Review follow-ups (cold review of PR #457):
- Marathon can reach PlayoutBuilder's default arm via `goto default` when
  its enumerator can't be built; the new warning claimed "Marathon is not
  supported by classic scheduling", which is false. Distinguish
  supported-but-failed-to-build (logs "could not build") from genuinely
  unsupported using PlaybackOrderSupport, which also makes Classic a
  runtime consumer of the matrix.
- PlaybackOrderSupportTests now asserts every SchedulingEngineKind has a
  matrix entry, so a new engine kind fails the test instead of throwing
  KeyNotFoundException at runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Owner

Independent cold adversarial review (review-only agent, no implementation role) completed on the current head, including a focused re-review of the fix commit.

Verified: matrix Supported sets match each engine's real dispatch switch exactly; Block's allow-list semantics are byte-identical to the old hardcoded list; the explicit Random arm and the Playlist/Classic fallbacks are behavior-preserving (goldens don't move); the tripwire is non-vacuous (forces classification of a new order AND a new engine kind); no log-spam path; reverse _ => None deferral is correct. The two Low findings from the first pass (misleading Marathon message; engine-axis coverage gap) are fixed in 1d95cbfe with no regressions.

Review-verdict: MERGEABLE @ 1d95cbfee6

Independent cold adversarial review (review-only agent, no implementation role) completed on the current head, including a focused re-review of the fix commit. Verified: matrix Supported sets match each engine's real dispatch switch exactly; Block's allow-list semantics are byte-identical to the old hardcoded list; the explicit `Random` arm and the Playlist/Classic fallbacks are behavior-preserving (goldens don't move); the tripwire is non-vacuous (forces classification of a new order AND a new engine kind); no log-spam path; reverse `_ => None` deferral is correct. The two Low findings from the first pass (misleading Marathon message; engine-axis coverage gap) are fixed in 1d95cbfe with no regressions. Review-verdict: MERGEABLE @ 1d95cbfee6d833f1ba2e27696c4d1c144888799d
timothy merged commit 8152afead1 into main 2026-07-18 19:10:36 +02:00
timothy deleted branch feat/403-loud-playbackorder 2026-07-18 19:10:37 +02:00
Sign in to join this conversation.