Skip to content

fix(playback): bind virtual subtitle evidence to the exact catalog row - #219

Merged
drondeseries merged 13 commits into
mainfrom
fix/candidate-row-evidence
Oct 5, 2026
Merged

drondeseries merged 13 commits into
mainfrom
fix/candidate-row-evidence

Conversation

@randrini

@randrini randrini commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator

Problem

Related issue: N/A
Validation tasks: none

Duplicate catalog rows for one release break the invariant the evidence system relies on: provenance is keyed on the virtual candidate URI, not the row. Probe evidence rotates onto sibling rows, and serve/resume paths apply any URI-matching evidence, so playing one candidate can serve another candidate's audio/subtitle tracks. Menus and served tracks then diverge between cold start and resume.

Approach

Record the evidence row id at plan time and require row id plus candidate URI to match when applying evidence or publishing refused-probe inventory (URI-only kept for legacy sessions). Refuse probe rotation onto duplicate sibling rows while keeping distinct-release rotation. Collapse byte-identical duplicate rows in the version list, keeping unprobed placeholders only when no probed copy exists.

Validation

  • New tests for evidence matching, rotation, and version dedup
  • go test on touched packages (api/handlers, playback, catalog, scanner) pass; gofmt, vet, build clean
  • golangci-lint not available locally; full CI green on the final head required

Risks

Sessions bound to a duplicate row now only accept evidence recorded for that exact row. Legacy sessions without evidence row ids keep URI-only matching. No API, migration, or config impact.

Checklist

  • I read and can explain the complete diff.
  • This pull request addresses one concern.

AI Disclosure

  • Harness: OpenCode
  • Tool(s): OpenCode (explorer, fixer subagents)
  • Model(s): opencode-go/muse-spark-1.3-contributor
  • Involvement: AI-assisted
  • Adversarial review: n/a - full review left to PR reviewers

randrini added 3 commits October 4, 2026 13:45
Duplicate catalog rows for one release share candidate URIs and neutral
keys. A serve-layer probe rotation could move a session's binding from the
played row A onto sibling row B, and the URI-only evidence match then applied
A's audio/subtitle inventory to B and B's to A. The same ambiguity let the
version list project one release twice.

Record the evidence row id at plan time and require it, together with the
candidate URI, when applying carried evidence:
- Session/SessionStreamState carry VirtualSubtitleEvidenceFileID, captured
  from the effective file in v3SessionStreamState and cleared with the rest of
  the evidence. A zero id (legacy or reconstructed session) keeps the URI-only
  fallback.
- virtualEvidenceMatchesBoundFile and refusedProbeInventoryFile require the
  row id and URI to match, so the in-memory refused-probe publish reaches only
  the row that captured the evidence.

Stop cross-row contamination on probe rotation: when a probed candidate path
is owned by a sibling row of the same release (matching durable provider
identity), virtualProbeEvidenceRotateTarget refuses the rotation instead of
overlaying the requested row's tracks onto the duplicate sibling, and serves
the probed inventory in memory to the requested row's own sessions. A
genuinely different release still rotates to its owner row.

Collapse duplicate virtual rows in the version list: byte-identical rows
collapse to the first occurrence, and an unprobed placeholder collapses when
a probed copy of the same neutral release exists. A probed copy is preferred;
a placeholder with no probed copy stays listed.

Tests prove each behavior; all touched packages pass.
@drondeseries

Copy link
Copy Markdown
Collaborator

Production-readiness review: Request changes (Grade 62/100)

Reviewed 2410cc3 (+1309/-96, 13 files). The row-ID provenance field is a sound improvement, but version collapsing and frontend admission introduce correctness gaps. No merge.

Merge blocker — required lint checks fail (fix first)

  • internal/catalog/detail.go:4124 — goconst (literal "virtual" ×12, make a const per project convention)
  • internal/api/handlers/candidate_row_evidence_test.go:103 — gofmt

MERGEABLE means conflict-free, not ready. These block merge independently of the findings below.

Blocker 1 — High: version collapse treats "unprobed" as "placeholder"

internal/catalog/detail.go, collapseDuplicateVirtualVersionFiles / virtualNeutralReleaseKey — any unprobed row disappears when a probed row shares its content and URI after stripping result. The comparison ignores size and durable provider identity, so a concrete, unprobed alternate release can vanish simply because another candidate was probed. The new tests bless collapsing different result values without proving same release.

