# Per-channel fault detection (#415) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Surface four more per-channel fault classes (never-built, build-failed, empty-upcoming, broken-source) beyond today's "no playout", on the channel API resource and as a single "Problems" filter in the SPA.

**Architecture:** Detect every fault from the *built* timeline (`Playout.BuildStatus` + upcoming `PlayoutItem`s joined to `MediaItem.State`) rather than per-kind config, so all five `PlayoutScheduleKind` values are covered by construction and broken-source attribution is exact. A server-derived `health` object is folded onto `ChannelResponseModel`/`ChannelDetailResponseModel` so SPA and MCP read one authoritative verdict.

**Tech Stack:** C# (.NET, LanguageExt, EF Core, MediatR-style handlers), NUnit + Shouldly + NSubstitute; React + TypeScript SPA, Vitest + @testing-library/react; OpenAPI-generated TS types.

## Global Constraints

- Full design + rationale: `docs/superpowers/specs/2026-07-23-channel-fault-detection-design.md`. Read it first.
- "Upcoming" window is `PlayoutItem.Finish >= nowUtc` (current + future built items); no arbitrary N-hour horizon.
- Enum-like API fields are **const-string classes** (mirror `ChannelPreviewAvailability`), NOT C# enums — keeps OpenAPI simple; the SPA hand-maintains its union.
- New API fields are **additive trailing params** on the existing positional records — never reorder existing params. `/api/v1` is additive-only after freeze.
- Assessable gate: `isOnDemand = channel.PlayoutMode == ChannelPlayoutMode.OnDemand`. On-demand suppresses `neverBuilt`+`emptyUpcoming` (absence signals); `buildFailed`+`brokenSource` (presence signals) always apply.
- `MediaItemState` bad values: `FileNotFound` (1), `Unavailable` (2).
- Backend tests: NUnit `[TestFixture]`/`[Test]`, Shouldly, NSubstitute. **Channel-health tests live in `ErsatzTV.Tests/Application/Channels/`** (namespace `ErsatzTV.Tests.Application.Channels`) — that project has `InternalsVisibleTo` access to `ErsatzTV.Application` (needed for `internal GetHealth`); `ErsatzTV.Core.Tests` does NOT. Real-DB tests use the **`InMemoryTvContext`** harness (`ErsatzTV.Tests/Support/InMemoryTvContext.cs`): real `:memory:` SQLite with FKs OFF (seed partial graphs), exposing `_db.Factory` (`IDbContextFactory<TvContext>`) and `_db.CreateContext()`. Reusable seed helpers in `ErsatzTV.Tests/Support/ChannelHandlerTestBase.cs` (`SeedChannel`, `SeedPlayout`, `SeedFFmpegProfile`). Mirror `ErsatzTV.Tests/Application/Channels/GetChannelStatesForApiHandlerTests.cs` for the setup/teardown/seed pattern. `Channel` has NO parameterless ctor — always `new Channel(Guid.NewGuid()) { ... }`.
- SPA tests: Vitest + testing-library, mock `window.fetch`; fixtures use runtime camelCase.
- PR routine for API changes: build `ErsatzTV` project → `./scripts/update-openapi.sh` → `cd web && npm run generate:api`. BOM-check touched `.cs` + format gate under `bash -c` before push. Rebase (never merge) on `origin/main`; regenerate generated artifacts after any conflicted rebase.
- Independent review is mandatory (API write-adjacent + >150 C# lines likely). Live-E2E before push via `scripts/e2e-local.sh` (fresh config dir, curl endpoints).
- Never set `ETV_UPDATE_GOLDENS`. Commit with `-c core.hooksPath=/dev/null` + `--no-verify` in the worktree; verify format manually.

---

### Task 1: Health DTO, constants, and the pure `GetHealth` derivation

The core of the feature: a pure function mapping a `Channel` (+ its playout-upcoming aggregate) to a `ChannelHealthResponseModel`. Fully unit-testable with domain objects, no DB.

**Files:**
- Create: `ErsatzTV.Core/Api/Channels/ChannelHealthResponseModel.cs`
- Modify: `ErsatzTV.Application/Channels/Mapper.cs` (add `GetHealth` + a small aggregate type)
- Test: `ErsatzTV.Tests/Application/Channels/ChannelHealthTests.cs` (new; namespace `ErsatzTV.Tests.Application.Channels`)

**Interfaces:**
- Produces:
  - `record ChannelHealthResponseModel(string Status, string[] Faults, int PlayoutCount, int BrokenSourceItemCount)` (in `ErsatzTV.Core/Api/Channels/`)
  - `static class ChannelHealthStatus { const string Healthy="Healthy", Problems="Problems", Unknown="Unknown"; }`
  - `static class ChannelFault { const string NoPlayout="NoPlayout", NeverBuilt="NeverBuilt", BuildFailed="BuildFailed", EmptyUpcoming="EmptyUpcoming", BrokenSource="BrokenSource"; }`
  - `readonly record struct PlayoutUpcoming(int TotalUpcoming, int BrokenUpcoming)` — **define in Core** (`ErsatzTV.Core/Api/Channels/ChannelHealthResponseModel.cs`), NOT Application, so the Task 2 repository interface (Core) can use it. This supersedes any "in Application" mention below.
  - `internal static ChannelHealthResponseModel GetHealth(Channel channel, int playoutCount, IReadOnlyDictionary<int, PlayoutUpcoming> upcoming)` — `internal` is visible to `ErsatzTV.Tests` via existing `InternalsVisibleTo`.

- [ ] **Step 1: Create the DTO + constant classes**

Create `ErsatzTV.Core/Api/Channels/ChannelHealthResponseModel.cs`:

```csharp
#nullable enable
namespace ErsatzTV.Core.Api.Channels;

// Server-derived per-channel health. The SPA and MCP both read Status/Faults rather than deriving a
// verdict themselves (the api.channel-health-signal decision, superseding #72's raw-fact-only stance).
// Faults are plain strings (see ChannelFault) not a C# enum, to keep the OpenAPI schema simple — the
// SPA hand-maintains its own union, matching the ChannelPreviewAvailability pattern.
public record ChannelHealthResponseModel(
    // One of ChannelHealthStatus's values.
    string Status,
    // The specific fault classes that fired (each a ChannelFault value); empty when Healthy/Unknown.
    string[] Faults,
    // Retained #72 fact: total playouts (mirror-aware).
    int PlayoutCount,
    // Count of upcoming built items pointing at a FileNotFound/Unavailable MediaItem; 0 when none.
    int BrokenSourceItemCount);

public static class ChannelHealthStatus
{
    public const string Healthy = "Healthy";
    public const string Problems = "Problems";
    public const string Unknown = "Unknown";
}

public static class ChannelFault
{
    public const string NoPlayout = "NoPlayout";
    public const string NeverBuilt = "NeverBuilt";
    public const string BuildFailed = "BuildFailed";
    public const string EmptyUpcoming = "EmptyUpcoming";
    public const string BrokenSource = "BrokenSource";
}

// Per-playout upcoming-item aggregate (Task 2's repository query fills this). Lives in Core so both
// the repository interface (Core) and Mapper (Application) can reference it.
public readonly record struct PlayoutUpcoming(int TotalUpcoming, int BrokenUpcoming);
```

- [ ] **Step 2: Write the failing derivation tests**

Create `ErsatzTV.Tests/Application/Channels/ChannelHealthTests.cs`. `GetHealth` is `internal` in `ErsatzTV.Application`; `ErsatzTV.Tests` already has `InternalsVisibleTo` access, so `using static ErsatzTV.Application.Channels.Mapper;` works. `PlayoutUpcoming` and the DTO live in `ErsatzTV.Core.Api.Channels`. This is a PURE test — no DB, build domain objects directly (`Channel` needs the `Guid` ctor).

```csharp
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using NUnit.Framework;
using Shouldly;
using static ErsatzTV.Application.Channels.Mapper;

namespace ErsatzTV.Tests.Application.Channels;

[TestFixture]
public class ChannelHealthTests
{
    private static Channel ChannelWith(
        ChannelPlayoutMode mode,
        params Playout[] playouts) =>
        new(Guid.NewGuid())
        {
            Id = 1,
            Number = "1",
            Name = "Test",
            PlayoutSource = ChannelPlayoutSource.Generated,
            PlayoutMode = mode,
            Playouts = playouts.ToList()
        };

    private static Playout BuiltPlayout(
        int id,
        PlayoutScheduleKind kind = PlayoutScheduleKind.Classic,
        bool success = true) =>
        new()
        {
            Id = id,
            ScheduleKind = kind,
            BuildStatus = new PlayoutBuildStatus
            {
                PlayoutId = id,
                LastBuild = new DateTimeOffset(2026, 7, 23, 12, 0, 0, TimeSpan.Zero),
                Success = success
            }
        };

    private static Playout NeverBuiltPlayout(int id) =>
        new() { Id = id, ScheduleKind = PlayoutScheduleKind.Classic, BuildStatus = null };

    private static IReadOnlyDictionary<int, PlayoutUpcoming> Upcoming(
        params (int playoutId, int total, int broken)[] rows) =>
        rows.ToDictionary(r => r.playoutId, r => new PlayoutUpcoming(r.total, r.broken));

    [Test]
    public void No_Playout_Is_Problems_With_NoPlayout_Fault()
    {
        Channel channel = ChannelWith(ChannelPlayoutMode.Continuous);
        ChannelHealthResponseModel health = GetHealth(channel, 0, Upcoming());
        health.Status.ShouldBe(ChannelHealthStatus.Problems);
        health.Faults.ShouldBe([ChannelFault.NoPlayout]);
    }

    [Test]
    public void Continuous_Never_Built_Is_Problems()
    {
        Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, NeverBuiltPlayout(10));
        ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming());
        health.Status.ShouldBe(ChannelHealthStatus.Problems);
        health.Faults.ShouldContain(ChannelFault.NeverBuilt);
    }

    [Test]
    public void Build_Failed_Is_Problems_In_Any_Mode()
    {
        Channel channel = ChannelWith(ChannelPlayoutMode.OnDemand, BuiltPlayout(10, success: false));
        ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 5, 0)));
        health.Status.ShouldBe(ChannelHealthStatus.Problems);
        health.Faults.ShouldContain(ChannelFault.BuildFailed);
    }

    [Test]
    public void Built_But_Zero_Upcoming_Is_EmptyUpcoming_When_Continuous()
    {
        Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, BuiltPlayout(10));
        ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 0, 0)));
        health.Status.ShouldBe(ChannelHealthStatus.Problems);
        health.Faults.ShouldContain(ChannelFault.EmptyUpcoming);
    }

    [Test]
    public void Broken_Upcoming_Item_Is_BrokenSource_With_Count()
    {
        Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, BuiltPlayout(10));
        ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 8, 3)));
        health.Status.ShouldBe(ChannelHealthStatus.Problems);
        health.Faults.ShouldContain(ChannelFault.BrokenSource);
        health.BrokenSourceItemCount.ShouldBe(3);
    }

    [Test]
    public void Built_NonEmpty_Clean_Is_Healthy()
    {
        Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, BuiltPlayout(10));
        ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 12, 0)));
        health.Status.ShouldBe(ChannelHealthStatus.Healthy);
        health.Faults.ShouldBeEmpty();
    }

    [Test]
    public void OnDemand_Never_Built_Is_Unknown_Not_Problems()
    {
        Channel channel = ChannelWith(ChannelPlayoutMode.OnDemand, NeverBuiltPlayout(10));
        ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming());
        health.Status.ShouldBe(ChannelHealthStatus.Unknown);
        health.Faults.ShouldBeEmpty();
    }

    [Test]
    public void OnDemand_Empty_Upcoming_Is_Unknown_Not_Problems()
    {
        Channel channel = ChannelWith(ChannelPlayoutMode.OnDemand, BuiltPlayout(10));
        ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 0, 0)));
        health.Status.ShouldBe(ChannelHealthStatus.Unknown);
    }

    [Test]
    public void OnDemand_With_Broken_Upcoming_Is_Problems()
    {
        Channel channel = ChannelWith(ChannelPlayoutMode.OnDemand, BuiltPlayout(10));
        ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 4, 2)));
        health.Status.ShouldBe(ChannelHealthStatus.Problems);
        health.Faults.ShouldContain(ChannelFault.BrokenSource);
    }

    [Test]
    public void Multi_Playout_Unions_Faults_And_Problems_Wins()
    {
        Channel channel = ChannelWith(
            ChannelPlayoutMode.Continuous,
            BuiltPlayout(10),
            BuiltPlayout(11));
        // playout 10 healthy, playout 11 empty -> union has EmptyUpcoming, status Problems
        ChannelHealthResponseModel health = GetHealth(channel, 2, Upcoming((10, 5, 0), (11, 0, 0)));
        health.Status.ShouldBe(ChannelHealthStatus.Problems);
        health.Faults.ShouldContain(ChannelFault.EmptyUpcoming);
    }

    // #71 guard: the seam is kind-agnostic — an empty upcoming window flags EmptyUpcoming identically
    // regardless of PlayoutScheduleKind (the four non-Classic kinds have no ProgramSchedule).
    [TestCase(PlayoutScheduleKind.Classic)]
    [TestCase(PlayoutScheduleKind.Block)]
    [TestCase(PlayoutScheduleKind.Sequential)]
    [TestCase(PlayoutScheduleKind.Scripted)]
    [TestCase(PlayoutScheduleKind.ExternalJson)]
    public void Empty_Upcoming_Is_Detected_For_Every_Schedule_Kind(PlayoutScheduleKind kind)
    {
        Channel channel = ChannelWith(ChannelPlayoutMode.Continuous, BuiltPlayout(10, kind));
        ChannelHealthResponseModel health = GetHealth(channel, 1, Upcoming((10, 0, 0)));
        health.Faults.ShouldContain(ChannelFault.EmptyUpcoming);
    }
}
```

- [ ] **Step 3: Run the tests, verify they fail**

Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~ChannelHealthTests`
Expected: FAIL to compile (`GetHealth` / `PlayoutUpcoming` undefined).

- [ ] **Step 4: Implement `PlayoutUpcoming` + `GetHealth` in Mapper.cs**

Add to `ErsatzTV.Application/Channels/Mapper.cs` the derivation method (the `PlayoutUpcoming` struct is already in Core from Step 1 — add `using ErsatzTV.Core.Api.Channels;` to Mapper.cs if absent). Iterate the channel's contributing playouts (own + mirror source's, matching `GetPlayoutsCount`), classify each, union faults, roll up status.

```csharp
internal static ChannelHealthResponseModel GetHealth(
    Channel channel,
    int playoutCount,
    IReadOnlyDictionary<int, PlayoutUpcoming> upcoming)
{
    if (playoutCount == 0)
    {
        return new ChannelHealthResponseModel(
            ChannelHealthStatus.Problems,
            [ChannelFault.NoPlayout],
            0,
            0);
    }

    var faults = new HashSet<string>();
    var brokenSourceItemCount = 0;
    var sawAssessable = false;
    var isOnDemand = channel.PlayoutMode == ChannelPlayoutMode.OnDemand;

    foreach (Playout playout in ContributingPlayouts(channel))
    {
        upcoming.TryGetValue(playout.Id, out PlayoutUpcoming u);
        brokenSourceItemCount += u.BrokenUpcoming;

        bool built = playout.BuildStatus is not null && playout.BuildStatus.LastBuild != default;

        // Presence signals — always live.
        if (built && playout.BuildStatus.Success == false)
        {
            faults.Add(ChannelFault.BuildFailed);
        }

        if (u.BrokenUpcoming > 0)
        {
            faults.Add(ChannelFault.BrokenSource);
        }

        // Absence signals — suppressed for on-demand (drains between tune-ins).
        if (!isOnDemand)
        {
            if (!built)
            {
                faults.Add(ChannelFault.NeverBuilt);
            }
            else if (u.TotalUpcoming == 0)
            {
                faults.Add(ChannelFault.EmptyUpcoming);
            }
            else
            {
                sawAssessable = true;
            }
        }
        else if (built && u.TotalUpcoming > 0)
        {
            sawAssessable = true;
        }
    }

    string status = faults.Count > 0
        ? ChannelHealthStatus.Problems
        : sawAssessable
            ? ChannelHealthStatus.Healthy
            : ChannelHealthStatus.Unknown;

    return new ChannelHealthResponseModel(
        status,
        faults.ToArray(),
        playoutCount,
        brokenSourceItemCount);
}

private static IEnumerable<Playout> ContributingPlayouts(Channel channel)
{
    if (channel.Playouts is not null)
    {
        foreach (Playout p in channel.Playouts)
        {
            yield return p;
        }
    }

    if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts is not null)
    {
        foreach (Playout p in channel.MirrorSourceChannel.Playouts)
        {
            yield return p;
        }
    }
}
```

Note: `Faults.ShouldBe([ChannelFault.NoPlayout])` in the NoPlayout test relies on order; every other test uses `ShouldContain`. `HashSet` iteration order is insertion-ish but not guaranteed — the NoPlayout case returns a hardcoded single-element array so that test is stable. If any multi-fault test asserts exact array equality, sort `faults.ToArray()` or use `ShouldContain`; the tests above only use `ShouldContain` for multi-fault cases.

- [ ] **Step 5: Run the tests, verify they pass**

Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~ChannelHealthTests`
Expected: PASS (all 15+ cases).

