# Per-schedule clock-boundary padding toggle (#392) — 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:** Add a per-`ProgramSchedule` "pad every content item up to the next N-minute clock boundary" option
that the Classic playout builder honors automatically — no hand-wired Pad `FillerPreset` — filling each gap
with the schedule item's `FallbackFiller` if configured, else an offline gap; plus a 60-minute increment in
the filler-preset editor.

**Architecture:** Reuse the existing per-episode Pad machinery. `AddFiller`
(`PlayoutModeSchedulerBase`) already runs once per emitted content item. We add a **self-contained synthetic
-pad branch** in `AddFiller` that engages only when the item has no own Pad filler and its parent schedule
has `PadToNearestMinute` set; it computes the boundary target, fills with `FallbackFillerForPad`, and — when
no fallback content exists — returns an offline target that each scheduler uses to advance the build clock
(leaving an implicit offline gap, exactly as fixed-start items do). The schedule-level divisor reaches
`AddFiller` by populating the (currently null) `ProgramScheduleItem.ProgramSchedule` reverse navigation once,
centrally, in `PlayoutBuilder`.

**Tech Stack:** C#/.NET 10, EF Core (dual-provider Sqlite + MySql migrations), MediatR CQRS, LanguageExt,
NUnit + Shouldly golden tests, ChicoryTV React SPA (Vite/TS), OpenAPI-generated TS types.

## Global Constraints

- Work in the worktree `/Users/timothy/ersatztv/.claude/worktrees/392-clock-align` on branch
  `feat/392-clock-align-schedule` (off `origin/main`). Never commit in the shared tree.
- DB model changes need a migration in **both** `ErsatzTV.Infrastructure.Sqlite` and
  `ErsatzTV.Infrastructure.MySql`, generated via `scripts/add-migration.sh <Name>` (never hand-authored
  snapshots).
- Any `/api/*` DTO change must regenerate OpenAPI artifacts in the SAME diff: `dotnet build ErsatzTV.sln`
  → `./scripts/update-openapi.sh` → `cd web && npm run generate:api`; commit
  `ErsatzTV/wwwroot/openapi/v1.json`, `web/src/api/generated/v1.d.ts`, `docs/endpoint-index.md`.
- Response DTOs carry a file-scoped `#nullable enable`; request DTOs do not.
- All ProgramSchedule DTOs/records/VMs are **positional records** — add the new field in a CONSISTENT
  position (append to the end) across every one, or the positional constructors misalign.
- **Never** set `ETV_UPDATE_PLAYOUT_GOLDENS` in CI; only locally to regenerate a golden, then review + commit it.
- Before any push touching `.cs`: BOM-check the touched set (`git diff --name-only origin/main | grep '\.cs$'`
  then `xxd -p <f> | grep -c '^efbbbf'` must be 0) and run the format gate under `bash -c`.
- Commit hooks in the worktree can misfire — commit with `git -c core.hooksPath=/dev/null commit`, then run
  the format/BOM checks manually.
- Independent cross-model review is MANDATORY before push (migration + builder + API write-path).
- Pad math is TZ-independent ONLY when the divisor divides 60 (all real IANA offsets are multiples of 15).
  Keep golden divisors at 15; the SPA offers only 5/10/15/30/60.

---

### Task 1: 60-minute increment in the filler-preset editor (SPA, standalone)

Independent of everything else — ship first.

**Files:**
- Modify: `web/src/screens/FillerPresetsScreen.tsx:42`

**Interfaces:**
- Consumes: nothing.
- Produces: nothing other tasks depend on.

- [ ] **Step 1: Add 60 to `PAD_OPTIONS`.** Change line 42 from:

```tsx
const PAD_OPTIONS = [5, 10, 15, 30].map((minutes) => ({ label: String(minutes), value: String(minutes) }));
```
to:
```tsx
const PAD_OPTIONS = [5, 10, 15, 30, 60].map((minutes) => ({ label: String(minutes), value: String(minutes) }));
```

- [ ] **Step 2: Build the web app to verify no type/lint error.**

Run: `cd web && npm run build`
Expected: build succeeds.

- [ ] **Step 3: Lint.**

Run: `cd web && npm run lint`
Expected: no new errors in `FillerPresetsScreen.tsx`.

- [ ] **Step 4: Commit.**

```bash
git -c core.hooksPath=/dev/null add web/src/screens/FillerPresetsScreen.tsx
git -c core.hooksPath=/dev/null commit -m "feat(392): add 60-minute option to filler-preset pad increment"
```

---

### Task 2: `ProgramSchedule.PadToNearestMinute` column + dual-provider migration

**Files:**
- Modify: `ErsatzTV.Core/Domain/ProgramSchedule.cs`
- Generated: `ErsatzTV.Infrastructure.Sqlite/Migrations/*_Add_ProgramSchedule_PadToNearestMinute.cs` (+ `.Designer.cs` + snapshot)
- Generated: `ErsatzTV.Infrastructure.MySql/Migrations/*_Add_ProgramSchedule_PadToNearestMinute.cs` (+ `.Designer.cs` + snapshot)

**Interfaces:**
- Produces: `ProgramSchedule.PadToNearestMinute` — `public int? PadToNearestMinute { get; set; }` (null = feature off).

- [ ] **Step 1: Add the property.** In `ErsatzTV.Core/Domain/ProgramSchedule.cs`, after
`FixedStartTimeBehavior` (currently the last scalar before the nav collections), add:

```csharp
    public int? PadToNearestMinute { get; set; }
```

- [ ] **Step 2: Generate both migrations.** From the worktree root:

