feat: enforce matching decisions for playlist suggestions - #31
Draft
Iranman wants to merge 2 commits into
Draft
Conversation
Iranman
added a commit
that referenced
this pull request
Jul 23, 2026
Corrects integrity defects from independent review of PR #31: decision_version is now mandatory (400 decision_version_required) instead of optional, apply is strictly all-or-nothing (any stale/conflicted/review-required/duplicate row rejects the whole batch with zero writes), persistence verification and rollback now key off a stable per-row manifest row_id instead of "any row with this text" (fixing false verification and cross-row rollback ambiguity), replay of an already-applied submission is a true idempotent no-op, AcoustID evidence now models lookup_failed/no_match/matched/conflict/mapped_unverified truthfully instead of collapsing them into contradictory states, library- identity verification re-reads the exact Beets item by id instead of trusting a cached index payload, and the frontend now branches on the structured error code (stale/in-progress/rejected) instead of treating every failure alike.
Migrates playlist missing-track suggestions (GET /api/playlists/<name>/suggestions, POST /api/playlists/<name>/apply-safe-suggestions) to the shared backend-authoritative matching contract introduced for Import Review (PR #19), so playlist auto-resolution eligibility is deterministic, backend-controlled, stale-safe, auditable, and rollback-capable instead of ad hoc confidence/safe/reason heuristics.
Corrects integrity defects from independent review of PR #31: decision_version is now mandatory (400 decision_version_required) instead of optional, apply is strictly all-or-nothing (any stale/conflicted/review-required/duplicate row rejects the whole batch with zero writes), persistence verification and rollback now key off a stable per-row manifest row_id instead of "any row with this text" (fixing false verification and cross-row rollback ambiguity), replay of an already-applied submission is a true idempotent no-op, AcoustID evidence now models lookup_failed/no_match/matched/conflict/mapped_unverified truthfully instead of collapsing them into contradictory states, library- identity verification re-reads the exact Beets item by id instead of trusting a cached index payload, and the frontend now branches on the structured error code (stale/in-progress/rejected) instead of treating every failure alike.
Iranman
force-pushed
the
matching-contract-playlist-suggestions
branch
from
July 24, 2026 02:19
4884819 to
ad0eaf6
Compare
This was referenced Jul 28, 2026
Iranman
added a commit
that referenced
this pull request
Jul 30, 2026
* fix: close non-app backend path flows and sanitize errors (SEC-002 wave 2) Independently traced and closed all 25 open CodeQL alerts in routes_submissions.py, routes_setup.py, routes_lidarr.py, and backend/transaction_engine.py (scope agreed with repo owner; app.py, index.html, and frontend files remain out of scope for a future wave). Genuine fixes: - routes_submissions.py _abs_resolved(): performed zero containment before Path.expanduser().resolve(); reachable from an authenticated GET query parameter (/api/submissions/target?path=...) with no other validation in the chain -- an arbitrary directory-read/audio-tag-parse primitive anywhere in the container filesystem. Fixed by requiring the resolved path fall under MUSIC_ROOT or DOWNLOADS_ROOT, reusing the existing _path_is_under()/_SUBMISSION_ALLOWED_ROOTS already present in the file (CodeQL #128). - routes_submissions.py: sanitized 3 genuinely broad `except Exception` handlers (submission_target, submission_reference_url's metadata fetch, validate_musicbrainz_release) that returned raw exception text; deliberate ValueError/KeyError paths with fixed safe messages are unchanged (CodeQL #323, #34, #36). - routes_setup.py: sanitized 6 broad-except response leaks (_write_env_file x2 call sites, AI/MusicBrainz/AcoustID/Plex connectivity tests) plus 2 unreported instances of the identical pattern found while reviewing the same functions (fpcalc check, dormant _check_path helper) (CodeQL #24, #25, #26, #27, #322). - routes_setup.py setup_test_ai(): "openai.com" in base_url matched any URL merely containing that substring; replaced with a real parsed- hostname check (CodeQL #102, py/incomplete-url-substring-sanitization). - routes_lidarr.py wanted_lidarr(): the true unbounded str(exc) source is app.py's _acq_fetch_lidarr_wanted() (out of scope this wave); fixed at this file's boundary instead, only echoing the known-safe "not configured" message (CodeQL #19). Proven false positives (14 total, dismissed on GitHub, each with an individual justification and a passing behavioral test -- see docs/TECHNICAL_DEBT.md SEC-002 for full per-alert detail): - #23, #28, #30, #31, #32, #33, #321: ValueError/KeyError raised only with fixed, deliberately-crafted, non-sensitive messages (env-var key names, numeric IDs the caller supplied, or OutboundPolicyError's own safe text), never a wrapped raw system exception. - #20, #21: routes_lidarr._http_error_message() already redacts every exception type except a RuntimeError carrying one of two fixed config-status strings from _lidarr_config_error(). - #129, #130, #131, #132, #133: TransactionStore._path() requires a txn_ prefix and rejects /, \, and NUL before any of its 5 flagged sink lines; it is the only path-construction site in the file. Also corrects docs/TECHNICAL_DEBT.md SEC-002's prior description of PR #52's #332/#334/#335 (previously described only as "fixed"; records that CodeQL's rescan still flagged them post-fix and they required a separate post-merge dismissal, with the reason each fix wasn't recognized by CodeQL's static model). * docs: update SEC-002 remediation history Corrects the PR #52 record (#332/#334/#335 required a post-merge dismissal, not just a code fix -- CodeQL's static model didn't recognize either mitigation) and records wave 2's full accounting: starting/in-scope counts, alert numbers, root-cause groups, fixes, dismissals, remaining 287-alert count (all in app.py/frontend), and the next recommended wave. * fix: check Lidarr config status directly instead of matching error text CodeQL flagged a new alert (#407) against the previous wanted_lidarr() fix: "not configured" in error.lower() distinguishes safe-to-echo messages from _acq_fetch_lidarr_wanted()'s own unbounded str(exc) by pattern-matching the exception TEXT, which isn't airtight -- a coincidentally-worded exception could slip through. Fixed by checking this file's own type-safe _lidarr_config_error() before ever calling _acq_fetch_lidarr_wanted(), removing the text-matching entirely.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Migrates one additional matching consumer -- playlist missing-track suggestions -- to the shared backend-authoritative matching contract PR #19 established for Import Review's attach-recording endpoint.
14c7f02d778326792cf9984aed9d2a8224536dfc(main, includes merged PR fix: enforce matching decisions for import review attachment #19 and PR fix: bump Next.js to 16.2.11 for high-severity advisory cluster #30/Next.js 16.2.11)4884819094a57f0aa54b7efbebcd4fbb1ffe7145Update (review-correction pass, head
4884819): independent review found the first pass (1880970) accepted a missingdecision_version, allowed a batch to partially apply when one row was stale/conflicted, and verified/rolled back persisted rows by matching artist/title text instead of an exact row identity (both false-positive- and cross-row-prone). All three are fixed below -- see "Review-correction summary."Endpoints migrated
POST /api/playlists/<name>/resolve-track(manual resolution) is untouched -- it stays an explicit, separately-audited user action, not folded into automatic eligibility (per scope).Trust boundary
The backend rebuilds candidates and the matching decision fresh on every request; the browser never supplies or is trusted for safety, confidence, conflicts, or eligibility.
POST apply-safe-suggestionsnow accepts only{ suggestions: [{ track_key, mb_trackid?, item_id?, decision_version }] }. Any other client-supplied field (safe,confidence,conflicts,action_eligibility, ...) is parsed and ignored.GET suggestionsresponses are enriched with the same decision shape Import Review uses:mb_trackid/mb_albumid/mb_releasegroupid,decision_version(drv2:), and a fulldecisionobject (confidence_score,safety_key,review_required,requires_confirmation,conflicts,warnings,eligibility_reason,action_eligibility), plus anevidenceblock. The legacy top-levelconfidence/safe/reasonfields remain for compatibility but are now derived only from the decision and marked@deprecatedin TypeScript.Matching-contract changes (
backend/matching_contract.py)build_recording_matching_decision()gained two additive, opt-in parameters -- Import Review's own call sites never pass them, so its behavior is bit-for-bit unchanged:library_identity_verified: bool = False-- lets a caller assert "this candidate is an existing Beets library item whose artist/title deterministically match the query." When true and the match is strong (title/artist similarity >= 0.94/0.90, no conflicts), the safety ladder reachessafety_key: "safe"without requiring a MusicBrainz Recording ID at all -- playlist resolution never needs to write a Recording ID, only correct the playlist's own desired-track entry.action_eligibility.playlist_resolve_without_reviewfield reflects this (attach_without_review OR library_eligible).attach_without_reviewwas re-scoped fromsafety_key == "safe"to the underlying recording-ID-specific boolean directly, so a library-identity-only "safe" decision can never be misread as "safe to attach a Recording ID" (there may be none).extra_conflicts: Sequence[str] = ()-- lets a caller fold an adapter-level conflict (e.g. a competing-candidate tie) into the same conflicts list the safety ladder anddecision_versionhash both already consume, so a forced-review state can never be applied after the version was computed (which would desync the hash from what was displayed).Safe eligibility rules (playlist_resolve_without_review)
attach_without_review(resolved Recording ID, release-group identity present, no conflicts, evidence-supported). A plain MB text-search hit without release-group resolution stays review-required -- this PR does not add release-group resolution to the playlist MB path (scoped out; see Known limitations).AcoustID / AI
_acoustid_fingerprint_ids()helper whenever the top library candidate has a resolvable file path; when it doesn't, fingerprint state is truthfullynot_attempted(never reported as matched/verified).Review-correction summary
decision_version: every submitted row must include a nonemptydecision_versionor the whole batch is rejected400 decision_version_required(or the generic400 playlist_batch_rejectedwhen mixed with other malformed rows) before any candidate lookup happens. A row that supplies some version (even forged) is no longer "malformed" -- it is correctly evaluated and rejected for staleness instead, never silently trusted.409for business-rule rejections,400for structurally malformed ones) with zero manifest writes and no transaction created. Only a batch where every row is either already-resolved (idempotent no-op) or newly safe-to-apply proceeds, and then exactly one manifest write covers all of it.track_keysubmitted twice with different candidates/versions is rejected (playlist_duplicate_submission, folds into the same all-or-nothing rejection); an exact duplicate of the same row is silently deduplicated before validation (not applied twice, no duplicate rollback record).row_id): the desired-track manifest schema gained arow_idfield, minted once (self-healing/idempotent migration for manifests written before this existed) and preserved through every read/write/merge.track_keyin the API is thisrow_id. Persistence verification and rollback now locate and confirm the exact row byrow_id-- never "any row whose text happens to match" -- so two rows resolving to the same artist/title, or a pre-existing unrelated row that already has the target text, can never be confused with each other or double-counted as proof of a change.row_idin the full manifest (not just the "still missing" subset) and compares its current identity to what the submission would resolve to. An exact match is a genuine200 {"changed": false, "unchanged": [...], "reason": "already_resolved"}no-op -- no second write, no second transaction -- rather than the previous behavior of reporting a409stale rejection just because the row had moved out of the "missing" list.not_attempted/lookup_failed/no_match/matched/conflict/mapped_unverifiedinstead of collapsing a real fpcalc/network exception into "no evidence," or reporting a real mapped recording id while simultaneously claimingno_result.conflict(AcoustID disagrees with the item's own Recording ID) reaches the shared contract as a genuine fingerprint mismatch and blocks eligibility, exactly like Import Review's own AcoustID handling.item_id(lib.get_item) rather than trusting the (possibly stale-cached) library index payload; a deleted/moved item is dropped from the candidate set entirely instead of being offered as a safe suggestion from cached data.Failed(sanitized message, no raw paths/exceptions) and returns500/playlist_persistence_failed-- it can never end a request leftRunning.handleApplySafeSuggestionsnow inspects the response's structuredcodevia the existingapiErrorBody()helper instead of treating every rejection identically.playlist_suggestions_stalereloads suggestions;playlist_update_in_progressand batch-rejection codes (playlist_batch_rejected,playlist_review_required,playlist_conflict,candidate_not_in_trusted_set,decision_version_required,playlist_duplicate_submission) show counts/reasons without clearing the suggestion rows; only a fully atomic success clears rows and reloads playlist detail.manifest_updated/m3u_updated(alwaysfalse) /sync_required, so the UI never implies the M3U or Plex sync already reflects a manifest-only change (consistent with the pre-existing, unauditedresolve-trackaction's own scope).Stale-decision / staleness behavior
At apply time the backend: acquires a per-playlist reservation -> reloads the manifest (self-healing
row_idmigration happens here if needed) -> rebuilds the trusted candidate set fresh for every row -> verifies every row's structure (nonemptydecision_version) -> verifies every row's candidate is in the trusted set -> checks idempotency (row already resolved to this identity) -> verifiesdecision_versionmatches -> verifiesplaylist_resolve_without_reviewis still true. Any single row failing any of these checks (other than the idempotent-no-op case) rejects the entire batch:409 playlist_suggestions_stale/playlist_review_required/playlist_conflict/etc. (the specific code when every blocking row shares one cause, else the genericplaylist_batch_rejected), or400 decision_version_required/playlist_batch_rejectedfor structurally malformed rows. Zero rows are written and no transaction is created for a rejected batch.Batch / atomicity behavior
apply-safe-suggestionsvalidates the entire submitted batch in two phases -- structural (dict shape, nonemptytrack_key/decision_version, duplicate-submission detection) then business (trusted-set membership, idempotency, staleness, eligibility) -- before writing anything. Any blocking row anywhere in the batch rejects it wholesale with zero mutation. Only when every row is either an idempotent no-op or newly safe does it proceed: exactly one manifest write (_playlist_replace_rows_by_id, keyed by each row's exactrow_id) covers every applied row. The manifest is re-read afterward and each applied row'srow_idis individually confirmed to hold its exact intended identity before the transaction is markedCompleted; if any row fails this exact-identity verification the transaction is markedFailedand the response is500 playlist_persistence_failed(never a false partial success). Reapplying an already-resolved row is a true no-op (unchanged, reasonalready_resolved) with no duplicate manifest write or transaction.Concurrency
Reuses the same process-local, per-key-reservation pattern PR #19 established for attach-recording (
_reserve_attach_recording_item/_ATTACH_RECORDING_RESERVATIONS_LOCK), scoped to playlist name: a secondapply-safe-suggestionsrequest for the same playlist gets409 playlist_update_in_progressimmediately; different playlists proceed fully in parallel. The reservation is released on success, every rejection path, and any unhandled exception. This deployment runs a singlepython app.pyprocess (seeDockerfile), so process-local locking is sufficient here, matching the existing attach-recording assumption -- documented as a known limitation if that ever changes.Transaction / audit behavior
Each successful batch creates one
TransactionStorerecord,operation_type: "Playlist Match"(added tobackend/transaction_engine.py'sTRANSACTION_TYPES), with onechanges[]entry per resolved row keyed byrow_id:current_metadata/new_metadata/metadata_diff,candidate_identity,persisted_identity(populated only from the post-write, per-row_id re-read -- never left{}on aCompletedtransaction),decision_version,confidence,reason,warnings,evidence. Rollback data (playlist_track_restoreoperations, one per row, each carrying that row's exactrow_idplus its previous/applied identity) is recorded before the manifest write. Any exception during the write/verify sequence marks the transactionFailedwith a sanitized message -- it is never leftRunning.Rollback behavior
playlist_track_restoreis a new operation type wired into the existing generic/api/transactions/<id>/rollbackdispatcher (_run_playlist_track_restore). It locates the exact row by its recordedrow_id(never by artist/title text), verifies the row's current identity still matches the recorded applied state (playlist_rollback_state_changedif something else changed it since -- refuses to overwrite blindly), restores the previous identity via the same_playlist_replace_rows_by_idpath, then re-reads the manifest and confirms that exactrow_idnow holds the previous identity (playlist_rollback_persistence_failedif not;playlist_rollback_row_missingif the row no longer exists at all). A restore that doesn't verify contributes toPartially Rolled Back, never a falseRolled Back. This is a metadata/manifest rollback only -- like attach-recording's own rollback, it does not restore downloaded audio or deleted files.Frontend changes
frontend/src/api/types.ts: newPlaylistSuggestionDecision/PlaylistActionEligibility/PlaylistSuggestionSubmission/PlaylistSuggestionOutcomeRowtypes;PlaylistTrackSuggestiongained the decision-shaped fields and markedconfidence/safe/reason@deprecated;PlaylistApplySuggestionsResponsenow matches the actual bucketed response shape.frontend/src/api/client.ts:applySafePlaylistSuggestions(name, suggestions)now takes and sends only the minimal submission list.frontend/src/views/Playlists.tsx:handleApplySafeSuggestionsbuilds submissions only fromtrack_key/mb_trackid/item_id/decision_version, filters bydecision.action_eligibility.playlist_resolve_without_review, reports all five outcome buckets to the user, and re-fetches playlist detail afterward (the apply response no longer carries a full detail payload).safeSuggestionCountreads the decision field directly. No redesign of the page, no new state library, existing pipeline controls untouched.Structured error codes
playlist_not_found,playlist_track_not_found,playlist_candidate_required,candidate_not_in_trusted_set,decision_version_required,playlist_suggestions_stale,playlist_batch_rejected,playlist_duplicate_submission,playlist_review_required,playlist_conflict,playlist_update_in_progress,playlist_persistence_failed,playlist_rollback_row_missing,playlist_rollback_state_changed,playlist_rollback_persistence_failed.(
invalid_recording_id,playlist_evidence_unavailableare reserved for future use; not reachable in this slice's scope.)Explicitly out of scope (untouched)
Playlist download methods (SLSKD/SpotiFLAC/yt-dlp/SoundCloud), staged-folder naming, import-downloaded workflow, Beets import behavior, Plex path mapping/sync, playlist source parsing/syncing, quality cleanup, Library Cleanup, Replacement jobs, submission workflows, album matching, Import Review behavior, PR #19 attachment behavior, AI provider configuration, manual
resolve-track(still a separate, unaudited-in-this-PR explicit action).Files changed
No package/dependency files, screenshots, or documentation changed.
Tests
test_import_review_attach_enforcement,test_import_review_attach_integrity,test_security_hardening,test_transaction_engine,test_transaction_integration,test_ai_batch_retry_race,test_ai_batch_import_reliability,test_matching_contract,test_routes_setup,test_review_queue_singleton_items): 367 tests, OK.test_playlist_saved_discovery,test_playlist_backend_job,test_playlist_pipeline,test_playlist_match_quality,test_playlist_resume,test_playlist_provider_album_guard,test_playlist_detail_performance): 87 tests, OK (pre-existing static-assertion tests updated only for the intentional rename/refactor, not weakened).unittest discover -s tests -p "test_*.py"run twice: 1286 tests, OK (1 skipped) both times (up from 1264; +22 new tests this pass covering mandatory decision_version, atomic-batch rejection, duplicate submissions, exact-row identity, AcoustID states, fresh library-identity re-read, and true idempotent replay).py_compile,security_secret_scan.py,validate_compose_security.py,git diff --check,git status --short: all clean.npm ci/typecheck/lint/build): all clean.npm audit --audit-level=high: 0 vulnerabilities.RUN_DOCKER_SMOKE=1,BeetsFreshInstallDockerSmokeTests): OK.Known limitations
attach_without_review(full release-group resolution required); this PR does not add MusicBrainz release-group resolution to the playlist search path, so a plain MB text-search hit always requires review here. Documented rather than expanded, to keep this slice narrow.python app.pyentrypoint); if the deployment ever moves to multiple web workers, this (and attach-recording's own reservation) would need a persistent/shared reservation instead.resolve-trackaction's own scope -- suggestion application only ever rewrites the desired-track manifest, never the M3U file or pipeline checkpoint. The response now says so truthfully (manifest_updated/m3u_updated: false/sync_required) instead of implying otherwise.resolve-trackremains unaudited viaTransactionStorein this PR (out of scope, per spec) and still uses the original text-based_playlist_apply_manifest_replacementspath (unchanged); it stays entirely separate from automatic eligibility and from the newrow_id-based apply/rollback path.row_idmanifest-schema migration is self-healing (assigned on first read/write of a legacy manifest) but not instantaneous: two concurrent first-time migrations of the same never-yet-migrated manifest could each mint different ids for the same logical row before either write lands; this fails closed (a mismatchedrow_idis reportedstale/not found, never applied to the wrong row) rather than silently misapplying anything.