Project observed release format in saved release cases
Summary
Release cases already read canonical Spotify release IDs and ordered track slots (context_release_track_slots), but the saved album observation (album_type, total_tracks, release_date, label, external_ids) was never projected, so singles, albums and compilations could not be distinguished in review (ticket Done-when 1). This migration adds an immutable SRP helper project_context_release_format(p_album, p_tracks, p_coverage) and replaces read_context_release_case with exactly one added key, release_format, and one added gap. Only observed values are projected, with explicit states: format_state is single/album/compilation only when Spotify reports exactly that, otherwise unknown; track_coverage is full only when the collector reported full extent and every reported slot is saved, otherwise partial (page limit or a failed later page) or uncollected; disc_count comes from the whole observed track array (not the 100-slot display page) and is reported only with full coverage and a disc number on every slot, otherwise null; multi_disc is true as soon as any saved slot shows disc 2 or higher, false only when disc_count is a definite 1, otherwise null. A partial track list therefore never reports a definite single disc; upc is passed through only when reported (upc_state observed/not_observed/uncollected); reissue and physical_format are the literal unknown because Spotify metadata cannot establish them; state is uncollected before any observation exists. No provider calls, no new tables, no identity or rights writes; grants unchanged (service role only).
Needs explicit approval: the case fingerprint hashes the whole projected body, so adding release_format and its gap changes every fingerprint even though no evidence changed. After release, every previously saved release review reads stale: true and operators must re-review those cases. This is a projection change, not changed evidence; the fixture asserts staleness only for a real album_type change. Whoever approves the merge should accept this re-review cost (or ask for the fingerprint to exclude the new key).
Refs recoupable/app#2139 (child of epic recoupable/app#2116).
Merge sequencing
docs (no change) → database (this PR) → api recoupable/api#1021 → app recoupable/app#2220. No docs change: docs/api-reference/openapi/context.json documents no release-case actions yet; that gap is left to #2125 to avoid a JSON merge conflict. Depends on no OPEN PR; builds on merged 20261008160000/20261008160100. #2142 may later create or replace the same read function; this change is isolated to one key via the new helper so a rebase is trivial.
Verification
- Ran (local PostgreSQL 17.7,
export PATH="/opt/homebrew/opt/postgresql@17/bin:$PATH",LC_ALL=en_US.UTF-8):python3 -m unittest discover -s tests -p 'test_release_format.py'→ Ran 2 tests, OK (the inherited sibling review fixture against the replaced function, plus the new fixture ending inPASS: uncollected and unknown states, multi-disc album, disc beyond the 100-slot page, partial track list keeps disc structure unknown, single with observed UPC, repeated save without duplicates, changed-format staleness, service role and outsider denial).python3 -m unittest discover -s tests -p 'test_release_cases.py'→ Ran 1 test, OK (existing fixture unchanged).python3 -m unittest discover -s tests -p 'test_company_assessment.py'→ Ran 3 tests, OK.
- Red → green: with the migration file absent, the new fixture failed with
ERROR: release_format missing from uncollected case: {...}; after adding the migration both tests pass. The new python test subclassestests/test_release_cases.py(same pattern astest_company_assessment.py) instead of copying the harness. - Second commit (review follow-up): the fixture now also covers a 101-track release whose second disc starts only at slot 101; the display page shows 100 disc-1 slots while
release_formatreportsdisc_count: 2. Mutation check: limiting the helper input to the first 100 tracks makes the fixture fail withDisc structure beyond the 100-slot page was under-counted; the unmodified migration passes. - Third commit (independent review follow-up): the helper took the highest saved disc number as a definite count even when the saved track list was partial, so an album reporting 60 tracks with only its first 50 disc-1 tracks collected read
disc_count: 1, multi_disc: false. The helper now takestrackCoverageand reportstrack_coverage. New fixture cases: a saved 50-of-60 partial album (disc_count/multi_discnull), a partial list that already reaches disc 2 (multi_disc: true,disc_countnull), a claimedfullextent whose slot count differs from the reported total (treated as partial), and a full list with an unnumbered slot (disc_countnull).- Red: before the helper change the updated fixture failed (
Uncollected format was not explicit,track_coverageabsent). - Mutation check: restoring coverage-blind disc counting in the fixed migration makes the fixture fail with
Partial track list reported a definite disc structure: {... "disc_count": 1, "multi_disc": false, "track_coverage": "partial" ...}; the real migration passes. - Rerun after the last change (PostgreSQL 17.7):
test_release_format.pyRan 2 tests OK;test_release_cases.pyRan 1 test OK;test_company_assessment.pyRan 3 tests OK.
- Red: before the helper change the updated fixture failed (
- Environment note: on this macOS host the disposable cluster needs
LC_ALLset (postmaster became multithreaded during startup); CI on Ubuntu is unaffected..github/workflows/release-cases.ymlgains one line running the new test on PostgreSQL 15 and 16 (green on the first commit). - Not verified: the production database (the migration has not been applied there), hosted deployment of api/app, live providers, PostgreSQL 15/16 locally (CI matrix only), real Spotify payload shapes beyond the fixture and the collector's
.passthrough()album schema. The repository's Supabase GitHub integration automatically built a Supabase preview branch for this PR; that is the integration's standard PR behavior, not a manual apply, and it is not counted as verification.
Out of scope / known gaps
- Catalog member expansion with bounded concurrency and resumable child requests (Done-when 2 remainder): needs a Vercel Workflow plus child-request RPCs and the shared dispatcher; member collection stays policy-blocked.
- Reissue and physical-format detection: not observable from Spotify; requires other evidence sources (cross-source comparison is a separate slice).
- Docs OpenAPI documentation of release-case actions (none exists today; left to #2125).
labelis an observed string only; it does not verify rights, ownership or distribution.- Done-when 3 remainder: catalog members repeated across pages, partial failures during catalog expansion and access revocation are not covered here; this slice only keeps a partial release track list from producing a definite disc structure.
🤖 Generated with Claude Code