Run: `./scripts/add-migration.sh Add_ProgramSchedule_PadToNearestMinute`
Expected: creates one migration pair under each provider's `Migrations/` folder and updates both
`TvContextModelSnapshot.cs`. The generated `Up` should contain an `AddColumn<int>` on `ProgramSchedule`
named `PadToNearestMinute`, `nullable: true`, no `defaultValue`.

- [ ] **Step 3: Verify the generated Up/Down.** Open both generated `*_Add_ProgramSchedule_PadToNearestMinute.cs`
and confirm they match this shape (Sqlite `type: "INTEGER"`, MySql `type: "int"`):

```csharp
protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.AddColumn<int>(
        name: "PadToNearestMinute",
        table: "ProgramSchedule",
        type: "INTEGER", // MySql: "int"
        nullable: true);
}

protected override void Down(MigrationBuilder migrationBuilder)
{
    migrationBuilder.DropColumn(
        name: "PadToNearestMinute",
        table: "ProgramSchedule");
}
```
If EF emitted `defaultValue: 0` or `nullable: false`, fix it to the above (a nullable int must have neither).

- [ ] **Step 4: Build the two migration projects.**

Run: `dotnet build ErsatzTV.Infrastructure.Sqlite ErsatzTV.Infrastructure.MySql`
Expected: build succeeds.

- [ ] **Step 5: Commit.**

```bash
git -c core.hooksPath=/dev/null add ErsatzTV.Core/Domain/ProgramSchedule.cs \
  ErsatzTV.Infrastructure.Sqlite/Migrations ErsatzTV.Infrastructure.MySql/Migrations
git -c core.hooksPath=/dev/null commit -m "feat(392): add ProgramSchedule.PadToNearestMinute column (dual-provider migration)"
```

---

### Task 3: Expose `padToNearestMinute` through the REST API

Depends on Task 2. Adds the field to the read + write chains and regenerates OpenAPI.

**Files:**
- Modify: `ErsatzTV.Application/ProgramSchedules/ProgramScheduleViewModel.cs`
- Modify: `ErsatzTV.Application/ProgramSchedules/Mapper.cs`
- Modify: `ErsatzTV.Core/Api/Scheduling/ProgramScheduleResponseModel.cs`
- Modify: `ErsatzTV/Controllers/Api/ScheduleController.cs` (`ProjectToResponseModel`)
- Modify: `ErsatzTV/Controllers/Api/Requests/CreateScheduleRequest.cs`
- Modify: `ErsatzTV/Controllers/Api/Requests/UpdateScheduleRequest.cs`
- Modify: `ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramSchedule.cs`
- Modify: `ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramScheduleHandler.cs`
- Modify: `ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramSchedule.cs`
- Modify: `ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramScheduleHandler.cs`
- Generated (regen, commit): `ErsatzTV/wwwroot/openapi/v1.json`, `web/src/api/generated/v1.d.ts`, `docs/endpoint-index.md`
- Test: `ErsatzTV.Application.Tests` (or the existing schedule handler test project — grep for
  `UpdateProgramScheduleHandler`/`CreateProgramScheduleHandler` tests; if none exist, add a minimal one as below)

**Interfaces:**
- Consumes: `ProgramSchedule.PadToNearestMinute` (Task 2).
- Produces:
  - `ProgramScheduleResponseModel(..., FixedStartTimeBehavior, int? PadToNearestMinute)`
  - `ProgramScheduleViewModel(..., FixedStartTimeBehavior, int? PadToNearestMinute, int Version)` — note
    `PadToNearestMinute` goes **before** `Version` to match existing positional order (Version is last).
  - `CreateScheduleRequest`/`UpdateScheduleRequest`/`CreateProgramSchedule`/`UpdateProgramSchedule` each gain a
    trailing `int? PadToNearestMinute` positional member.
  - TS type `components['schemas']['ProgramScheduleResponseModel'].padToNearestMinute: number | null` and the
    matching Create/Update request schemas.

- [ ] **Step 1: Write a failing handler round-trip test.** Find the existing schedule handler tests (grep
`FullyQualifiedName~ProgramSchedule` under `*.Tests`). If a `CreateProgramScheduleHandler`/`UpdateProgramScheduleHandler`
test class exists, add a test there; otherwise create
`ErsatzTV.Application.Tests/ProgramSchedules/ProgramSchedulePadToNearestMinuteTests.cs` mirroring the nearest
existing handler test's setup (in-memory `TvContext` via the shared test fixture). The test asserts the field
round-trips create→read and update→read:

```csharp
[Test]
public async Task Create_and_update_persist_PadToNearestMinute()
{
    // Arrange: create a schedule with PadToNearestMinute = 30 via CreateProgramSchedule,
    // then load it and assert entity.PadToNearestMinute == 30;
    // then UpdateProgramSchedule with PadToNearestMinute = null and assert it clears to null.
    // (Mirror the arrange/act pattern of the nearest existing ProgramSchedule handler test.)
}
```

Keep it minimal and consistent with the existing handler-test style (do not invent a new harness).