- [ ] **Step 6: Commit**

```bash
git add ErsatzTV.Core/Api/Channels/ChannelHealthResponseModel.cs ErsatzTV.Application/Channels/Mapper.cs ErsatzTV.Core.Tests/Channels/ChannelHealthTests.cs
git -c core.hooksPath=/dev/null commit --no-verify -m "feat(415): pure channel-health derivation + health DTO"
```

---

### Task 2: Repository upcoming-item aggregate + BuildStatus includes

Add the one bounded aggregate query (`GROUP BY PlayoutId` over upcoming `PlayoutItem`s with broken-source counts) and include `BuildStatus` on the channel reads.

**Files:**
- Modify: `ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs`
- Modify: `ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs`
- Test: `ErsatzTV.Tests/Application/Channels/ChannelRepositoryHealthTests.cs` (new; namespace `ErsatzTV.Tests.Application.Channels`; uses `InMemoryTvContext`).

**Interfaces:**
- Consumes: `PlayoutUpcoming` (Task 1, in Core).
- Produces on `IChannelRepository`:
  `Task<Dictionary<int, PlayoutUpcoming>> GetPlayoutUpcomingHealth(IReadOnlyCollection<int> playoutIds, DateTime nowUtc, CancellationToken cancellationToken)`

- [ ] **Step 1: Add the interface method**

