Ships the fault signal #72 calls "the important one": a channel with no playout can never play, and the lineup gave no sign of it.
What landed
fix(72) — the channels API could not answer "will this channel play?" ChannelRepository.GetChannel never included Playouts. The read is AsNoTracking with no lazy-loading proxies, so GetChannelByIdForApiHandler's channel.Playouts?.Count ?? 0 could only ever evaluate to 0 — GET /api/v1/channels/{id} reported playoutCount: 0 for every channel on the system. The detail path also never counted a Mirror channel's source playouts. Both call sites now share Mapper.GetPlayoutsCount (Mirror-aware), and ChannelResponseModel gains PlayoutCount.
feat(72) — "No playout" badge + a Problems segmented filter on the channels list.
The channel editor gates its Playout-source select on playoutCount > 0 ("Cannot be changed once a generated channel has a playout"). Because the count was always 0, that guard never engaged. The server still enforces the invariant (UpdateChannelHandler coerces Mirror→Generated), so nothing was corrupted — but the user's change was silently discarded. That silent coercion is filed as #401, deliberately not fixed here (it is an API-semantics change deserving its own review).
Why PlayoutCount is free
GetAllalreadyIncludes Playouts and MirrorSourceChannel.Playouts and was discarding them. No extra query, no N+1 on a large lineup — which matters, since #72 is explicitly about large lineups.
Design decisions (frontier pass) — see decisions.md 2026-07-17
Lean list DTO, not a new endpoint, not /channels/state. Health is config-derived; /channels/state is the fast-poll runtime-liveness feed. Precedent: the IsLocked stamp on PlayoutListItemResponseModel (api-conventions §3a).
A raw fact, not a status enum. The auto-tune arc (#383/#384) is about to churn the status taxonomy; freezing an enum on a frozen-additive /api/v1 guarantees a breaking rev.
Deliberately NOT computed (each would be wrong today, not merely absent)
Empty schedule — EmptyScheduleHealthCheck only understands ClassicProgramSchedule playouts. Block/Sequential/Scripted/ExternalJson channels have none, so a badge driven off it would silently lie on four of the five schedule kinds.
Broken source — FileNotFound/Unavailable are server-wide counts with no channel attribution.
Auto-tuned vs user origin — no honest signal exists (ChannelPlayoutSource.Generated is a playout strategy; SPA-created blanks carry it too). Needs a column + dual-provider migration on the auto-tune arc's live surface. The "Channel Lineups" playlist-group join was rejected as a heuristic that breaks on edit.
Keeping these out held the PR to read-path only: no migration, no write-handler live-E2E.
Negative control: the new repository tests were proven non-vacuous — removing the include again turns the 2-playout and mirror cases red (with 0 CS errors, so no stale-dll false pass, per the #250 trap).
Tests run the real repository against a real context on purpose: a handler test with a substituted IChannelRepository populates Playouts itself and passes whether or not the query includes them. Only a repository-level read catches a missing include.
Fixes #72
Ships the fault signal #72 calls **"the important one"**: a channel with no playout can never play, and the lineup gave no sign of it.
## What landed
1. **`fix(72)` — the channels API could not answer "will this channel play?"**
`ChannelRepository.GetChannel` never included `Playouts`. The read is `AsNoTracking` with no lazy-loading proxies, so `GetChannelByIdForApiHandler`'s `channel.Playouts?.Count ?? 0` could only ever evaluate to **0** — `GET /api/v1/channels/{id}` reported `playoutCount: 0` for *every channel on the system*. The detail path also never counted a Mirror channel's source playouts. Both call sites now share `Mapper.GetPlayoutsCount` (Mirror-aware), and `ChannelResponseModel` gains `PlayoutCount`.
2. **`feat(72)`** — "No playout" badge + a **Problems** segmented filter on the channels list.
3. **`docs(72)`** — `decisions.md` + `domain-model.md` + design-system mirror (#388).
## Live user-facing consequence of (1)
The channel editor gates its Playout-source select on `playoutCount > 0` ("Cannot be changed once a generated channel has a playout"). Because the count was always 0, **that guard never engaged**. The server still enforces the invariant (`UpdateChannelHandler` coerces Mirror→Generated), so nothing was corrupted — but the user's change was silently discarded. That *silent coercion* is filed as **#401**, deliberately not fixed here (it is an API-semantics change deserving its own review).
## Why `PlayoutCount` is free
`GetAll` **already** `Include`s `Playouts` and `MirrorSourceChannel.Playouts` and was discarding them. No extra query, no N+1 on a large lineup — which matters, since #72 is explicitly about *large* lineups.
## Design decisions (frontier pass) — see `decisions.md` 2026-07-17
- **Lean list DTO, not a new endpoint, not `/channels/state`.** Health is config-derived; `/channels/state` is the fast-poll runtime-liveness feed. Precedent: the `IsLocked` stamp on `PlayoutListItemResponseModel` (api-conventions §3a).
- **A raw fact, not a status enum.** The auto-tune arc (#383/#384) is about to churn the status taxonomy; freezing an enum on a frozen-additive `/api/v1` guarantees a breaking rev.
## Deliberately NOT computed (each would be wrong today, not merely absent)
- **Empty schedule** — `EmptyScheduleHealthCheck` only understands **Classic** `ProgramSchedule` playouts. Block/Sequential/Scripted/ExternalJson channels have none, so a badge driven off it would silently lie on four of the five schedule kinds.
- **Broken source** — `FileNotFound`/`Unavailable` are server-wide counts with no channel attribution.
- **Auto-tuned vs user origin** — no honest signal exists (`ChannelPlayoutSource.Generated` is a playout *strategy*; SPA-created blanks carry it too). Needs a column + dual-provider migration on the auto-tune arc's live surface. The `"Channel Lineups"` playlist-group join was rejected as a heuristic that breaks on edit.
Keeping these out held the PR to **read-path only**: no migration, no write-handler live-E2E.
## Verification
- Backend **1641 passed / 0 failed**; SPA **795 passed / 0 failed** (92 files); lint clean; `npm run build` ok; `npm run check:api` exits 0.
- **Negative control**: the new repository tests were proven non-vacuous — removing the include again turns the 2-playout and mirror cases **red** (with **0 CS errors**, so no stale-dll false pass, per the #250 trap).
- Tests run the **real repository against a real context** on purpose: a handler test with a substituted `IChannelRepository` populates `Playouts` itself and passes whether or not the query includes them. Only a repository-level read catches a missing include.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
The channels API could not answer "will this channel play?", which #72 needs
to flag a broken channel in the lineup at a glance.
Two defects, one root cause each:
1. `ChannelRepository.GetChannel` never included `Playouts`. The read is
AsNoTracking with no lazy-loading proxies, so the navigation came back
empty and `GetChannelByIdForApiHandler`'s `channel.Playouts?.Count ?? 0`
could only ever evaluate to 0 — `GET /api/v1/channels/{id}` reported
`playoutCount: 0` for every channel on the system. That silently disabled
the channel editor's playout-source guard (ChannelEditScreen:820, gated on
`playoutCount > 0`), so the "Cannot be changed once a generated channel has
a playout" control was always live. The server still enforces the invariant
(UpdateChannelHandler coerces Mirror back to Generated), so nothing was
corrupted — but the user's change was silently discarded. That silent
coercion is filed separately as #401.
2. The detail path counted only the channel's own playouts, never the mirror
source's, so a working Mirror channel would read as "no playout" even once
the include landed.
Both call sites now share `Mapper.GetPlayoutsCount` (previously private to
GetAllChannelsHandler), which handles the Mirror case. `ChannelResponseModel`
gains `PlayoutCount` so the list — #72's actual surface — can render it; the
count is free there, since `GetAll` already includes `Playouts` and
`MirrorSourceChannel.Playouts` and simply discarded them.
Tests run the real repository against a real context on purpose: a handler
test with a substituted IChannelRepository populates `Playouts` itself, so it
passes whether or not the query includes them. Proven non-vacuous — removing
the include again turns the 2-playout and mirror cases red (0 CS errors, so
no stale-dll false pass).
Refs #72
Surfaces the per-channel playout count the previous commit put on the API as
the fault state #72 calls "the important one": a channel with no playout can
never play, and until now the lineup gave no sign of it.
- "No playout" renders as a Badge with visible text rather than another
single-letter marker like D/H. D/H flag states the user chose deliberately;
this is a fault they need to notice and act on, and a tooltip-only glyph is
invisible on touch and easy to miss in a long lineup.
- A "Problems" segmented filter answers the issue's actual goal — seeing the
health of a LARGE lineup at a glance — by collapsing it to just the faults.
- `willNeverPlay` is one predicate shared by the badge, the count and the
filter, so the three cannot disagree.
Mirror channels relay their source's playouts and the server folds those into
playoutCount, so a working mirror is not false-flagged (covered by a test).
Deliberately not approximated here, per the design pass: an empty schedule
behind an existing playout (EmptyScheduleHealthCheck only understands Classic
ProgramSchedule playouts — a badge driven by it would silently lie on Block,
Sequential, Scripted and ExternalJson channels) and broken/missing sources
(FileNotFound/Unavailable are server-wide counts with no channel attribution).
Both stay open on #72 rather than shipping a signal that is wrong for four of
the five schedule kinds.
The test fixture now sends playoutCount, matching the API, so the fault state
is opt-in per test instead of depending on an absent field.
Refs #72
Same-PR docs obligations for #72:
- decisions.md — why health is the raw `PlayoutCount` fact on the lean list DTO
rather than a derived status enum or a new endpoint, and, more importantly,
why empty-schedule / broken-source / auto-tuned-origin are deliberately NOT
computed. The empty-schedule one is the trap worth writing down: it only
understands Classic ProgramSchedule playouts, so a badge driven off it would
silently lie on the other four schedule kinds.
- domain-model.md — a glossary row for channel health, drawing the line against
/channels/state's OnAir (runtime liveness, not "would play if tuned").
- design-system — mirror the "No playout" badge + Problems filter into the
Channels prototype so design-system/ stays a faithful mirror of prod
(design-sync #388). Nature Docs carries playouts: 0 so the fault state is
visible in the canvas.
Refs #72
Adversarial review, Medium: the "Problems" filter over-promised. The badge is
honestly named ("No playout"), but the filter claimed a taxonomy that does not
exist — decisions.md enumerates three fault classes this deliberately does NOT
compute (empty schedule behind a playout, broken source, origin). A user whose
Classic schedule is empty would read "Problems 0" as "lineup healthy" and ship
a dead channel. A false all-clear is worse than no affordance.
Renamed the filter to "No playout" so badge and filter both name exactly the
one fault the API can prove, leaving "Problems" free for when the taxonomy
behind it actually exists. Rationale recorded at the predicate so the next
person doesn't "improve" the label back.
Also from the review:
- The zero-playout test's comment claimed include coverage it does not provide
(it passes with or without the include — 0 == 0). Re-stated as what it is: a
mapper boundary check. Its two siblings are the include coverage.
- UpdateChannelHandler:179 is a fourth call site the "shared by all three"
framing excluded. Harmless (the controller discards the view model and
re-projects through GetChannelByIdForApi), but a trap: its query lacks the
MirrorSourceChannel include, so swapping in the shared helper would report 0
for a working mirror. Documented that the fix there is a QUERY change.
- Mirrored the rename into the design-system prototype.
The rename collided the badge and filter labels, so the screen tests now scope
the badge assertion to the table (spa-conventions §6). This also fixed a real
weakness: the mirror test's unscoped queryByText would have matched the filter
button and asserted nothing.
Review note: the reviewer's strongest hypothesis — that PUT and GET could
disagree on a mirror channel's count — was investigated and does NOT hold.
Refs #72
Re-review, Low: domain-model.md still documented a "Problems" filter — a line
added by 50005b12 earlier in this same PR, so the PR contradicted itself once
38483681 renamed the filter to "No playout".
Ironic in exactly the way the rename is about: the next person implementing the
deferred fault classes would read the doc, see "Problems" documented, and
restore the over-promising label that ChannelsScreen's own comment asks them
not to. Stale docs re-introduce the bug the code comment defends against.
decisions.md needed no change — it only ever named the badge, never the filter.
Refs #72
Independent review — two passes, both cold, review-only
Pass 1 (full PR diff @ 50005b12): no Blockers, no Highs. The reviewer ran its own negative control (reverting the two .Include lines → 2 of 3 repo tests fail) rather than trusting the claim, and independently confirmed ChannelEditScreen.tsx:820's guard was genuinely dead code.
Attacks that failed (recording so they aren't re-litigated):
LEFT JOIN safety — MirrorSourceChannelId is int? with .IsRequired(false)/OnDelete(SetNull) → optional nav → LEFT JOIN. Non-mirror channels are not filtered out; the identical include shape already ships on GetAll.
Cartesian explosion — Startup.cs sets a global QuerySplittingBehavior.SplitQuery; extra round-trips, not an N×M blowup.
Mirror chains / self-mirror — MirrorSourceMustBeValid requires the source be Generated, so chains are unreachable. Mirror with a NULL source (deleted → SetNull) → 0 → flagged, which is correct.
PUT vs GET disagreeing on a mirror's count — my own strongest hypothesis, and it does not hold: ChannelController.cs:145-153 discards the view model and re-projects through GetChannelByIdForApi, so UpdateChannelHandler's non-mirror-aware count never reaches the wire.
Medium (fixed, 38483681) — the "Problems" filter over-promised. decisions.md records three fault classes deliberately not computed, so "Problems 0" would read as "lineup healthy" to a user with a dead empty-schedule channel. A false all-clear is worse than no affordance. Badge and filter now both say "No playout" — naming only the fault the API can prove.
Pass 2 (scoped re-review of the fix commit @ 38483681): MERGEABLE WITH NITS. It verified both claims in the new UpdateChannelHandler comment against source (controller re-projects ✓; that handler's query lacks the MirrorSourceChannel include, so the shared helper would return 0 for a mirror anyway ✓ — the prescription "fix the QUERY first" is correct). It also proved the new within(table) scoping non-vacuous by mutating willNeverPlay to return true and confirming both tests fail — so the mirror assertion genuinely asserts.
Its one Low (fixed, b2ca92d3): domain-model.md still documented a "Problems" filter — a line added earlier in this same PR, so the PR contradicted itself. Left alone, the next person would read the doc and restore the label the code comment asks them not to.
Verification @ b2ca92d3
Backend 1641/1641; SPA 795/795 (92 files) across 5 consecutive runs; lint clean; npm run build ok; npm run check:api exit 0.
Negative control on the include: removed → 2 red (0 CS errors, so no stale-dll false pass); restored → 3 green.
Stated review exemption for the head delta
b2ca92d3 is docs-only (one line in domain-model.md) on top of the independently re-reviewed 38483681. Per the skip rubric that is a docs leaf with no server-state effect, so it carries a stated exemption rather than a third cold pass. All code in this PR has had an independent pass.
Not claimed
CI is not green yet — run 1001 was cancelled (not failed), so #72 has no completed CI verdict. Its EF migration integrity job did genuinely fail earlier at Setup .NET with an act-cache error, but the host was at load average 243 at the time; run 998 on main independently died on a buildx DeadlineExceeded in the same window. Both are host saturation, not this diff — no TvContext model change exists here for the migration job to test. Re-running at normal load.
One SPA test file failed once in an early full run and could not be reproduced across 5 subsequent runs; unidentified, recorded rather than dismissed.
## Independent review — two passes, both cold, review-only
**Pass 1** (full PR diff @ `50005b12`): **no Blockers, no Highs.** The reviewer ran its own negative control (reverting the two `.Include` lines → 2 of 3 repo tests fail) rather than trusting the claim, and independently confirmed `ChannelEditScreen.tsx:820`'s guard was genuinely dead code.
Attacks that **failed** (recording so they aren't re-litigated):
- **LEFT JOIN safety** — `MirrorSourceChannelId` is `int?` with `.IsRequired(false)`/`OnDelete(SetNull)` → optional nav → LEFT JOIN. Non-mirror channels are not filtered out; the identical include shape already ships on `GetAll`.
- **Cartesian explosion** — `Startup.cs` sets a global `QuerySplittingBehavior.SplitQuery`; extra round-trips, not an N×M blowup.
- **Mirror chains / self-mirror** — `MirrorSourceMustBeValid` requires the source be `Generated`, so chains are unreachable. Mirror with a NULL source (deleted → SetNull) → 0 → flagged, which is correct.
- **PUT vs GET disagreeing on a mirror's count** — *my own strongest hypothesis, and it does not hold*: `ChannelController.cs:145-153` discards the view model and re-projects through `GetChannelByIdForApi`, so `UpdateChannelHandler`'s non-mirror-aware count never reaches the wire.
**Medium (fixed, `38483681`)** — the "Problems" filter over-promised. `decisions.md` records three fault classes deliberately **not** computed, so "Problems 0" would read as "lineup healthy" to a user with a dead empty-schedule channel. A false all-clear is worse than no affordance. Badge and filter now both say **"No playout"** — naming only the fault the API can prove.
**Pass 2** (scoped re-review of the fix commit @ `38483681`): **MERGEABLE WITH NITS**. It verified both claims in the new `UpdateChannelHandler` comment against source (controller re-projects ✓; that handler's query lacks the `MirrorSourceChannel` include, so the shared helper would return 0 for a mirror anyway ✓ — the prescription "fix the QUERY first" is correct). It also proved the new `within(table)` scoping non-vacuous by mutating `willNeverPlay` to `return true` and confirming **both** tests fail — so the mirror assertion genuinely asserts.
Its one **Low (fixed, `b2ca92d3`)**: `domain-model.md` still documented a "Problems" filter — a line added earlier *in this same PR*, so the PR contradicted itself. Left alone, the next person would read the doc and restore the label the code comment asks them not to.
## Verification @ `b2ca92d3`
- Backend **1641/1641**; SPA **795/795** (92 files) across 5 consecutive runs; lint clean; `npm run build` ok; `npm run check:api` exit 0.
- Negative control on the include: removed → 2 red (**0 CS errors**, so no stale-dll false pass); restored → 3 green.
## Stated review exemption for the head delta
`b2ca92d3` is **docs-only** (one line in `domain-model.md`) on top of the independently re-reviewed `38483681`. Per the skip rubric that is a docs leaf with no server-state effect, so it carries a stated exemption rather than a third cold pass. All code in this PR has had an independent pass.
## Not claimed
- **CI is not green yet** — run 1001 was **cancelled** (not failed), so #72 has no completed CI verdict. Its `EF migration integrity` job did genuinely fail earlier at `Setup .NET` with an act-cache error, but the host was at **load average 243** at the time; run 998 on main independently died on a buildx `DeadlineExceeded` in the same window. Both are host saturation, not this diff — no `TvContext` model change exists here for the migration job to test. Re-running at normal load.
- One SPA test file failed once in an early full run and could not be reproduced across 5 subsequent runs; unidentified, recorded rather than dismissed.
Review-verdict: MERGEABLE @ b2ca92d30ac2c5a610d94221b77766ac4638a68a
CI's "Formatting (changed .cs conform to .editorconfig)" job failed with
`error CHARSET: Fix file encoding` on all six pre-existing BOM'd files this PR
touches. The #311 gate is fix-as-you-touch: any .cs a PR touches must conform to
.editorconfig (charset=utf-8), and these carried BOMs inherited from upstream.
BOM removal only — six files, one line each, zero content change. The three
files already without a BOM (GetChannelByIdForApiHandler + both new/changed
Tests files) needed nothing.
Note for the next person: this cannot be validated locally on this Mac —
`dotnet format --include` silently no-ops here, so the gate is only observable
in CI. Check `head -c3 <file> | xxd -p` for `efbbbf` on every touched .cs before
pushing instead of trusting a local format run.
Refs #72
CI: clean green — 0 failed, 0 cancelled. Build & test ✅ · EF migration integrity ✅ · Functional E2E ✅ · API docs in sync ✅ · Formatting ✅ · decisions.md append-only ✅ · Docs reminder ✅ (Build & push image skipped — PR runs don't push images).
It took three attempts to get an uncontaminated run, none of them this diff's fault:
Attempt
Died at
Marker?
Cause
1
mid-dotnet build
none
runner retune killed in-flight jobs
2
.NET telemetry banner (setup)
none
NuGet cache step
3
—
—
green
Both earlier reds reported conclusion: failure while being kills — the tell is a log that stops mid-step with no ❌ Failure - Main marker. Worth recording: attempts 1–2 "failed" EF migration integrity and Functional E2E on a BOM-removal-only delta (6 files, 1 line each, zero content change) that cannot affect either — and both went green on the same unchanged sha once the environment settled. A diff that cannot cause a failure is evidence the failure isn't yours.
Deferred scope now filed (the last open Done-when box)
#414 — auto-tuned-vs-user origin marker. Needs a Channel column + dual-provider migration, and provenance belongs to the auto-tune arc, so it is blocked-by #383 (dependency set + verified). The "Channel Lineups" playlist-group join is recorded as rejected, not forgotten.
#415 — per-channel empty-schedule + broken-source attribution, carrying the trap that deferred them: EmptyScheduleHealthCheck only understands Classic playouts, so a badge driven off it would be silently wrong for four of the five schedule kinds.
Both are priority: low-labeled (H12 clean). The SPA filter stays "No playout" until #415 gives "Problems" a real taxonomy — willNeverPlay's comment says so at the call site.
Review trail
Pass 1 (full diff): no Blockers, no Highs; reviewer ran its own negative control on the include and confirmed ChannelEditScreen's guard was genuinely dead code. Killed my own strongest hypothesis (PUT/GET disagreeing on a mirror count — the controller re-projects, so it never reaches the wire).
Medium fixed (38483681): the "Problems" filter over-promised → renamed.
Pass 2 (scoped re-review of the fix commit): MERGEABLE WITH NITS; verified both claims in the new UpdateChannelHandler comment against source, and proved the within(table) scoping non-vacuous by mutating willNeverPlay.
Low fixed (b2ca92d3): domain-model.md still said "Problems", contradicting the PR's own rename.
Head delta since the reviewed commit — stated exemption
18818e16 is BOM removal only (6 files, 1 line each, zero content change) on top of the reviewed b2ca92d3, to satisfy the #311 fix-as-you-touch gate. No logic delta, so it carries a stated exemption rather than a third cold pass. All code in this PR has had an independent review.
## CI green + Done-when satisfied @ `18818e16`
**CI: clean green — 0 failed, 0 cancelled.** Build & test ✅ · EF migration integrity ✅ · Functional E2E ✅ · API docs in sync ✅ · Formatting ✅ · decisions.md append-only ✅ · Docs reminder ✅ (Build & push image `skipped` — PR runs don't push images).
It took three attempts to get an uncontaminated run, none of them this diff's fault:
| Attempt | Died at | Marker? | Cause |
|---|---|---|---|
| 1 | mid-`dotnet build` | none | runner retune killed in-flight jobs |
| 2 | .NET telemetry banner (setup) | none | NuGet cache step |
| 3 | — | — | **green** |
Both earlier reds reported `conclusion: failure` while being **kills** — the tell is a log that stops mid-step with no `❌ Failure - Main` marker. Worth recording: attempts 1–2 "failed" `EF migration integrity` and `Functional E2E` on a **BOM-removal-only** delta (6 files, 1 line each, zero content change) that cannot affect either — and both went green on the same unchanged sha once the environment settled. A diff that *cannot* cause a failure is evidence the failure isn't yours.
## Deferred scope now filed (the last open Done-when box)
- **#414** — auto-tuned-vs-user **origin** marker. Needs a `Channel` column + dual-provider migration, and provenance belongs to the auto-tune arc, so it is **blocked-by #383** (dependency set + verified). The `"Channel Lineups"` playlist-group join is recorded as **rejected**, not forgotten.
- **#415** — per-channel **empty-schedule** + **broken-source** attribution, carrying the trap that deferred them: `EmptyScheduleHealthCheck` only understands **Classic** playouts, so a badge driven off it would be silently wrong for four of the five schedule kinds.
Both are `priority: low`-labeled (H12 clean). The SPA filter stays **"No playout"** until #415 gives "Problems" a real taxonomy — `willNeverPlay`'s comment says so at the call site.
## Review trail
- Pass 1 (full diff): **no Blockers, no Highs**; reviewer ran its own negative control on the include and confirmed `ChannelEditScreen`'s guard was genuinely dead code. Killed my own strongest hypothesis (PUT/GET disagreeing on a mirror count — the controller re-projects, so it never reaches the wire).
- **Medium fixed** (`38483681`): the "Problems" filter over-promised → renamed.
- Pass 2 (scoped re-review of the fix commit): **MERGEABLE WITH NITS**; verified both claims in the new `UpdateChannelHandler` comment against source, and proved the `within(table)` scoping non-vacuous by mutating `willNeverPlay`.
- **Low fixed** (`b2ca92d3`): `domain-model.md` still said "Problems", contradicting the PR's own rename.
## Head delta since the reviewed commit — stated exemption
`18818e16` is **BOM removal only** (6 files, 1 line each, zero content change) on top of the reviewed `b2ca92d3`, to satisfy the #311 fix-as-you-touch gate. No logic delta, so it carries a stated exemption rather than a third cold pass. All *code* in this PR has had an independent review.
Review-verdict: MERGEABLE @ 18818e163c09001eb94993ee434d9f64bb311fb0
The branch was 16 commits behind main (parallel-session merges), so it was rebased onto origin/main (not merged — per the rebase-don't-merge rule). CI is clean green on the rebased head: Build & test ✅ · EF migration integrity ✅ · Functional E2E ✅ · API docs in sync ✅ · Formatting ✅ · decisions.md append-only ✅ · CI image pin ✅.
Rebase resolution (mechanical, verified — no logic touched)
decisions.md (append-only, CI-enforced): both new entries kept — #384's "Auto-Tune DetailPanel member list" and this PR's "Channel health on the API". The decisions.md append-only check passed.
domain-model.md: #384 had extended the same "Channel concepts" row, so the resolution kept their updated row (with the auto-tune/members note) plus this PR's new "Channel health" row — not a naive keep-both that would have duplicated the row. Verified: 1 Channel-concepts row, 1 Channel-health row, #384's note intact, the "No playout" rename intact, no adjacent row dropped.
Generated artifacts regenerated, not hand-merged (v1.json / v1.d.ts / endpoint-index.md): ./scripts/update-openapi.sh + npm run generate:api produced no diff vs git's auto-merge, so the text-merge was correct this time — but verified rather than trusted. Both /api/v1/channels/auto-tune/members (#384) and playoutCount (this PR) are present; 164→165 endpoints.
Re-verification @ 73f36908
BOM-clean · backend 1651/1651 · SPA 795/795 · lint clean · npm run build ok · npm run check:api exit 0.
Exemption for the head delta
The delta since the twice-reviewed code (b2ca92d3) is rebase + mechanical doc-conflict resolution + spec regen — zero logic change. Stated exemption rather than a third cold pass; all code in this PR has had two independent review passes (full diff + fix-commit re-review, both MERGEABLE).
Supersedes the prior Review-verdict @ 18818e16 (that sha was rebased away).
## Rebased onto main + re-verified @ `73f36908`
The branch was 16 commits behind main (parallel-session merges), so it was rebased onto `origin/main` (not merged — per the rebase-don't-merge rule). CI is **clean green** on the rebased head: Build & test ✅ · EF migration integrity ✅ · Functional E2E ✅ · API docs in sync ✅ · Formatting ✅ · decisions.md append-only ✅ · CI image pin ✅.
### Rebase resolution (mechanical, verified — no logic touched)
- **`decisions.md`** (append-only, CI-enforced): both new entries kept — #384's "Auto-Tune DetailPanel member list" and this PR's "Channel health on the API". The `decisions.md append-only` check passed.
- **`domain-model.md`**: #384 had extended the same "Channel concepts" row, so the resolution kept **their** updated row (with the `auto-tune/members` note) **plus** this PR's new "Channel health" row — not a naive keep-both that would have duplicated the row. Verified: 1 Channel-concepts row, 1 Channel-health row, #384's note intact, the "No playout" rename intact, no adjacent row dropped.
- **Generated artifacts regenerated, not hand-merged** (`v1.json` / `v1.d.ts` / `endpoint-index.md`): `./scripts/update-openapi.sh` + `npm run generate:api` produced **no diff** vs git's auto-merge, so the text-merge was correct this time — but verified rather than trusted. Both `/api/v1/channels/auto-tune/members` (#384) and `playoutCount` (this PR) are present; 164→165 endpoints.
### Re-verification @ `73f36908`
BOM-clean · backend **1651/1651** · SPA **795/795** · lint clean · `npm run build` ok · `npm run check:api` exit 0.
### Exemption for the head delta
The delta since the twice-reviewed code (`b2ca92d3`) is **rebase + mechanical doc-conflict resolution + spec regen — zero logic change**. Stated exemption rather than a third cold pass; all *code* in this PR has had two independent review passes (full diff + fix-commit re-review, both MERGEABLE).
Supersedes the prior `Review-verdict @ 18818e16` (that sha was rebased away).
Review-verdict: MERGEABLE @ 73f36908283c6fdb3a1ec79c6f1b50c709d87fbb
Re-verdict @ 1e35248e (rebase over CI-config commits — code unchanged)
Head moved 73f36908 → 1e35248e by a second rebase onto main (parallel sessions kept merging). This rebase replayed my 6 commits over CI-config-only main commits (#406: Dockerfile, workflows, ci-cd.md, a BOM-guard hook) with zero overlap on my files.
Proof the code is identical to the twice-reviewed, CI-green 73f36908:
git diff --stat 73f36908 1e35248e = only other sessions' files (.gitea/workflows/*, docker/Dockerfile, docs/ci-cd.md, docs/contributing.md, scripts/migration-smoke.sh, .claude/*). None of ErsatzTV.*/**, web/**, or the OpenAPI artifacts appear.
git range-diff maps my 6 commits 1:1; only the docs commit changed, and only in its append-only decisions.md keep-both + the domain-model.md row resolution.
Re-verified locally on 1e35248e: spec regen produced no diff, BOM-clean, dotnet build succeeds. Required CI checks (Build & test, EF migration integrity) are green on this head.
Stated exemption: the delta since the reviewed code is rebase + mechanical doc-conflict resolution, no logic change — so this is a re-verdict at the new head, not a third cold pass. All code has had two independent review passes.
## Re-verdict @ `1e35248e` (rebase over CI-config commits — code unchanged)
Head moved `73f36908` → `1e35248e` by a second rebase onto main (parallel sessions kept merging). This rebase replayed my 6 commits over **CI-config-only** main commits (#406: Dockerfile, workflows, ci-cd.md, a BOM-guard hook) with **zero overlap on my files**.
**Proof the code is identical to the twice-reviewed, CI-green `73f36908`:**
- `git diff --stat 73f36908 1e35248e` = only *other sessions'* files (`.gitea/workflows/*`, `docker/Dockerfile`, `docs/ci-cd.md`, `docs/contributing.md`, `scripts/migration-smoke.sh`, `.claude/*`). **None** of `ErsatzTV.*/**`, `web/**`, or the OpenAPI artifacts appear.
- `git range-diff` maps my 6 commits 1:1; only the docs commit changed, and only in its append-only `decisions.md` keep-both + the `domain-model.md` row resolution.
- Re-verified locally on `1e35248e`: spec regen produced **no diff**, BOM-clean, `dotnet build` succeeds. Required CI checks (Build & test, EF migration integrity) are **green** on this head.
**Stated exemption:** the delta since the reviewed code is rebase + mechanical doc-conflict resolution, no logic change — so this is a re-verdict at the new head, not a third cold pass. All *code* has had two independent review passes.
Supersedes `Review-verdict @ 73f36908` (rebased away).
Review-verdict: MERGEABLE @ 1e35248edf28fb25b8b8b259cdeeca6e7acb6d99
timothy
scheduled this pull request to auto merge when all checks succeed 2026-07-17 18:13:49 +02:00
timothy
merged commit ac7c978824 into main2026-07-17 18:13:52 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Fixes #72
Ships the fault signal #72 calls "the important one": a channel with no playout can never play, and the lineup gave no sign of it.
What landed
fix(72)— the channels API could not answer "will this channel play?"ChannelRepository.GetChannelnever includedPlayouts. The read isAsNoTrackingwith no lazy-loading proxies, soGetChannelByIdForApiHandler'schannel.Playouts?.Count ?? 0could only ever evaluate to 0 —GET /api/v1/channels/{id}reportedplayoutCount: 0for every channel on the system. The detail path also never counted a Mirror channel's source playouts. Both call sites now shareMapper.GetPlayoutsCount(Mirror-aware), andChannelResponseModelgainsPlayoutCount.feat(72)— "No playout" badge + a Problems segmented filter on the channels list.docs(72)—decisions.md+domain-model.md+ design-system mirror (#388).Live user-facing consequence of (1)
The channel editor gates its Playout-source select on
playoutCount > 0("Cannot be changed once a generated channel has a playout"). Because the count was always 0, that guard never engaged. The server still enforces the invariant (UpdateChannelHandlercoerces Mirror→Generated), so nothing was corrupted — but the user's change was silently discarded. That silent coercion is filed as #401, deliberately not fixed here (it is an API-semantics change deserving its own review).Why
PlayoutCountis freeGetAllalreadyIncludesPlayoutsandMirrorSourceChannel.Playoutsand was discarding them. No extra query, no N+1 on a large lineup — which matters, since #72 is explicitly about large lineups.Design decisions (frontier pass) — see
decisions.md2026-07-17/channels/state. Health is config-derived;/channels/stateis the fast-poll runtime-liveness feed. Precedent: theIsLockedstamp onPlayoutListItemResponseModel(api-conventions §3a)./api/v1guarantees a breaking rev.Deliberately NOT computed (each would be wrong today, not merely absent)
EmptyScheduleHealthCheckonly understands ClassicProgramScheduleplayouts. Block/Sequential/Scripted/ExternalJson channels have none, so a badge driven off it would silently lie on four of the five schedule kinds.FileNotFound/Unavailableare server-wide counts with no channel attribution.ChannelPlayoutSource.Generatedis a playout strategy; SPA-created blanks carry it too). Needs a column + dual-provider migration on the auto-tune arc's live surface. The"Channel Lineups"playlist-group join was rejected as a heuristic that breaks on edit.Keeping these out held the PR to read-path only: no migration, no write-handler live-E2E.
Verification
npm run buildok;npm run check:apiexits 0.IChannelRepositorypopulatesPlayoutsitself and passes whether or not the query includes them. Only a repository-level read catches a missing include.🤖 Generated with Claude Code
Adversarial review, Medium: the "Problems" filter over-promised. The badge is honestly named ("No playout"), but the filter claimed a taxonomy that does not exist — decisions.md enumerates three fault classes this deliberately does NOT compute (empty schedule behind a playout, broken source, origin). A user whose Classic schedule is empty would read "Problems 0" as "lineup healthy" and ship a dead channel. A false all-clear is worse than no affordance. Renamed the filter to "No playout" so badge and filter both name exactly the one fault the API can prove, leaving "Problems" free for when the taxonomy behind it actually exists. Rationale recorded at the predicate so the next person doesn't "improve" the label back. Also from the review: - The zero-playout test's comment claimed include coverage it does not provide (it passes with or without the include — 0 == 0). Re-stated as what it is: a mapper boundary check. Its two siblings are the include coverage. - UpdateChannelHandler:179 is a fourth call site the "shared by all three" framing excluded. Harmless (the controller discards the view model and re-projects through GetChannelByIdForApi), but a trap: its query lacks the MirrorSourceChannel include, so swapping in the shared helper would report 0 for a working mirror. Documented that the fix there is a QUERY change. - Mirrored the rename into the design-system prototype. The rename collided the badge and filter labels, so the screen tests now scope the badge assertion to the table (spa-conventions §6). This also fixed a real weakness: the mirror test's unscoped queryByText would have matched the filter button and asserted nothing. Review note: the reviewer's strongest hypothesis — that PUT and GET could disagree on a mirror channel's count — was investigated and does NOT hold. Refs #72Independent review — two passes, both cold, review-only
Pass 1 (full PR diff @
50005b12): no Blockers, no Highs. The reviewer ran its own negative control (reverting the two.Includelines → 2 of 3 repo tests fail) rather than trusting the claim, and independently confirmedChannelEditScreen.tsx:820's guard was genuinely dead code.Attacks that failed (recording so they aren't re-litigated):
MirrorSourceChannelIdisint?with.IsRequired(false)/OnDelete(SetNull)→ optional nav → LEFT JOIN. Non-mirror channels are not filtered out; the identical include shape already ships onGetAll.Startup.cssets a globalQuerySplittingBehavior.SplitQuery; extra round-trips, not an N×M blowup.MirrorSourceMustBeValidrequires the source beGenerated, so chains are unreachable. Mirror with a NULL source (deleted → SetNull) → 0 → flagged, which is correct.ChannelController.cs:145-153discards the view model and re-projects throughGetChannelByIdForApi, soUpdateChannelHandler's non-mirror-aware count never reaches the wire.Medium (fixed,
38483681) — the "Problems" filter over-promised.decisions.mdrecords three fault classes deliberately not computed, so "Problems 0" would read as "lineup healthy" to a user with a dead empty-schedule channel. A false all-clear is worse than no affordance. Badge and filter now both say "No playout" — naming only the fault the API can prove.Pass 2 (scoped re-review of the fix commit @
38483681): MERGEABLE WITH NITS. It verified both claims in the newUpdateChannelHandlercomment against source (controller re-projects ✓; that handler's query lacks theMirrorSourceChannelinclude, so the shared helper would return 0 for a mirror anyway ✓ — the prescription "fix the QUERY first" is correct). It also proved the newwithin(table)scoping non-vacuous by mutatingwillNeverPlaytoreturn trueand confirming both tests fail — so the mirror assertion genuinely asserts.Its one Low (fixed,
b2ca92d3):domain-model.mdstill documented a "Problems" filter — a line added earlier in this same PR, so the PR contradicted itself. Left alone, the next person would read the doc and restore the label the code comment asks them not to.Verification @
b2ca92d3npm run buildok;npm run check:apiexit 0.Stated review exemption for the head delta
b2ca92d3is docs-only (one line indomain-model.md) on top of the independently re-reviewed38483681. Per the skip rubric that is a docs leaf with no server-state effect, so it carries a stated exemption rather than a third cold pass. All code in this PR has had an independent pass.Not claimed
EF migration integrityjob did genuinely fail earlier atSetup .NETwith an act-cache error, but the host was at load average 243 at the time; run 998 on main independently died on a buildxDeadlineExceededin the same window. Both are host saturation, not this diff — noTvContextmodel change exists here for the migration job to test. Re-running at normal load.Review-verdict: MERGEABLE @
b2ca92d30aCI green + Done-when satisfied @
18818e16CI: clean green — 0 failed, 0 cancelled. Build & test ✅ · EF migration integrity ✅ · Functional E2E ✅ · API docs in sync ✅ · Formatting ✅ · decisions.md append-only ✅ · Docs reminder ✅ (Build & push image
skipped— PR runs don't push images).It took three attempts to get an uncontaminated run, none of them this diff's fault:
dotnet buildBoth earlier reds reported
conclusion: failurewhile being kills — the tell is a log that stops mid-step with no❌ Failure - Mainmarker. Worth recording: attempts 1–2 "failed"EF migration integrityandFunctional E2Eon a BOM-removal-only delta (6 files, 1 line each, zero content change) that cannot affect either — and both went green on the same unchanged sha once the environment settled. A diff that cannot cause a failure is evidence the failure isn't yours.Deferred scope now filed (the last open Done-when box)
Channelcolumn + dual-provider migration, and provenance belongs to the auto-tune arc, so it is blocked-by #383 (dependency set + verified). The"Channel Lineups"playlist-group join is recorded as rejected, not forgotten.EmptyScheduleHealthCheckonly understands Classic playouts, so a badge driven off it would be silently wrong for four of the five schedule kinds.Both are
priority: low-labeled (H12 clean). The SPA filter stays "No playout" until #415 gives "Problems" a real taxonomy —willNeverPlay's comment says so at the call site.Review trail
ChannelEditScreen's guard was genuinely dead code. Killed my own strongest hypothesis (PUT/GET disagreeing on a mirror count — the controller re-projects, so it never reaches the wire).38483681): the "Problems" filter over-promised → renamed.UpdateChannelHandlercomment against source, and proved thewithin(table)scoping non-vacuous by mutatingwillNeverPlay.b2ca92d3):domain-model.mdstill said "Problems", contradicting the PR's own rename.Head delta since the reviewed commit — stated exemption
18818e16is BOM removal only (6 files, 1 line each, zero content change) on top of the reviewedb2ca92d3, to satisfy the #311 fix-as-you-touch gate. No logic delta, so it carries a stated exemption rather than a third cold pass. All code in this PR has had an independent review.Review-verdict: MERGEABLE @
18818e163c18818e163cto73f3690828Rebased onto main + re-verified @
73f36908The branch was 16 commits behind main (parallel-session merges), so it was rebased onto
origin/main(not merged — per the rebase-don't-merge rule). CI is clean green on the rebased head: Build & test ✅ · EF migration integrity ✅ · Functional E2E ✅ · API docs in sync ✅ · Formatting ✅ · decisions.md append-only ✅ · CI image pin ✅.Rebase resolution (mechanical, verified — no logic touched)
decisions.md(append-only, CI-enforced): both new entries kept — #384's "Auto-Tune DetailPanel member list" and this PR's "Channel health on the API". Thedecisions.md append-onlycheck passed.domain-model.md: #384 had extended the same "Channel concepts" row, so the resolution kept their updated row (with theauto-tune/membersnote) plus this PR's new "Channel health" row — not a naive keep-both that would have duplicated the row. Verified: 1 Channel-concepts row, 1 Channel-health row, #384's note intact, the "No playout" rename intact, no adjacent row dropped.v1.json/v1.d.ts/endpoint-index.md):./scripts/update-openapi.sh+npm run generate:apiproduced no diff vs git's auto-merge, so the text-merge was correct this time — but verified rather than trusted. Both/api/v1/channels/auto-tune/members(#384) andplayoutCount(this PR) are present; 164→165 endpoints.Re-verification @
73f36908BOM-clean · backend 1651/1651 · SPA 795/795 · lint clean ·
npm run buildok ·npm run check:apiexit 0.Exemption for the head delta
The delta since the twice-reviewed code (
b2ca92d3) is rebase + mechanical doc-conflict resolution + spec regen — zero logic change. Stated exemption rather than a third cold pass; all code in this PR has had two independent review passes (full diff + fix-commit re-review, both MERGEABLE).Supersedes the prior
Review-verdict @ 18818e16(that sha was rebased away).Review-verdict: MERGEABLE @ 73f36908283c6fdb3a1ec79c6f1b50c709d87fbb
73f3690828to1e35248edfRe-verdict @
1e35248e(rebase over CI-config commits — code unchanged)Head moved
73f36908→1e35248eby a second rebase onto main (parallel sessions kept merging). This rebase replayed my 6 commits over CI-config-only main commits (#406: Dockerfile, workflows, ci-cd.md, a BOM-guard hook) with zero overlap on my files.Proof the code is identical to the twice-reviewed, CI-green
73f36908:git diff --stat 73f36908 1e35248e= only other sessions' files (.gitea/workflows/*,docker/Dockerfile,docs/ci-cd.md,docs/contributing.md,scripts/migration-smoke.sh,.claude/*). None ofErsatzTV.*/**,web/**, or the OpenAPI artifacts appear.git range-diffmaps my 6 commits 1:1; only the docs commit changed, and only in its append-onlydecisions.mdkeep-both + thedomain-model.mdrow resolution.1e35248e: spec regen produced no diff, BOM-clean,dotnet buildsucceeds. Required CI checks (Build & test, EF migration integrity) are green on this head.Stated exemption: the delta since the reviewed code is rebase + mechanical doc-conflict resolution, no logic change — so this is a re-verdict at the new head, not a third cold pass. All code has had two independent review passes.
Supersedes
Review-verdict @ 73f36908(rebased away).Review-verdict: MERGEABLE @
1e35248edf