- [ ] **Step 2: Run it — expect a COMPILE failure** (the new positional arg doesn't exist yet).

Run: `dotnet test ErsatzTV.Application.Tests --filter "FullyQualifiedName~PadToNearestMinute"`
Expected: does not compile / FAIL.

- [ ] **Step 3: Add the field to the read chain.**

`ErsatzTV.Application/ProgramSchedules/ProgramScheduleViewModel.cs` — add `int? PadToNearestMinute` before `int Version`:
```csharp
public record ProgramScheduleViewModel(
    int Id,
    string Name,
    bool KeepMultiPartEpisodesTogether,
    bool TreatCollectionsAsShows,
    bool ShuffleScheduleItems,
    bool RandomStartPoint,
    FixedStartTimeBehavior FixedStartTimeBehavior,
    int? PadToNearestMinute,
    int Version);
```

`ErsatzTV.Application/ProgramSchedules/Mapper.cs` — add the argument before `Version`:
```csharp
    internal static ProgramScheduleViewModel ProjectToViewModel(ProgramSchedule programSchedule) =>
        new(
            programSchedule.Id,
            programSchedule.Name,
            programSchedule.KeepMultiPartEpisodesTogether,
            programSchedule.TreatCollectionsAsShows,
            programSchedule.ShuffleScheduleItems,
            programSchedule.RandomStartPoint,
            programSchedule.FixedStartTimeBehavior,
            programSchedule.PadToNearestMinute,
            programSchedule.Version);
```

`ErsatzTV.Core/Api/Scheduling/ProgramScheduleResponseModel.cs` — append the member:
```csharp
public record ProgramScheduleResponseModel(
    int Id,
    string Name,
    bool KeepMultiPartEpisodesTogether,
    bool TreatCollectionsAsShows,
    bool ShuffleScheduleItems,
    bool RandomStartPoint,
    FixedStartTimeBehavior FixedStartTimeBehavior,
    int? PadToNearestMinute);
```

`ErsatzTV/Controllers/Api/ScheduleController.cs` `ProjectToResponseModel` — append `vm.PadToNearestMinute`:
```csharp
    private static ProgramScheduleResponseModel ProjectToResponseModel(ProgramScheduleViewModel vm) =>
        new(
            vm.Id,
            vm.Name,
            vm.KeepMultiPartEpisodesTogether,
            vm.TreatCollectionsAsShows,
            vm.ShuffleScheduleItems,
            vm.RandomStartPoint,
            vm.FixedStartTimeBehavior,
            vm.PadToNearestMinute);
```

- [ ] **Step 4: Add the field to the write chain.**

`ErsatzTV/Controllers/Api/Requests/CreateScheduleRequest.cs` — append `int? PadToNearestMinute` to the record
and to `ToCreateCommand()`:
```csharp
public record CreateScheduleRequest(
    string Name,
    bool KeepMultiPartEpisodesTogether,
    bool TreatCollectionsAsShows,
    bool ShuffleScheduleItems,
    bool RandomStartPoint,
    FixedStartTimeBehavior FixedStartTimeBehavior,
    int? PadToNearestMinute)
{
    public CreateProgramSchedule ToCreateCommand() =>
        new(
            Name,
            KeepMultiPartEpisodesTogether,
            TreatCollectionsAsShows,
            ShuffleScheduleItems,
            RandomStartPoint,
            FixedStartTimeBehavior,
            PadToNearestMinute);
}
```

`ErsatzTV/Controllers/Api/Requests/UpdateScheduleRequest.cs` — same, `ToCommand(int id)`:
```csharp
public record UpdateScheduleRequest(
    string Name,
    bool KeepMultiPartEpisodesTogether,
    bool TreatCollectionsAsShows,
    bool ShuffleScheduleItems,
    bool RandomStartPoint,
    FixedStartTimeBehavior FixedStartTimeBehavior,
    int? PadToNearestMinute)
{
    public UpdateProgramSchedule ToCommand(int id) =>
        new(
            id,
            Name,
            KeepMultiPartEpisodesTogether,
            TreatCollectionsAsShows,
            ShuffleScheduleItems,
            RandomStartPoint,
            FixedStartTimeBehavior,
            PadToNearestMinute);
}
```

`ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramSchedule.cs` — append the member:
```csharp
public record CreateProgramSchedule(
    string Name,
    bool KeepMultiPartEpisodesTogether,
    bool TreatCollectionsAsShows,
    bool ShuffleScheduleItems,
    bool RandomStartPoint,
    FixedStartTimeBehavior FixedStartTimeBehavior,
    int? PadToNearestMinute) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
```

`ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramSchedule.cs`:
```csharp
public record UpdateProgramSchedule(
    int ProgramScheduleId,
    string Name,
    bool KeepMultiPartEpisodesTogether,
    bool TreatCollectionsAsShows,
    bool ShuffleScheduleItems,
    bool RandomStartPoint,
    FixedStartTimeBehavior FixedStartTimeBehavior,
    int? PadToNearestMinute) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
```

`CreateProgramScheduleHandler.cs` — set the field when building the entity (in the `new ProgramSchedule { ... }`
initializer), normalizing a non-positive value to null so only meaningful divisors persist:
```csharp
                PadToNearestMinute = request.PadToNearestMinute is int m && m > 0 ? m : null
```
(add as the last initializer member; keep the existing trailing members intact).

`UpdateProgramScheduleHandler.cs` `ApplyUpdateRequest` — add to BOTH the rebuild diff and the write:

In `needToRefreshPlayout` (append a clause):
```csharp
            programSchedule.FixedStartTimeBehavior != request.FixedStartTimeBehavior ||
            programSchedule.PadToNearestMinute != (request.PadToNearestMinute is int upm && upm > 0 ? upm : null);
```
In the field writes (append):
```csharp
        programSchedule.PadToNearestMinute = request.PadToNearestMinute is int upm2 && upm2 > 0 ? upm2 : null;
```
(Use distinct local names `upm`/`upm2` to avoid a redeclaration; or hoist a single `int? normalizedPad`
local before both uses — implementer's choice, keep it compiling.)

- [ ] **Step 5: Fix any OTHER positional constructor call sites the compiler flags.** Build and let the
compiler find every place that constructs these records positionally (e.g. Auto-Tune / channel-builder code
paths, Blazor-era callers if any remain). Add the new trailing arg (`null` where the caller has no pad
concept). Run:

Run: `dotnet build ErsatzTV.sln`
Expected: build succeeds after you supply the new arg at each flagged call site.

- [ ] **Step 6: Run the handler test — expect PASS.**

Run: `dotnet test ErsatzTV.Application.Tests --filter "FullyQualifiedName~PadToNearestMinute"`
Expected: PASS.

- [ ] **Step 7: Regenerate OpenAPI + TS types.**

```bash
dotnet build ErsatzTV.sln
./scripts/update-openapi.sh
cd web && npm run generate:api && cd ..
```
Expected: `v1.json`, `web/src/api/generated/v1.d.ts`, `docs/endpoint-index.md` now show `padToNearestMinute`
on `ProgramScheduleResponseModel`, `CreateScheduleRequest`, `UpdateScheduleRequest`.

- [ ] **Step 8: Verify the API-contract check is clean.**

Run: `cd web && npm run check:api && cd ..`
Expected: no diff (generated types match committed `v1.json`).

- [ ] **Step 9: Commit.**

```bash
git -c core.hooksPath=/dev/null add ErsatzTV.Application ErsatzTV.Core ErsatzTV/Controllers \
  ErsatzTV/wwwroot/openapi/v1.json web/src/api/generated/v1.d.ts docs/endpoint-index.md
git -c core.hooksPath=/dev/null commit -m "feat(392): expose ProgramSchedule.padToNearestMinute on the REST API"
```

---

### Task 4: Classic builder — synthetic schedule-level pad + offline advance

Depends on Task 2. The core behavior. Self-contained synthetic-pad branch in `AddFiller`; reverse-nav
population in `PlayoutBuilder`; offline-target advance in the 4 schedulers.

**Files:**
- Modify: `ErsatzTV.Core/Scheduling/PlayoutBuilder.cs` (populate `ProgramScheduleItem.ProgramSchedule` reverse nav)
- Modify: `ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs` (`AddFiller` return type + synthetic-pad branch)
- Modify: `ErsatzTV.Core/Scheduling/PlayoutModeSchedulerFlood.cs` (line ~115-138)
- Modify: `ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs` (line ~203-221)
- Modify: `ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs` (line ~139-150)
- Modify: `ErsatzTV.Core/Scheduling/PlayoutModeSchedulerOne.cs` (line ~85-97)
- Test: `ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs`
- Golden data: `ErsatzTV.Core.Tests/Scheduling/Goldens/Goldens/classic-schedule-clock-padded-offline.txt`,
  `...-fallback.txt`

**Interfaces:**
- Consumes: `ProgramSchedule.PadToNearestMinute` (Task 2); the existing
  `FallbackFillerForPad(playoutBuilderState, enumerators, scheduleItem, duration, cancellationToken)` →
  `Option<PlayoutItem>` (unchanged).
- Produces: `AddFiller(...)` returns `(List<PlayoutItem> Items, DateTimeOffset? OfflinePadTarget)` instead of
  `List<PlayoutItem>`. `OfflinePadTarget` is non-null ONLY for a synthetic schedule pad that emitted no
  filler — callers must advance `CurrentTime` to it when it exceeds the content/filler end.

- [ ] **Step 1: Write failing golden-invariant tests.** In `PlayoutBuildGoldenTests.cs`, add two tests plus
their fixtures, modeled on `Classic_clock_padded` / `SeedPaddedData` (which build a `ProgramScheduleItemOne`,
a `ProgramSchedule`, a `Playout`, and reference data). The new fixtures set
`schedule.PadToNearestMinute = 15` and attach **no** Pad `FillerPreset` to the item.

Offline variant — no `FallbackFiller`, so gaps are offline (no filler items emitted), and each content item
after the first starts on a :15 boundary:
```csharp
    // Issue #392: schedule-level clock padding with NO fallback filler → each content item is padded up to
    // the next :15 boundary with an OFFLINE gap (no filler items). Proves the synthetic schedule pad advances
    // the build clock to the boundary even when nothing fills the gap.
    [Test]
    public async Task Classic_schedule_clock_padded_offline()
    {
        (List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildSchedulePaddedPlayout(withFallback: false);

        List<PlayoutItem> content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList();
        content.Count.ShouldBeGreaterThan(2);

        // No filler of any kind is emitted (offline gaps only).
        items.ShouldNotContain(i => i.FillerKind != FillerKind.None);

        foreach (PlayoutItem item in content.Skip(1))
        {
            (item.Start.Minute % 15).ShouldBe(0, $"content item at {item.Start:HH:mm:ss} is not on a :15 boundary");
            item.Start.Second.ShouldBe(0);
        }

        await CompareGolden("classic-schedule-clock-padded-offline.txt", items, titles);
    }
```

Fallback variant — a `FallbackFiller` IS configured, so the gap is filled with `FillerKind.Fallback` content
up to the boundary:
```csharp
    // Issue #392: schedule-level clock padding WITH a fallback filler → gaps fill with Fallback content up to
    // the :15 boundary (no offline gap).
    [Test]
    public async Task Classic_schedule_clock_padded_fallback()
    {
        (List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildSchedulePaddedPlayout(withFallback: true);

        items.ShouldContain(i => i.FillerKind == FillerKind.Fallback);

        List<PlayoutItem> content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList();
        foreach (PlayoutItem item in content.Skip(1))
        {
            (item.Start.Minute % 15).ShouldBe(0, $"content item at {item.Start:HH:mm:ss} is not on a :15 boundary");
            item.Start.Second.ShouldBe(0);
        }

        await CompareGolden("classic-schedule-clock-padded-fallback.txt", items, titles);
    }
```

Add the fixture builder `BuildSchedulePaddedPlayout(bool withFallback)` + `SeedSchedulePaddedData` +
`GetSchedulePaddedReferenceData`, copied from `BuildPaddedPlayout`/`SeedPaddedData`/`GetPaddedReferenceData`
(lines 727-927) with these changes:
- The `ProgramScheduleItemOne` has **no** `PostRollFiller`. When `withFallback`, set `FallbackFiller` to a
  `FillerPreset { FillerKind = FillerKind.Fallback, FillerMode = FillerMode.None, CollectionType = CollectionType.Collection, Collection = fillerCollection, CollectionId = fillerCollection.Id }` and set the
  item's `FallbackFillerId`. When not, leave `FallbackFiller` null.
- The `ProgramSchedule` sets `PadToNearestMinute = 15` (and `Name = "Schedule Padded Test Schedule"`).
- `GetSchedulePaddedReferenceData` mirrors `GetPaddedReferenceData` but the schedule query must
  `.Include(ps => ps.Items).ThenInclude(psi => psi.FallbackFiller)` (needed for the fallback variant) in
  addition to `Collection`/`MediaItem`. It does NOT need `PostRollFiller`.

(Do NOT create the golden `.txt` files yet — Step 4 generates them.)

- [ ] **Step 2: Run the new tests — expect compile failure / FAIL** (the field and behavior don't exist / the
golden files are missing).

Run: `dotnet test ErsatzTV.Core.Tests --filter "FullyQualifiedName~PlayoutBuildGoldenTests.Classic_schedule_clock_padded"`
Expected: FAIL.

- [ ] **Step 3: Implement the builder change.**

**(a) `PlayoutBuilder.cs` — populate the reverse nav once, centrally.** At the top of the `Build(...)` method
body (after parameters are in scope, before the day loop), set each schedule item's parent so `AddFiller` can
read `scheduleItem.ProgramSchedule.PadToNearestMinute`. Use `referenceData.ProgramSchedule` and each alternate:

```csharp
        // #392: the build query does not populate the ProgramScheduleItem.ProgramSchedule reverse nav
        // (AsNoTracking). Populate it so schedule-level settings (PadToNearestMinute) are readable in AddFiller.
        if (referenceData.ProgramSchedule?.Items is not null)
        {
            foreach (ProgramScheduleItem item in referenceData.ProgramSchedule.Items)
            {
                item.ProgramSchedule = referenceData.ProgramSchedule;
            }
        }

        foreach (ProgramScheduleAlternate alternate in referenceData.ProgramScheduleAlternates)
        {
            if (alternate.ProgramSchedule?.Items is not null)
            {
                foreach (ProgramScheduleItem item in alternate.ProgramSchedule.Items)
                {
                    item.ProgramSchedule = alternate.ProgramSchedule;
                }
            }
        }
```
Verify the exact accessor names against `PlayoutReferenceData.cs` and `ProgramScheduleAlternate` (the record
exposes `ProgramSchedule`). If an accessor differs, adjust — the intent is "every scheduled item points at its
own parent schedule."

**(b) `PlayoutModeSchedulerBase.cs` — extract the pad boundary-math helper, change `AddFiller` return type, add the synthetic-pad branch.**

First, extract the boundary-math (currently inlined at lines 604-626) into one private static helper that BOTH
the existing item-pad path and the new synthetic branch call (DRY; the existing `Classic_clock_padded` golden
proves this extraction is behavior-preserving). Add this method to the class:
```csharp
    // #392: shared clock-boundary ceiling used by both the item-level Pad filler and the schedule-level pad.
    // Returns the next `padToNearestMinute` boundary at or after (blockStart + totalDuration); when the block
    // already ends exactly on a boundary it advances one full interval (matches the pre-#392 Pad behavior).
    private static DateTimeOffset ComputePadBoundary(
        DateTimeOffset blockStart,
        TimeSpan totalDuration,
        int padToNearestMinute)
    {
        int currentMinute = (blockStart + totalDuration).Minute;
        int targetMinute = (currentMinute + padToNearestMinute - 1) / padToNearestMinute * padToNearestMinute;

        DateTimeOffset almostTargetTime = blockStart + totalDuration -
                                          TimeSpan.FromMinutes(currentMinute) +
                                          TimeSpan.FromMinutes(targetMinute);

        var targetTime = new DateTimeOffset(
            almostTargetTime.Year,
            almostTargetTime.Month,
            almostTargetTime.Day,
            almostTargetTime.Hour,
            almostTargetTime.Minute,
            0,
            almostTargetTime.Offset);

        // ensure filler works for content less than one interval (and for content already on a boundary)
        if (targetTime <= blockStart + totalDuration)
        {
            targetTime = targetTime.AddMinutes(padToNearestMinute);
        }

        return targetTime;
    }
```
Then, in the EXISTING item-pad block, replace the inlined ceiling math (lines 604-626 — the `int currentMinute
= ...` through the `if (targetTime <= ...) { targetTime = targetTime.AddMinutes(...); }`) with a single call,
leaving the `remainingToFill` line (628) intact:
```csharp
            DateTimeOffset targetTime = ComputePadBoundary(
                playoutItem.StartOffset, totalDuration, padFiller.PadToNearestMinute.Value);

            TimeSpan remainingToFill = targetTime - totalDuration - playoutItem.StartOffset;
```
(This is a pure extraction: the helper body is byte-identical logic to the removed lines. The
`Classic_clock_padded` golden in Step 7 must stay green, proving no behavior change.)

Change the `AddFiller` signature (line 282) return type to a named tuple:
```csharp
    internal (List<PlayoutItem> Items, DateTimeOffset? OfflinePadTarget) AddFiller(
        PlayoutBuilderState playoutBuilderState,
        Dictionary<CollectionKey, IMediaCollectionEnumerator> enumerators,
        ProgramScheduleItem scheduleItem,
        PlayoutItem playoutItem,
        List<MediaChapter> chapters,
        PlayoutBuildWarnings warnings,
        CancellationToken cancellationToken)
```
Update the two guard-rail early returns (lines 302, 313) from `return [playoutItem];` to
`return ([playoutItem], null);`. Add a local near the top of the method (before the pad section):
```csharp
        DateTimeOffset? offlinePadTarget = null;
```
Immediately BEFORE the existing item-pad section (the `foreach (FillerPreset padFiller in Optional(
allFiller.FirstOrDefault(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue)))` at line 583),
insert the self-contained synthetic branch. It engages ONLY when the item has no own Pad filler and the parent
schedule has a positive divisor — mutually exclusive with the existing block:
```csharp
        // #392: schedule-level clock padding. Applies only when the item has no own Pad filler preset (that
        // wins) and the parent schedule declares a positive divisor. Reuses the existing pad boundary math +
        // FallbackFillerForPad; when no fallback content exists, records an offline target so the caller
        // advances the build clock to the boundary (leaving an offline gap).
        bool itemHasPadFiller =
            allFiller.Any(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue);
        if (!itemHasPadFiller &&
            scheduleItem.ProgramSchedule?.PadToNearestMinute is int schedulePadMinutes &&
            schedulePadMinutes > 0)
        {
            TimeSpan totalDuration = result.Aggregate(
                TimeSpan.Zero,
                (acc, i) => acc + (i.FinishOffset - i.StartOffset));

            DateTimeOffset targetTime = ComputePadBoundary(
                playoutItem.StartOffset, totalDuration, schedulePadMinutes);

            TimeSpan remainingToFill = targetTime - totalDuration - playoutItem.StartOffset;
            if (remainingToFill > TimeSpan.Zero)
            {
                Option<PlayoutItem> maybeFallback = FallbackFillerForPad(
                    playoutBuilderState,
                    enumerators,
                    scheduleItem,
                    remainingToFill,
                    cancellationToken);

                if (maybeFallback.IsSome)
                {
                    foreach (PlayoutItem fallbackItem in maybeFallback)
                    {
                        result.Add(fallbackItem);
                    }
                }
                else
                {
                    // No fallback content: leave an offline gap up to the boundary.
                    offlinePadTarget = targetTime;
                }
            }
        }
```
Leave the existing item-pad `foreach` block (583-753) UNCHANGED. Finally, update the method's tail (line 766)
from `return result;` to:
```csharp
        return (result, offlinePadTarget);
```
Confirm the sequential Start/Finish rewrite (lines 755-766) runs unconditionally at the method tail (it does —
it precedes `return`), so an appended fallback item is sequenced correctly.

**(c) Update the 4 scheduler call sites** to destructure the tuple and honor `OfflinePadTarget`.

`PlayoutModeSchedulerFlood.cs` (~115-138) — replace:
```csharp
            List<PlayoutItem> maybePlayoutItems = AddFiller(
                nextState, collectionEnumerators, scheduleItem, playoutItem, itemChapters, warnings, cancellationToken);

            DateTimeOffset itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset);
```
with:
```csharp
            (List<PlayoutItem> maybePlayoutItems, DateTimeOffset? clockPadTarget) = AddFiller(
                nextState, collectionEnumerators, scheduleItem, playoutItem, itemChapters, warnings, cancellationToken);

            DateTimeOffset itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset);
            if (clockPadTarget is { } floodPadTarget && floodPadTarget > itemEndTimeWithFiller)
            {
                itemEndTimeWithFiller = floodPadTarget;
            }
```
(the existing `nextState = nextState with { CurrentTime = itemEndTimeWithFiller, ... }` then picks up the advance).

`PlayoutModeSchedulerDuration.cs` (~203-221) — same destructure; after computing
`itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset);` add the same `if (clockPadTarget is { } durPadTarget && durPadTarget > itemEndTimeWithFiller) itemEndTimeWithFiller = durPadTarget;` guard.

`PlayoutModeSchedulerMultiple.cs` (~139-150) — it currently inlines `.Max` into `CurrentTime`. Refactor:
```csharp
            (List<PlayoutItem> filled, DateTimeOffset? clockPadTarget) = AddFiller(
                nextState, collectionEnumerators, scheduleItem, playoutItem, itemChapters, warnings, cancellationToken);
            playoutItems.AddRange(filled);

            DateTimeOffset multipleEnd = playoutItems.Max(pi => pi.FinishOffset);
            if (clockPadTarget is { } mulPadTarget && mulPadTarget > multipleEnd)
            {
                multipleEnd = mulPadTarget;
            }

            nextState = nextState with
            {
                CurrentTime = multipleEnd,
```
(Adapt to the exact surrounding structure — the key change is that `CurrentTime` becomes
`max(items' FinishOffset, clockPadTarget)`. Preserve everything else in the `with` expression.)

`PlayoutModeSchedulerOne.cs` (~85-97) — same shape:
```csharp
            (List<PlayoutItem> playoutItems, DateTimeOffset? clockPadTarget) = AddFiller(
                playoutBuilderState, collectionEnumerators, scheduleItem, playoutItem, itemChapters, warnings, cancellationToken);

            DateTimeOffset oneEnd = playoutItems.Max(pi => pi.FinishOffset);
            if (clockPadTarget is { } onePadTarget && onePadTarget > oneEnd)
            {
                oneEnd = onePadTarget;
            }

            PlayoutBuilderState nextState = playoutBuilderState with
            {
                CurrentTime = oneEnd
            };
```

- [ ] **Step 4: Grep for any OTHER `AddFiller(` callers** the four above missed, and update them the same way.

Run: `grep -rn "AddFiller(" ErsatzTV.Core ErsatzTV.Core.Tests`
Expected: only the four schedulers (and possibly a direct test). Update any extra caller to destructure the tuple.

- [ ] **Step 5: Build.**

Run: `dotnet build ErsatzTV.Core`
Expected: build succeeds.

- [ ] **Step 6: Generate the two goldens, review, and verify.**

```bash
ETV_UPDATE_PLAYOUT_GOLDENS=1 dotnet test ErsatzTV.Core.Tests \
  --filter "FullyQualifiedName~PlayoutBuildGoldenTests.Classic_schedule_clock_padded"
```
Expected: writes `classic-schedule-clock-padded-offline.txt` and `...-fallback.txt` (tests report
Inconclusive). **Read both golden files** and confirm: offline variant has only `None` content lines with
boundary-aligned starts and NO filler lines; fallback variant interleaves `Fallback` filler lines ending on
:15 boundaries. Then run WITHOUT the env var:
```bash
dotnet test ErsatzTV.Core.Tests --filter "FullyQualifiedName~PlayoutBuildGoldenTests.Classic_schedule_clock_padded"
```
Expected: PASS.

- [ ] **Step 7: Run the FULL golden suite** to prove no regression to the existing `Classic_clock_padded`
(item-level pad path must be byte-identical — the synthetic branch is gated off when an item pad exists).

Run: `dotnet test ErsatzTV.Core.Tests --filter "FullyQualifiedName~PlayoutBuildGoldenTests"`
Expected: PASS (all goldens, including `Classic_clock_padded`, unchanged).

- [ ] **Step 8: Add a precedence test** proving an item's own Pad filler wins when the schedule pad is also set.
Add to `PlayoutBuildGoldenTests.cs` a test that builds the SAME fixture as `Classic_clock_padded` but also sets
`schedule.PadToNearestMinute = 30` on that schedule, and asserts the output is byte-identical to the existing
`classic-clock-padded.txt` golden (the item's :15 PostRoll pad wins; the schedule's :30 is ignored):
```csharp
    // #392: an item's own Pad filler takes precedence over the schedule-level pad (no double-pad).
    [Test]
    public async Task Classic_item_pad_wins_over_schedule_pad()
    {
        (List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildPaddedPlayout(schedulePadMinutes: 30);
        await CompareGolden("classic-clock-padded.txt", items, titles); // identical to the item-pad-only golden
    }
```
Give `BuildPaddedPlayout`/`SeedPaddedData` an optional `int? schedulePadMinutes = null` parameter that, when
set, assigns `schedule.PadToNearestMinute`; the default keeps every existing caller unchanged.

Run: `dotnet test ErsatzTV.Core.Tests --filter "FullyQualifiedName~PlayoutBuildGoldenTests.Classic_item_pad_wins_over_schedule_pad"`
Expected: PASS (reuses the existing golden — no new golden file).

- [ ] **Step 9: Commit.**

```bash
git -c core.hooksPath=/dev/null add ErsatzTV.Core/Scheduling ErsatzTV.Core.Tests/Scheduling/Goldens
git -c core.hooksPath=/dev/null commit -m "feat(392): honor ProgramSchedule.PadToNearestMinute in the Classic builder"
```

---

### Task 5: SPA schedule editor control

Depends on Task 3 (generated TS type). Adds the pad control to the schedule form.

**Files:**
- Modify: `web/src/schedules/ScheduleForm.tsx`

**Interfaces:**
- Consumes: `ProgramSchedule.padToNearestMinute` / `CreateScheduleRequest.padToNearestMinute` (Task 3, generated types).
- Produces: nothing downstream.

- [ ] **Step 1: Seed state from the loaded schedule.** In `ScheduleForm.tsx`, next to the existing
`randomStart` / `fixedBehavior` state (lines ~25-26), add (value is a string for the `Select`, `''` = none):
```tsx
  const [padToNearest, setPadToNearest] = useState<string>(
    schedule?.padToNearestMinute != null ? String(schedule.padToNearestMinute) : ''
  );
```

- [ ] **Step 2: Include it in the request body.** In the `body: CreateScheduleRequest = { ... }` object
(lines ~41-48), append:
```tsx
      padToNearestMinute: padToNearest === '' ? null : Number(padToNearest)
```

- [ ] **Step 3: Render the control.** After the `Fixed start time behavior` `Select` (lines ~96-102), add an
object-options `Select` with a `(none)` entry (mirrors `FillerPresetsScreen`'s pad control):
```tsx
        <Select
          label="Pad to clock boundary"
          value={padToNearest}
          onChange={(e) => setPadToNearest(e.target.value)}
          options={[
            { label: '(none)', value: '' },
            { label: '5', value: '5' },
            { label: '10', value: '10' },
            { label: '15', value: '15' },
            { label: '30', value: '30' },
            { label: '60', value: '60' }
          ]}
        />
```
If the local `Select` component's `options` prop does not accept `{label,value}[]` (the reference used a plain
`string[]`), check the `Select` signature in `web/src/components` — `FillerPresetsScreen.tsx` passes
`{label,value}[]` to the same component, so this shape is supported; match whichever form the component expects.

- [ ] **Step 4: Type-check + build.**

Run: `cd web && npm run build`
Expected: build succeeds; `padToNearestMinute` is a known field on the request type.

- [ ] **Step 5: Lint.**

Run: `cd web && npm run lint`
Expected: no new errors.

- [ ] **Step 6: Commit.**

```bash
git -c core.hooksPath=/dev/null add web/src/schedules/ScheduleForm.tsx
git -c core.hooksPath=/dev/null commit -m "feat(392): add pad-to-clock-boundary control to the schedule editor"
```

---

### Task 6: Docs — decision record + domain model

Depends on the behavior existing (Tasks 2-5). Docs-in-same-PR rule.

**Files:**
- Modify: `docs/decisions.md` (new active record)
- Modify: `docs/domain-model.md` (ProgramSchedule `PadToNearestMinute` field)
- Generated: `docs/decisions/README.md` (via `scripts/build_decisions_catalog.py`)

**Interfaces:**
- Consumes/Produces: none (docs only).

- [ ] **Step 1: Add the decision record.** Append to `docs/decisions.md` a record following the lifecycle
schema (5-field metadata block), extending — not superseding — `sched.clock-padding-existing`:

```markdown
## 2026-07-22 — per-schedule clock-boundary padding is a synthetic content-less Pad over the existing per-episode machinery (#392)

`key: sched.clock-padding-schedule-toggle`
`status: active`
`since: 2026-07-22`
`supersedes: none`
`superseded-by: none`

A `ProgramSchedule.PadToNearestMinute` (nullable int; null = off) makes the Classic builder pad every content
item up to the next N-minute clock boundary WITHOUT a hand-wired Pad `FillerPreset`. It reuses the existing
per-content-item Pad path (`PlayoutModeSchedulerBase.AddFiller`, already called once per emitted item): a
self-contained synthetic branch engages only when the item has no own `FillerMode.Pad` filler (the item's Pad
wins — no double-pad) and the parent schedule declares a positive divisor. The gap fills with the schedule
item's `FallbackFiller` via the existing `FallbackFillerForPad`; when no fallback content exists, the branch
records an offline target and the four schedulers advance `PlayoutBuilderState.CurrentTime` to the boundary,
leaving an implicit offline gap (the same representation fixed-start items use — absence of a `PlayoutItem`,
rendered as "Channel is Offline" at stream time). This is the per-schedule convenience layer deferred behind
#388 in `sched.clock-padding-existing`; that record's per-item Pad-preset behavior is unchanged. Determinism
needs no new anchor/seed state (the pad math is a pure function of offsets). The SPA schedule editor exposes it
as a 5/10/15/30/60 minute picker; TZ-independence holds only for divisors of 60. See #77 (prior art) and #392.
```
(If `sched.clock-padding-existing` should cross-reference this successor, add a one-line "Extended by
`sched.clock-padding-schedule-toggle` (#392)." note to its body — a reference, not a status change, since it
is not superseded.)

- [ ] **Step 2: Update the domain model.** In `docs/domain-model.md`, in the `ProgramSchedule` entity/field
listing, add a row/line for `PadToNearestMinute` — "optional clock-boundary padding divisor (minutes); when
set, the Classic builder pads each content item up to the next boundary, filling with the item's FallbackFiller
else offline (#392)."

- [ ] **Step 3: Regenerate the decisions catalog + validate.**

```bash
python3 scripts/build_decisions_catalog.py
python3 scripts/decisions_validate.py
```
Expected: `docs/decisions/README.md` now lists `sched.clock-padding-schedule-toggle`; the validator passes
(reproduce any failure locally — a lone `decisions lifecycle` CI red is a known flake, but a local validator
error is real and must be fixed).

- [ ] **Step 4: Commit.**

```bash
git -c core.hooksPath=/dev/null add docs/decisions.md docs/decisions/README.md docs/domain-model.md
git -c core.hooksPath=/dev/null commit -m "docs(392): record per-schedule clock-padding decision + domain-model field"
```

---

## Pre-push gate (run after all tasks, before opening the PR)

- [ ] Full solution build: `dotnet build ErsatzTV.sln` — succeeds.
- [ ] Full test run of touched projects: `dotnet test ErsatzTV.Core.Tests ErsatzTV.Application.Tests` — green.
- [ ] Web: `cd web && npm run build && npm run lint && npm run check:api` — green.
- [ ] BOM check on touched `.cs`: `for f in $(git diff --name-only origin/main | grep '\.cs$'); do xxd -p "$f" | grep -q '^efbbbf' && echo "BOM: $f"; done` — prints nothing.
- [ ] Format gate under bash: `bash -c 'dotnet format whitespace ErsatzTV.sln --folder --verify-no-changes --include $(git diff --name-only origin/main | grep "\.cs$" | tr "\n" " ")'` — clean.
- [ ] Live-E2E (`scripts/e2e-local.sh`): create a schedule with `padToNearestMinute` via the API, read it back, build a Classic playout on a channel using it, and confirm (curl the playout detail / XMLTV) that content items land on boundaries. Curl endpoints, never a browser tab; fresh config dir.
- [ ] Independent cross-model review of the whole diff (migration + builder + API). Re-review the fix commit if the first review finds issues; loop to a clean `Review-verdict: MERGEABLE @ <head-sha>`.
- [ ] Tick the issue's `## Done-when` boxes only against real evidence; post the `Review-verdict` referencing the PR head sha.

## Self-review notes (coverage check)

Spec sections → tasks: data model → T2; builder wiring (synthesize + offline branch + precedence) → T4;
API → T3; SPA control + 60-min → T5 (+ T1); tests (with-fallback / offline / precedence) → T4; docs
(decision record + domain-model) → T6. Fill-source "fallback else offline" → T4 synthetic branch. The one
new behavior (advance-to-boundary offline) → T4 (b)/(c). No spec requirement is unassigned.
