WatermarkSelector resolved watermarks in two places with two policies. The deco path had its own copy of the image-source switch that returned whatever path it computed, unchecked. The precedence levels checked — though not uniformly: the playout-item level checked all three sources, while channel and global checked Custom/ChannelLogo and threw for Resource (no arm, so default:). So one channel could disagree with itself about whether an on-screen bug rendered, based only on how the watermark was attached.
Extracted one ResolveWatermark used by all four attachment points.
Policy chosen (owner's call): no usable logo ⇒ no on-screen bug, everywhere.
The divergence was wider than documented. It was never ChannelLogo-only — the deco path skipped the existence check for Custom and Resource too. Three arms, not one. Fixing the named arm and leaving two is exactly how this defect reached triplicate before #502.
The severity was not cosmetic. A dead local path is not harmlessly skipped: CanUseFFmpegNativeWatermark hands a single permanent watermark to ffmpeg as a bare -i and excludes only URLs. So the deco path could hand ffmpeg a nonexistent input file. URLs, by contrast, reach the graphics engine where load failures are caught into "overlay disabled".
The nameplate: it was real, and it is deliberately removed
A channel with no logo artwork used to get a generated-initials nameplate — only via the deco path. A live-E2E on a real transcoded frame confirmed it composited (/iptv/logos/gen is on ArtworkController, which carries no auth filter unlike IptvController, so the container-internal self-fetch succeeded). The #502-era comment claiming "it has never rendered here" was wrong; the new record says so explicitly.
It is still removed: serving it means an HTTP fetch inside stream startup — what graphics.channel-logo-caching (#525) eliminated for logos — and it depends on #1's hardcoded localhost. Reviving it by caching the image is #652.
Blast radius measured, not assumed: prod has 0 Deco rows, 0 DecoWatermark rows, and all 43 channels have logo artwork. No rendered output changes.
pre-fix
post-fix
Frame with deco attached
20,522 bytes
8,693 bytes
Frame with deco detached (control)
8,693 bytes
8,693 bytes
Changed pixels, whole frame
41,278 (1.99%)
0 (0.00%)
Three crash-shaped fixes found along the way
Channel/global threw NotSupportedException on a Resource watermark; they now resolve it.
The Resource arm gained a null guard it never had. CreateWatermarkHandler/UpdateWatermarkHandler write Image = null for every non-Custom watermark, so an API-created Resource watermark reached Path.Combine(folder, null) → ArgumentNullException at any level.
Channel/global Custom had no blank-image guard, so a cleared image hit ImageCache's fileName[..2] and threw out of stream startup — killing playback, not just the overlay.
Deliberately preserved / honestly declared
A playout-item Custom with a blank image still falls through to channel/global. Unifying resolution must not change which watermark wins. This is the one surviving per-caller policy and it lives in the caller.
Routing: the predicate is unchanged, its input is not.CanUseFFmpegNativeWatermark also tests Count == 1, and dropping an unresolvable watermark shortens the list — so a deco with one valid and one missing permanent watermark now routes ffmpeg-native where it previously routed to the graphics engine. Intended, but observable; pinned by a test that calls the real predicate.
Scope boundary: "one resolver" is true of the selector, not the application. The song-progress overlay is built as a WatermarkOptions directly by the streaming and troubleshooting handlers, still unchecked, and can still hit the bare--i hazard. Pre-existing, left alone, tracked as #653.
Review
Three rounds, two model families (Codex cross-family + cold Opus), every finding verified against source before acting. Rounds 1 and 2 both returned BLOCKED.
The two findings worth highlighting were confidently-wrong justifications in my own documentation, not broken code: the "exactly one resolver" over-claim and "routing is unaffected". Round 3 then caught that two tests I had added to close round-1 findings could not fail — one whose fall-through fallback was itself unresolvable, one that asserted a list length while the routing claim lived in a function it never called. Both fixed.
Verification
2,772 tests green across 5 projects, 0 failures.
Mutation sensitivity measured: 19 of the 29 new cases fail when the pre-fix resolver is restored. The other 10 pin preserved behavior, so passing both ways is correct for them.
Live-E2E re-run against the exact shipping binary (frame diff 0, warning emitted, 0 watermark init failures).
dotnet format exit 0, no BOMs, decisions-validate: OK.
Docs
New record ffmpeg.watermark-resolution-unified + regenerated catalog.
docs/channels.md → Watermarks.
Declared decline: the record is 81 prose lines against the 60-line ceiling — a non-blocking warning. Per docs.corpus-size-signal a long record that is all distinct findings is a legitimate decline, and the length is the review findings above. Flagging it rather than cutting substance the reviewers asked for.
ffmpeg/external-logo-graphics-engine.md (#502) is left unedited even though its forward-looking prose is now outdated, per docs.decision-lifecycle (records are point-in-time, superseded by newer ones, not continuously edited). The new record carries the correction. Push back if you'd rather I amend it with a Decisions-Edit: trailer.
fixes #510
## What
`WatermarkSelector` resolved watermarks in **two places with two policies**. The deco path had its own copy of the image-source switch that returned whatever path it computed, **unchecked**. The precedence levels checked — though not uniformly: the playout-item level checked all three sources, while channel and global checked `Custom`/`ChannelLogo` and **threw** for `Resource` (no arm, so `default:`). So one channel could disagree with itself about whether an on-screen bug rendered, based only on how the watermark was attached.
Extracted one `ResolveWatermark` used by all four attachment points.
**Policy chosen (owner's call): no usable logo ⇒ no on-screen bug, everywhere.**
## Two things #510's own text got wrong
1. **The divergence was wider than documented.** It was never `ChannelLogo`-only — the deco path skipped the existence check for `Custom` and `Resource` too. Three arms, not one. Fixing the named arm and leaving two is exactly how this defect reached triplicate before #502.
2. **The severity was not cosmetic.** A dead *local* path is not harmlessly skipped: `CanUseFFmpegNativeWatermark` hands a single permanent watermark to ffmpeg as a bare `-i` and excludes only URLs. So the deco path could hand ffmpeg a nonexistent input file. URLs, by contrast, reach the graphics engine where load failures are caught into "overlay disabled".
## The nameplate: it was real, and it is deliberately removed
A channel with no logo artwork used to get a generated-initials nameplate — **only** via the deco path. A live-E2E on a real transcoded frame confirmed it composited (`/iptv/logos/gen` is on `ArtworkController`, which carries no auth filter unlike `IptvController`, so the container-internal self-fetch succeeded). The #502-era comment claiming *"it has never rendered here"* was wrong; the new record says so explicitly.
It is still removed: serving it means an HTTP fetch inside stream startup — what `graphics.channel-logo-caching` (#525) eliminated for logos — and it depends on #1's hardcoded `localhost`. Reviving it by **caching** the image is **#652**.
**Blast radius measured, not assumed:** prod has 0 `Deco` rows, 0 `DecoWatermark` rows, and all 43 channels have logo artwork. No rendered output changes.
| | pre-fix | post-fix |
|---|---|---|
| Frame with deco attached | 20,522 bytes | **8,693 bytes** |
| Frame with deco detached (control) | 8,693 bytes | 8,693 bytes |
| Changed pixels, whole frame | 41,278 (1.99%) | **0 (0.00%)** |
## Three crash-shaped fixes found along the way
- Channel/global threw `NotSupportedException` on a `Resource` watermark; they now resolve it.
- The `Resource` arm gained a **null guard it never had**. `CreateWatermarkHandler`/`UpdateWatermarkHandler` write `Image = null` for every non-`Custom` watermark, so an API-created `Resource` watermark reached `Path.Combine(folder, null)` → `ArgumentNullException` at *any* level.
- Channel/global `Custom` had **no blank-image guard**, so a cleared image hit `ImageCache`'s `fileName[..2]` and threw out of stream startup — killing playback, not just the overlay.
## Deliberately preserved / honestly declared
- A playout-item `Custom` with a blank image still falls **through** to channel/global. Unifying resolution must not change which watermark *wins*. This is the one surviving per-caller policy and it lives in the caller.
- **Routing: the predicate is unchanged, its input is not.** `CanUseFFmpegNativeWatermark` also tests `Count == 1`, and dropping an unresolvable watermark shortens the list — so a deco with one valid and one missing permanent watermark now routes ffmpeg-native where it previously routed to the graphics engine. Intended, but observable; pinned by a test that calls the real predicate.
- **Scope boundary:** "one resolver" is true *of the selector*, not the application. The song-progress overlay is built as a `WatermarkOptions` directly by the streaming and troubleshooting handlers, still unchecked, and can still hit the bare-`-i` hazard. Pre-existing, left alone, tracked as **#653**.
## Review
Three rounds, two model families (Codex cross-family + cold Opus), every finding verified against source before acting. Rounds 1 and 2 both returned BLOCKED.
The two findings worth highlighting were **confidently-wrong justifications in my own documentation**, not broken code: the "exactly one resolver" over-claim and "routing is unaffected". Round 3 then caught that **two tests I had added to close round-1 findings could not fail** — one whose fall-through fallback was itself unresolvable, one that asserted a list length while the routing claim lived in a function it never called. Both fixed.
## Verification
- **2,772 tests green** across 5 projects, 0 failures.
- **Mutation sensitivity measured: 19 of the 29 new cases fail when the pre-fix resolver is restored.** The other 10 pin *preserved* behavior, so passing both ways is correct for them.
- Live-E2E re-run against the exact shipping binary (frame diff 0, warning emitted, 0 watermark init failures).
- `dotnet format` exit 0, no BOMs, `decisions-validate: OK`.
## Docs
- New record `ffmpeg.watermark-resolution-unified` + regenerated catalog.
- `docs/channels.md` → Watermarks.
- **Declared decline:** the record is 81 prose lines against the 60-line ceiling — a non-blocking warning. Per `docs.corpus-size-signal` a long record that is all distinct findings is a legitimate decline, and the length *is* the review findings above. Flagging it rather than cutting substance the reviewers asked for.
- `ffmpeg/external-logo-graphics-engine.md` (#502) is **left unedited** even though its forward-looking prose is now outdated, per `docs.decision-lifecycle` (records are point-in-time, superseded by newer ones, not continuously edited). The new record carries the correction. Push back if you'd rather I amend it with a `Decisions-Edit:` trailer.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
WatermarkSelector resolved watermarks in two places with two policies. The
three precedence levels (playout item, channel, global) existence-checked
every image source and degraded to None; the deco path had its own copy of
the same switch that returned whatever path it computed, unchecked. So one
channel could disagree with itself about whether an on-screen bug rendered,
based only on how the watermark was attached.
#502 deferred this here but scoped it to ChannelLogo. It was never
ChannelLogo-only: the deco path skipped the existence check for Custom and
Resource too. Extract one ResolveWatermark used by all four sites.
Severity is not cosmetic. A dead LOCAL path is not harmlessly skipped --
CanUseFFmpegNativeWatermark hands a single permanent watermark to ffmpeg as
a bare -i argument and excludes only URLs, so the deco path could hand
ffmpeg a nonexistent input file.
The generated-initials nameplate was real: a live-E2E on a real transcoded
frame confirmed it composited via the deco path (/iptv/logos/gen is on
ArtworkController, which has no auth filter, so the container-internal
self-fetch succeeded). The #502-era comment claiming "it has never rendered
here" was wrong, and the new record says so. It is still removed: serving it
means an HTTP fetch inside stream startup, which graphics.channel-logo-caching
(#525) eliminated for logos, and it depends on #1's hardcoded localhost.
Reviving it by caching the image instead is #652.
Measured blast radius on prod: 0 Deco rows, 0 DecoWatermark rows, all 43
channels have logo artwork -- no rendered output changes.
Preserved deliberately: a playout-item Custom watermark with a blank image
still falls THROUGH to the channel/global watermark; unifying resolution must
not change which watermark wins. Routing is untouched.
Strict improvement: the channel and global arms previously threw
NotSupportedException on a Resource watermark; they now resolve it. The
default arm still throws so a new image source fails loudly.
Tests: 18 new cases including a positive control and 8 deco-vs-channel parity
cases. 12 of the 18 fail against the pre-fix resolver, which is what proves
they are load-bearing rather than vacuous.
fixes#510
Two independent reviews (cross-family Codex + cold Opus) both returned
BLOCKED. Findings, all verified against source before acting:
1. Resource arm could throw ArgumentNullException (Codex, Medium). Making the
channel/global Resource arm reachable exposed that CreateWatermarkHandler
and UpdateWatermarkHandler write `Image = null` for EVERY non-Custom
watermark, so an API-created Resource watermark reached
Path.Combine(folder, null). Added the blank/null guard the arm never had.
This was live at the playout-item level too, not just newly-reachable code.
2. "Routing is unaffected" was false (Codex, Low but sharp). The predicate is
unchanged, but CanUseFFmpegNativeWatermark also tests Count == 1, and
dropping an unresolvable watermark shortens the list. A deco with one valid
and one missing permanent watermark now routes ffmpeg-native where it
previously routed to the graphics engine. Intended, but observable -- so it
is documented and pinned by a test rather than claimed away.
3. "Exactly one resolver" over-claimed (Opus, High). True of the selector, not
the application: the song-progress overlay is built as a WatermarkOptions
directly by the streaming and troubleshooting handlers, unchecked, and can
still hand ffmpeg a nonexistent -i. Pre-existing; scoped the claim in the
record and channels.md and filed #653.
4. Undeclared crash->degrade change (Opus, Medium). Channel/global Custom had
no blank-image guard, so a cleared image hit ImageCache's fileName[..2] and
threw out of stream startup. Now declared in the record and tested.
5. Contradictory rule text (Opus, Medium) -- the catalog one-liner said
"always no bug" while the body documents the playout-item fall-through
exception. Qualified; catalog regenerated.
6. History was wrong in both the record and the XML docs: the three precedence
levels did NOT all check every source -- channel/global had no Resource arm
and threw. Corrected.
Tests: 30 in the fixture now (was 18). New coverage for the preserved
blank-Custom fall-through (to channel AND to global), the complement case
(missing-but-named must NOT fall through), null/blank Resource, and the
valid+missing routing case. 17 of 24 failed against the pre-fix resolver
before this round; the fixture stays mutation-sensitive.
Also: hoisted the mock-filesystem Initialize() out of its loop so a
multi-file case cannot silently seed only the last file, and marked the
generated-URL test honestly as redundant-by-construction rather than
claiming independent coverage.
The decision record is now 81 prose lines, over the 60-line ceiling. Declared
as a legitimate decline per docs.corpus-size-signal: the length is the review
findings above, each a distinct fact, not redundancy.
refs #510#652#653
Re-review of the previous fix commit found that two tests added to close
round-1 findings could not fail. Both verified before fixing:
1. Missing_But_Named_Custom_Playout_Item_Watermark_Should_Not_Fall_Through
gave the channel-level fallback the SAME missing custom path as the
playout-item watermark, so a wrongly-widened guard would have fallen
through to a fallback that also resolved to None -- the assertion held
either way. The fallback is now an independently resolvable ChannelLogo
whose cached file exists, so a fall-through returns it and fails the test.
Added the matching positive control (blank -> falls through and DOES
return that logo), so the pair shows the guard distinguishes blank from
unresolvable instead of both landing on None.
2. Deco_With_One_Valid_And_One_Missing_Watermark... asserted a filtered list
length while the routing claim the decision record cited it for lives in
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark, which the test
never called. It now calls the real predicate.
Also, three wrong claims of my own:
3. The Resource arm comment said "nothing in the app writes a Resource
watermark to the database". False -- CreateWatermarkHandler and
UpdateWatermarkHandler persist whatever ImageSource the request names, so
a Resource watermark IS creatable through the API, always with
Image = null. That is precisely why the new null guard is load-bearing,
so the comment was arguing for its own removal.
4. "One resolver and no per-caller policy" contradicted the surviving
playout-item blank-Custom fall-through documented a few lines later.
Reworded in both the record and the XML docs: one resolver, and exactly
one piece of per-caller policy which lives in the CALLER.
5. The record's "12 of 18 new tests fail pre-fix" was stale. Re-measured
against the final fixture: 19 of 29. The other 10 pass both ways by
design because they pin preserved behavior, which the record now says
explicitly rather than leaving the gap to be read as weakness.
Removed the vacuous generated-URL test rather than keeping it with an honest
comment -- an empty list trivially contains no URL, so it implied coverage it
never had. Its assertion is folded into the sibling test that has a real
arrangement.
Gates: 2772 tests green across 5 projects, dotnet format exit 0, no BOMs,
decisions-validate OK, live-E2E re-run against this binary (0 changed pixels,
nameplate absent, warning emitted).
refs #510
CI's script-tests job (test_frontmatter_reader_matches_pyyaml_on_every_real_record)
caught that the dependency-free hand parser and PyYAML disagreed on this
record's `rule:` field.
Cause: `rule:` was an unquoted plain YAML scalar containing " #653)", and in a
plain scalar " #" starts a comment. PyYAML therefore truncated the value at
"...(the song-progress overlay," and silently dropped the rest of the clause --
including the scope carve-out naming #653, which is the whole point of that
sentence. The generated catalog row was built from the truncated value, so the
active-decisions view was quietly missing it.
Same trap as fix(631) (a record TITLE whose unquoted " #" was a comment). The
`signals:` and `mechanics:` fields were already single-quoted, which is why only
`rule:` diverged; `title:` is safe because its "(#510)" has no space before the
"#". Single-quoted `rule:` and regenerated the catalog, which now carries the
full sentence.
Worth recording: I ran scripts/decisions_validate.py locally but NOT
scripts/tests, so the gate that catches exactly this class was the one local
check I skipped. Touching the decisions corpus means running the pytest suite,
not just the validator -- the validator reads through the hand parser and so
cannot see a hand-parser-vs-PyYAML divergence by construction.
Local: 225/225 script tests pass, decisions-validate OK.
refs #510
That commit fixes a real problem but mis-states its impact, and since a wrong explanation outlives the fix it is worth correcting here rather than leaving it in the history unqualified.
The commit message says:
The generated catalog row was built from the truncated value, so the active-decisions view was quietly missing it.
That is wrong.scripts/build_decisions_catalog.py imports scripts.decisions_lib and reads frontmatter through the repo's dependency-free hand parser, not PyYAML — deliberately, per read-path-runs-where-deps-dont-exist. The hand parser does not treat # in a plain scalar as a comment, so it read the full value all along. Verified after the fact:
$ git show edf8be4b:docs/decisions/README.md | grep -c "song-progress overlay, #653) are not covered"
1
The catalog row was complete both before and after. Regenerating it produced no diff, which is why that commit touches exactly one file.
What the defect actually was: the unquoted rule: scalar meant the two parsers disagreed — PyYAML truncated at #653), the hand parser did not. Nothing in the repo reads that field with PyYAML today except the test that caught it, so the impact was latent, not active: a trap for any future consumer using a real YAML parser, and a genuine violation of the invariant test_frontmatter_reader_matches_pyyaml_on_every_real_record exists to protect. Quoting it is still the right fix; the blast radius was zero rather than "the catalog was wrong".
The rest of that commit message stands, including the part I do want on the record: I ran decisions_validate.py locally but not scripts/tests, and the validator structurally cannot catch this class — it reads through the hand parser, so a hand-parser-vs-PyYAML divergence is invisible to it by construction. Touching the decisions corpus means running the pytest suite too.
## Correction to `d189d171`'s commit message
That commit fixes a real problem but **mis-states its impact**, and since a wrong explanation outlives the fix it is worth correcting here rather than leaving it in the history unqualified.
The commit message says:
> The generated catalog row was built from the truncated value, so the active-decisions view was quietly missing it.
**That is wrong.** `scripts/build_decisions_catalog.py` imports `scripts.decisions_lib` and reads frontmatter through the repo's **dependency-free hand parser**, not PyYAML — deliberately, per `read-path-runs-where-deps-dont-exist`. The hand parser does *not* treat ` #` in a plain scalar as a comment, so it read the full value all along. Verified after the fact:
```
$ git show edf8be4b:docs/decisions/README.md | grep -c "song-progress overlay, #653) are not covered"
1
```
The catalog row was complete both before and after. Regenerating it produced no diff, which is why that commit touches exactly one file.
**What the defect actually was:** the unquoted `rule:` scalar meant the two parsers disagreed — PyYAML truncated at ` #653)`, the hand parser did not. Nothing in the repo reads that field with PyYAML today except the test that caught it, so the impact was **latent, not active**: a trap for any future consumer using a real YAML parser, and a genuine violation of the invariant `test_frontmatter_reader_matches_pyyaml_on_every_real_record` exists to protect. Quoting it is still the right fix; the blast radius was zero rather than "the catalog was wrong".
The rest of that commit message stands, including the part I do want on the record: I ran `decisions_validate.py` locally but not `scripts/tests`, and **the validator structurally cannot catch this class** — it reads through the hand parser, so a hand-parser-vs-PyYAML divergence is invisible to it by construction. Touching the decisions corpus means running the pytest suite too.
Round-4 review found the fall-through tests, while now falsifiable, still did
not pin the whole guard. Two gaps, both closed and both verified by running the
mutation rather than by asserting the test would catch it:
1. All three fall-through tests used only " ", so narrowing
IsNullOrWhiteSpace to `image == " "` would have passed while breaking
fall-through for null and "" -- and null is the form the API actually
persists. Parameterized over null, "" and " ".
2. Nothing pinned the guard's `ImageSource is Custom` clause. This is the
sharper of the two: a ChannelLogo watermark's Image is NORMALLY blank
(the API writes Image = null for every non-Custom source), so dropping the
discriminator would send every playout-item ChannelLogo watermark down the
fall-through path instead of resolving the channel's own logo -- with a
fully green suite. Added
Blank_Image_ChannelLogo_Playout_Item_Watermark_Should_Win_And_Not_Fall_Through,
which distinguishes the two levels by watermark Id so a fall-through is
observable even though both resolve to the same cached path.
Mutation results (each mutation applied on its own, then reverted):
drop `is Custom` from the guard -> 1 failure, and the new test is the ONLY
test that catches it
IsNullOrWhiteSpace -> == " " -> the null and "" parameterized cases fail
Negative control re-measured on the final 32-case fixture: 19 fail against the
origin/main resolver. The 13 that pass both ways pin deliberately preserved
behavior plus the positive control, which the record now states explicitly
along with the mutation table.
This round's lesson, recorded in the record: a test's NAME is not evidence it
pins what it claims, and a whole-file revert cannot show that a test aimed at a
specific clause actually reaches that clause -- only mutating the clause can.
Gates: 2661 tests green across 4 projects, 225/225 script tests (the gate I
skipped before the last push), decisions-validate OK, format exit 0, no BOMs.
refs #510
Five review rounds across two model families (Codex cross-family, cold Opus). Rounds 1, 2, 3 and 4 returned BLOCKED; every finding verified against source before acting. Round 5 clean with no findings at any severity. Two guard mutations applied individually and measured, not merely asserted. 2661 tests green, 225/225 script tests, live-E2E re-run on the shipping binary.
Review-verdict: MERGEABLE @ bc1a37f
Five review rounds across two model families (Codex cross-family, cold Opus). Rounds 1, 2, 3 and 4 returned BLOCKED; every finding verified against source before acting. Round 5 clean with no findings at any severity. Two guard mutations applied individually and measured, not merely asserted. 2661 tests green, 225/225 script tests, live-E2E re-run on the shipping binary.
timothy
merged commit e4c0db7702 into main2026-07-26 22:16:36 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
fixes #510
What
WatermarkSelectorresolved watermarks in two places with two policies. The deco path had its own copy of the image-source switch that returned whatever path it computed, unchecked. The precedence levels checked — though not uniformly: the playout-item level checked all three sources, while channel and global checkedCustom/ChannelLogoand threw forResource(no arm, sodefault:). So one channel could disagree with itself about whether an on-screen bug rendered, based only on how the watermark was attached.Extracted one
ResolveWatermarkused by all four attachment points.Policy chosen (owner's call): no usable logo ⇒ no on-screen bug, everywhere.
Two things #510's own text got wrong
ChannelLogo-only — the deco path skipped the existence check forCustomandResourcetoo. Three arms, not one. Fixing the named arm and leaving two is exactly how this defect reached triplicate before #502.CanUseFFmpegNativeWatermarkhands a single permanent watermark to ffmpeg as a bare-iand excludes only URLs. So the deco path could hand ffmpeg a nonexistent input file. URLs, by contrast, reach the graphics engine where load failures are caught into "overlay disabled".The nameplate: it was real, and it is deliberately removed
A channel with no logo artwork used to get a generated-initials nameplate — only via the deco path. A live-E2E on a real transcoded frame confirmed it composited (
/iptv/logos/genis onArtworkController, which carries no auth filter unlikeIptvController, so the container-internal self-fetch succeeded). The #502-era comment claiming "it has never rendered here" was wrong; the new record says so explicitly.It is still removed: serving it means an HTTP fetch inside stream startup — what
graphics.channel-logo-caching(#525) eliminated for logos — and it depends on #1's hardcodedlocalhost. Reviving it by caching the image is #652.Blast radius measured, not assumed: prod has 0
Decorows, 0DecoWatermarkrows, and all 43 channels have logo artwork. No rendered output changes.Three crash-shaped fixes found along the way
NotSupportedExceptionon aResourcewatermark; they now resolve it.Resourcearm gained a null guard it never had.CreateWatermarkHandler/UpdateWatermarkHandlerwriteImage = nullfor every non-Customwatermark, so an API-createdResourcewatermark reachedPath.Combine(folder, null)→ArgumentNullExceptionat any level.Customhad no blank-image guard, so a cleared image hitImageCache'sfileName[..2]and threw out of stream startup — killing playback, not just the overlay.Deliberately preserved / honestly declared
Customwith a blank image still falls through to channel/global. Unifying resolution must not change which watermark wins. This is the one surviving per-caller policy and it lives in the caller.CanUseFFmpegNativeWatermarkalso testsCount == 1, and dropping an unresolvable watermark shortens the list — so a deco with one valid and one missing permanent watermark now routes ffmpeg-native where it previously routed to the graphics engine. Intended, but observable; pinned by a test that calls the real predicate.WatermarkOptionsdirectly by the streaming and troubleshooting handlers, still unchecked, and can still hit the bare--ihazard. Pre-existing, left alone, tracked as #653.Review
Three rounds, two model families (Codex cross-family + cold Opus), every finding verified against source before acting. Rounds 1 and 2 both returned BLOCKED.
The two findings worth highlighting were confidently-wrong justifications in my own documentation, not broken code: the "exactly one resolver" over-claim and "routing is unaffected". Round 3 then caught that two tests I had added to close round-1 findings could not fail — one whose fall-through fallback was itself unresolvable, one that asserted a list length while the routing claim lived in a function it never called. Both fixed.
Verification
dotnet formatexit 0, no BOMs,decisions-validate: OK.Docs
ffmpeg.watermark-resolution-unified+ regenerated catalog.docs/channels.md→ Watermarks.docs.corpus-size-signala long record that is all distinct findings is a legitimate decline, and the length is the review findings above. Flagging it rather than cutting substance the reviewers asked for.ffmpeg/external-logo-graphics-engine.md(#502) is left unedited even though its forward-looking prose is now outdated, perdocs.decision-lifecycle(records are point-in-time, superseded by newer ones, not continuously edited). The new record carries the correction. Push back if you'd rather I amend it with aDecisions-Edit:trailer.🤖 Generated with Claude Code
Correction to
d189d171's commit messageThat commit fixes a real problem but mis-states its impact, and since a wrong explanation outlives the fix it is worth correcting here rather than leaving it in the history unqualified.
The commit message says:
That is wrong.
scripts/build_decisions_catalog.pyimportsscripts.decisions_liband reads frontmatter through the repo's dependency-free hand parser, not PyYAML — deliberately, perread-path-runs-where-deps-dont-exist. The hand parser does not treat#in a plain scalar as a comment, so it read the full value all along. Verified after the fact:The catalog row was complete both before and after. Regenerating it produced no diff, which is why that commit touches exactly one file.
What the defect actually was: the unquoted
rule:scalar meant the two parsers disagreed — PyYAML truncated at#653), the hand parser did not. Nothing in the repo reads that field with PyYAML today except the test that caught it, so the impact was latent, not active: a trap for any future consumer using a real YAML parser, and a genuine violation of the invarianttest_frontmatter_reader_matches_pyyaml_on_every_real_recordexists to protect. Quoting it is still the right fix; the blast radius was zero rather than "the catalog was wrong".The rest of that commit message stands, including the part I do want on the record: I ran
decisions_validate.pylocally but notscripts/tests, and the validator structurally cannot catch this class — it reads through the hand parser, so a hand-parser-vs-PyYAML divergence is invisible to it by construction. Touching the decisions corpus means running the pytest suite too.Round-4 review found the fall-through tests, while now falsifiable, still did not pin the whole guard. Two gaps, both closed and both verified by running the mutation rather than by asserting the test would catch it: 1. All three fall-through tests used only " ", so narrowing IsNullOrWhiteSpace to `image == " "` would have passed while breaking fall-through for null and "" -- and null is the form the API actually persists. Parameterized over null, "" and " ". 2. Nothing pinned the guard's `ImageSource is Custom` clause. This is the sharper of the two: a ChannelLogo watermark's Image is NORMALLY blank (the API writes Image = null for every non-Custom source), so dropping the discriminator would send every playout-item ChannelLogo watermark down the fall-through path instead of resolving the channel's own logo -- with a fully green suite. Added Blank_Image_ChannelLogo_Playout_Item_Watermark_Should_Win_And_Not_Fall_Through, which distinguishes the two levels by watermark Id so a fall-through is observable even though both resolve to the same cached path. Mutation results (each mutation applied on its own, then reverted): drop `is Custom` from the guard -> 1 failure, and the new test is the ONLY test that catches it IsNullOrWhiteSpace -> == " " -> the null and "" parameterized cases fail Negative control re-measured on the final 32-case fixture: 19 fail against the origin/main resolver. The 13 that pass both ways pin deliberately preserved behavior plus the positive control, which the record now states explicitly along with the mutation table. This round's lesson, recorded in the record: a test's NAME is not evidence it pins what it claims, and a whole-file revert cannot show that a test aimed at a specific clause actually reaches that clause -- only mutating the clause can. Gates: 2661 tests green across 4 projects, 225/225 script tests (the gate I skipped before the last push), decisions-validate OK, format exit 0, no BOMs. refs #510Review-verdict: MERGEABLE @
bc1a37fFive review rounds across two model families (Codex cross-family, cold Opus). Rounds 1, 2, 3 and 4 returned BLOCKED; every finding verified against source before acting. Round 5 clean with no findings at any severity. Two guard mutations applied individually and measured, not merely asserted. 2661 tests green, 225/225 script tests, live-E2E re-run on the shipping binary.