Fix: require positive placeholder identification or verified duplicate identity; absence of a probe stamp is not sufficient.
Test: probed candidate + distinct unprobed candidate under the same neutral URI — both must remain selectable.

Blocker 2 — High: outgoing candidate can veto the explicitly winning plan

web/src/player/hooks/usePlaybackSession.ts, deferredIdentityIsAdmissible — the baseline loop runs before checking the replacement plan's concrete URI. Outgoing (F, A) + winning replacement (F, B) + queued push (F, B): the loop rejects B against A even though B exactly matches the authoritative plan, contradicting the function's stated precedence.

Fix: resolve replacement-plan authority first; apply ambiguity guards only when the winning plan cannot identify the candidate.
Test: the counterexample above — B's inventory must apply.

Blocker 3 — High: URI-less plans admit pushes with no file identity

Same function, final return — admission explicitly accepts identity.fileId == null. With neither baseline supplying a concrete URI, a URI-only push passes with no demonstrated relationship to the winning plan's file. Broader than the stated same-file relaxation; can admit unrelated deferred inventory.

Fix: require a matching, non-null file ID for a URI-less winner, unless another explicit source-generation binding proves ownership.
Test: unrelated URI-only deferred inventory against a URI-less winner — must not fold.

Blocker 4 — Medium: known row provenance can bypass the row check

internal/api/handlers/stream.go, virtualEvidenceMatchesBoundFile — the empty-evidence-URI branch returns on source-URI equality before checking VirtualSubtitleEvidenceFileID, so evidence with known row A but empty URI can match bound row B. The final branch permits an unknown bound ID even when the evidence ID is known. Neither is the documented zero-evidence-ID legacy exception.

Fix: enforce known evidence-row identity before either URI path; keep URI-only compatibility only for genuinely unknown evidence provenance.
Test: missing evidence URI + mismatched row; known evidence row + unknown bound row.

Acceptance: lint green, the four fixes with their regressions, refused-sibling rotation verified via reconnect/poll (not just WS delivery), focused Go/frontend suites + both lint gates rerun. Keep the row-provenance improvement; tighten or split out version-collapse and frontend-admission if they can't be proven. No merge until then.

randrini added 2 commits October 4, 2026 15:38
…ushes

Reconcile deferred realtime pushes against the adoption that actually won,
closing three frontend review blockers.

- Decide a replaced plan that names a candidate URI by exact identity before
  the outgoing/applied ambiguity guards, so an outgoing candidate the session
  was leaving cannot veto the plan the server just selected.
- Carry whether an entry's arrival-time outgoing baseline was itself produced
  by an earlier deferred source commit from the same queue. A URI-bearing source
  commit is judged by arrival order, so such a queue-produced baseline no longer
  vetoes a newer same-file commit; an external poll fold still does.
- Require corroboration for a URI-only push against a URI-less winner: admit it
  only when the arrival-time live source was already on the settled file, so an
  unrelated replacement file never inherits its candidate.

Add regressions for all three; the primary relaxation (same-file
candidate-bearing pushes against URI-less plans; refused revisions refoldable)
is preserved.

@drondeseries drondeseries left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review (oracle, final): 65/100 — needs work, not ready to merge.

The change is still needed after #220 (no same-hunk overlap; complementary).

Ordered fixes:

  1. Serving fallback bypass — stream.go:859,879-901: carry the cross-row rejection through GetByPath. The URI-only fallback must not reauthorize a rejected row. Test that fallback cannot bypass the check.
  2. Cross-tier dedup — playback_virtual.go:3977-3985 with resolver.go:717-732: strongest-tier-only keys cannot tell a name-only candidate from a GUID/hash equivalent, so duplicates rotate instead of refusing. Compare compatible tiers or fail closed. Test mixed-tier duplicates without rotation.
  3. Zero-ID sessions — playback_service.go:1127: guard with session.MediaFileID > 0, matching stream.go:927. Add a push-path regression test.
  4. Probed churn — detail.go:4189-4207: apply neutralVirtualMediaPath in version grouping for probed entries too, keeping genuine version distinctions. Test two probed paths differing only in result=.
  5. Post-rotation refresh — session.go:1472-1490: invalidate stale track evidence and trigger a probe of the replacement; keep the gate fail-closed until refreshed. Test rotation through recovery.

