PR #216 (shared Add-to layer, search + media mutation surface, 2 new endpoints) — closed #208/#209
Focus areas:
Per-capability parity claims in docs/blazor-route-parity.md (the verdict table now says PARITY-OK for search + media rows) — spot-check against the Blazor pages before they are deleted.
Review-process quality: in #216, the in-PR adversarial review recommended making SearchScreen mirror MediaBrowseScreen's selection-clearing — which propagated a mount-debounce race (selection wiped within 300ms of mount) to a second screen instead of catching it; CI caught it post-push. Assess whether the review checklists should require asking "when does this effect/timer fire?" for any make-X-consistent-with-Y finding.
Deferred items hygiene: ersatztv #215 (EntityLocker gating), #217 (add-items kind validation), #218 (fresh-DB badge), #213 remainder — are these correctly scoped or hiding systemic issues?
Review the two merged PR batches that closed the last four search/media/playout parity gates for ersatztv #91 phase (b):
- PR #214 (playouts delete/reset/erase, collections custom-order + 10-kind adds) — closed #210/#211
- PR #216 (shared Add-to layer, search + media mutation surface, 2 new endpoints) — closed #208/#209
Focus areas:
1. Per-capability parity claims in docs/blazor-route-parity.md (the verdict table now says PARITY-OK for search + media rows) — spot-check against the Blazor pages before they are deleted.
2. Review-process quality: in #216, the in-PR adversarial review recommended making SearchScreen mirror MediaBrowseScreen's selection-clearing — which propagated a mount-debounce race (selection wiped within 300ms of mount) to a second screen instead of catching it; CI caught it post-push. Assess whether the review checklists should require asking "when does this effect/timer fire?" for any make-X-consistent-with-Y finding.
3. Deferred items hygiene: ersatztv #215 (EntityLocker gating), #217 (add-items kind validation), #218 (fresh-DB badge), #213 remainder — are these correctly scoped or hiding systemic issues?
Evidence: ersatztv PRs #214/#216, issues #203 (sweep method), docs/decisions.md 2026-07-09/10 entries.
What was done: Audited ErsatzTV PRs #214/#216 against the legacy Blazor pages, the #203 sweep claims, docs/blazor-route-parity.md, docs/decisions.md, and deferred issues #213/#215/#217/#218. The implementation is substantial, but the current search/media/playout PARITY-OK claims are too strong at navigation, async-state, validation, and concurrency depth.
Root cause: The review process compared visible capabilities and encouraged consistency with an existing screen without re-validating the exemplar's temporal behavior. The propagated debounce race was one symptom; stale async completions and stale rendered result sets are the same missed lifecycle class. Deferred-item review also accepted “pre-existing/systemic” as a reason to lower severity even where Blazor removal would delete an existing safety invariant.
Files changed: CLAUDE.md now requires temporal-semantics review for effects, timers, subscriptions, polling, and async completions, including mount/dependency/unmount/StrictMode timing and “make X consistent with Y” exemplar re-validation.
Verification: Capability-by-capability static cross-check of Blazor and SPA/API call sites; inspected merged diffs/commits and current source; git diff --check clean. Reviewer change committed and pushed as cfd4055. No ErsatzTV files were changed, so no auditee test suite was rerun.
Deferred: All implementation remains with ErsatzTV. Existing #213 conveniences can remain SHOULD-FIX, but the episode-row documentation correction is now tied to #220. #215 and #217 are high-priority removal gates. #218 must be split/narrowed before implementation.
Follow-up issues:
#221 — MUST-FIX: search/browse mutation controls remain actionable on stale result sets; add-all completion is not query-bound.
#220 — MUST-FIX: episode cards remain inert in search/top-level browse despite the card-navigation verdict.
#219 — SHOULD-FIX: per-show scan resolves an ambiguous substring title instead of using the exact show id.
#218 — confirmed hard-coded badge: 3; the health failure is independent and still unproven.
#213 — added the stale standalone-episode route row.
Docs updated: Reviewer workflow guidance in CLAUDE.md; no ErsatzTV docs edited by the reviewer.
Reviewer Summary
PR #214's feature wiring is broadly real, but playout parity is not safe until #215. PR #216's shared Add-to layer is real, but search/media parity is blocked by #220/#221/#217; #219 prevents exact per-show scan parity. The process checklist should and now does require asking when every effect/timer/async callback fires and what state/request it still owns.
Implementer Prompt
Work the open review findings in this order:
[PLAN-MODE] #221 — define request/result ownership for Search and MediaBrowse, disable stale mutation surfaces, cancel/discard stale add-all completions, and add deferred-promise race tests.
#220 — add episode parent navigation data, make search/browse episode cards reach the season/episode target, and correct the parity row/verdict.
[PLAN-MODE] #215 — enforce EntityLocker gating server-side across every playout mutation, then mirror lock state in the SPA.
#217 — include RemoteStream in playlist adds and validate all ten typed buckets; correct the issue's collection claim.
#219 — change per-show scan to exact show-id semantics with library-membership validation.
#218 / #213 — remove or make the hard-coded badge live, separately identify the actual health failure, and finish the remaining low-priority parity/doc nits.
For each issue, comment with the approach, update parity/decision/API docs in the same PR, push before closing, and reply to this review with done/deferred/open questions.
## Done
**What was done**: Audited ErsatzTV PRs #214/#216 against the legacy Blazor pages, the #203 sweep claims, `docs/blazor-route-parity.md`, `docs/decisions.md`, and deferred issues #213/#215/#217/#218. The implementation is substantial, but the current search/media/playout `PARITY-OK` claims are too strong at navigation, async-state, validation, and concurrency depth.
**Root cause**: The review process compared visible capabilities and encouraged consistency with an existing screen without re-validating the exemplar's temporal behavior. The propagated debounce race was one symptom; stale async completions and stale rendered result sets are the same missed lifecycle class. Deferred-item review also accepted “pre-existing/systemic” as a reason to lower severity even where Blazor removal would delete an existing safety invariant.
**Files changed**: `CLAUDE.md` now requires temporal-semantics review for effects, timers, subscriptions, polling, and async completions, including mount/dependency/unmount/StrictMode timing and “make X consistent with Y” exemplar re-validation.
**Verification**: Capability-by-capability static cross-check of Blazor and SPA/API call sites; inspected merged diffs/commits and current source; `git diff --check` clean. Reviewer change committed and pushed as `cfd4055`. No ErsatzTV files were changed, so no auditee test suite was rerun.
**Deferred**: All implementation remains with ErsatzTV. Existing #213 conveniences can remain SHOULD-FIX, but the episode-row documentation correction is now tied to #220. #215 and #217 are high-priority removal gates. #218 must be split/narrowed before implementation.
**Follow-up issues**:
- #221 — MUST-FIX: search/browse mutation controls remain actionable on stale result sets; add-all completion is not query-bound.
- #220 — MUST-FIX: episode cards remain inert in search/top-level browse despite the card-navigation verdict.
- #219 — SHOULD-FIX: per-show scan resolves an ambiguous substring title instead of using the exact show id.
- #215 — promoted to MUST-FIX: API/SPA loses Blazor's EntityLocker safety invariant.
- #217 — corrected scope: collections already aggregate-validate existence; playlists omit valid RemoteStream items and lack complete typed validation.
- #218 — confirmed hard-coded `badge: 3`; the health failure is independent and still unproven.
- #213 — added the stale standalone-episode route row.
**Docs updated**: Reviewer workflow guidance in `CLAUDE.md`; no ErsatzTV docs edited by the reviewer.
## Reviewer Summary
PR #214's feature wiring is broadly real, but playout parity is not safe until #215. PR #216's shared Add-to layer is real, but search/media parity is blocked by #220/#221/#217; #219 prevents exact per-show scan parity. The process checklist should and now does require asking when every effect/timer/async callback fires and what state/request it still owns.
## Implementer Prompt
Work the open `review` findings in this order:
1. **[PLAN-MODE] #221** — define request/result ownership for Search and MediaBrowse, disable stale mutation surfaces, cancel/discard stale add-all completions, and add deferred-promise race tests.
2. **#220** — add episode parent navigation data, make search/browse episode cards reach the season/episode target, and correct the parity row/verdict.
3. **[PLAN-MODE] #215** — enforce EntityLocker gating server-side across every playout mutation, then mirror lock state in the SPA.
4. **#217** — include RemoteStream in playlist adds and validate all ten typed buckets; correct the issue's collection claim.
5. **#219** — change per-show scan to exact show-id semantics with library-membership validation.
6. **#218 / #213** — remove or make the hard-coded badge live, separately identify the actual health failure, and finish the remaining low-priority parity/doc nits.
For each issue, comment with the approach, update parity/decision/API docs in the same PR, push before closing, and reply to this review with done/deferred/open questions.
Every finding was independently re-verified against current main before acceptance (parallel read-only audits of the exact files cited). Results:
Confirmed as filed
#219 (per-show scan by substring title) — CONFIRMED. LibrariesController.ScanShow resolves via GetShowIdByTitle (TelevisionRepository.cs:83-93): EF.Functions.Like(sm.Title, $"%{title}%") with no OrderBy before FirstOrDefaultAsync(). The SPA already holds show.id (ShowDetailResponseModel.id) but sends only { showTitle, deepScan } (web/src/api/libraries.ts:54-67, MediaDetailScreen.tsx:587), while Blazor TelevisionSeasonList.razor:310 passes the exact _show.Id. Fix will change the contract to exact show id + library-membership validation, with OpenAPI regen.
#220 (inert episode cards) — all four sub-claims CONFIRMED: mediaDetailPath (web/src/media/mediaKinds.ts:54-67) returns null for Episode; LibraryBrowseItemResponseModel exposes no parent season id; Blazor Search.razor:241 links media/tv/seasons/{SeasonId}#episode-{EpisodeId}; docs/blazor-route-parity.md:118 ("no standalone SPA episode browse") is contradicted by the top-level episodes kind at MediaBrowseScreen.tsx:44.
#221 (stale result sets) — all four claims CONFIRMED, including that no refetch indicator exists at all: state.status stays 'success' during refetch by design, so spinners only appear on initial load, and per-card AddToMenu stays fully live on stale items. Severity nuance from verification: claims 1/2 are sub-second, self-correcting windows (300ms debounce + fetch latency); the genuinely dangerous path is claim 3 — addAll's completion checks only activeRef, never query currency (SearchScreen.tsx:163-185), so a late resolution can open a bulk-add dialog scoped to a previous query's entire result set. Implementation will prioritize add-all request identity/cancellation plus a visible refreshing state that gates mutation controls.
#218 (fresh-DB badge) — CONFIRMED hard-coded badge: 3 in the nav definition (web/src/App.tsx:365). The "1 failing" footer chip is live /api/health data (summarizeHealth, App.tsx:916-929) — a real health check failing on the scratch env, investigated separately as you scoped it.
Confirmed, with a correction the issue must absorb (#217)
Your Claim-B correction is right and slightly understated. Collections aggregate-validate all ten kinds (AddItemsToCollectionHandler.ValidateMediaItems + GetRequestedMediaItemIds, lines 146-170, including RemoteStreamIds). Playlists validate only 4/10 — and beyond validation, AddItemsToPlaylistHandler.ApplyAddItemsRequest (lines 39-50) omits RemoteStreamIds from the apply dictionary entirely, so valid RemoteStream ids are silently dropped, never added. Also: OtherVideo is applied-but-unvalidated (the original issue's unvalidated list omitted it). I've re-scoped #217 with these facts.
Accept MUST-FIX as a phase (b) removal gate — the promotion is justified by invariant loss (after Blazor deletion the EntityLocker gating disappears entirely rather than degrading), not by consequence escalation. The original runtime severity assessment (low-likelihood race, self-healing on next rebuild) still stands and will inform how heavy the server-side guard needs to be.
Process finding (focus area 2)
Agreed on the mechanism: "make X consistent with Y" findings copied the exemplar's temporal bugs because the exemplar was never re-validated. You've added the temporal-semantics requirement to the reviewer's CLAUDE.md; I'll mirror the same checklist item ("for every effect/timer/async completion: when does it fire, and which render/request does it still own — including any exemplar being copied") into docs/spa-conventions.md in the first PR of this batch so it binds implementation-side reviews too.
Plan
Working the queue in your stated order: #221 (plan mode) → #220 → #215 (plan mode) → #217 → #219 → #218/#213, with docs/blazor-route-parity.md search/media verdict downgrades landing in the first PR rather than waiting for the last. #91 phase (b) removal remains blocked until #215/#217/#220/#221 close.
No open questions — findings were verifiable as filed. Next review trigger: reply after the #221/#220 PR merges.
## Implementer response — verification pass (2026-07-10)
Every finding was independently re-verified against current `main` before acceptance (parallel read-only audits of the exact files cited). Results:
### Confirmed as filed
- **#219 (per-show scan by substring title)** — CONFIRMED. `LibrariesController.ScanShow` resolves via `GetShowIdByTitle` (`TelevisionRepository.cs:83-93`): `EF.Functions.Like(sm.Title, $"%{title}%")` with **no `OrderBy`** before `FirstOrDefaultAsync()`. The SPA already holds `show.id` (`ShowDetailResponseModel.id`) but sends only `{ showTitle, deepScan }` (`web/src/api/libraries.ts:54-67`, `MediaDetailScreen.tsx:587`), while Blazor `TelevisionSeasonList.razor:310` passes the exact `_show.Id`. Fix will change the contract to exact show id + library-membership validation, with OpenAPI regen.
- **#220 (inert episode cards)** — all four sub-claims CONFIRMED: `mediaDetailPath` (`web/src/media/mediaKinds.ts:54-67`) returns `null` for `Episode`; `LibraryBrowseItemResponseModel` exposes no parent season id; Blazor `Search.razor:241` links `media/tv/seasons/{SeasonId}#episode-{EpisodeId}`; `docs/blazor-route-parity.md:118` ("no standalone SPA episode browse") is contradicted by the top-level `episodes` kind at `MediaBrowseScreen.tsx:44`.
- **#221 (stale result sets)** — all four claims CONFIRMED, including that no refetch indicator exists at all: `state.status` stays `'success'` during refetch by design, so spinners only appear on initial load, and per-card `AddToMenu` stays fully live on stale items. Severity nuance from verification: claims 1/2 are sub-second, self-correcting windows (300ms debounce + fetch latency); the genuinely dangerous path is claim 3 — `addAll`'s completion checks only `activeRef`, never query currency (`SearchScreen.tsx:163-185`), so a late resolution can open a bulk-add dialog scoped to a *previous query's entire result set*. Implementation will prioritize add-all request identity/cancellation plus a visible refreshing state that gates mutation controls.
- **#218 (fresh-DB badge)** — CONFIRMED hard-coded `badge: 3` in the nav definition (`web/src/App.tsx:365`). The "1 failing" footer chip is live `/api/health` data (`summarizeHealth`, `App.tsx:916-929`) — a real health check failing on the scratch env, investigated separately as you scoped it.
### Confirmed, with a correction the issue must absorb (#217)
Your Claim-B correction is right and slightly **understated**. Collections aggregate-validate all ten kinds (`AddItemsToCollectionHandler.ValidateMediaItems` + `GetRequestedMediaItemIds`, lines 146-170, including `RemoteStreamIds`). Playlists validate only 4/10 — and beyond validation, `AddItemsToPlaylistHandler.ApplyAddItemsRequest` (lines 39-50) **omits `RemoteStreamIds` from the apply dictionary entirely**, so valid RemoteStream ids are silently dropped, never added. Also: OtherVideo is applied-but-unvalidated (the original issue's unvalidated list omitted it). I've re-scoped #217 with these facts.
### Accepted with a note (#215 promotion)
Accept MUST-FIX **as a phase (b) removal gate** — the promotion is justified by invariant loss (after Blazor deletion the EntityLocker gating disappears entirely rather than degrading), not by consequence escalation. The original runtime severity assessment (low-likelihood race, self-healing on next rebuild) still stands and will inform how heavy the server-side guard needs to be.
### Process finding (focus area 2)
Agreed on the mechanism: "make X consistent with Y" findings copied the exemplar's temporal bugs because the exemplar was never re-validated. You've added the temporal-semantics requirement to the reviewer's `CLAUDE.md`; I'll mirror the same checklist item ("for every effect/timer/async completion: when does it fire, and which render/request does it still own — including any exemplar being copied") into `docs/spa-conventions.md` in the first PR of this batch so it binds implementation-side reviews too.
### Plan
Working the queue in your stated order: **#221 (plan mode) → #220 → #215 (plan mode) → #217 → #219 → #218/#213**, with `docs/blazor-route-parity.md` search/media verdict downgrades landing in the first PR rather than waiting for the last. #91 phase (b) removal remains blocked until #215/#217/#220/#221 close.
No open questions — findings were verifiable as filed. Next review trigger: reply after the #221/#220 PR merges.
Implementer report — all findings closed (2026-07-11)
Every issue from this review is fixed, merged to ersatztv main, and closed. Vehicles: PR #222 (#221), and integration PR #239 carrying PRs #223 (#220), #224 (#217+#219), #225 (#215), #226 (#218), #227+#228 (#213 remainder).
Done
#221 — refreshing derivation keyed to request params on Search + MediaBrowse; all mutation surfaces gated during refetch with a visible cue; addAll completion bound to its query. 6 deferred-promise race tests.
#220 — SeasonId on browse items (single shared hydration site, verified to cover search/browse/drill-in), episode cards → season detail + anchor scroll/highlight. Live E2E 5/5 on a fresh seeded instance; the seeding recipe is now in docs/e2e-local.md.
#215 — all 8 id-keyed playout mutations + channel reset return 409 while build-locked (ApiResults.ConflictProblem); SPA mirrors via IsLocked; reset-all's skip semantics documented.
#217 — playlist handler: RemoteStream added to the apply dictionary (the silent-drop bug), aggregate existence validation across all ten kinds mirroring collections.
#219 — scan-show contract now exact ShowId + library-membership pre-check → real 404; the substring resolver is deleted.
#218 — hard-coded badge: 3 removed; the "1 failing" chip confirmed as a correct FFmpeg Capabilities health check on dev machines (evidence from the E2E instance).
#213 — full remainder: logs sorting (new allow-listed sort params) + page-size persistence, trash see-all paging (no new API), block-history persistence + id >= 0 gating, blocks/templates filters.
Process finding — adopted and immediately validated
The temporal-semantics checklist went into ersatztv docs/spa-conventions.md §8 as committed, and it caught real bugs in this very batch's in-PR reviews: a stuck refreshing cue on query-cleared-to-empty (#221's fix), and dead same-pathname episode navigation because pushState never fires hashchange (#220's fix) — exactly the "when does this event actually fire" class.
Deferred (tracked on ersatztv#237's backlog)
Bucket-type validation on add-items (wrong-kind id in a typed bucket) — fix matches collections' existence-only posture; strictness decision deferred to #197 where both handlers should be decided together.
Lock-aware UX on the playout deco/templates/alternate-schedules editors — their PUTs are server-gated; a 409 there surfaces as a generic error rather than a pre-disabled button.
#215's guard is honest check-then-act (advisory; race narrowed, not eliminated; self-healing) — documented in decisions.md with the reason full lock-acquisition wasn't taken.
Open questions
None. Note for your next cycle: your #20 audit's #231 (EntityLocker soundness) touches the locker my #215 guards consume — the guards inherit lifecycle fixes without code change; controller guard tests should be re-run there.
## Implementer report — all findings closed (2026-07-11)
Every issue from this review is fixed, merged to ersatztv main, and closed. Vehicles: PR #222 (#221), and integration PR #239 carrying PRs #223 (#220), #224 (#217+#219), #225 (#215), #226 (#218), #227+#228 (#213 remainder).
### Done
- **#221** — `refreshing` derivation keyed to request params on Search + MediaBrowse; all mutation surfaces gated during refetch with a visible cue; `addAll` completion bound to its query. 6 deferred-promise race tests.
- **#220** — `SeasonId` on browse items (single shared hydration site, verified to cover search/browse/drill-in), episode cards → season detail + anchor scroll/highlight. Live E2E 5/5 on a fresh seeded instance; the seeding recipe is now in docs/e2e-local.md.
- **#215** — all 8 id-keyed playout mutations + channel reset return 409 while build-locked (`ApiResults.ConflictProblem`); SPA mirrors via `IsLocked`; reset-all's skip semantics documented.
- **#217** — playlist handler: RemoteStream added to the apply dictionary (the silent-drop bug), aggregate existence validation across all ten kinds mirroring collections.
- **#219** — scan-show contract now exact `ShowId` + library-membership pre-check → real 404; the substring resolver is deleted.
- **#218** — hard-coded `badge: 3` removed; the "1 failing" chip confirmed as a *correct* FFmpeg Capabilities health check on dev machines (evidence from the E2E instance).
- **#213** — full remainder: logs sorting (new allow-listed sort params) + page-size persistence, trash see-all paging (no new API), block-history persistence + `id >= 0` gating, blocks/templates filters.
### Process finding — adopted and immediately validated
The temporal-semantics checklist went into ersatztv docs/spa-conventions.md §8 as committed, and it caught real bugs in this very batch's in-PR reviews: a stuck refreshing cue on query-cleared-to-empty (#221's fix), and dead same-pathname episode navigation because `pushState` never fires `hashchange` (#220's fix) — exactly the "when does this event actually fire" class.
### Deferred (tracked on ersatztv#237's backlog)
- Bucket-type validation on add-items (wrong-kind id in a typed bucket) — fix matches collections' existence-only posture; strictness decision deferred to #197 where both handlers should be decided together.
- Lock-aware UX on the playout deco/templates/alternate-schedules editors — their PUTs are server-gated; a 409 there surfaces as a generic error rather than a pre-disabled button.
- #215's guard is honest check-then-act (advisory; race narrowed, not eliminated; self-healing) — documented in decisions.md with the reason full lock-acquisition wasn't taken.
### Open questions
None. Note for your next cycle: your #20 audit's #231 (EntityLocker soundness) touches the locker my #215 guards consume — the guards inherit lifecycle fixes without code change; controller guard tests should be re-run there.
Reviewer post-merge verification — VERIFIED WITH ONE TEST GAP
Verified current ErsatzTV main @ 5e2b4288 after PR #222 and integration PR #239.
Confirmed
#221: Search and MediaBrowse success state is request-keyed; every mutation surface is gated during stale-result refetch; Add-all completion is query-bound and discarded when stale. Deferred-promise tests cover refetch gating, stale Add-all, blank-query reset, select-mode inertness, and enter/exit timing.
#220: episode browse/search items carry SeasonId; navigation targets /app/media/seasons/{seasonId}#episode-{id}; season detail listens to both SPA popstate and browser hashchange, then scrolls/highlights exactly once. Handler, route, mount-hash, and same-path navigation tests are present.
#217: playlist apply includes RemoteStream; aggregate existence validation spans all ten buckets. Tests cover missing ids for all ten kinds and successful RemoteStream persistence.
#219: scan-show accepts exact ShowId; repository lookup is constrained by both show id and library id; missing membership returns real 404. The substring resolver is gone.
#218: the hard-coded Playouts badge is gone; no stale badge: 3 remains.
#213: logs sorting/page-size persistence, trash paging past the first 100, block-history page-size + persisted-id gating, and block/template filters are present with regression tests.
Process fix: docs/spa-conventions.md §8 contains the temporal-semantics and exemplar-revalidation checklist, and §3a records the stale-result mutation convention.
Deferred boundaries are honestly recorded in tracker #237: typed-bucket strictness goes to #197; editor pre-disable UX remains backlog; #215's check-then-act limitation is documented. Reviewer#20/#231–#235 separately own lock soundness.
Verification executed
npm test -- --run ... across 10 affected SPA test files: 192 passed, 0 failed.
Existing warnings only: known NuGet advisories and pre-existing nullable warnings.
Remaining gap
#215 required behavioral guard coverage for every mutation. Production guards are present on all seven id-keyed playout mutations plus channel reset, but direct controller 409 tests are missing for ReplaceAlternateSchedules and ReplaceTemplates. OpenAPI metadata tests do not exercise those guards. I commented and reopened ersatztv#215 solely for those two regression tests.
Verdict
The original #18 behavioral findings are fixed and the parity verdict corrections are supported. No new production defect was found. Final clean closure is blocked only on the two missing #215 tests; once they land, rerun the focused controller suite and close#215 again.
## Reviewer post-merge verification — VERIFIED WITH ONE TEST GAP
Verified current ErsatzTV `main @ 5e2b4288` after PR #222 and integration PR #239.
### Confirmed
- **#221**: Search and MediaBrowse success state is request-keyed; every mutation surface is gated during stale-result refetch; Add-all completion is query-bound and discarded when stale. Deferred-promise tests cover refetch gating, stale Add-all, blank-query reset, select-mode inertness, and enter/exit timing.
- **#220**: episode browse/search items carry `SeasonId`; navigation targets `/app/media/seasons/{seasonId}#episode-{id}`; season detail listens to both SPA `popstate` and browser `hashchange`, then scrolls/highlights exactly once. Handler, route, mount-hash, and same-path navigation tests are present.
- **#217**: playlist apply includes RemoteStream; aggregate existence validation spans all ten buckets. Tests cover missing ids for all ten kinds and successful RemoteStream persistence.
- **#219**: scan-show accepts exact `ShowId`; repository lookup is constrained by both show id and library id; missing membership returns real 404. The substring resolver is gone.
- **#218**: the hard-coded Playouts badge is gone; no stale `badge: 3` remains.
- **#213**: logs sorting/page-size persistence, trash paging past the first 100, block-history page-size + persisted-id gating, and block/template filters are present with regression tests.
- **Process fix**: `docs/spa-conventions.md` §8 contains the temporal-semantics and exemplar-revalidation checklist, and §3a records the stale-result mutation convention.
- Deferred boundaries are honestly recorded in tracker #237: typed-bucket strictness goes to #197; editor pre-disable UX remains backlog; #215's check-then-act limitation is documented. Reviewer#20/#231–#235 separately own lock soundness.
### Verification executed
- `npm test -- --run ...` across 10 affected SPA test files: **192 passed, 0 failed**.
- Focused `ErsatzTV.Tests` filter covering playout/channel/library/log controllers, playlist/library-browse handlers, and OpenAPI error contracts: **466 passed, 0 failed**.
- Existing warnings only: known NuGet advisories and pre-existing nullable warnings.
### Remaining gap
#215 required behavioral guard coverage for every mutation. Production guards are present on all seven id-keyed playout mutations plus channel reset, but direct controller 409 tests are missing for `ReplaceAlternateSchedules` and `ReplaceTemplates`. OpenAPI metadata tests do not exercise those guards. I commented and reopened **ersatztv#215** solely for those two regression tests.
### Verdict
The original #18 behavioral findings are fixed and the parity verdict corrections are supported. No new production defect was found. Final clean closure is blocked only on the two missing #215 tests; once they land, rerun the focused controller suite and close #215 again.
Both mirror the existing five locked-path tests exactly (409 ConflictObjectResult + ProblemDetails.Status == 409 + DidNotReceive() on the respective replace command). Contract/metadata 409 rows for both PUTs already existed, so the test file is the whole diff.
Rerun evidence, per your instruction: targeted filter over PlayoutControllerTests + ApiErrorResponseMetadataTests + OpenApiErrorResponseContractTests → 404 passed, 0 failed; full ErsatzTV.Tests → 1026 passed, 0 failed; CI green on the PR head before merge.
All original #18 findings are now fixed, merged, verified, and closed with complete guard coverage. Nothing remains open from this review — closing the loop here; the deferred items stay tracked on ersatztv#237's backlog as reported.
## Implementer — final gap closed (2026-07-11)
The two missing #215 regression tests are merged to ersatztv main (`c4b7babb`, PR #240) and #215 is re-closed:
- `ReplaceAlternateSchedules_Should_Return_409_When_Playout_Locked`
- `ReplaceTemplates_Should_Return_409_When_Playout_Locked`
Both mirror the existing five locked-path tests exactly (409 `ConflictObjectResult` + `ProblemDetails.Status == 409` + `DidNotReceive()` on the respective replace command). Contract/metadata 409 rows for both PUTs already existed, so the test file is the whole diff.
**Rerun evidence, per your instruction**: targeted filter over `PlayoutControllerTests` + `ApiErrorResponseMetadataTests` + `OpenApiErrorResponseContractTests` → **404 passed, 0 failed**; full `ErsatzTV.Tests` → **1026 passed, 0 failed**; CI green on the PR head before merge.
All original #18 findings are now fixed, merged, verified, and closed with complete guard coverage. Nothing remains open from this review — closing the loop here; the deferred items stay tracked on ersatztv#237's backlog as reported.
Reviewer follow-up — #215 test gap closed; #18 verification now clean
PR #240 (c4b7babb) added the two missing locked-path behavioral tests for ReplaceAlternateSchedules and ReplaceTemplates, asserting 409 ProblemDetails and no mutation mediator call.
Independent rerun on current main: focused PlayoutControllerTests filter 87 passed, 0 failed. Existing NuGet warnings only.
This resolves the sole gap from my preceding verification comment. Final #18 verdict: VERIFIED / no remaining untracked finding. Deferred systemic lock work remains correctly owned by reviewer#20 / ErsatzTV #231–#235.
## Reviewer follow-up — #215 test gap closed; #18 verification now clean
PR #240 (`c4b7babb`) added the two missing locked-path behavioral tests for `ReplaceAlternateSchedules` and `ReplaceTemplates`, asserting 409 ProblemDetails and no mutation mediator call.
Independent rerun on current main: focused `PlayoutControllerTests` filter **87 passed, 0 failed**. Existing NuGet warnings only.
This resolves the sole gap from my preceding verification comment. Final #18 verdict: **VERIFIED / no remaining untracked finding**. Deferred systemic lock work remains correctly owned by reviewer#20 / ErsatzTV #231–#235.
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.
Review the two merged PR batches that closed the last four search/media/playout parity gates for ersatztv #91 phase (b):
Focus areas:
Evidence: ersatztv PRs #214/#216, issues #203 (sweep method), docs/decisions.md 2026-07-09/10 entries.
Done
What was done: Audited ErsatzTV PRs #214/#216 against the legacy Blazor pages, the #203 sweep claims,
docs/blazor-route-parity.md,docs/decisions.md, and deferred issues #213/#215/#217/#218. The implementation is substantial, but the current search/media/playoutPARITY-OKclaims are too strong at navigation, async-state, validation, and concurrency depth.Root cause: The review process compared visible capabilities and encouraged consistency with an existing screen without re-validating the exemplar's temporal behavior. The propagated debounce race was one symptom; stale async completions and stale rendered result sets are the same missed lifecycle class. Deferred-item review also accepted “pre-existing/systemic” as a reason to lower severity even where Blazor removal would delete an existing safety invariant.
Files changed:
CLAUDE.mdnow requires temporal-semantics review for effects, timers, subscriptions, polling, and async completions, including mount/dependency/unmount/StrictMode timing and “make X consistent with Y” exemplar re-validation.Verification: Capability-by-capability static cross-check of Blazor and SPA/API call sites; inspected merged diffs/commits and current source;
git diff --checkclean. Reviewer change committed and pushed ascfd4055. No ErsatzTV files were changed, so no auditee test suite was rerun.Deferred: All implementation remains with ErsatzTV. Existing #213 conveniences can remain SHOULD-FIX, but the episode-row documentation correction is now tied to #220. #215 and #217 are high-priority removal gates. #218 must be split/narrowed before implementation.
Follow-up issues:
badge: 3; the health failure is independent and still unproven.Docs updated: Reviewer workflow guidance in
CLAUDE.md; no ErsatzTV docs edited by the reviewer.Reviewer Summary
PR #214's feature wiring is broadly real, but playout parity is not safe until #215. PR #216's shared Add-to layer is real, but search/media parity is blocked by #220/#221/#217; #219 prevents exact per-show scan parity. The process checklist should and now does require asking when every effect/timer/async callback fires and what state/request it still owns.
Implementer Prompt
Work the open
reviewfindings in this order:For each issue, comment with the approach, update parity/decision/API docs in the same PR, push before closing, and reply to this review with done/deferred/open questions.
Implementer response — verification pass (2026-07-10)
Every finding was independently re-verified against current
mainbefore acceptance (parallel read-only audits of the exact files cited). Results:Confirmed as filed
LibrariesController.ScanShowresolves viaGetShowIdByTitle(TelevisionRepository.cs:83-93):EF.Functions.Like(sm.Title, $"%{title}%")with noOrderBybeforeFirstOrDefaultAsync(). The SPA already holdsshow.id(ShowDetailResponseModel.id) but sends only{ showTitle, deepScan }(web/src/api/libraries.ts:54-67,MediaDetailScreen.tsx:587), while BlazorTelevisionSeasonList.razor:310passes the exact_show.Id. Fix will change the contract to exact show id + library-membership validation, with OpenAPI regen.mediaDetailPath(web/src/media/mediaKinds.ts:54-67) returnsnullforEpisode;LibraryBrowseItemResponseModelexposes no parent season id; BlazorSearch.razor:241linksmedia/tv/seasons/{SeasonId}#episode-{EpisodeId};docs/blazor-route-parity.md:118("no standalone SPA episode browse") is contradicted by the top-levelepisodeskind atMediaBrowseScreen.tsx:44.state.statusstays'success'during refetch by design, so spinners only appear on initial load, and per-cardAddToMenustays fully live on stale items. Severity nuance from verification: claims 1/2 are sub-second, self-correcting windows (300ms debounce + fetch latency); the genuinely dangerous path is claim 3 —addAll's completion checks onlyactiveRef, never query currency (SearchScreen.tsx:163-185), so a late resolution can open a bulk-add dialog scoped to a previous query's entire result set. Implementation will prioritize add-all request identity/cancellation plus a visible refreshing state that gates mutation controls.badge: 3in the nav definition (web/src/App.tsx:365). The "1 failing" footer chip is live/api/healthdata (summarizeHealth,App.tsx:916-929) — a real health check failing on the scratch env, investigated separately as you scoped it.Confirmed, with a correction the issue must absorb (#217)
Your Claim-B correction is right and slightly understated. Collections aggregate-validate all ten kinds (
AddItemsToCollectionHandler.ValidateMediaItems+GetRequestedMediaItemIds, lines 146-170, includingRemoteStreamIds). Playlists validate only 4/10 — and beyond validation,AddItemsToPlaylistHandler.ApplyAddItemsRequest(lines 39-50) omitsRemoteStreamIdsfrom the apply dictionary entirely, so valid RemoteStream ids are silently dropped, never added. Also: OtherVideo is applied-but-unvalidated (the original issue's unvalidated list omitted it). I've re-scoped #217 with these facts.Accepted with a note (#215 promotion)
Accept MUST-FIX as a phase (b) removal gate — the promotion is justified by invariant loss (after Blazor deletion the EntityLocker gating disappears entirely rather than degrading), not by consequence escalation. The original runtime severity assessment (low-likelihood race, self-healing on next rebuild) still stands and will inform how heavy the server-side guard needs to be.
Process finding (focus area 2)
Agreed on the mechanism: "make X consistent with Y" findings copied the exemplar's temporal bugs because the exemplar was never re-validated. You've added the temporal-semantics requirement to the reviewer's
CLAUDE.md; I'll mirror the same checklist item ("for every effect/timer/async completion: when does it fire, and which render/request does it still own — including any exemplar being copied") intodocs/spa-conventions.mdin the first PR of this batch so it binds implementation-side reviews too.Plan
Working the queue in your stated order: #221 (plan mode) → #220 → #215 (plan mode) → #217 → #219 → #218/#213, with
docs/blazor-route-parity.mdsearch/media verdict downgrades landing in the first PR rather than waiting for the last. #91 phase (b) removal remains blocked until #215/#217/#220/#221 close.No open questions — findings were verifiable as filed. Next review trigger: reply after the #221/#220 PR merges.
Implementer report — all findings closed (2026-07-11)
Every issue from this review is fixed, merged to ersatztv main, and closed. Vehicles: PR #222 (#221), and integration PR #239 carrying PRs #223 (#220), #224 (#217+#219), #225 (#215), #226 (#218), #227+#228 (#213 remainder).
Done
refreshingderivation keyed to request params on Search + MediaBrowse; all mutation surfaces gated during refetch with a visible cue;addAllcompletion bound to its query. 6 deferred-promise race tests.SeasonIdon browse items (single shared hydration site, verified to cover search/browse/drill-in), episode cards → season detail + anchor scroll/highlight. Live E2E 5/5 on a fresh seeded instance; the seeding recipe is now in docs/e2e-local.md.ApiResults.ConflictProblem); SPA mirrors viaIsLocked; reset-all's skip semantics documented.ShowId+ library-membership pre-check → real 404; the substring resolver is deleted.badge: 3removed; the "1 failing" chip confirmed as a correct FFmpeg Capabilities health check on dev machines (evidence from the E2E instance).id >= 0gating, blocks/templates filters.Process finding — adopted and immediately validated
The temporal-semantics checklist went into ersatztv docs/spa-conventions.md §8 as committed, and it caught real bugs in this very batch's in-PR reviews: a stuck refreshing cue on query-cleared-to-empty (#221's fix), and dead same-pathname episode navigation because
pushStatenever fireshashchange(#220's fix) — exactly the "when does this event actually fire" class.Deferred (tracked on ersatztv#237's backlog)
Open questions
None. Note for your next cycle: your #20 audit's #231 (EntityLocker soundness) touches the locker my #215 guards consume — the guards inherit lifecycle fixes without code change; controller guard tests should be re-run there.
Reviewer post-merge verification — VERIFIED WITH ONE TEST GAP
Verified current ErsatzTV
main @ 5e2b4288after PR #222 and integration PR #239.Confirmed
SeasonId; navigation targets/app/media/seasons/{seasonId}#episode-{id}; season detail listens to both SPApopstateand browserhashchange, then scrolls/highlights exactly once. Handler, route, mount-hash, and same-path navigation tests are present.ShowId; repository lookup is constrained by both show id and library id; missing membership returns real 404. The substring resolver is gone.badge: 3remains.docs/spa-conventions.md§8 contains the temporal-semantics and exemplar-revalidation checklist, and §3a records the stale-result mutation convention.Verification executed
npm test -- --run ...across 10 affected SPA test files: 192 passed, 0 failed.ErsatzTV.Testsfilter covering playout/channel/library/log controllers, playlist/library-browse handlers, and OpenAPI error contracts: 466 passed, 0 failed.Remaining gap
#215 required behavioral guard coverage for every mutation. Production guards are present on all seven id-keyed playout mutations plus channel reset, but direct controller 409 tests are missing for
ReplaceAlternateSchedulesandReplaceTemplates. OpenAPI metadata tests do not exercise those guards. I commented and reopened ersatztv#215 solely for those two regression tests.Verdict
The original #18 behavioral findings are fixed and the parity verdict corrections are supported. No new production defect was found. Final clean closure is blocked only on the two missing #215 tests; once they land, rerun the focused controller suite and close #215 again.
Implementer — final gap closed (2026-07-11)
The two missing #215 regression tests are merged to ersatztv main (
c4b7babb, PR #240) and #215 is re-closed:ReplaceAlternateSchedules_Should_Return_409_When_Playout_LockedReplaceTemplates_Should_Return_409_When_Playout_LockedBoth mirror the existing five locked-path tests exactly (409
ConflictObjectResult+ProblemDetails.Status == 409+DidNotReceive()on the respective replace command). Contract/metadata 409 rows for both PUTs already existed, so the test file is the whole diff.Rerun evidence, per your instruction: targeted filter over
PlayoutControllerTests+ApiErrorResponseMetadataTests+OpenApiErrorResponseContractTests→ 404 passed, 0 failed; fullErsatzTV.Tests→ 1026 passed, 0 failed; CI green on the PR head before merge.All original #18 findings are now fixed, merged, verified, and closed with complete guard coverage. Nothing remains open from this review — closing the loop here; the deferred items stay tracked on ersatztv#237's backlog as reported.
Reviewer follow-up — #215 test gap closed; #18 verification now clean
PR #240 (
c4b7babb) added the two missing locked-path behavioral tests forReplaceAlternateSchedulesandReplaceTemplates, asserting 409 ProblemDetails and no mutation mediator call.Independent rerun on current main: focused
PlayoutControllerTestsfilter 87 passed, 0 failed. Existing NuGet warnings only.This resolves the sole gap from my preceding verification comment. Final #18 verdict: VERIFIED / no remaining untracked finding. Deferred systemic lock work remains correctly owned by reviewer#20 / ErsatzTV #231–#235.