fix(880): move the empty-set rejection into the handlers, where the stored row is
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 8s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 18s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 24s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 18s
PR Gates / decisions lifecycle (pull_request) Successful in 24s
review-verdict/h10 Review-verdict: MERGEABLE @ 3500bf3 (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 27s
PR Gates / Docs update reminder (pull_request) Successful in 16s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 11m5s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 11m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 7m7s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m41s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 5m21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s

Independent cold review (Codex, cross-family) returned BLOCKED on the first
implementation with three findings. All three were verified against the code
before acting; all three were real.

1. HIGH -- an unchanged legacy empty set was rejected, making the row
   uneditable. api.ffmpeg-profile-numeric-bounds requires rejecting a NEWLY
   submitted bad value and accepting an unchanged one, and that binds harder
   here than in the ffmpeg case: both PUT paths are WHOLE-LIST replaces, so one
   pre-existing empty row made every OTHER item in the playout unsaveable. The
   docs I wrote even claimed legacy rows stayed editable, which the code did not
   do. The comparison needs the STORED row, which the controller does not have.
2. MEDIUM -- the alternate-schedule catch-all was falsely rejected. The handler
   discards the highest-Index item's recurrence along with its date range, so an
   empty set there cannot make anything "never apply"; the 422 stated a reason
   that is false for exactly that item. The ValidateDateRanges precedent I cited
   is precedent for a STYLE, not evidence the behaviour is correct -- and my own
   rejection tests used a one-item (i.e. catch-all) request, so they pinned the
   defect instead of the rule.
3. MEDIUM -- the message said "omit the field", but making a C# property
   nullable does not make it optional: all three are still in `required` in
   v1.json with type ["null","array"], so a client generated from the published
   contract cannot omit them. It now says "send null", which is contract-legal.

Findings 1 and 2 share one root -- validation was in the wrong layer -- so both
are fixed by the same move. The rejection is now RecurrenceSetBounds
(ErsatzTV.Application/Scheduling), one validator called from both replace
handlers, which is the FFmpegProfileBounds shape that api.ffmpeg-profile-
numeric-bounds already prescribes. The handler has `existing` loaded, so the
exemption is free; and it derives the validated set from `incoming` -- the exact
list whose recurrence the writes below iterate -- so the catch-all is excluded by
construction rather than by restating "which item is the catch-all" in a second
place (api.put-replace-index-order). The exemption is per FIELD, not per row, and
a stored NULL is not exempt, because null means unrestricted.

Normalization is unchanged and stays in the request records.

Three mutation proofs, executed separately, each reddening an explainable set:
reverting `?? All*()` reddens exactly the 3 normalization tests; neutering
IsNewlyEmpty reddens exactly the 3 rejection tests; dropping only the stored
comparison reddens the 2 exemption tests (the second because the rejection then
names DaysOfWeek instead of MonthsOfYear). Rejection tests assert the version did
NOT bump, so a 422 that had already mutated cannot pass.

One fixture trap worth the comment it got: the template handler's
`.ThenInclude(t => t.Template)` navigation is REQUIRED, so a PlayoutTemplate
seeded without its Template row is joined out of `existing` entirely -- the
stored row goes invisible and the exemption test fails claiming the rule is
broken when the fixture is.

refs #880

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019zUmJZHhVP7kXg5DV237TW
This commit is contained in:
Timothy
2026-08-30 10:46:43 +02:00
co-authored by Claude Opus 5
parent 4466ea894c
commit 3500bf3727
9 changed files with 349 additions and 182 deletions
@@ -1,4 +1,5 @@
using System.Threading.Channels;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
@@ -86,6 +87,29 @@ public class ReplacePlayoutAlternateScheduleItemsHandler(
var incoming = request.Items.Except([highest]).ToList();
// Reject an EXPLICITLY empty recurrence set before any mutation (#880). The checked set is
// `incoming` -- the exact list whose DaysOfWeek/DaysOfMonth/MonthsOfYear the loops below
// write -- so the check and its subject cannot drift apart. That EXCLUDES the highest-Index
// catch-all by construction: its recurrence is discarded along with its date range (only its
// ProgramScheduleId is read, further down), so an empty set there cannot make anything "never
// apply" and rejecting it would state a reason that is false for that item.
foreach (ReplacePlayoutAlternateSchedule item in incoming)
{
ProgramScheduleAlternate stored = existing.FirstOrDefault(e => e.Id == item.Id);
Option<BaseError> recurrenceError = RecurrenceSetBounds.Validate(
item.DaysOfWeek,
item.DaysOfMonth,
item.MonthsOfYear,
stored?.DaysOfWeek,
stored?.DaysOfMonth,
stored?.MonthsOfYear);
foreach (BaseError error in recurrenceError)
{
return error;
}
}
var toAdd = incoming.Filter(x => existing.All(e => e.Id != x.Id)).ToList();
var toRemove = existing.Filter(e => incoming.All(m => m.Id != e.Id)).ToList();
var toUpdate = incoming.Except(toAdd).ToList();
@@ -44,6 +44,25 @@ public class ReplacePlayoutTemplateItemsHandler(
List<ReplacePlayoutTemplate> incoming = request.Items;
// Same rule as the alternate-schedule path (#880), over ALL items: unlike that one, every
// template item's recurrence IS stored, so there is no catch-all to exclude here.
foreach (ReplacePlayoutTemplate item in incoming)
{
PlayoutTemplate stored = existing.FirstOrDefault(e => e.Id == item.Id);
Option<BaseError> recurrenceError = RecurrenceSetBounds.Validate(
item.DaysOfWeek,
item.DaysOfMonth,
item.MonthsOfYear,
stored?.DaysOfWeek,
stored?.DaysOfMonth,
stored?.MonthsOfYear);
if (recurrenceError.IsSome)
{
return recurrenceError;
}
}
var toAdd = incoming.Filter(x => existing.All(e => e.Id != x.Id)).ToList();
var toRemove = existing.Filter(e => incoming.All(m => m.Id != e.Id)).ToList();
var toUpdate = incoming.Except(toAdd).ToList();
@@ -0,0 +1,74 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Scheduling;
/// <summary>
/// Validates the three recurrence sets shared by <c>ProgramScheduleAlternate</c> and
/// <c>PlayoutTemplate</c> (ersatztv#880). One validator called from BOTH replace handlers, mirroring
/// <c>FFmpegProfileBounds</c> — the exemplar for `api.ffmpeg-profile-numeric-bounds`, whose shape this
/// follows deliberately.
/// </summary>
/// <remarks>
/// <para>
/// An EMPTY set is rejected because the three are read CONJUNCTIVELY by
/// <c>AlternateScheduleSelector.GetScheduleForDate</c> — a miss on any one continues to the next
/// item — so an empty one matches NO date and stores an item that can never apply. Rejecting
/// rather than substituting is the point: accept-then-rewrite would make an explicit `[]`
/// indistinguishable from an omitted field, which is the very collapse this issue removed.
/// </para>
/// <para>
/// An UNCHANGED empty set that the row ALREADY holds is let through. Both PUT paths are
/// whole-list replaces, so a hard rejection would make every OTHER item in the playout
/// uneditable over a row the operator never touched — the same reason
/// `api.ffmpeg-profile-numeric-bounds` rejects only a NEWLY submitted out-of-range value. A row
/// whose stored set is NULL is NOT exempt: null means unrestricted, so submitting `[]` for it is
/// a new emptying, not an unchanged legacy value.
/// </para>
/// <para>
/// This runs on the COMMAND, after the request records have normalized an ABSENT array to the
/// All*() sets, so an empty set reaching here is one a caller sent EXPLICITLY. That also means a
/// direct (non-HTTP) caller is held to the same rule rather than being able to write a dead row.
/// </para>
/// </remarks>
public static class RecurrenceSetBounds
{
public static Option<BaseError> Validate(
ICollection<DayOfWeek> daysOfWeek,
ICollection<int> daysOfMonth,
ICollection<int> monthsOfYear,
ICollection<DayOfWeek> storedDaysOfWeek,
ICollection<int> storedDaysOfMonth,
ICollection<int> storedMonthsOfYear)
{
if (IsNewlyEmpty(daysOfWeek, storedDaysOfWeek))
{
return Some(BaseError.New(Message("DaysOfWeek", "no day of the week")));
}
if (IsNewlyEmpty(daysOfMonth, storedDaysOfMonth))
{
return Some(BaseError.New(Message("DaysOfMonth", "no day of the month")));
}
if (IsNewlyEmpty(monthsOfYear, storedMonthsOfYear))
{
return Some(BaseError.New(Message("MonthsOfYear", "no month")));
}
return Option<BaseError>.None;
}
// "send null" rather than "omit the property": all three are listed in the schema's `required` array
// in v1.json (they are nullable, not optional), so a client generated from the published contract
// cannot omit them. Omitting also works at runtime -- Newtonsoft maps a missing property and an
// explicit null to the same thing -- but naming only that would tell a conforming client to send
// something its own schema forbids.
private static string Message(string field, string consequence) =>
$"[{field}] must not be empty; an empty set matches {consequence}, so the item would never apply. " +
"Send null to leave it unrestricted";
// A new item (no stored row) has `stored` null, so an empty set is newly empty and is rejected.
// Only a stored set that is ITSELF already empty exempts an empty submission.
private static bool IsNewlyEmpty<T>(ICollection<T> submitted, ICollection<T> stored) =>
submitted is { Count: 0 } && stored is not { Count: 0 };
}
@@ -4,6 +4,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
@@ -121,13 +122,200 @@ public class PlayoutHandlerTests
LeftOf(result).Value.ShouldContain("must not be empty");
}
// ---- #880 empty recurrence sets ----
[Test]
public async Task ReplaceAlternateSchedules_Should_Reject_A_Newly_Empty_Recurrence_On_A_Stored_Item()
{
await SeedPlayout(1, version: 1);
var handler = new ReplacePlayoutAlternateScheduleItemsHandler(
_db.Factory,
_worker,
NullLogger<ReplacePlayoutAlternateScheduleItemsHandler>.Instance);
// TWO items: index 1 is the catch-all (highest index), so index 0 is a real alternate whose
// recurrence IS stored. The empty set goes on THAT one.
ReplacePlayoutAlternateSchedule empty = AltItem(index: 0) with { DaysOfWeek = [] };
Either<BaseError, Unit> result = await handler.Handle(
new ReplacePlayoutAlternateScheduleItems(1, [empty, AltItem(index: 1)]),
CancellationToken.None);
LeftOf(result).Value.ShouldContain("[DaysOfWeek]");
LeftOf(result).Value.ShouldContain("no day of the week");
// rejected BEFORE any mutation -- the version bump is the observable proof nothing was written
(await ReadPlayoutVersion(1)).ShouldBe(1);
}
// The catch-all's recurrence is discarded by the handler (only its ProgramScheduleId is used), so an
// empty set there cannot make anything "never apply". Rejecting it would state a reason that is FALSE
// for that item, which is why the check walks `incoming` rather than every submitted item.
[Test]
public async Task ReplaceAlternateSchedules_Should_Allow_An_Empty_Recurrence_On_The_CatchAll_Item()
{
await SeedPlayout(1, version: 1);
var handler = new ReplacePlayoutAlternateScheduleItemsHandler(
_db.Factory,
_worker,
NullLogger<ReplacePlayoutAlternateScheduleItemsHandler>.Instance);
// a single item IS the catch-all
ReplacePlayoutAlternateSchedule catchAll = AltItem(index: 0) with { DaysOfWeek = [], MonthsOfYear = [] };
Either<BaseError, Unit> result = await handler.Handle(
new ReplacePlayoutAlternateScheduleItems(1, [catchAll]),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
(await ReadPlayoutVersion(1)).ShouldBe(2);
}
[Test]
public async Task ReplaceTemplates_Should_Reject_A_Newly_Empty_Recurrence()
{
await SeedPlayout(1, version: 1);
var handler = new ReplacePlayoutTemplateItemsHandler(
_db.Factory,
NullLogger<ReplacePlayoutTemplateItemsHandler>.Instance);
ReplacePlayoutTemplate empty = TemplateItem() with { DaysOfMonth = [] };
Option<BaseError> result = await handler.Handle(
new ReplacePlayoutTemplateItems(1, [empty]),
CancellationToken.None);
result.IfNone(() => throw new AssertionException("Expected a Some(error)"))
.Value.ShouldContain("[DaysOfMonth]");
(await ReadPlayoutVersion(1)).ShouldBe(1);
}
// `api.ffmpeg-profile-numeric-bounds`: reject a NEWLY submitted bad value, not an UNCHANGED one the row
// already holds. Both PUT paths are whole-list replaces, so without this a single pre-existing empty row
// would make every OTHER item in the playout uneditable.
[Test]
public async Task ReplaceTemplates_Should_Allow_An_UNCHANGED_Empty_Recurrence_That_Is_Already_Stored()
{
int templateItemId = await SeedPlayoutWithEmptyTemplateRecurrence();
var handler = new ReplacePlayoutTemplateItemsHandler(
_db.Factory,
NullLogger<ReplacePlayoutTemplateItemsHandler>.Instance);
// same row, same empty DaysOfWeek -- an edit to some OTHER field on the same list
ReplacePlayoutTemplate unchanged = TemplateItem() with { Id = templateItemId, DaysOfWeek = [] };
Option<BaseError> result = await handler.Handle(
new ReplacePlayoutTemplateItems(1, [unchanged]),
CancellationToken.None);
result.IsNone.ShouldBeTrue();
(await ReadPlayoutVersion(1)).ShouldBe(2);
}
// ... and the complement: the SAME stored row rejects a DIFFERENT field being newly emptied, so the
// exemption is per-field rather than "this row is grandfathered".
[Test]
public async Task ReplaceTemplates_Should_Still_Reject_A_Different_Field_Newly_Emptied_On_A_Stored_Row()
{
int templateItemId = await SeedPlayoutWithEmptyTemplateRecurrence();
var handler = new ReplacePlayoutTemplateItemsHandler(
_db.Factory,
NullLogger<ReplacePlayoutTemplateItemsHandler>.Instance);
// DaysOfWeek is the stored-empty one; MonthsOfYear is stored FULL, so emptying it is new
ReplacePlayoutTemplate item = TemplateItem() with
{
Id = templateItemId,
DaysOfWeek = [],
MonthsOfYear = []
};
Option<BaseError> result = await handler.Handle(
new ReplacePlayoutTemplateItems(1, [item]),
CancellationToken.None);
result.IfNone(() => throw new AssertionException("Expected a Some(error)"))
.Value.ShouldContain("[MonthsOfYear]");
(await ReadPlayoutVersion(1)).ShouldBe(1);
}
private async Task<int> SeedPlayoutWithEmptyTemplateRecurrence()
{
await using TvContext context = _db.CreateContext();
// The handler loads templates with `.Include(p => p.Templates).ThenInclude(t => t.Template)`, and
// that navigation is required -- so a PlayoutTemplate whose Template row does not exist is joined
// OUT and never reaches `existing`. Without seeding this, the stored row is invisible, the
// exemption cannot match, and the test fails for a reason that has nothing to do with the rule.
context.TemplateGroups.Add(new TemplateGroup { Id = 5, Name = "Group", Templates = [] });
context.Templates.Add(new Template { Id = 20, TemplateGroupId = 5, Name = "Template 20", Items = [] });
await context.SaveChangesAsync();
var template = new PlayoutTemplate
{
Index = 0,
TemplateId = 20,
DaysOfWeek = [],
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = false,
StartMonth = 1,
StartDay = 1,
EndMonth = 12,
EndDay = 31
};
context.Playouts.Add(
new Playout
{
Id = 1,
ChannelId = 1,
ProgramScheduleId = 10,
Version = 1,
Items = [],
ProgramScheduleAlternates = [],
Templates = [template]
});
await context.SaveChangesAsync();
return template.Id;
}
// ---- #253 optimistic concurrency (alternate schedules #7 + templates #8, shared Playout.Version) ----
private static ReplacePlayoutAlternateSchedule AltItem(int programScheduleId = 10) =>
new(0, 0, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null);
// Recurrence sets are UNRESTRICTED here, not empty (#880): an empty set now means "matches no date"
// and is rejected on any item whose recurrence is stored, so an empty fixture would make these
// concurrency tests measure the recurrence guard instead of the version check.
private static ReplacePlayoutAlternateSchedule AltItem(int programScheduleId = 10, int index = 0) =>
new(
0,
index,
programScheduleId,
AlternateScheduleSelector.AllDaysOfWeek(),
AlternateScheduleSelector.AllDaysOfMonth(),
AlternateScheduleSelector.AllMonthsOfYear(),
false,
1,
1,
null,
12,
31,
null);
private static ReplacePlayoutTemplate TemplateItem(int templateId = 20) =>
new(0, 0, templateId, null, [], [], [], false, 1, 1, null, 12, 31, null);
new(
0,
0,
templateId,
null,
AlternateScheduleSelector.AllDaysOfWeek(),
AlternateScheduleSelector.AllDaysOfMonth(),
AlternateScheduleSelector.AllMonthsOfYear(),
false,
1,
1,
null,
12,
31,
null);
private async Task SeedPlayout(int id, int version, int? programScheduleId = 10)
{
@@ -1155,36 +1155,6 @@ public class PlayoutControllerTests
Arg.Any<CancellationToken>());
}
[TestCase("DaysOfWeek", "no day of the week")]
[TestCase("DaysOfMonth", "no day of the month")]
[TestCase("MonthsOfYear", "no month")]
public async Task ReplaceAlternateSchedules_Should_Return_422_For_Empty_Recurrence_Without_Dispatch(
string field,
string consequence)
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
var request = new ReplacePlayoutAlternateSchedulesRequest(
[MakeAltRequestWithEmpty(field)]);
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
problem.Status.ShouldBe(422);
// Pin WHICH validation rejected it, and that the message names the consequence rather than only
// the field -- otherwise this passes on any unrelated 422 (`api.ffmpeg-profile-numeric-bounds`).
problem.Detail.ShouldNotBeNull();
problem.Detail.ShouldContain($"[{field}]");
problem.Detail.ShouldContain(consequence);
await _mediator.DidNotReceive().Send(Arg.Any<GetAllProgramSchedules>(), Arg.Any<CancellationToken>());
await _mediator.DidNotReceive()
.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task ReplaceTemplates_Should_Normalize_Absent_Recurrence_To_Unrestricted()
{
@@ -1213,65 +1183,6 @@ public class PlayoutControllerTests
Arg.Any<CancellationToken>());
}
[TestCase("DaysOfWeek", "no day of the week")]
[TestCase("DaysOfMonth", "no day of the month")]
[TestCase("MonthsOfYear", "no month")]
public async Task ReplaceTemplates_Should_Return_422_For_Empty_Recurrence_Without_Dispatch(
string field,
string consequence)
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(
Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
var request = new ReplacePlayoutTemplatesRequest([MakeTemplateRequestWithEmpty(field)]);
IActionResult result = await _controller.ReplaceTemplates(9, request, CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
problem.Status.ShouldBe(422);
problem.Detail.ShouldNotBeNull();
problem.Detail.ShouldContain($"[{field}]");
problem.Detail.ShouldContain(consequence);
await _mediator.DidNotReceive().Send(Arg.Any<GetAllTemplates>(), Arg.Any<CancellationToken>());
await _mediator.DidNotReceive().Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>());
}
// Exactly ONE of the three arrays is empty per case; the other two are ABSENT, so a case can only be
// rejected by the field it names.
private static PlayoutAlternateScheduleItemRequest MakeAltRequestWithEmpty(string field) =>
new(
0,
7,
field == "DaysOfWeek" ? [] : null,
field == "DaysOfMonth" ? [] : null,
field == "MonthsOfYear" ? [] : null,
false,
1,
1,
null,
12,
31,
null);
private static PlayoutTemplateItemRequest MakeTemplateRequestWithEmpty(string field) =>
new(
0,
7,
null,
field == "DaysOfWeek" ? [] : null,
field == "DaysOfMonth" ? [] : null,
field == "MonthsOfYear" ? [] : null,
false,
1,
1,
null,
12,
31,
null);
// ----- Playout templates -----
[Test]
@@ -372,11 +372,6 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
return error.ToErrorResult();
}
foreach (BaseError error in ValidateRecurrenceSets(items.Select(ToRecurrenceCheck)))
{
return error.ToErrorResult();
}
List<ProgramScheduleViewModel> schedules =
await mediator.Send(new GetAllProgramSchedules(), cancellationToken);
var scheduleIds = schedules.Select(s => s.Id).ToHashSet();
@@ -488,11 +483,6 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
return error.ToErrorResult();
}
foreach (BaseError error in ValidateRecurrenceSets(items.Select(ToRecurrenceCheck)))
{
return error.ToErrorResult();
}
List<TemplateViewModel> templates = await mediator.Send(new GetAllTemplates(), cancellationToken);
var templateIds = templates.Select(t => t.Id).ToHashSet();
var missingTemplateIds = items.Select(i => i.TemplateId).Distinct()
@@ -792,61 +782,6 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
PlayoutTemplateItemRequest item) =>
(item.LimitToDateRange, item.StartMonth, item.StartDay, item.EndMonth, item.EndDay);
private static (List<DayOfWeek> DaysOfWeek, List<int> DaysOfMonth, List<int> MonthsOfYear)
ToRecurrenceCheck(PlayoutAlternateScheduleItemRequest item) =>
(item.DaysOfWeek, item.DaysOfMonth, item.MonthsOfYear);
private static (List<DayOfWeek> DaysOfWeek, List<int> DaysOfMonth, List<int> MonthsOfYear)
ToRecurrenceCheck(PlayoutTemplateItemRequest item) =>
(item.DaysOfWeek, item.DaysOfMonth, item.MonthsOfYear);
// An EXPLICITLY EMPTY recurrence array is rejected; an ABSENT one is not (#880). The distinction is
// the whole point: the three sets are conjunctive in AlternateScheduleSelector.GetScheduleForDate, so
// an empty one means the item matches NO date and can never apply -- previously stored with a 200 and
// silent thereafter, which is the trap this closes. Absence is a different request and means
// UNRESTRICTED; the request records normalize it to the All*() sets before the command is built, which
// is why this must run on the REQUEST (where null still means absent) and not on the command.
//
// Rejecting rather than rewriting follows `api.ffmpeg-profile-numeric-bounds`: name the consequence,
// do not accept-then-substitute. Only the WRITE side rejects -- the read side still tolerates an empty
// set, because a row written before this validation existed must stay readable and editable.
//
// Every item is checked, INCLUDING the last one. That is deliberate and it is the same choice
// ValidateDateRanges already makes: the highest-Index item is the catch-all default, and
// ReplacePlayoutAlternateScheduleItemsHandler discards its recurrence fields along with its date range.
// Exempting it here would mean restating "which item is the catch-all" in the controller, a second copy
// of a rule that lives in the handler and could drift from it. Sending `[]` for an item whose recurrence
// is ignored is a client error either way, and omitting the field is always accepted.
private static Option<BaseError> ValidateRecurrenceSets(
IEnumerable<(List<DayOfWeek> DaysOfWeek, List<int> DaysOfMonth, List<int> MonthsOfYear)> items)
{
foreach ((List<DayOfWeek> daysOfWeek, List<int> daysOfMonth, List<int> monthsOfYear) in items)
{
if (daysOfWeek is { Count: 0 })
{
return Some(
BaseError.New(
"[DaysOfWeek] must not be empty; an empty set matches no day of the week, so the item would never apply. Omit the field to leave it unrestricted"));
}
if (daysOfMonth is { Count: 0 })
{
return Some(
BaseError.New(
"[DaysOfMonth] must not be empty; an empty set matches no day of the month, so the item would never apply. Omit the field to leave it unrestricted"));
}
if (monthsOfYear is { Count: 0 })
{
return Some(
BaseError.New(
"[MonthsOfYear] must not be empty; an empty set matches no month, so the item would never apply. Omit the field to leave it unrestricted"));
}
}
return Option<BaseError>.None;
}
private static Option<BaseError> ValidateDateRanges(
IEnumerable<(bool LimitToDateRange, int StartMonth, int StartDay, int EndMonth, int EndDay)> items)
{
+21 -13
View File
@@ -400,20 +400,28 @@ Decide both, separately:
Distinguishing them requires the request record's property to be **nullable** (`List<T>?`, with a
per-file `#nullable enable` where the project has annotations off), because `?? …` on a
non-nullable-annotated `List<T>` cannot tell absence from empty. Exemplars:
`ErsatzTV/Controllers/Api/Requests/PlayoutAlternateScheduleItemRequest.cs` (normalization) and
`PlayoutController.ValidateRecurrenceSets` (rejection).
non-nullable-annotated `List<T>` cannot tell absence from empty. Normalization belongs in the request
record's `ToReplaceItem`; **rejection does not belong beside it.** Three rules that come with it:
Two rules that come with it:
- **Match the permissive value to the read side's, symbol for symbol.** The recurrence fields
normalize to `AlternateScheduleSelector.All*()` — the same helpers the selector's null guard and
both `Mapper.ProjectToViewModel` overloads use — so "absent" means one thing across read and write.
Picking an equivalent-looking literal instead is how the two halves drift apart again.
- **Reject on the WRITE side only.** The read side must keep tolerating an empty set: rows written
before the validation existed have to stay readable, and editable. This is §3d's "keep the
render-time clamp" in another guise — validation is what stops new bad rows, not what repairs old
ones.
- **Say "send `null`", not "omit the property".** Making a C# property nullable does NOT make it
optional in the generated schema: all three recurrence properties are still listed in `required` in
`v1.json`, with type `["null","array"]`. A client generated from the published contract therefore
*cannot* omit them, and an error message telling it to would be instructing a schema violation.
Omission still works at runtime; `null` is the form that is also contract-legal.
- **Validate where the STORED value is in hand — the handler, not the controller.** §3d's rule that an
*unchanged* bad value must still be accepted applies here in its sharpest form: these are whole-list
replace PUTs, so rejecting a pre-existing empty set would make every *other* item in the list
uneditable over a row the operator never touched. That comparison needs the stored row, which the
controller does not have and the handler already loaded. Exemplar: `RecurrenceSetBounds`
(`ErsatzTV.Application/Scheduling`), one validator called from both replace handlers — the same shape
as `FFmpegProfileBounds`. The exemption is **per field**, not per row: a row grandfathered on
`DaysOfWeek` still cannot newly empty `MonthsOfYear`.
- **Derive the validated set from the list the handler actually writes.** In the alternate-schedule
path that is `incoming`, which *excludes* the highest-`Index` catch-all — the handler discards that
item's recurrence along with its date range, so an empty set there cannot make anything "never
apply" and rejecting it would state a reason that is false for that item. Walking the same list the
writes iterate is what keeps the check and its subject from drifting; do not re-derive "which item is
the catch-all" in a second place (`api.put-replace-index-order`).
## 4. Artwork contract
+1 -1
View File
@@ -8,7 +8,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| Key | Current rule | Since | Record |
| --- | --- | --- | --- |
| `api.absent-collection-means-unrestricted` | A nullable collection on a write path carries TWO distinct requests and they get DIFFERENT answers. ABSENT (property missing, or explicit `null` — Newtonsoft maps both to null, so they are indistinguishable and are treated as one case) means the client expresses NO restriction and normalizes to the PERMISSIVE value; EXPLICITLY EMPTY (`[]`) is a well-formed request whose outcome is meaningless for a conjunctive filter, and is REJECTED with a 422 naming the consequence rather than rewritten. Collapsing the two is what ersatztv#880 records: `?? []` on the three recurrence arrays of `PlayoutAlternateScheduleItemRequest` / `PlayoutTemplateItemRequest` returned HTTP 200 and stored a row that could NEVER apply on any date, because `AlternateScheduleSelector.GetScheduleForDate` treats DaysOfWeek/DaysOfMonth/MonthsOfYear CONJUNCTIVELY (a miss on any one `continue`s), so empty is the maximally RESTRICTIVE value, not a neutral one. The permissive value is not chosen freshly: it must be the SAME SYMBOL the read side substitutes for the same absence — here `AlternateScheduleSelector.All*()`, already used by the selector's null guard and both `Mapper.ProjectToViewModel` overloads (`media.nullable-primitive-collection-mutation`), whose residual (3) this closes. An equivalent-looking literal is how the two halves drift apart again. Distinguishing absence from empty REQUIRES the property to be nullable (`List<T>?`), which in the `ErsatzTV` web project needs a per-file `#nullable enable` — the project has annotations off, and 16 files including sibling request records already use that form. Rejection lives on the WRITE side ONLY: the read side keeps tolerating an empty set so a row written before the validation stays readable AND editable, which is `api.ffmpeg-profile-numeric-bounds`' "keep the render-time clamp" in another guise. EVERY item is validated, INCLUDING the highest-Index catch-all whose recurrence `ReplacePlayoutAlternateScheduleItemsHandler` discards — deliberately, because exempting it would restate "which item is the catch-all" in the controller, a second copy of a rule that lives in the handler (`api.put-replace-index-order`) and could drift from it; `ValidateDateRanges` already makes exactly this choice for the date fields it equally discards. Do NOT cite the OpenAPI surface as evidence either way: the spec and the generated SPA client are BYTE-IDENTICAL before and after, because a non-nullable-annotated `List<T>` was ALREADY emitted as nullable — the contract shape did not move, only the behaviour behind it, so a green `update-openapi.sh` diff says nothing about whether this shipped. | 2026-08-30 | [link](records/api/absent-collection-means-unrestricted.md) |
| `api.absent-collection-means-unrestricted` | A nullable collection on a write path carries TWO distinct requests and they get DIFFERENT answers. ABSENT (property missing, or explicit `null` — Newtonsoft maps both to null, so they are indistinguishable and are treated as ONE case) means the client expresses NO restriction and NORMALIZES to the PERMISSIVE value; EXPLICITLY EMPTY (`[]`) is a well-formed request whose outcome is meaningless for a conjunctive filter, and is REJECTED with a 422 naming the consequence rather than rewritten. Collapsing the two is what ersatztv#880 records: `?? []` on the three recurrence arrays of `PlayoutAlternateScheduleItemRequest` / `PlayoutTemplateItemRequest` returned HTTP 200 and stored a row that could NEVER apply on any date, because `AlternateScheduleSelector.GetScheduleForDate` treats DaysOfWeek/DaysOfMonth/MonthsOfYear CONJUNCTIVELY (a miss on any one `continue`s), so empty is the maximally RESTRICTIVE value, not a neutral one. The permissive value is not chosen freshly: it must be the SAME SYMBOL the read side substitutes for the same absence — here `AlternateScheduleSelector.All*()`, already used by the selector's null guard and both `Mapper.ProjectToViewModel` overloads (`media.nullable-primitive-collection-mutation`), whose residual (3) this closes. Distinguishing absence from empty REQUIRES the property to be nullable (`List<T>?`), which in the `ErsatzTV` web project needs a per-file `#nullable enable` — the project has annotations off, and sibling request records already use that form. THE TWO HALVES LIVE IN DIFFERENT LAYERS AND THAT SPLIT IS THE DECISION: normalization sits in the request record's `ToReplaceItem`, but REJECTION SITS IN THE HANDLER, in `RecurrenceSetBounds` (`ErsatzTV.Application/Scheduling`) called from BOTH replace handlers — the `FFmpegProfileBounds` shape. It CANNOT sit beside the normalization in the controller, because `api.ffmpeg-profile-numeric-bounds`' rule that an UNCHANGED bad value must still be accepted binds here in its sharpest form: both PUT paths are WHOLE-LIST replaces, so rejecting a pre-existing empty set would make every OTHER item in the list uneditable over a row the operator never touched. That comparison needs the STORED row, which the controller does not have and the handler already loaded. The exemption is PER FIELD, not per row — a row grandfathered on `DaysOfWeek` still cannot newly empty `MonthsOfYear` — and a STORED NULL is NOT exempt, because null means unrestricted, so submitting `[]` against it is a new emptying. The validated set is DERIVED from the list the handler actually writes (`incoming`), which EXCLUDES the highest-`Index` catch-all: the handler discards that item's recurrence along with its date range, so an empty set there cannot make anything "never apply" and rejecting it would state a reason that is FALSE for that item — do not re-derive "which item is the catch-all" in a second place (`api.put-replace-index-order`). Finally, the error message says SEND NULL, not "omit the property": making a C# property nullable does NOT make it optional in the generated schema — all three are still listed in `required` in `v1.json` with type `["null","array"]` — so a client generated from the published contract cannot omit them, and telling it to would instruct a schema violation. Omission still works at runtime; `null` is the form that is also contract-legal. | 2026-08-30 | [link](records/api/absent-collection-means-unrestricted.md) |
| `api.artwork-rooted-urls` | API response DTOs return artwork as rooted, directly-usable URLs (plus passthrough for absolute/proxy URLs), never relative Blazor-convention paths. | 2026-07-07 | [link](records/api/artwork-rooted-urls.md) |
| `api.async-op-contract` | Queue-triggering `/api/*` endpoints normalize onto one contract — 202 Accepted (queued), 404 (missing entity), 409 (lock held), 422 (domain precondition) — with Trakt as the reference implementation; playout list/detail GETs also carry an `isLocked` observability flag as the HTTP-observable substitute for a live push channel. | 2026-07-11 | [link](records/api/async-op-contract.md) |
| `api.channel-health-object` | `ChannelResponseModel`/`ChannelDetailResponseModel` carry a server-derived `health` object (`ChannelHealthResponseModel { Status, Faults[], PlayoutCount, BrokenSourceItemCount }`) computed **read-time** from the built timeline (`Playout.BuildStatus` + upcoming `PlayoutItem → MediaItem.State`, `Finish >= now`), kind-agnostic across all 5 `PlayoutScheduleKind` values; `Status`/`Faults` are const-string classes (`ChannelHealthStatus`, `ChannelFault`), not C# enums, so the SPA hand-maintains the union (mirrors `ChannelPreviewAvailability`). This supersedes #72's "raw fact only, no derived enum, empty-schedule/broken-source deliberately not computed" stance now that the auto-tune taxonomy churn (#383/#384) it was waiting on has landed (see `channel.origin-marker` sibling record, #414). | 2026-07-23 | [link](records/api/channel-health-object.md) |
@@ -5,9 +5,9 @@ status: active
since: '2026-08-30'
supersedes: none
superseded-by: none
rule: 'A nullable collection on a write path carries TWO distinct requests and they get DIFFERENT answers. ABSENT (property missing, or explicit `null` — Newtonsoft maps both to null, so they are indistinguishable and are treated as one case) means the client expresses NO restriction and normalizes to the PERMISSIVE value; EXPLICITLY EMPTY (`[]`) is a well-formed request whose outcome is meaningless for a conjunctive filter, and is REJECTED with a 422 naming the consequence rather than rewritten. Collapsing the two is what ersatztv#880 records: `?? []` on the three recurrence arrays of `PlayoutAlternateScheduleItemRequest` / `PlayoutTemplateItemRequest` returned HTTP 200 and stored a row that could NEVER apply on any date, because `AlternateScheduleSelector.GetScheduleForDate` treats DaysOfWeek/DaysOfMonth/MonthsOfYear CONJUNCTIVELY (a miss on any one `continue`s), so empty is the maximally RESTRICTIVE value, not a neutral one. The permissive value is not chosen freshly: it must be the SAME SYMBOL the read side substitutes for the same absence — here `AlternateScheduleSelector.All*()`, already used by the selector''s null guard and both `Mapper.ProjectToViewModel` overloads (`media.nullable-primitive-collection-mutation`), whose residual (3) this closes. An equivalent-looking literal is how the two halves drift apart again. Distinguishing absence from empty REQUIRES the property to be nullable (`List<T>?`), which in the `ErsatzTV` web project needs a per-file `#nullable enable` — the project has annotations off, and 16 files including sibling request records already use that form. Rejection lives on the WRITE side ONLY: the read side keeps tolerating an empty set so a row written before the validation stays readable AND editable, which is `api.ffmpeg-profile-numeric-bounds`'' "keep the render-time clamp" in another guise. EVERY item is validated, INCLUDING the highest-Index catch-all whose recurrence `ReplacePlayoutAlternateScheduleItemsHandler` discards — deliberately, because exempting it would restate "which item is the catch-all" in the controller, a second copy of a rule that lives in the handler (`api.put-replace-index-order`) and could drift from it; `ValidateDateRanges` already makes exactly this choice for the date fields it equally discards. Do NOT cite the OpenAPI surface as evidence either way: the spec and the generated SPA client are BYTE-IDENTICAL before and after, because a non-nullable-annotated `List<T>` was ALREADY emitted as nullable — the contract shape did not move, only the behaviour behind it, so a green `update-openapi.sh` diff says nothing about whether this shipped.'
signals: 'omitted array means never applies · absent versus explicitly empty on a write path · `?? []` inverts a conjunctive filter · empty set is the most restrictive value not the neutral one · normalize absence to the same symbol the read side substitutes · `List<T>?` plus per-file `#nullable enable` in a project with annotations off · reject an empty recurrence with 422 naming the consequence · read side still tolerates empty so legacy rows stay editable · validate the catch-all too rather than restating which item it is · OpenAPI unchanged because the field was already emitted nullable · paths: `ErsatzTV/Controllers/Api/Requests/PlayoutAlternateScheduleItemRequest.cs`, `ErsatzTV/Controllers/Api/Requests/PlayoutTemplateItemRequest.cs`, `ErsatzTV/Controllers/Api/PlayoutController.cs`, `ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs` · issues: #880, #823, #701'
mechanics: 'Pinned in `ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs` by three normalization tests (`Replace{AlternateSchedules,Templates}_Should_Normalize_Absent_Recurrence_To_Unrestricted` and `ReplaceAlternateSchedules_Should_Preserve_An_Explicit_Recurrence_Selection`) and six rejection cases (`Replace{AlternateSchedules,Templates}_Should_Return_422_For_Empty_Recurrence_Without_Dispatch`, one `[TestCase]` per field). BOTH halves were mutation-proved by EXECUTION, separately, against the real predecessor clause rather than a hand-written mutant. (1) Reverting the normalization ALONE — the three `?? All*()` clauses back to `?? []` in both request records, leaving the nullable annotations and the rejection in place — reddens EXACTLY the three normalization tests (110 tests, 3 failed). (2) Removing the two `ValidateRecurrenceSets` call sites ALONE, leaving normalization intact, reddens EXACTLY the six rejection cases (110 tests, 6 failed). The two mutations redden DISJOINT sets, which is what shows the guards are not masking each other. The rejection cases assert `problem.Detail` CONTAINS both the bracketed field name and its consequence phrase, not merely `Status == 422`: the same request shape is also reachable by the date-range validator, so a status-only assertion would pass on an unrelated rejection. Each case leaves the OTHER two arrays ABSENT so it can only be rejected by the field it names. Anti-vacuity is the `DidNotReceive()` pair already used by the sibling date-range tests — a 422 that still dispatched would be a different bug.'
rule: 'A nullable collection on a write path carries TWO distinct requests and they get DIFFERENT answers. ABSENT (property missing, or explicit `null` — Newtonsoft maps both to null, so they are indistinguishable and are treated as ONE case) means the client expresses NO restriction and NORMALIZES to the PERMISSIVE value; EXPLICITLY EMPTY (`[]`) is a well-formed request whose outcome is meaningless for a conjunctive filter, and is REJECTED with a 422 naming the consequence rather than rewritten. Collapsing the two is what ersatztv#880 records: `?? []` on the three recurrence arrays of `PlayoutAlternateScheduleItemRequest` / `PlayoutTemplateItemRequest` returned HTTP 200 and stored a row that could NEVER apply on any date, because `AlternateScheduleSelector.GetScheduleForDate` treats DaysOfWeek/DaysOfMonth/MonthsOfYear CONJUNCTIVELY (a miss on any one `continue`s), so empty is the maximally RESTRICTIVE value, not a neutral one. The permissive value is not chosen freshly: it must be the SAME SYMBOL the read side substitutes for the same absence — here `AlternateScheduleSelector.All*()`, already used by the selector''s null guard and both `Mapper.ProjectToViewModel` overloads (`media.nullable-primitive-collection-mutation`), whose residual (3) this closes. Distinguishing absence from empty REQUIRES the property to be nullable (`List<T>?`), which in the `ErsatzTV` web project needs a per-file `#nullable enable` — the project has annotations off, and sibling request records already use that form. THE TWO HALVES LIVE IN DIFFERENT LAYERS AND THAT SPLIT IS THE DECISION: normalization sits in the request record''s `ToReplaceItem`, but REJECTION SITS IN THE HANDLER, in `RecurrenceSetBounds` (`ErsatzTV.Application/Scheduling`) called from BOTH replace handlers — the `FFmpegProfileBounds` shape. It CANNOT sit beside the normalization in the controller, because `api.ffmpeg-profile-numeric-bounds`'' rule that an UNCHANGED bad value must still be accepted binds here in its sharpest form: both PUT paths are WHOLE-LIST replaces, so rejecting a pre-existing empty set would make every OTHER item in the list uneditable over a row the operator never touched. That comparison needs the STORED row, which the controller does not have and the handler already loaded. The exemption is PER FIELD, not per row — a row grandfathered on `DaysOfWeek` still cannot newly empty `MonthsOfYear` — and a STORED NULL is NOT exempt, because null means unrestricted, so submitting `[]` against it is a new emptying. The validated set is DERIVED from the list the handler actually writes (`incoming`), which EXCLUDES the highest-`Index` catch-all: the handler discards that item''s recurrence along with its date range, so an empty set there cannot make anything "never apply" and rejecting it would state a reason that is FALSE for that item — do not re-derive "which item is the catch-all" in a second place (`api.put-replace-index-order`). Finally, the error message says SEND NULL, not "omit the property": making a C# property nullable does NOT make it optional in the generated schema — all three are still listed in `required` in `v1.json` with type `["null","array"]` — so a client generated from the published contract cannot omit them, and telling it to would instruct a schema violation. Omission still works at runtime; `null` is the form that is also contract-legal.'
signals: 'omitted array means never applies · absent versus explicitly empty on a write path · `?? []` inverts a conjunctive filter · empty set is the most restrictive value not the neutral one · normalize absence to the same symbol the read side substitutes · `List<T>?` plus per-file `#nullable enable` in a project with annotations off · reject an empty recurrence with 422 naming the consequence · rejection needs the STORED row so it lives in the handler not the controller · unchanged stored empty is exempt per FIELD not per row · a stored NULL is not exempt because null means unrestricted · validate the list the handler writes so the catch-all is excluded by construction · nullable does not mean optional in the generated schema so say send null not omit · paths: `ErsatzTV.Application/Scheduling/RecurrenceSetBounds.cs`, `ErsatzTV/Controllers/Api/Requests/PlayoutAlternateScheduleItemRequest.cs`, `ErsatzTV/Controllers/Api/Requests/PlayoutTemplateItemRequest.cs`, `ErsatzTV.Application/Playouts/Commands/ReplacePlayoutAlternateScheduleItemsHandler.cs`, `ErsatzTV.Application/Scheduling/Commands/ReplacePlayoutTemplateItemsHandler.cs` · issues: #880, #823, #701'
mechanics: 'Two halves, pinned in two files and mutation-proved SEPARATELY by EXECUTION against the real predecessor clause. NORMALIZATION is pinned in `ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs` by `Replace{AlternateSchedules,Templates}_Should_Normalize_Absent_Recurrence_To_Unrestricted` plus `ReplaceAlternateSchedules_Should_Preserve_An_Explicit_Recurrence_Selection` — the last is the complement, without which a fix that substituted `All*()` UNCONDITIONALLY would still pass. Proof: reverting the three `?? All*()` clauses to `?? []` in both request records reddens EXACTLY those three. REJECTION is pinned in `ErsatzTV.Tests/Application/Playouts/PlayoutHandlerTests.cs` against a real `InMemoryTvContext`, and the four cases are chosen to be mutually distinguishing: a newly-empty field on a NON-catch-all item is rejected; the same empty on the CATCH-ALL is ACCEPTED; an UNCHANGED stored empty is accepted; and the SAME stored row still REJECTS a different field newly emptied. Each rejection case also asserts the version did NOT bump, which is the anti-vacuity control — a 422 that had already mutated would be a different bug. One fixture trap is load-bearing and was measured, not reasoned about: the template handler loads `.Include(p => p.Templates).ThenInclude(t => t.Template)` and that navigation is REQUIRED, so a `PlayoutTemplate` seeded WITHOUT its `Template` row is joined OUT and never reaches `existing` — the stored row is then invisible, the exemption cannot match, and the test fails claiming the rule is broken when the fixture is. Seed the `TemplateGroup` and `Template` too.'
---
**The empty set is not the neutral value, and that is the whole defect.** For a conjunctive filter,
@@ -25,15 +25,23 @@ nullable annotation was added to express. Rejecting absence instead would break
legitimately omits an optional field. The pair — normalize absence, reject empty — is the only
assignment that leaves both requests expressible and neither one silent.
**The first implementation put the rejection in the controller, and an independent review was right to
block it.** Validating the request there is cheaper and matches the sibling `ValidateDateRanges`, but
it has no access to the stored row, so it rejected an unchanged legacy empty and — because these are
whole-list replaces — would have made every sibling item unsaveable. It also validated EVERY item
uniformly, including the catch-all whose recurrence is discarded, producing a 422 whose stated reason
("the item would never apply") is false for exactly that item. Both disappear once the check moves to
the handler and derives its population from `incoming`. The uniformity of `ValidateDateRanges` is
precedent for a STYLE, not evidence that the behaviour is correct; that was the reasoning error.
**Prod was measured before choosing to reject rather than grandfather** (2026-08-30, jazz
`/config/ersatztv.sqlite3`): `ProgramScheduleAlternate` and `PlayoutTemplate` both hold ZERO rows, so
no stored `[]` exists to be bricked by the new 422 and `api.ffmpeg-profile-numeric-bounds`' compare-
against-the-stored-value exemption was not needed. That exemption is the right tool if this rule is
ever extended to a populated table, and the reason it was skipped is the measurement, not the
argument — record a new measurement rather than inheriting this one.
`/config/ersatztv.sqlite3`): `ProgramScheduleAlternate` and `PlayoutTemplate` both hold ZERO rows. That
measurement is why the exemption could not be justified by pointing at live data — it had to be
justified by the shape of a whole-list replace instead, which holds for any deployment. Do not cite
the zero-row count as a reason to drop the exemption.
**The SPA can still produce an empty set** (`PlayoutScheduleEditors.tsx` toggles days individually,
with no floor at one), so a user who unchecks every day now receives the 422 instead of silently
saving a dead row. That is the intended outcome and it is a visible behaviour change, not an
oversight; a client-side floor mirroring the server message would be the §3d "mirror the exemption in
the client" follow-up if the error proves awkward in the form.
saving a dead row. That is the intended outcome and it is a visible behaviour change; a client-side
floor mirroring the server message would be the §3d "mirror the exemption in the client" follow-up if
the error proves awkward in the form.