Hygiene: rebase onto current main (post-#220) and require green CI before merge.

randrini added 3 commits October 4, 2026 19:32
A URI-bearing source commit deferred behind a start/switch/replan ignored
the applied candidate entirely, so a poll that folded a newer candidate
after the commit was queued could be overwritten once the plan settled.
In-queue sequence sorting covers order inside the queue, not between the
queue and an applied poll.

Judge a URI-bearing commit against its own arrival baseline and the
queue's earlier commits by arrival order, but still veto it with a
candidate a poll folded after the entry was queued, tracked by a
source-identity transition clock. Record the poll's candidate so it
survives the winning plan's own adoption overwriting the menu mirror
before the flush runs.

Also fix stale wording about refused revisions being unrecorded at the
admissibility doc and revision-scope comment, and add a null-URI
bogus-candidate regression.
Address five review findings on the exact-row evidence binding:

- stream.go: carry the cross-row rejection through the URI-only live-row
  fallback in bindSessionVirtualSourceWithTracks, so a rejected sibling row
  cannot be reauthorized by GetByPath.
- playback_virtual.go/resolver.go: compare compatible durable identity tiers
  symmetrically (PersistedIdentitiesMatch) instead of a strongest-tier key, so
  a name-only row and a GUID/hash row of one release are recognized as the
  duplicate they are and the owner rotation refuses.
- playback_service.go: refuse the in-memory refused-probe override for a
  session with an unknown effective row id, matching the serving-path guard.
- detail.go: group probed version entries on the shared neutral path so
  result= churn collapses, while a distinct size or conflicting identity stays
  a genuine version.
- playback_service.go: after a committed rotation, re-resolve and re-probe the
  replacement through the bounded detached gate, generation-fenced so a
  superseded binding is never probed. The serve gate stays fail-closed until
  the replacement evidence lands.

Regression tests cover each path.
@randrini

randrini commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator Author

Review follow-up addressed and pushed on fix/candidate-row-evidence (0e821efa frontend + 1aa3e0a0 backend, post-#220 main merged in eb67ab00):

  1. Serving fallback bypass: bindSessionVirtualSourceWithTracks records the cross-row rejection; the URI-only GetByPath fallback can no longer reauthorize a rejected row (+ regression test).
  2. Cross-tier dedup: new resolver.PersistedIdentitiesMatch (symmetric per-tier comparison); name-only vs GUID/hash rows of one release now refuse rotation instead of repainting (+ tests). Chose compatible-tier comparison over fail-closed: no shared non-empty tier still rotates, preserving the legitimate distinct-release rotation pinned by existing tests.
  3. Zero-ID sessions: refusedProbeInventoryFile returns nil when session.MediaFileID <= 0 (+ push-path test).
  4. Probed churn: probed entries group on the neutral path, so result=-renumbered duplicates collapse while distinct sizes/identities stay listed (+ test).
  5. Post-rotation refresh: rotation schedules a bounded, generation-fenced re-resolve + probe of the replacement; serve gate stays fail-closed until refreshed evidence lands (+ test incl. superseded-generation fence).
  • Frontend half (poll-ordering blocker, stale wording, null-URI test) also in this branch via 0e821efa; same fix ported to fix(player): refresh track menus from deferred inventory pushes #218.
  • Verified: gofmt/diff-check clean, runnable Go packages pass, 127 vitest passing, prettier/tsc clean. Local go build of vips-dependent packages is broken in this shell (missing system libvips, pre-existing) — CI is the gate for those. Docker image building on this head.

@drondeseries drondeseries left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review (oracle, final): 85/100 — needs work before merge. All five fixes verified present and tested, but two items remain:

  1. Lint failure on this head: internal/api/handlers/playback_service.go:1037 comment reads authorise; the misspell linter requires authorize. One-word fix.
  2. Rotation race: in probeRotatedVirtualCandidate, the generation check runs before resolution. Resolution and probing can outlive another rotation, and probeVirtualSourceAndPersist takes no generation, so the "superseded binding is never probed" claim is not established by the check alone. Add a deterministic test that rotates while resolution/probing is blocked and proves stale work cannot persist or authorize stale tracks — or point at the persistence invariant that already enforces it.

Also: Go test, changed-lines, and router-recovery gates still pending on the final head. Rebase onto current main if it has moved, then require green CI.

randrini added 2 commits October 5, 2026 00:25
probeRotatedVirtualCandidate checked the binding generation only before
resolution. Resolution and the probe are separate blocking calls that can
each outlive a newer rotation, and probeVirtualSourceAndPersist took no
generation, so superseded work could still mark the failure damper,
release the sticky pin, or persist evidence for a candidate the session
no longer serves.

Thread a generation fence through the pipeline: re-check it after
resolution and again inside probeVirtualSourceAndPersist after the probe
returns and before any failure verdict, unpin, or catalog write. The
variadic fence keeps the existing callers unchanged; only the rotated
candidate refresh supplies one.

Add a deterministic race test that parks rotation A in the resolve stage
and separately in the probe stage, lands rotation B while A is blocked,
releases it, and proves no evidence is persisted for the superseded
candidate and the stale evidence cannot authorize serving.
@randrini

randrini commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator Author

Re-review follow-up pushed on fix/candidate-row-evidence (c364eb54 + 6cb9cf78):

  1. misspell: authorise → authorize in playback_service.go:1037.
  2. Rotation race: the pre-resolution generation check was indeed insufficient — a rotation landing during resolution or the probe let superseded work mark the failure damper, release the sticky pin, and persist evidence. Fix threads a generation fence through to the persist/authorize point: re-checked after resolution and again inside probeVirtualSourceAndPersist (new variadic fence arg; existing callers unchanged) before damper-mark, unpin, or persist. New deterministic test TestProbeRotatedVirtualCandidateRaceDoesNotPersistStaleEvidence blocks each stage on channels (no sleeps), rotates mid-flight, and proves zero persisted paths for the stale candidate plus rejection at the serve gate.
  • Verified: gofmt/diff-check clean, runnable Go packages pass, 127 vitest green. vips-dependent packages still gate in CI only (missing system libvips in this shell, pre-existing).

@randrini
randrini requested a review from drondeseries October 4, 2026 22:53
@randrini

randrini commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator Author

Both items from the latest review are now addressed and CI is green on the final head (6cb9cf78): (1) misspell fixed, (2) rotation race closed with a generation fence at the persist/authorize point plus a deterministic channel-gated test. Requesting re-review — holding off on merge until approval.

@randrini randrini self-assigned this Oct 4, 2026
@randrini randrini added the enhancement New feature or request label Oct 4, 2026

@drondeseries drondeseries left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Request changes — one merge blocker remains (87/100)

Verified on 6cb9cf78, all 8 CI checks green. Row-bound evidence, PersistedIdentitiesMatch, misspell fix, and the post-probe fence are real progress. Not merging yet for one scheduling race below.

Blocker: rotation refresh pairs a stale file snapshot with a newer generation

internal/api/handlers/playback_service.go:959-978 (PublishSourceCommitted) does GetSession, then GetByID(session.MediaFileID), then refreshRotatedVirtualCandidateBackground(ctx, session, file), which at :1061 reads inventorySourceGeneration(session.ID) separately.

If a rotation A→B lands between the session/file snapshot and the generation read, probeRotatedVirtualCandidate (:1074-1140) re-reads the new live session (live, URI_B) and passes the fence (current == generation == new), but retains the old file (file_A) for isVirtualPlaybackFile, cloneVirtualProbeTransient, bestResultCacheKey(file.ContentID, ...live.URI...), and probeVirtualSourceAndPersist(..., file, ...). The post-probe fence does not close this scheduling window — only downstream persistence guards stand between the mismatched pair and the catalog.

Fix (minimal, ordered):

  1. In PublishSourceCommitted, obtain the pair atomically via the existing sessionWithSourceGeneration(sessionID) (:1322, backed by GetSessionWithSourceGeneration), then load the file from that session's MediaFileID. Pass that generation through to refreshRotatedVirtualCandidateBackground instead of re-reading it separately.
  2. In refreshRotatedVirtualCandidateBackground, stop snapshotting generation apart from the session. Accept the atomic (session, generation) or re-derive both atomically inside, and reject if the binding moved before go func().
  3. In probeRotatedVirtualCandidate, after the live re-read + fence pass, reload/validate the file from live.MediaFileID (file.ID == live.MediaFileID, else drop) and use the live-derived file for the transient clone, neutral key, and persist call.
  4. Add a deterministic channel-gated test that rotates between the file lookup and the generation capture and proves the stale pair is dropped (zero persisted paths, serve-gate rejection). Extend the existing TestProbeRotatedVirtualCandidateRaceDoesNotPersistStaleEvidence rather than adding a sleep-based test.

Follow-ups (non-blocking, track separately)

  • Fence is checked once before mutate/persist (playback_virtual.go:3051-3089) — catches rotation during probe, not every rotation before persist completes. Don't claim atomic-commit semantics; rely on/document the persistence CAS.
  • detail.go dedup uses raw trimmed case-sensitive tier conflicts + ReleaseName, diverging from resolver-normalized PersistedIdentitiesMatch; exact-unprobed key omits provider identity/declared size. Add differential cases (churned dupes retained vs. distinct unprobed rows collapsed).
  • Recovery is lossy by design: detached-gate exhaustion abandons re-probe, and generation == 0 disables fencing. Confirm rotated prod sessions always carry nonzero generations; document/test post-saturation recovery.

Merge once the blocker + its regression test land and the required checks are green. Happy to re-review the fix head.

randrini added 2 commits October 5, 2026 01:08
PublishSourceCommitted read the session, then loaded the file from its
MediaFileID, then let refreshRotatedVirtualCandidateBackground snapshot the
generation separately. A rotation landing between the file load and the
generation read left probeRotatedVirtualCandidate re-reading the NEW live
session (so the fence passed) while still carrying the OLD file for the
virtual checks, transient clone, cache key, and persist call.

- PublishSourceCommitted now takes the (session, generation) pair from one
  read via sessionWithSourceGeneration (falling back to a session read plus
  a separate generation read), and loads the file from that session's
  effective row.
- refreshRotatedVirtualCandidateBackground re-reads the pair before
  scheduling and drops the work when the binding moved or the file no
  longer names the live effective row.
- probeRotatedVirtualCandidate re-derives the file from the live session's
  MediaFileID and drops the work unless it matches the carried file, so the
  transient clone, neutral key, and persist call all use the row the
  session actually serves.

Extend the deterministic rotation-race test with a schedule window (the
production scheduler, split session/generation reads) and an effective-row
window, alongside the resolve and probe stages.
@randrini

randrini commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator Author

Blocker fixed and pushed (f768f8ba, post-#218 main merged in cc7b3488):

  • PublishSourceCommitted now takes session+generation atomically via sessionWithSourceGeneration (split-read fallback explicitly documented as non-atomic) and passes the generation through.
  • refreshRotatedVirtualCandidateBackground takes the generation instead of snapshotting it; drops the work before go func() when the binding moved or the file no longer names the live row.
  • probeRotatedVirtualCandidate re-derives the file from live.MediaFileID after the live re-read and drops unless live/carried/file IDs all agree; transient clone, cache key, and persist all use the live file.
  • TestProbeRotatedVirtualCandidateRaceDoesNotPersistStaleEvidence extended to four deterministic channel-gated stages (schedule/effective-row/resolve/probe, no sleeps), each proving zero stale persists plus serve-gate rejection.
  • Non-blocking follow-ups intentionally untouched (tracked separately); removed the prior always-atomic pairing claim.
  • Verified: gofmt/diff-check clean, runnable Go packages pass + vet clean, 127 vitest green. vips-dependent packages gate in CI.

@drondeseries drondeseries left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: blocker closed, approve (94/100)

f768f8ba + 7cb43f08 on 7cb43f08ab:

  1. Torn-pair blocker — closed. PublishSourceCommitted now reads (session, generation) from one lock via sessionWithSourceGeneration; refreshRotatedVirtualCandidateBackground re-reads the pair before scheduling and drops when the binding moved or file no longer names the live effective row; probeRotatedVirtualCandidate re-derives the file and refuses unless liveFile.ID == live.MediaFileID == file.ID. The MediaFileID <= 0 path still fail-closes. The channel-gated race test covers the schedule/effective-row/resolve/probe stages.
  2. Test compile break — fixed (generation read moved above handler construction).

CI: 8/8 green on 7cb43f08 (Go contract checks, Go lint ×3, Go test, Web ×3).

Remaining items are follow-ups, not merge blockers: dedup tier parity with resolver.PersistedIdentitiesMatch, lossy recovery after detached-gate exhaustion, generation == 0 fencing semantics, and persistence CAS vs fence claim.

LGTM — merging is unblocked from my side.

@drondeseries
drondeseries merged commit 2fc0fe0 into main Oct 5, 2026
9 checks passed
@drondeseries
drondeseries deleted the fix/candidate-row-evidence branch October 5, 2026 00:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants