---
key: sched.playbackorder-support-matrix
title: 2026-07-18 — Unsupported PlaybackOrder is loud at build time; a declared support matrix and tripwire test make new orders safe by construction (#403)
status: active
since: '2026-07-18'
supersedes: none
superseded-by: none
rule: Every build-time dispatch site logs a loud (non-fatal) warning on an unsupported `PlaybackOrder`, and a declared `PlaybackOrderSupport` matrix + partition tripwire test makes adding a new order safe by construction.
signals: 'PlaybackOrder, support matrix, build-time dispatch · paths: `ErsatzTV.Core/Scheduling/PlaybackOrderSupport`, `PlaylistEnumerator.Create`, `BlockPlayoutBuilder` · issues: #403, #70'
mechanics: '`PlaybackOrderSupportTests` (partition assertion)'
---

`#70` closed the *persistence* hole for `WeightedShuffle` (the write path rejects it on the engines that
can't handle it) and made **YAML + Scripted** log a warning; `MultiCollectionGroup` already threw. It left
the three still-**silent** build-time dispatch sites — the ones this issue names — as an explicit non-goal.
`#403` makes those loud and adds the by-construction net.

- **Loud, but NON-FATAL, at every build site.** On an order it doesn't handle, each site now logs a
  `Warning` (naming the order + engine + the fallback taken) instead of silently substituting/dropping/
  skipping: Classic's `PlayoutBuilder` `default:` arm (was `// TODO`, silently returned Random), the
  `PlaylistEnumerator.Create` switch (had **no** `default:` arm → null enumerator → item dropped), and
  `BlockPlayoutBuilder`'s allow-list `continue` (silent skip). The actual fallback is **preserved** — a
  live channel airing wrong-but-something beats going dark on one misconfigured item — so scheduler goldens
  do not move. This matches the log-and-continue posture `#70` already shipped for YAML/Scripted.
  - `PlaylistEnumerator.Create` was `static` with **no logger**, which is *why* the playlist drop was
    unreportable. It gained an optional `Option<ILogger> logger = default` (mirrors the existing
    `GetStartTimeAfter(..., Option<ILogger>.None)` pattern); the three callers that have a logger pass it,
    the rest default to `None`.
  - `BlockPlayoutBuilder`'s `GetEnumerator` switch gained an explicit `PlaybackOrder.Random` arm. Random is
    in Block's allow-list but previously reached an enumerator only via the switch's coincidental `_ =>`
    Random fallback; the fallback is now a loud helper (defensive — the allow-list should make it
    unreachable).

- **Safe-by-construction net: a declared support matrix + a tripwire test.** `PlaybackOrderSupport`
  (`ErsatzTV.Core/Scheduling/`) declares, per `SchedulingEngineKind` (Classic / Playlist / Block / Yaml /
  Scripted), BOTH a `Supported` and an `Unsupported` set. `PlaybackOrderSupportTests` asserts the two sets
  **partition** every `PlaybackOrder` value (union total, intersection empty) for every engine — so adding a
  new enum value lands in neither set and **fails the test until it is consciously classified** and wired
  into the matching dispatch switch. Both sets are hand-maintained on purpose: deriving `Unsupported` as
  "everything not supported" would let a new order fall through silently, which is the exact defect being
  killed. The matrix is not pure test scaffolding — `BlockPlayoutBuilder` consumes it at runtime for its
  allow-list (replacing the previously duplicated inline list).

- **Write-path rejection is deliberately UNCHANGED.** `#70`'s per-engine guards already close the real
  persistence exposure, and the decisions log records that the "which order reaches which engine" perimeter
  has been wrong three times — always by enumerating from a list instead of grepping every writer.
  Broadening write-path rejection here would risk newly-`400`ing configs that currently save-and-fall-back,
  for no safety gain, so it was left alone. This PR hardens the *build-time* sites and adds the tripwire.

- **Reverse-mappings deferred (a different axis).** The three `_ => PlaybackOrder.None` arms that map an
  enumerator *class* back to a `PlaybackOrder` for `PlayoutHistory` were left alone: that path is reached in
  normal operation by legitimately-supported enumerator types (`ShuffleInOrderCollectionEnumerator`,
  `SeasonEpisodeMediaCollectionEnumerator`) that simply aren't reverse-mapped, so making it "loud" would
  emit false-positive warnings. Completing that reverse map is a separate concern from "an unsupported
  *order* degrades silently" and is not part of #403's scope.