`PlayoutUpcoming` already lives in `ErsatzTV.Core/Api/Channels/` (Task 1), so the Core interface can use it directly. In `ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs` add `using ErsatzTV.Core.Api.Channels;` and the method:

```csharp
Task<Dictionary<int, PlayoutUpcoming>> GetPlayoutUpcomingHealth(
    IReadOnlyCollection<int> playoutIds,
    DateTime nowUtc,
    CancellationToken cancellationToken);
```

- [ ] **Step 2: Write the failing repository test (InMemoryTvContext)**

Create the test using the `InMemoryTvContext` harness (real `:memory:` SQLite, FKs OFF — seed partial graphs). Mirror the `[SetUp]`/`[TearDown]` pattern from `GetChannelStatesForApiHandlerTests`. Seed two playouts + a `Movie` (a `MediaItem` subtype) with a set `State`, and `PlayoutItem`s. `PlayoutItem.Finish` is a `DateTime` (UTC); use a fixed `now`.

```csharp
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data.Repositories;
using ErsatzTV.Tests.Support;
using NUnit.Framework;
using Shouldly;

namespace ErsatzTV.Tests.Application.Channels;

[TestFixture]
public class ChannelRepositoryHealthTests
{
    private static readonly DateTime Now = new(2026, 7, 23, 12, 0, 0, DateTimeKind.Utc);
    private InMemoryTvContext _db = null!;

    [SetUp]
    public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();

    [TearDown]
    public async Task TearDown() => await _db.DisposeAsync();

    [Test]
    public async Task GetPlayoutUpcomingHealth_Counts_Upcoming_And_Broken()
    {
        await using (TvContext context = _db.CreateContext())
        {
            var normal = new Movie { Id = 1, State = MediaItemState.Normal };
            var missing = new Movie { Id = 2, State = MediaItemState.FileNotFound };
            var playout1 = new Playout { Id = 10, ChannelId = 1, Items = [] };
            var playout2 = new Playout { Id = 11, ChannelId = 1, Items = [] };
            context.Movies.AddRange(normal, missing);
            context.Playouts.AddRange(playout1, playout2);
            context.PlayoutItems.AddRange(
                new PlayoutItem { Id = 100, PlayoutId = 10, MediaItemId = 1, Start = Now, Finish = Now.AddHours(1), ChapterTitle = "" },
                new PlayoutItem { Id = 101, PlayoutId = 10, MediaItemId = 2, Start = Now.AddHours(1), Finish = Now.AddHours(2), ChapterTitle = "" },
                new PlayoutItem { Id = 102, PlayoutId = 10, MediaItemId = 1, Start = Now.AddHours(-2), Finish = Now.AddHours(-1), ChapterTitle = "" }, // past — excluded
                new PlayoutItem { Id = 103, PlayoutId = 11, MediaItemId = 1, Start = Now.AddHours(-3), Finish = Now.AddHours(-2), ChapterTitle = "" }); // playout2 all past
            await context.SaveChangesAsync();
        }

        var repo = new ChannelRepository(_db.Factory);
        Dictionary<int, PlayoutUpcoming> result =
            await repo.GetPlayoutUpcomingHealth([10, 11], Now, CancellationToken.None);

        result[10].TotalUpcoming.ShouldBe(2);
        result[10].BrokenUpcoming.ShouldBe(1);
        result.ContainsKey(11).ShouldBeFalse(); // no upcoming rows -> absent from dict
    }
}
```

