fix(823,824): a scheduling NULL collection reads as UNRESTRICTED and is guarded at both read sites; the Elastic indexer gets its own mutation proof #879

Merged
timothy merged 5 commits from fix/823-824-701-deferrals into main 2026-08-29 21:55:55 +02:00
Owner

Closes both #701 deferrals together, because both rewrite the same decision record.

#823 — is a runtime null reachable on the six scheduling collection columns?

Yes. Measured against a real TvContext on both providers (SQLite, and MySQL 8.4 on an ephemeral server), because the available reasoning pointed the wrong way: the two converters differ on their read side, so a NULL row was expected to behave differently per column. Neither converter runs — EF does not invoke a value converter for a NULL column at all, so all six materialize as CLR null and IntCollectionValueConverter's null-to-empty branch is dead on this path. Unguarded, AlternateScheduleSelector's .Contains calls throw NullReferenceException.

A NULL reads as UNRESTRICTED, not empty. The first draft had this backwards. It is decided by the one NULL reachable without any code writing one: SQLite's 20240113140741_Add_PlayoutTemplate_DaysOfMonth is an AddColumn with nullable: true and no defaultValue, so rows inserted before it hold NULL and by construction had no day-of-month restriction. Reading that as empty would invert the row's meaning and silently stop the template applying.

Two read sites, not one. Guarding only the selector would have left the entity→DTO mappers unguarded, and those feed the SPA — which spreads the collection ([...template.daysOfMonth]TypeError) and runs appliesToDate, an exact port of GetScheduleForDate. Both mappers now substitute the same defaults, so the preview agrees with what is scheduled. Neither guard is assigned back onto the entity.

Reachability, stated precisely: all six are nullable: true on both providers, but five were present at CreateTable, so a NULL there still needs code to write one — and on MySQL there is no code-path-free NULL for any of the six. The write path accepts a null; no caller supplies one today.

#824 — ElasticSearchIndex.UpdateSong had no regression test

Option 1 from the issue shipped, needing no new package: InMemoryRequestInvoker is public in the pinned Elastic.Transport, injected into the private _client the way #701 injects the Lucene IndexWriter. The canned response must carry X-Elastic-Product: Elasticsearch or the client's product check throws into UpdateSong's catch — which is how this fixture first failed, caught by the ThrowOnWarningLogger rather than passing vacuously.

No production change in ElasticSearchIndex.cs#824 is coverage only.

Six mutations executed, each disarming its own clause alone

Mutation Result
??= restored in ElasticSearchIndex only Elastic fixture red (metadata.Artists should be null but was []), Lucene fixture green — the #824 hole demonstrated
DaysOfWeek guard disarmed in the selector 4 red, 3 green — DaysOfMonth/MonthsOfYear unaffected, so each clause is independently load-bearing
DaysOfMonth guard disarmed in Playouts.Mapper 1 red, 2 green
Elastic dropped from the covered set coverage guard red
both indexers mapped to the SAME fixture coverage guard red
a fixture with no [Test] named coverage guard red

Verification

Local gate with the MySQL lane armed: ErsatzTV.Tests 2096 passed / 0 skipped, Core.Tests 693/1, Infrastructure.Tests 114, Architecture.Tests 7, Scanner.Tests 1504 — 0 failures each. scripts/tests 1228 passed / 2 skipped. Format clean, BOM check over the touched set with the population count asserted. decisions_validate OK. No golden file changed.

Reviewed cold by two independent reviewers (one cross-family); every finding addressed, including the semantic reversal and the mapper sweep, which both came from review.

Fixes #823
Fixes #824

