Both search indexers opened UpdateSong with
metadata.AlbumArtists ??= [];
metadata.Artists ??= [];
Artists/AlbumArtists hold the whole list in ONE COLUMN rather than
being navigations. So unlike the same `??= []` idiom on
Genres/Tags/Artwork all around them, the property IS the column value:
assigning it on a TRACKED entity flips the entry to Modified and the
next SaveChanges writes [] over a NULL column. This is the mechanism an
adversarial review demonstrated in #691, which is why that issue's
entity-level guard was reverted in favour of guarding at the read site.
Measured rather than reasoned about, per the issue's first done-when
box. Restoring ONLY the `??= []` clause (the real predecessor lines,
not a hand-written mutant) reddens the new fixture on
`metadata.Artists should be null but was []`; a probe variant with the
first two assertions replaced by prints reports STATE=Modified and the
raw column moving from NULL to "[]". Today's two feeds are both
AsNoTracking (SearchRepository.GetItemToIndex and GetAllSongs), so no
shipped caller loses data -- but that is a property of two callers, not
of the indexer, and #691 already recorded it as a loaded gun. The
fixture pins the indexer's own contract instead.
Removing the assignment is not sufficient alone: it was load-bearing
for the four reads below it, and deleting it by itself converts a
silent write into a live throw on every untagged song. Measured by
deleting only those two lines from the real predecessor file:
NullReferenceException, thrown at the foreach (cited by symbol: a line
number in a mutant that exists in no committed tree is unreproducible
by construction). The
exception type follows the read FORM, not the field -- foreach yields
NRE, string.Join/ToList yield ArgumentNullException -- and this PR
contains two of each, which is why no single exception-name grep
characterises the class. So each site moves together with its reads:
- LuceneSearchIndex.UpdateSong / ElasticSearchIndex.UpdateSong: hoist
Optional(...).Flatten().ToList() locals and read those.
- RefreshChannelDataHandler: the Scriban context took the raw nullable
lists (the issue's second item). The shipped _song.sbntxt only does
array.join, but a custom template is free to do anything.
The population was derived from the MODEL rather than from the issue's
file list, and the obvious derivation is wrong: "the IList<string>
properties under ErsatzTV.Core/Domain" returns two of eight. It misses
the six value-converted collections (ProgramScheduleAlternate and
PlayoutTemplate each carrying DaysOfMonth, MonthsOfYear, DaysOfWeek),
declared as plain ICollection<T> and made single columns only in
Data/Configurations -- and their storage differs (comma-separated text
for the int converter, JSON for the enum one), so the shared property
is "one scalar column", not the serialization. No site applies `??=`
to any of the six, so this defect has no instance there; whether a null
can REACH one at runtime is unverified and is filed as #823 rather than
asserted either way. Only the SongMetadata pair is left NULL in
practice, by FallbackMetadataProvider. Every site touching either field
was then swept; the remaining readers were already guarded by #691.
The fixture carries two anti-vacuity guards, both witnessed:
- A POSITIVE CONTROL (`writer.NumDocs.ShouldBe(1)`). Every other
assertion says something did NOT happen, so all of them hold
vacuously if UpdateSong never runs -- and it silently stops running
if a future refactor gates UpdateItems on `_initialized`, which this
fixture bypasses by injecting the writer. Verified BOTH directions:
with that gate added the control fails `NumDocs should be 1 but was
0`, and with the control removed the whole test PASSES while the code
under test is unreachable.
- A capturing logger, because UpdateSong wraps its body in a catch that
assigns metadata.Song = null -- severing a required relationship and
cascading the metadata to Deleted. Without it the probe silently
measures the error path; on the first run it did exactly that (a bare
ILanguageCodeService substitute NPEs inside AddLanguages). The raw
column helper also fails loudly on a missing row, since ExecuteScalar
returns CLR null for both "NULL column" and "no such row".
ElasticSearchIndex has no equivalent fixture -- it needs a stubbed
transport -- so its change is by inspection against the Lucene one, and
the gap is filed as #824 rather than covered by a source-text guard.
The whitespace-only churn in ElasticSearchIndex.cs is the #311
fix-as-you-touch format gate: it scopes to whole changed FILES.
`git diff -w` over that file shows only the two hunks above.
Local gate: ErsatzTV.Tests 2006 passed / 4 pre-existing skips,
Core.Tests 685/1 skip, Infrastructure.Tests 114, Architecture.Tests 7,
Scanner.Tests 1504 -- 0 failures in each. scripts/tests 874 passed / 2
skipped. dotnet format whitespace --verify-no-changes clean on the four
touched files, no BOM on any. decisions_validate OK.
Fixes #701
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
9.4 KiB
key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
| key | title | status | since | supersedes | superseded-by | rule | signals | mechanics |
|---|---|---|---|---|---|---|---|---|
| media.nullable-primitive-collection-mutation | 2026-08-22 — A nullable primitive collection is guarded at the READ SITE and never assigned back onto a possibly-tracked entity (#701) | active | 2026-08-22 | none | none | Code that reads a nullable EF PRIMITIVE COLLECTION guards it at the read site — `Optional(x).Flatten()` hoisted into a local — and NEVER writes the guard back onto the entity with `??= []`. Such a collection is stored WHOLE, in ONE COLUMN, so unlike the navigation collections that the same `??= []` idiom guards harmlessly all over this repo, the property IS the column value: assigning it on a TRACKED entity flips the entry to `Modified` and the next `SaveChanges` persists `[]` over what the database held as `NULL`. The population is derived from the MODEL, not from a grep of the domain classes, and is currently EIGHT columns: `SongMetadata.Artists`/`AlbumArtists` (EF-native primitive collections, no explicit configuration, stored as a JSON array) plus the six value-converted collections registered in `ErsatzTV.Infrastructure/Data/Configurations` — `ProgramScheduleAlternate` and `PlayoutTemplate` each carrying `DaysOfMonth`, `MonthsOfYear` (`IntCollectionValueConverter`, COMMA-SEPARATED text, not JSON) and `DaysOfWeek` (`EnumCollectionJsonValueConverter`, JSON). The shared property that matters is not the serialization format but that the collection is ONE SCALAR COLUMN, so do not reason from EF-native primitive-collection behaviour when standing in front of a converter. Only the `SongMetadata` pair is left NULL in practice, because `FallbackMetadataProvider` never assigns it. No site applies `??=` to any of the six (`grep -rn 'DaysOfMonth ??=|MonthsOfYear ??=|DaysOfWeek ??=' --include='*.cs' .` returns 0 at time of writing), so THIS defect has no instance there; whether a null can reach one of them at runtime is a SEPARATE question this record does not answer and does not assert — the API request records normalize with `?? []`, but `ReplacePlayoutAlternateScheduleItemsHandler` and `ReplacePlayoutTemplateItemsHandler` assign the command value straight onto the entity, so a non-API caller is UNVERIFIED (#823). A grep for `IList<string>` finds only two of the eight and an enum collection none of them — so a sweep follows the FIELD via the model configuration, not the file: the mutation and every read it was protecting must move together, or removing the assignment trades a silent write for a live throw. The read FORM decides which throw, and BOTH occur in this one PR: `foreach` over a null collection throws `NullReferenceException` (the two Lucene reads — measured), while `string.Join`/`Enumerable.ToList` on a null SOURCE throw `ArgumentNullException` (the two Elastic reads, and the `#671` mapper sites). Neither exception type alone characterises the class, so neither grep finds it — which is the actual reason a sweep must follow the FIELD rather than an exception name. Whether today's callers happen to be `AsNoTracking` is NOT the safety argument and must not be written down as one: it is a property of the current callers, not of the code holding the entity, and it is what #691 recorded as a loaded gun. | Artists ??= [] on a tracked entity · nullable primitive collection not a navigation · JSON array in one column · value converter is the same hazard as a primitive collection · IntCollectionValueConverter EnumCollectionJsonValueConverter · derive the collection-column population from the model configuration · SaveChanges writes empty array over NULL · entity flips to Modified on a read guard · AsNoTracking today is not a safety argument · untagged song loses its NULL artists · foreach over null throws NRE while string.Join throws ArgumentNullException · sweep by FIELD not by file · Optional Flatten hoisted local · paths: `ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs`, `ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs`, `ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs`, `ErsatzTV.Tests/Integration/SongIndexerMetadataMutationTests.cs` · issues: #701, #691, #671, #823, #824 | Pinned by `SongIndexerMetadataMutationTests.UpdateSong_Must_Not_Mutate_Nullable_Artists_On_A_Tracked_Entity`, which drives the real `LuceneSearchIndex` against a real `TvContext` on SQLite with a deliberately TRACKED song. That fixture covers LUCENE ONLY — `ElasticSearchIndex` holds an independent copy of the same code and needs a stubbed transport, so an Elastic-only reintroduction stays green (#824). No repo-wide detector: the `??=` idiom is correct on navigations and appears 92 times across the app projects (`grep -rn '??= ' --include='*.cs' ErsatzTV ErsatzTV.Core ErsatzTV.Application ErsatzTV.Infrastructure ErsatzTV.Scanner | grep -v '/obj/|/bin/' | grep -c ''`, 2026-08-22), so a grep for it would be noise — the eight-column population is small enough to sweep by field instead. |
Deriving this population from the domain classes gives the wrong answer. "The IList<string>
properties under ErsatzTV.Core/Domain" is the derivation that looks obviously right, and it is
REJECTED: it returns two of the eight. It is blind to the six value-converted collections, which are
declared as ordinary ICollection<int> / ICollection<DayOfWeek> and become single columns only in
ErsatzTV.Infrastructure/Data/Configurations, and blind to enum collections entirely. The
authoritative source is the model configuration — HasConversion<*CollectionValueConverter, …> plus
EF's native primitive-collection mapping. This is testing.guard-derives-population-from-source
applied to a sweep rather than to a guard.
The idiom is right almost everywhere it appears, which is what makes this hard to see. ??= []
on metadata.Genres, metadata.Tags, metadata.Artwork and their kin is harmless: those are
navigation collections, and setting a null navigation to an empty list is not a scalar property
change, so EF has nothing to persist. The reader who wrote metadata.Artists ??= [] two lines below
metadata.Genres ??= [] was following the surrounding code correctly. The difference is invisible at
the call site and lives in the model: Artists is a primitive collection — one column holding the
whole list.
Why the "it is AsNoTracking today" argument is banned rather than merely weak. Both feeds into
the search indexer — SearchRepository.GetItemToIndex and SearchRepository.GetAllSongs — are
AsNoTracking, so no shipped caller loses data, and that was true when #691 looked at it too. It is
a fact about two callers. Nothing in the indexer requires it, nothing tests for it, and a future
caller that drops AsNoTracking to reuse an existing context reintroduces silent data loss with no
diff anywhere near the indexer. Writing the observation down as a justification is what converts a
latent bug into a checked decision that talks the next reader out of verifying.
The measurement, so it is not re-argued. Restoring only the ??= [] clause — the real predecessor
lines, not a hand-written mutant — and re-running the fixture reports
metadata.Artists should be null but was [], and stops there: the first assertion short-circuits.
The persistence half needs a probe VARIANT with assertions 1 and 2 replaced by prints, which reports
STATE=Modified and the raw column moving from NULL to "[]". Both halves were executed. The
recipe is spelled out to that level of detail because the short version is not runnable as stated:
following it produces only the first failure, which reads as the record overstating itself.
The fixture's two anti-vacuity guards. A POSITIVE CONTROL (writer.NumDocs.ShouldBe(1)) is
required because every other assertion says something did NOT happen, so all of them hold vacuously
if UpdateSong never runs. Measured: gate UpdateItems on _initialized — which this fixture
bypasses by injecting the writer, so it is a plausible refactor — and with the control removed the
test PASSES with the code under test unreachable. Separately, the fixture fails loudly if
UpdateSong throws, because that method wraps its whole body in a catch that assigns
metadata.Song = null — which severs a required relationship and cascades the metadata to
Deleted. Without that check a probe silently measures the error path and reports the wrong cause,
and the raw-column helper likewise fails on a MISSING row, since ExecuteScalar returns CLR null
both for a NULL column and for no such row.
Removing the assignment is not sufficient on its own. The ??= [] was load-bearing for the four
reads below it (foreach (string artist in metadata.Artists), metadata.Artists.ToList()). Deleting
it alone converts a silent write into a live throw on every untagged song — measured, by deleting
only those two lines from the real predecessor file: NullReferenceException, thrown at the
foreach. (Cited by SYMBOL deliberately: a line number in a mutant that exists in no committed tree
is unreproducible by construction.) The exception type follows the read FORM, not the field:
foreach yields NRE, string.Join/ToList yield ArgumentNullException, and this PR contains two
of each. That is the same trap #691 hit from the other direction, and it is why the rule pairs the
removal with the read-site guard rather than stating them separately.
Related: api.selection-projection-include-chain (#671) records the read-site guard itself and the
sweep-by-FIELD instruction; this record covers the write half it does not address.