- [ ] **Step 3: Run it, verify it fails**

Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~ChannelRepositoryHealthTests`
Expected: FAIL to compile (`GetPlayoutUpcomingHealth` undefined).

- [ ] **Step 4: Implement the aggregate query + includes**

In `ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs`:

Add `.ThenInclude(p => p.BuildStatus)` to `GetAll` and `GetChannel` (both the direct `Playouts` and the `MirrorSourceChannel.Playouts` chains):

```csharp
// GetAll — after .Include(c => c.Playouts):
.Include(c => c.Playouts).ThenInclude(p => p.BuildStatus)
.Include(c => c.MirrorSourceChannel).ThenInclude(mc => mc.Playouts).ThenInclude(p => p.BuildStatus)
```
(Keep the existing includes; add the `BuildStatus` ThenIncludes. Verify the exact fluent shape compiles — EF allows repeating `.Include(c => c.Playouts).ThenInclude(...)`.)

Add the aggregate method:

```csharp
public async Task<Dictionary<int, PlayoutUpcoming>> GetPlayoutUpcomingHealth(
    IReadOnlyCollection<int> playoutIds,
    DateTime nowUtc,
    CancellationToken cancellationToken)
{
    if (playoutIds.Count == 0)
    {
        return new Dictionary<int, PlayoutUpcoming>();
    }

    await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
    return await dbContext.PlayoutItems
        .AsNoTracking()
        .Where(pi => playoutIds.Contains(pi.PlayoutId) && pi.Finish >= nowUtc)
        .GroupBy(pi => pi.PlayoutId)
        .Select(g => new
        {
            PlayoutId = g.Key,
            Total = g.Count(),
            Broken = g.Count(pi =>
                pi.MediaItem.State == MediaItemState.FileNotFound ||
                pi.MediaItem.State == MediaItemState.Unavailable)
        })
        .ToDictionaryAsync(
            x => x.PlayoutId,
            x => new PlayoutUpcoming(x.Total, x.Broken),
            cancellationToken);
}
```
Add `using ErsatzTV.Core.Api.Channels;` for `PlayoutUpcoming` if not already present.

- [ ] **Step 5: Run it, verify it passes**

Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~ChannelRepositoryHealthTests`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add ErsatzTV.Core/Interfaces/Repositories/IChannelRepository.cs ErsatzTV.Core/Api/Channels/ChannelHealthResponseModel.cs ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs ErsatzTV.Application/Channels/Mapper.cs <the repo test file>
git -c core.hooksPath=/dev/null commit --no-verify -m "feat(415): upcoming-item aggregate query + BuildStatus includes"
```

---

### Task 3: Wire the aggregate through the mappers and handlers

Thread the aggregate into `ProjectToResponseModel`/`ProjectToDetailResponseModel` and populate `Health` on both DTOs from the two API handlers.

**Files:**
- Modify: `ErsatzTV.Application/Channels/Mapper.cs` (signatures of `ProjectToResponseModel`, `ProjectToDetailResponseModel`)
- Modify: `ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs`, `ChannelDetailResponseModel.cs` (add trailing `Health`)
- Modify: `ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs`, `GetChannelByIdForApiHandler.cs`
- Test: `ErsatzTV.Tests/Application/Channels/GetAllChannelsForApiHandlerHealthTests.cs` (new; NSubstitute mock of `IChannelRepository` — this task tests handler wiring in isolation; Task 2 already covers the real query)

**Interfaces:**
- Consumes: `IChannelRepository.GetPlayoutUpcomingHealth` (Task 2), `Mapper.GetHealth` (Task 1).
- Produces: `ChannelResponseModel.Health`, `ChannelDetailResponseModel.Health` populated.

- [ ] **Step 1: Add `Health` as a trailing param on both DTOs**

`ChannelResponseModel.cs` — append after `ChannelOrigin Origin`:
```csharp
    ChannelOrigin Origin,
    // Server-derived health rollup (api.channel-health-signal). See ChannelHealthResponseModel.
    ChannelHealthResponseModel Health);