Closes both #701 deferrals together, because both rewrite the same decision record. ## #823 — is a runtime null reachable on the six scheduling collection columns? Yes. **Measured** against a real `TvContext` on **both** providers (SQLite, and MySQL 8.4 on an ephemeral server), because the available reasoning pointed the wrong way: the two converters differ on their read side, so a NULL row was expected to behave differently per column. **Neither converter runs** — EF does not invoke a value converter for a NULL column at all, so all six materialize as CLR `null` and `IntCollectionValueConverter`'s null-to-empty branch is dead on this path. Unguarded, `AlternateScheduleSelector`'s `.Contains` calls throw `NullReferenceException`. **A NULL reads as UNRESTRICTED, not empty.** The first draft had this backwards. It is decided by the one NULL reachable without any code writing one: SQLite's `20240113140741_Add_PlayoutTemplate_DaysOfMonth` is an `AddColumn` with `nullable: true` and no `defaultValue`, so rows inserted before it hold NULL and by construction had no day-of-month restriction. Reading that as empty would **invert** the row's meaning and silently stop the template applying. **Two read sites, not one.** Guarding only the selector would have left the entity→DTO mappers unguarded, and those feed the SPA — which spreads the collection (`[...template.daysOfMonth]` → `TypeError`) and runs `appliesToDate`, an exact port of `GetScheduleForDate`. Both mappers now substitute the same defaults, so the preview agrees with what is scheduled. Neither guard is assigned back onto the entity. **Reachability, stated precisely:** all six are `nullable: true` on both providers, but five were present at `CreateTable`, so a NULL there still needs code to write one — and on MySQL there is **no** code-path-free NULL for any of the six. The write path *accepts* a null; no caller supplies one today. ## #824 — ElasticSearchIndex.UpdateSong had no regression test Option 1 from the issue shipped, needing no new package: `InMemoryRequestInvoker` is public in the pinned `Elastic.Transport`, injected into the private `_client` the way #701 injects the Lucene `IndexWriter`. The canned response must carry `X-Elastic-Product: Elasticsearch` or the client's product check throws **into** `UpdateSong`'s catch — which is how this fixture first failed, caught by the `ThrowOnWarningLogger` rather than passing vacuously. **No production change in `ElasticSearchIndex.cs` — #824 is coverage only.** ## Six mutations executed, each disarming its own clause alone | Mutation | Result | |---|---| | `??=` restored in `ElasticSearchIndex` only | Elastic fixture **red** (`metadata.Artists should be null but was []`), Lucene fixture **green** — the #824 hole demonstrated | | `DaysOfWeek` guard disarmed in the selector | 4 red, 3 green — `DaysOfMonth`/`MonthsOfYear` unaffected, so each clause is independently load-bearing | | `DaysOfMonth` guard disarmed in `Playouts.Mapper` | 1 red, 2 green | | Elastic dropped from the covered set | coverage guard red | | both indexers mapped to the SAME fixture | coverage guard red | | a fixture with no `[Test]` named | coverage guard red | ## Verification Local gate with the MySQL lane armed: `ErsatzTV.Tests` 2096 passed / 0 skipped, `Core.Tests` 693/1, `Infrastructure.Tests` 114, `Architecture.Tests` 7, `Scanner.Tests` 1504 — 0 failures each. `scripts/tests` 1228 passed / 2 skipped. Format clean, BOM check over the touched set with the population **count asserted**. `decisions_validate` OK. No golden file changed. Reviewed cold by two independent reviewers (one cross-family); every finding addressed, including the semantic reversal and the mapper sweep, which both came from review. Fixes #823 Fixes #824
timothy added 1 commit 2026-08-29 20:08:03 +02:00
fix(823,824): a scheduling NULL collection reads as UNRESTRICTED and is guarded at both read sites; the Elastic indexer gets its own mutation proof
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 18s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Docs update reminder (pull_request) Successful in 21s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 11s
review-verdict/h10 Review-verdict: MERGEABLE @ 95b2700 (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 23s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 8m6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m55s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m35s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Canceled after 2m56s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Canceled after 0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Canceled after 0s
95b2700f09
Both issues are #701 deferrals, and they land together because both rewrite
the same decision record.

#823 -- can a null reach one of the six collection-valued scalar columns?

MEASURED against a real TvContext on BOTH providers (SQLite, and MySQL 8.4
on an ephemeral server), because the reasoning available beforehand pointed
the wrong way. The two converters differ on their read side --
IntCollectionValueConverter maps null-or-blank to Array.Empty<int>(), while
EnumCollectionJsonValueConverter would dereference the result of
JsonConvert.DeserializeObject -- so the expectation was that a NULL row
behaves differently per column. NEITHER RUNS: EF does not invoke a value
converter for a NULL column at all. All six materialize as CLR null, the
int converter's null-to-empty branch is dead on this path, and unguarded
each .Contains in AlternateScheduleSelector throws NullReferenceException.

A NULL reads as UNRESTRICTED -- the All*() sets -- not as empty. This is
the whole semantic question and the first draft got it backwards. It is
decided by the one NULL reachable WITHOUT any code writing one: Sqlite's
20240113140741_Add_PlayoutTemplate_DaysOfMonth adds the column with
nullable:true and NO defaultValue, so a PlayoutTemplate row inserted before
it holds NULL and by construction had no day-of-month restriction. Reading
that as empty INVERTS the row's meaning and silently stops the template
applying at all. All*() preserves it, and is how "no restriction recorded"
is already represented (GetPlayoutAlternateSchedulesHandler,
PreviewBlockPlayoutHandler). What does NOT decide it, and was wrongly cited
in the first draft: the API request records normalize an omitted field with
`?? []`, but that is a client omitting a field on a WRITE and says nothing
about what a legacy database NULL meant.

Two read sites, not one. Guarding only the selector would have left the
entity->DTO mappers unguarded, and those feed the SPA: PlayoutScheduleEditors
spreads the collection (`[...template.daysOfMonth]` -> TypeError on a JSON
null) and playoutTemplateCalendar's appliesToDate -- an exact port of
GetScheduleForDate -- calls .includes on it. Both mappers now substitute the
SAME defaults, so the preview agrees with what is actually scheduled. Neither
guard is assigned back onto the entity, which is the
media.nullable-primitive-collection-mutation mechanism.

Reachability, stated precisely rather than overclaimed. All six are
nullable:true on both providers, but a nullable column does not produce a
NULL row: five of the six were present at CreateTable, so a NULL there still
needs code to write one, and on MySQL there is NO code-path-free NULL for any
of the six. The write path ACCEPTS a null (SaveChanges succeeds, stores SQL
NULL) but no caller supplies one today -- every production construction of the
two commands goes through the request records. That is a property of the code,
not a live caller; claiming otherwise would be the banned "it's AsNoTracking
today" argument pointed the other way.

#824 -- ElasticSearchIndex.UpdateSong had no regression test

Issue option 1 (a non-network transport) shipped, and needed no new package:
Elastic.Transport.InMemoryRequestInvoker is public in the pinned version and
ElasticsearchClientSettings(NodePool, IRequestInvoker) accepts it, injected
into the private _client the way #701 injects the Lucene IndexWriter.
UpdateItems never runs `_client ??= CreateClient()`, so the injected instance
is the one used.

Two traps there are load-bearing, both measured: the canned response must
carry an `X-Elastic-Product: Elasticsearch` header or the client's product
check throws UnsupportedProductException INTO UpdateSong's catch, and an empty
body fails to deserialize the same way. Either turns the fixture into a green
measurement of the error path -- which is how it first failed here, caught by
the ThrowOnWarningLogger. The document id is asserted as the LAST PATH SEGMENT,
not by substring: the index name carries digits, so ShouldContain would stop
discriminating for a song whose id collided with one.

Six mutations executed, each disarming ITS OWN clause alone:

- `??=` restored in ElasticSearchIndex only -> the Elastic fixture reddens on
  "metadata.Artists should be null but was []" while the LUCENE fixture stays
  GREEN. The #824 hole demonstrated, not described.
- DaysOfWeek guard disarmed in the selector -> 4 red, 3 green (DaysOfMonth and
  MonthsOfYear unaffected). Each clause is independently load-bearing.
- DaysOfMonth guard disarmed in Playouts.Mapper -> 1 red, 2 green.
- Elastic dropped from the covered set / mapped to the SAME fixture as Lucene /
  mapped to a class with no [Test] -> SearchIndexMutationCoverageTests reddens
  on each.

That coverage guard is the boundary fix the issue asked for: the covered set is
compared against an ISearchIndex population DERIVED FROM THE ASSEMBLY. Its claim
stops where the check does -- no static check can establish that a named fixture
actually DRIVES its indexer, so it forces a human to look rather than proving
coverage. ThrowOnWarningLogger moved to ErsatzTV.Tests/Support so both fixtures
share it; the Lucene fixture's assertions are otherwise untouched, since it is a
witnessed proof artifact.

No production change in ElasticSearchIndex.cs -- #824 is coverage only.

Docs: testing.md gains a "Provider-parity fixtures" section naming all THREE
opt-in-MySQL fixtures and recording that CI runs none of them (#627);
docs/README.md gains the matching task signal; guard-inventory.md's
hand-written C# guard list goes from five files to six. Scheduling/Mapper.cs
loses the UTF-8 BOM it inherited, per #311 fix-as-you-touch.

Local gate (with the MySQL lane armed): ErsatzTV.Tests 2096 passed / 0 skipped,
Core.Tests 693/1, Infrastructure.Tests 114, Architecture.Tests 7, Scanner.Tests
1504 -- 0 failures in each. scripts/tests 1228 passed / 2 skipped. dotnet format
whitespace --verify-no-changes clean; BOM check over the touched set with the
population COUNT asserted, because a bare zsh loop silently checks one
concatenated filename. decisions_validate OK.

Fixes #823
Fixes #824

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019zUmJZHhVP7kXg5DV237TW
Author
Owner

Review-verdict: MERGEABLE @ 95b2700

Two independent cold reviews, one cross-family (GPT-5.6) and one worktree-isolated Opus. 17 findings between them, no Blockers; every actionable finding addressed on this head. The two most important both came FROM review and changed the design: the NULL semantics were reversed from empty to unrestricted, and the sweep was extended from the selector alone to the entity-to-DTO mappers that feed the SPA. Prose claims that review measured as false (reachability on both providers, a non-API caller existing) were corrected rather than softened. Every new guard carries an executed clause mutation: six applied, six witnessed red, each scoped so its siblings stayed green. Caveat stated rather than hidden: a third re-review pass over the fix delta was dispatched and did not return, so that delta was verified by the author instead - field sweep complete, migration enumeration re-derived, DTO consumers enumerated (SPA only), goldens unchanged.

Review-verdict: MERGEABLE @ 95b2700 Two independent cold reviews, one cross-family (GPT-5.6) and one worktree-isolated Opus. 17 findings between them, no Blockers; every actionable finding addressed on this head. The two most important both came FROM review and changed the design: the NULL semantics were reversed from empty to unrestricted, and the sweep was extended from the selector alone to the entity-to-DTO mappers that feed the SPA. Prose claims that review measured as false (reachability on both providers, a non-API caller existing) were corrected rather than softened. Every new guard carries an executed clause mutation: six applied, six witnessed red, each scoped so its siblings stayed green. Caveat stated rather than hidden: a third re-review pass over the fix delta was dispatched and did not return, so that delta was verified by the author instead - field sweep complete, migration enumeration re-derived, DTO consumers enumerated (SPA only), goldens unchanged.
timothy added 1 commit 2026-08-29 20:27:36 +02:00
fix(823,824): close the review round's findings — a discriminating control, the second mapper's empty case, and honest test detection
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 16s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 17s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 14s
review-verdict/h10 Awaiting review verdict for 6200713
Review verdict / Set review-verdict status (pull_request_target) Successful in 11s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 7m53s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Canceled after 7m59s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Canceled after 0s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Canceled after 0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Canceled after 0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Canceled after 0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Canceled after 0s
6200713965
Follow-up commit (the branch is pushed, so not an amend). Two more cold
reviews landed on the previous head; both reported 0 Blocker and 0 High, and
these are their Mediums and Lows. Each fix carries its own witnessed mutation.

1. The selector fixture could not tell the fix from a much broader one.
   Every null test set a NULL and expected the item SELECTED, so all of them
   pass equally under "NULL means unrestricted" and under "any NULL makes this
   item match unconditionally" -- a refactor short-circuiting the whole date
   check on any null kept them green. Added the discriminating control: a NULL
   DaysOfWeek paired with MonthsOfYear = [1] against a MARCH date must be None.
   Only the narrow reading passes.

2. A_Null_Item_Does_Not_Disturb_Selection_Of_A_Later_Item never measured its
   own docstring. The nulled item was unrestricted and at Index 0, so it always
   won and the second item was never evaluated -- the stated invariant ("a null
   on the first item must not decide the second") went unmeasured while the
   test passed. Split into two: one where the nulled item genuinely does not
   match, which measures that the loop CONTINUES; and one that pins the
   index-order win separately.

3. The empty-preservation control existed for one of two identical mappers.
   The anti-mutant test for "empty or null becomes All*" covered only
   Playouts.Mapper; Scheduling.Mapper is a byte-identical triple in another
   file and had none, so a defensive edit to it alone would have rewritten a
   deliberately-empty user selection to 1..31 with the suite green. That is the
   one-helper-two-callers shape this repo has been bitten by. Added the
   matching test.

4. The coverage guard's [Test] clause did not check what its message claimed.
   GetMethods() without BindingFlags returns INHERITED methods, so a fixture
   that merely subclasses another satisfied it while driving the wrong indexer
   -- and Values.Distinct() cannot catch that, since the two Types differ. It
   also matched TestAttribute alone, so a future fixture written as [TestCase]
   would have falsely reddened, and it accepted an [Explicit]/[Ignore]d fixture
   that never runs, which is the "wired is not running" failure the guard
   exists to prevent. Now DeclaredOnly, the full test-method vocabulary, and
   Explicit/Ignore rejected at both method and fixture level.

5. Three residuals recorded on media.nullable-primitive-collection-mutation
   that the previous head asserted nothing about:
   - the LOUDNESS change, worst for an all-three-NULL ProgramScheduleAlternate,
     which now matches unconditionally and shadows the default schedule where
     it previously threw. Unreachable today, and a choice over an unreachable
     state rather than a measured requirement -- said plainly.
   - the normalization is ONE-WAY and WHOLE-LIST: both PUT paths are full
     replaces, so editing any row persists All*() over EVERY NULL row in that
     playout, and afterwards "the operator selected all 31" and "this is a
     legacy row" are indistinguishable. An ordinary user action closes that
     door.
   - the WRITE side disagrees with the READ side about what ABSENCE means: an
     omitted daysOfWeek normalizes to [] ("never applies") while a NULL column
     reads as unrestricted, so an API client gets HTTP 200 and a row that
     silently never fires. Filed as #880 rather than folded in here, because a
     client omitting a field on a write is a different question from what a
     legacy NULL meant.

Two more mutations executed, both witnessed:
- DaysOfWeek guard disarmed in Scheduling.Mapper -> 1 red, 3 green.
- A fixture with no DECLARED test named in the covered set -> coverage red.

Local gate (MySQL lane armed): ErsatzTV.Tests 2097 passed / 0 skipped,
Core.Tests 695/1 -- 0 failures. Format clean, no BOM on the touched set with
the population count asserted. decisions_validate OK.

Refs #823
Refs #824
Refs #880

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019zUmJZHhVP7kXg5DV237TW
timothy added 1 commit 2026-08-29 20:43:12 +02:00
fix(823): the decision record argued BOTH readings — and the per-dimension mutant that survived the fixture
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 16s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 16s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
review-verdict/h10 Awaiting review verdict for ea88801
Review verdict / Set review-verdict status (pull_request_target) Successful in 18s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 7m4s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m55s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Canceled after 4m22s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Canceled after 0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Canceled after 0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Canceled after 0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Canceled after 0s
ea888011aa
Round-three review findings. One HIGH, and it was in the durable artifact
rather than the code.

THE HIGH: the record stated the shipped reading and its inverse.

The semantic reversal (empty -> unrestricted) rewrote the residual and the
write-half of `media.nullable-primitive-collection-mutation` but left the
ORIGINAL reasoning standing two sentences earlier: "A null reads as EMPTY, so
the item matches nothing"; "the REJECTED alternative was the All*() set";
"SKIPPING the row is the conservative repair". The shipped code is
`?? AllDaysOfWeek()` -- precisely the alternative that passage calls rejected.
The previous commit then inserted residual (1), which reasons entirely FROM
the All*() reading, two sentences after the sentence denying it.

That is worse than a stale comment. A session resolving this key -- or reading
the MemPalace mirror, which carries `rule:` verbatim -- would have been told to
write the guard the other way, i.e. talked into the `[]` reading that the same
record elsewhere argues is data corruption one save later. Replaced the whole
passage, then swept the record for every other mention of the empty reading
rather than trusting the one replacement: the only survivor is the new sentence
that records EMPTY as the rejected alternative, which is the direction that
stops it being re-adopted.

THE MEDIUM: one arrangement did not close the hole it claimed to.

The discriminating control added last commit nulls DaysOfWeek against a
restrictive MonthsOfYear. It excludes "any NULL matches unconditionally" only
for that dimension. The review supplied the surviving mutant --
`if (item.MonthsOfYear is null) { return item; }` ahead of the checks -- and
traced it green through all nine tests. Verified by EXECUTION, not by reading:
applied to the previous fixture it passes; applied now it FAILS 1 of 11. Each
of the three dimensions is now nulled against a restriction on a different
dimension.

The rest, all from the same round:

- The coverage guard's test detection listed attribute TYPES, and each list
  falsely reddened whatever it omitted: TestAttribute alone missed [TestCase],
  and the three-type replacement missed [Theory]. Now decided by NUnit's own
  ITestBuilder/ISimpleTestBuilder interfaces, which cannot fall behind the
  vocabulary. It also dropped BindingFlags.Static (GetMethods() defaults to
  including it), which would have falsely reddened a static test method.
- The same guard accepted an ABSTRACT fixture -- NUnit never instantiates one.
  The indexer population already filtered IsAbstract; the fixture side now
  mirrors it.
- The record's `mechanics:` still described the old `[Test]`-only clause, in
  the same file the change edited.
- An <inheritdoc> made the ProgramScheduleAlternate empty-case test inherit a
  docstring written from the PlayoutTemplate test's viewpoint.

Two more mutations executed:
- `if (item.MonthsOfYear is null) return item;` -> 1 red, 10 green. This is the
  mutant that survived the previous head; it no longer does.
- an abstract type named in the covered set -> coverage guard red.

Local gate: ErsatzTV.Tests 2091 passed / 6 skipped (the three fixtures' MySQL
halves, skipping visibly without ETV_TEST_MYSQL_CONNECTION), Core.Tests 697/1
-- 0 failures. Format clean, no BOM on the touched set. decisions_validate OK.

Refs #823
Refs #824

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019zUmJZHhVP7kXg5DV237TW
timothy added 1 commit 2026-08-29 21:00:25 +02:00
fix(823): stop patching the record by grep — a second ?? [] survived, and my "swept it" claim was false
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 18s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 9s
PR Gates / decisions lifecycle (pull_request) Successful in 14s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
review-verdict/h10 Awaiting review verdict for 3951fcf
Review verdict / Set review-verdict status (pull_request_target) Successful in 16s
PR Gates / Docs update reminder (pull_request) Successful in 11s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 7m52s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Canceled after 0s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Canceled after 0s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Canceled after 0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Canceled after 0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Canceled after 0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Canceled after 0s
3951fcf516
Round-four review. One HIGH, again in the decision record, and the previous
commit message asserted this exact class was cleared. It was not.

THE HIGH, and the reason it recurred.

A second sentence still described the rejected reading: "The guard form is
`?? []` into a local rather than this record's Optional(x).Flatten(), a STATED
deviation". The shipped guard is `?? AllDaysOfWeek()`. That sentence is the one
that dictates guard FORM to the next implementer, so it would have taught the
`[]` reading the same record spends a paragraph calling data corruption -- and
it had already propagated into docs/decisions/README.md, the mandated entry
point, which carries `rule:` verbatim.

The mechanism, not the sentence, is the defect. I swept with a regex keyed on
"null" plus a reading word; this sentence talks about guard FORM and contains
neither, so it could not match. That is grepping the retracted WORDING instead
of sweeping the CONCEPT, which is exactly what this corpus warns about -- and
three rounds in a row have now found a defect introduced by the previous
round's targeted string edit. So the fix is not another targeted edit: the
whole `rule:` field was split into its 39 sentences and read back one by one
against the code. Everything below came out of that pass rather than a grep.

Its secondary damage is worth recording because it is the shape of a rationale
that outlives its claim: the deviation was justified by ".ToList() allocates
for nothing", which is now BOTH irrelevant to the choice AND false about the
shipped code, since AllDaysOfMonth()/AllMonthsOfYear() are themselves
Enumerable.Range(...).ToList() on exactly the null path it describes.

- The opening sentence of `rule:` prescribed Optional(x).Flatten() as THE
  read-site form. It is the sentence most likely to be read in isolation, and
  it is wrong for six of the eight columns. It now separates the universal half
  (a LOCAL, never assigned back) from the half that is not (the substituted
  value), and names where each applies.
- `signals:` had never been touched, so roughly 60% of `rule:` was unreachable
  by the discovery surface built for it -- no AlternateScheduleSelector, no
  mapper, no "unrestricted", and its paths: list named none of the files this
  work touched. It also advertised "Optional Flatten hoisted local" as the
  form, which is precisely what the six do NOT use.
- The body prose was still entirely about SongMetadata while `rule:` had grown
  a whole second subject. Added the two results that contradicted the prior
  reasoning, in prose, where a reader meets them.

A REAL BUG in my own guard, not just prose:

  fixture.IsAbstract.ShouldBeFalse(...)

A C# `static class` compiles to `abstract sealed`, and NUnit runs tests
declared in one -- this repo already has such a fixture
(AlternateScheduleSelectorTests is `public static class`). So the check I added
one commit ago to reject an un-runnable fixture would have falsely reddened a
perfectly good static one. Now rejects an abstract BASE (abstract and NOT
sealed), which is the case NUnit actually cannot instantiate.

A SURVIVING MUTANT the added controls did not kill:

AnyDate was 2024-03-06. With a day <= 12 a CROSS-WIRED substitution survives
the whole fixture -- `DaysOfMonth ?? AllMonthsOfYear()` hands back 1..12, which
still contains day 6, so every assertion passes while the guard substitutes the
wrong set. Moved to 2024-03-20, still a Wednesday in March, outside 1..12.
Measured both ways rather than reasoned: the cross-wire mutant passes the old
fixture and FAILS 2 of 11 on the new one.

Also re-witnessed, because I had modified that file and never re-proved it:
restoring `??=` in LuceneSearchIndex reddens the LUCENE fixture (1 red, 1
green) -- the exact mirror of the Elastic mutation. Extracting
ThrowOnWarningLogger did not cost #701 its proof, and the two fixtures are
independently load-bearing in both directions.

The record is now 73 prose lines, over the 60-line WARNING ceiling. Stated
rather than trimmed: it is 42nd of 42 records over that line, and the added
content is distinct findings (a second subject, a migration analysis and three
residuals), not redundancy against a sibling.

Local gate: ErsatzTV.Tests 2091 passed / 6 skipped (the three fixtures' MySQL
halves), Core.Tests 697/1 -- 0 failures. Format clean, no BOM. decisions
validate OK.

Refs #823
Refs #824

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019zUmJZHhVP7kXg5DV237TW
timothy added 1 commit 2026-08-29 21:14:30 +02:00
fix(823): three cleanups from the coherence pass — a wrong witness, a wrong because, a duplicated paths:
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 14s
review-verdict/h10 Review-verdict: MERGEABLE @ 9685132 (base: main)
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 12s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 13s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 43s
Review verdict / Set review-verdict status (pull_request_target) Successful in 9s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 11m28s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 14m32s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m10s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 9m23s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 10s
9685132ee0
Round-five review confirmed the decision record is coherent with no third
survivor of the empty reading, and returned three LOW findings. All are in
prose I wrote in the last two commits.

- The comment defending `(IsAbstract && !IsSealed)` cited
  AlternateScheduleSelectorTests as an in-repo static-fixture witness. That
  class IS static, but it merely NESTS its [TestFixture]es and declares no test
  of its own, so it would fail the sibling "declares no runnable test" assertion
  rather than demonstrating the point. The rule is right and the witness was
  wrong, which is the worse of the two failures because a wrong example is what
  a reader checks the rule against. No witness is cited now, and why is stated.

- A mis-bound `because` in `rule:`: "assigning a null and calling SaveChanges
  SUCCEEDS ... because only the HTTP request records normalize with `?? []`".
  The `?? []` clause explains how a null could REACH the entity; what makes the
  save succeed is the column being nullable. A right observation with a wrong
  cause attached. Split into the two claims.

- `signals:` carried the literal token `paths:` twice, an artifact of appending
  the #823 path list to the existing one. It degrades the field the discovery
  surface parses.

Also recorded from that review, and NOT changed: `MonthsOfYear ?? AllDaysOfMonth()`
survives the selector fixture and no date can kill it -- 1..31 contains every
valid month, so it is an EQUIVALENT mutant there rather than a coverage gap.
Its non-equivalent twin at the DTO boundary is pinned per-dimension by
RecurrenceLimitsMapperNullTests. Left alone deliberately: chasing an equivalent
mutant with a contrived date would buy nothing and cost the fixture's
readability.

Local gate: ErsatzTV.Tests 2091 passed / 6 skipped, Core.Tests 697/1 -- 0
failures. Format clean, no BOM. decisions_validate OK.

Refs #823
Refs #824

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019zUmJZHhVP7kXg5DV237TW
Author
Owner

Review-verdict: MERGEABLE @ 9685132

Five independent cold review rounds, one cross-family (GPT-5.6) and four worktree-isolated Opus, across four heads. Final round confirmed the decision record coherent with no surviving assertion of the rejected reading, and returned 0 Blocker and 0 High. Two HIGHs were found and fixed during the rounds and both were in the durable artifact rather than the code: the record asserted the shipped reading AND its inverse, twice, because I was patching a large YAML scalar by targeted string replacement and sweeping with a regex keyed on the retracted wording. The mechanism was replaced - the field is now split into its sentences and read back against the code - and the second HIGH is the reason this PR has five rounds rather than three. Ten clause mutations executed and witnessed red across the branch, each scoped so its siblings stayed green, including two that a reviewer proved had SURVIVED an earlier head. Review skipped on this final head alone, with its reason: 3 lines of comment and two prose corrections, each specified verbatim by the previous round, no logic change. One finding deliberately not acted on and recorded instead - MonthsOfYear taking AllDaysOfMonth is an EQUIVALENT mutant at the selector since 1..31 contains every month, and its non-equivalent twin is pinned at the mapper.

Review-verdict: MERGEABLE @ 9685132 Five independent cold review rounds, one cross-family (GPT-5.6) and four worktree-isolated Opus, across four heads. Final round confirmed the decision record coherent with no surviving assertion of the rejected reading, and returned 0 Blocker and 0 High. Two HIGHs were found and fixed during the rounds and both were in the durable artifact rather than the code: the record asserted the shipped reading AND its inverse, twice, because I was patching a large YAML scalar by targeted string replacement and sweeping with a regex keyed on the retracted wording. The mechanism was replaced - the field is now split into its sentences and read back against the code - and the second HIGH is the reason this PR has five rounds rather than three. Ten clause mutations executed and witnessed red across the branch, each scoped so its siblings stayed green, including two that a reviewer proved had SURVIVED an earlier head. Review skipped on this final head alone, with its reason: 3 lines of comment and two prose corrections, each specified verbatim by the previous round, no logic change. One finding deliberately not acted on and recorded instead - MonthsOfYear taking AllDaysOfMonth is an EQUIVALENT mutant at the selector since 1..31 contains every month, and its non-equivalent twin is pinned at the mapper.
timothy merged commit 1afad0851d into main 2026-08-29 21:55:55 +02:00
timothy deleted branch fix/823-824-701-deferrals 2026-08-29 21:56:00 +02:00
Sign in to join this conversation.