```
`ChannelDetailResponseModel.cs` — append after `int[] GraphicsElementIds`:
```csharp
    int[] GraphicsElementIds,
    ChannelHealthResponseModel Health);
```

- [ ] **Step 2: Update mapper signatures to accept the aggregate and set Health**

Change `ProjectToResponseModel` and `ProjectToDetailResponseModel` to take `IReadOnlyDictionary<int, PlayoutUpcoming> upcoming` and pass `GetHealth(channel, playoutCount, upcoming)` as the trailing arg:

```csharp
internal static ChannelResponseModel ProjectToResponseModel(
    Channel channel,
    int playoutCount,
    IReadOnlyDictionary<int, PlayoutUpcoming> upcoming) =>
    new(
        // ... existing args unchanged, up to and including ...
        channel.Origin,
        GetHealth(channel, playoutCount, upcoming));
```
```csharp
internal static ChannelDetailResponseModel ProjectToDetailResponseModel(
    Channel channel,
    int playoutCount,
    IReadOnlyDictionary<int, PlayoutUpcoming> upcoming)
{
    // ... existing body ...
    return new ChannelDetailResponseModel(
        // ... existing args unchanged, up to and including GraphicsElementIds arg ...
        channel.ChannelGraphicsElements?.Map(x => x.GraphicsElementId).ToArray() ?? [],
        GetHealth(channel, playoutCount, upcoming));
}
```

- [ ] **Step 3: Write the failing handler test (Style-A)**

`ErsatzTV.Core.Tests/Channels/GetAllChannelsForApiHandlerHealthTests.cs`:

```csharp
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using NSubstitute;
using NUnit.Framework;
using Shouldly;

namespace ErsatzTV.Tests.Application.Channels;

[TestFixture]
public class GetAllChannelsForApiHandlerHealthTests
{
    [Test]
    public async Task Populates_Health_From_Aggregate()
    {
        var channel = new Channel(Guid.NewGuid())
        {
            Id = 1, Number = "1", Name = "News", Group = "ETV", IsEnabled = true,
            StreamingMode = StreamingMode.HttpLiveStreamingDirect,
            PlayoutSource = ChannelPlayoutSource.Generated,
            PlayoutMode = ChannelPlayoutMode.Continuous,
            Artwork = [],
            FFmpegProfile = new FFmpegProfile { Name = "P" },
            Playouts =
            [
                new Playout
                {
                    Id = 10,
                    BuildStatus = new PlayoutBuildStatus
                    {
                        PlayoutId = 10, Success = true,
                        LastBuild = new DateTimeOffset(2026, 7, 23, 0, 0, 0, TimeSpan.Zero)
                    }
                }
            ]
        };

        var repo = Substitute.For<IChannelRepository>();
        repo.GetAll(Arg.Any<CancellationToken>()).Returns([channel]);
        repo.GetPlayoutUpcomingHealth(Arg.Any<IReadOnlyCollection<int>>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
            .Returns(new Dictionary<int, PlayoutUpcoming> { [10] = new(6, 2) });

        var handler = new GetAllChannelsForApiHandler(repo);
        List<ChannelResponseModel> result = await handler.Handle(new GetAllChannelsForApi(), CancellationToken.None);

        result[0].Health.Status.ShouldBe(ChannelHealthStatus.Problems);
        result[0].Health.Faults.ShouldContain(ChannelFault.BrokenSource);
        result[0].Health.BrokenSourceItemCount.ShouldBe(2);
    }
}
```

- [ ] **Step 4: Run it, verify it fails**

Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~GetAllChannelsForApiHandlerHealthTests`
Expected: FAIL (handler still calls 2-arg mapper; won't compile).

- [ ] **Step 5: Update both handlers to fetch + pass the aggregate**

`GetAllChannelsForApiHandler.cs`:
```csharp
public async Task<List<ChannelResponseModel>> Handle(
    GetAllChannelsForApi request,
    CancellationToken cancellationToken)
{
    List<Channel> channels = await channelRepository.GetAll(cancellationToken);
    var playoutIds = channels
        .SelectMany(ContributingPlayoutIds)
        .Distinct()
        .ToList();
    Dictionary<int, PlayoutUpcoming> upcoming =
        await channelRepository.GetPlayoutUpcomingHealth(playoutIds, DateTime.UtcNow, cancellationToken);
    return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c), upcoming)).ToList();
}

private static IEnumerable<int> ContributingPlayoutIds(Channel channel)
{
    if (channel.Playouts is not null)
    {
        foreach (Playout p in channel.Playouts) yield return p.Id;
    }
    if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts is not null)
    {
        foreach (Playout p in channel.MirrorSourceChannel.Playouts) yield return p.Id;
    }
}
```
(If a shared `ContributingPlayoutIds` is cleaner, put it as a `public static` helper on `Mapper` and reuse from both handlers — DRY with `ContributingPlayouts` from Task 1. Prefer exposing `Mapper.ContributingPlayouts` and doing `.Select(p => p.Id)`.)

`GetChannelByIdForApiHandler.cs`:
```csharp
public async Task<Option<ChannelDetailResponseModel>> Handle(
    GetChannelByIdForApi request,
    CancellationToken cancellationToken)
{
    Option<Channel> maybeChannel = await channelRepository.GetChannel(request.Id);
    return await maybeChannel.MapAsync(async channel =>
    {
        var playoutIds = Mapper.ContributingPlayouts(channel).Select(p => p.Id).Distinct().ToList();
        Dictionary<int, PlayoutUpcoming> upcoming =
            await channelRepository.GetPlayoutUpcomingHealth(playoutIds, DateTime.UtcNow, cancellationToken);
        return ProjectToDetailResponseModel(channel, GetPlayoutsCount(channel), upcoming);
    });
}
```
(If `MapAsync` on `Option<T>` isn't available in this LanguageExt version, fall back to `maybeChannel.Match(...)` / manual `if (maybeChannel.IsSome)`. Change `ContributingPlayouts` visibility to `internal`/`public` on Mapper so the handler can use it. Handler signature changes from expression-bodied to async — update accordingly.)

- [ ] **Step 6: Run the handler test + full Channels test suite**

Run: `dotnet test ErsatzTV.Tests --filter FullyQualifiedName~Channels`
Expected: PASS. Then build the whole solution: `dotnet build ErsatzTV.sln` — fix any other callers of the two mapper methods the signature change broke (grep `ProjectToResponseModel(` / `ProjectToDetailResponseModel(` across the solution and thread `upcoming` — likely only these two handlers call them; if another caller exists that has no aggregate, pass an empty dict).

- [ ] **Step 7: Commit**

```bash
git add -A
git -c core.hooksPath=/dev/null commit --no-verify -m "feat(415): populate Health on channel list + detail DTOs"
```

---

### Task 4: Regenerate OpenAPI + TypeScript types

**Files:**
- Modify (generated): `ErsatzTV/wwwroot/.../v1.json` (or wherever the spec lands), `docs/endpoint-index.md`, `web/src/api/generated/v1.d.ts`

- [ ] **Step 1: Regenerate the OpenAPI document**

Run: `./scripts/update-openapi.sh`
Expected: builds `ErsatzTV`, regenerates the v1 spec + endpoint index with the new `ChannelHealthResponseModel` schema and `health` on the two channel models.

- [ ] **Step 2: Regenerate TS types**

Run: `cd web && npm run generate:api`
Expected: `src/api/generated/v1.d.ts` now has `ChannelHealthResponseModel` and `health` on `ChannelResponseModel`/`ChannelDetailResponseModel`.

- [ ] **Step 3: Verify the gate is clean**

Run: `cd web && npm run check:api`
Expected: exit 0 (generated file matches).

- [ ] **Step 4: Commit generated artifacts**

```bash
git add -A
git -c core.hooksPath=/dev/null commit --no-verify -m "chore(415): regenerate OpenAPI + TS types for channel health"
```

---

### Task 5: SPA — Problems filter, per-fault badges, tests

**Files:**
- Modify: `web/src/screens/ChannelsScreen.tsx`
- Test: `web/src/screens/ChannelsScreen.test.tsx`
- (Types come free from Task 4's `v1.d.ts`; `ChannelSummary` now carries `health`.)

**Interfaces:**
- Consumes: `ChannelSummary['health']` = `{ status, faults, playoutCount, brokenSourceItemCount }` (nullable per core-dtos-generate-nullable-in-spa — coerce).

- [ ] **Step 1: Write the failing SPA tests**

In `ChannelsScreen.test.tsx`, first extend the `channelRow` fixture default to include a healthy `health` object (add to the returned object):
```tsx
    health: { status: 'Healthy', faults: [], playoutCount: 1, brokenSourceItemCount: 0 },
```
Then add tests (mirroring the existing "No playout" test structure):
```tsx
it('flags channels with problems and filters the lineup down to them', async () => {
  mockApi({
    channels: [
      channelRow({ id: 1, name: 'Healthy', number: '1',
        health: { status: 'Healthy', faults: [], playoutCount: 1, brokenSourceItemCount: 0 } }),
      channelRow({ id: 2, name: 'Empty', number: '2', playoutCount: 1,
        health: { status: 'Problems', faults: ['EmptyUpcoming'], playoutCount: 1, brokenSourceItemCount: 0 } }),
      channelRow({ id: 3, name: 'Dead', number: '3', playoutCount: 0,
        health: { status: 'Problems', faults: ['NoPlayout'], playoutCount: 0, brokenSourceItemCount: 0 } })
    ]
  });

  render(<ChannelsScreen />);
  const table = await screen.findByRole('table', { name: 'Channels lineup' });
  expect(within(table).getByText('Empty schedule')).toBeInTheDocument();
  expect(within(table).getByText('No playout')).toBeInTheDocument();

  fireEvent.click(screen.getByRole('button', { name: 'Problems 2' }));
  expect(screen.getByText('Empty')).toBeInTheDocument();
  expect(screen.getByText('Dead')).toBeInTheDocument();
  expect(screen.queryByText('Healthy')).not.toBeInTheDocument();
});

it('does not render a problem badge for an unknown-health channel', async () => {
  mockApi({
    channels: [channelRow({ id: 1, name: 'OnDemandIdle', number: '1',
      health: { status: 'Unknown', faults: [], playoutCount: 1, brokenSourceItemCount: 0 } })]
  });
  render(<ChannelsScreen />);
  const table = await screen.findByRole('table', { name: 'Channels lineup' });
  expect(within(table).queryByText('No playout')).not.toBeInTheDocument();
  expect(screen.getByRole('button', { name: 'Problems 0' })).toBeInTheDocument();
});
```

- [ ] **Step 2: Run them, verify they fail**

Run: `cd web && npx vitest run src/screens/ChannelsScreen.test.tsx`
Expected: FAIL (no "Problems" button; badges say "No playout" only).

- [ ] **Step 3: Update the filter type, predicate, badge mapping**

In `ChannelsScreen.tsx`:

Replace the filter union (line 34): `'noplayout'` → `'problems'`:
```tsx
type ChannelViewFilter = 'all' | 'onair' | 'disabled' | 'problems';
```
Replace `willNeverPlay` + its comment (lines 36-48) with `hasProblems` + a fault-label map:
```tsx
// #415: per-channel fault taxonomy derived server-side from the built timeline (health.status).
// The SPA reads the verdict; it no longer derives eligibility from raw playoutCount. Each row's badge
// still spells out the specific fault (below); the filter rolls them up under "Problems".
// Design: docs/superpowers/specs/2026-07-23-channel-fault-detection-design.md; decision: api.channel-health-signal.
function hasProblems(channel: ChannelSummary): boolean {
  return channel.health?.status === 'Problems';
}

const FAULT_LABELS: Record<string, string> = {
  NoPlayout: 'No playout',
  NeverBuilt: 'Never built',
  BuildFailed: 'Build failed',
  EmptyUpcoming: 'Empty schedule',
  BrokenSource: 'Broken source'
};
```
Update the count + filter (lines 215, 225-227):
```tsx
  const problemsCount = channels.filter(hasProblems).length;
```
```tsx
    if (filter === 'problems') {
      return hasProblems(channel);
    }
```
Update the filter tab button (line 432):
```tsx
              <button type="button" aria-pressed={filter === 'problems'} onClick={() => changeFilter('problems')}>Problems <code>{problemsCount}</code></button>
```
Replace the badge render (line 616) — render one badge per fault:
```tsx
              {(channel.health?.faults ?? []).map((fault) => (
                <Badge key={fault} tone="error">{FAULT_LABELS[fault] ?? fault}</Badge>
              ))}
```

- [ ] **Step 4: Run SPA tests + typecheck**

Run: `cd web && npx vitest run src/screens/ChannelsScreen.test.tsx && npm run typecheck`
Expected: PASS. Fix any other `noplayout`/`willNeverPlay` references the compiler flags (grep both across `web/src`).

- [ ] **Step 5: Commit**

```bash
git add -A
git -c core.hooksPath=/dev/null commit --no-verify -m "feat(415): SPA Problems filter + per-fault channel badges"
```

---

### Task 6: Documentation

**Files:**
- Modify: `docs/decisions.md` (supersede `api.channel-health-signal`)
- Modify: `docs/domain-model.md`, `docs/api-conventions.md`, `docs/spa-conventions.md`

- [ ] **Step 1: Supersede the decision record**

In `docs/decisions.md`, follow the append/supersede convention (grep the file header for the exact `[decisions-edit]` / supersession mechanics; do NOT rename an existing heading — add a new dated record and mark the old one superseded per `decisions-lifecycle-matches-by-heading`). New record `api.channel-health-signal` (v2): the derived `health` object on the channel DTOs (Status/Faults/PlayoutCount/BrokenSourceItemCount), built-timeline detection, the three accepted defaults, and the assessable gate (absence vs presence signals). Note it supersedes the #72 raw-fact-only stance now that #383 has cleared.

- [ ] **Step 2: Update the other three convention docs**

- `docs/domain-model.md`: update the `PlayoutCount`/channel-health row to describe the `health` derivation from `Playout.BuildStatus` + upcoming `PlayoutItem`→`MediaItem.State`.
- `docs/api-conventions.md`: document `ChannelHealthResponseModel` shape + the const-string `status`/`faults` (SPA hand-maintains the union, like `ChannelPreviewAvailability`).
- `docs/spa-conventions.md`: the `Problems` rollup filter + per-fault badge convention; note `health` is nullable-coerced at the boundary.

- [ ] **Step 3: Commit**

```bash
git add docs/decisions.md docs/domain-model.md docs/api-conventions.md docs/spa-conventions.md
git -c core.hooksPath=/dev/null commit --no-verify -m "docs(415): channel health taxonomy (decision + conventions)"
```

---

### Task 7: Live-E2E verification + cost measurement

**Files:** none (verification only).

- [ ] **Step 1: Build backend + SPA**

Run: `dotnet build ErsatzTV.sln && (cd web && npm run build)`
Expected: both succeed.

- [ ] **Step 2: Launch a fresh local instance**

Run: `scripts/e2e-local.sh` (captures a fresh temp CONFIG_DIR). Record `PORT`, `PID`, `LOG`.

- [ ] **Step 3: Verify the health object on the API**

Run: `curl -s http://localhost:$PORT/api/v1/channels | python3 -m json.tool | grep -A6 '"health"'`
Expected: each channel carries a `health` object. Induce faults on a seeded channel (create a channel with no playout → `NoPlayout`; if feasible, point an item at a missing file to exercise `BrokenSource`) and re-curl to confirm `status: "Problems"` + the expected `faults`.

- [ ] **Step 4: Measure cost — confirm no N+1**

Run: inspect `$LOG` for the EF query log around a `/api/v1/channels` request (or set `Logging:LogLevel:Microsoft.EntityFrameworkCore.Database.Command=Information` in the fresh config). Confirm the upcoming aggregate is a **single** `GROUP BY` query, not one-per-channel. Record the observation in the closing record.

- [ ] **Step 5: Stop the instance**

Run: `kill $PID`

---

## Self-review notes (author)

- **Spec coverage:** all 5 faults (Task 1), broken-source attribution (Tasks 1-2), all-kinds correctness (Task 1 `[TestCase]` per kind), cost/no-N+1 (Tasks 2 + 7 step 4), SPA rename + comment (Task 5), docs incl. decisions + domain-model (Task 6), adversarial review (post-plan gate). ✓
- **Cross-task type consistency:** `PlayoutUpcoming` is defined in Core (`ErsatzTV.Core/Api/Channels/`) from Task 1 Step 1, so the Task 2 repository interface (Core) and Mapper (Application) both reference it without a layering violation. `GetHealth`, `ContributingPlayouts`, `GetPlayoutUpcomingHealth`, `ChannelHealthStatus`/`ChannelFault` names used identically across tasks. ✓
- **Test harness (corrected pre-flight):** all backend tests live in `ErsatzTV.Tests` (has `InternalsVisibleTo`); real-DB tests use `InMemoryTvContext` (`:memory:` SQLite, FKs off); `Channel` uses `new Channel(Guid.NewGuid())`. ✓
- **Known follow-up flagged, not silently dropped:** possible `(PlayoutId, Finish)` index is a tuning follow-up only if Task 7 step 4 shows the aggregate is hot.
