Project observed release format in saved release cases

SI
9d ago
+335-0

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 in PASS: 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 subclasses tests/test_release_cases.py (same pattern as test_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_format reports disc_count: 2. Mutation check: limiting the helper input to the first 100 tracks makes the fixture fail with Disc 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 takes trackCoverage and reports track_coverage. New fixture cases: a saved 50-of-60 partial album (disc_count/multi_disc null), a partial list that already reaches disc 2 (multi_disc: true, disc_count null), a claimed full extent whose slot count differs from the reported total (treated as partial), and a full list with an unnumbered slot (disc_count null).
    • Red: before the helper change the updated fixture failed (Uncollected format was not explicit, track_coverage absent).
    • 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.py Ran 2 tests OK; test_release_cases.py Ran 1 test OK; test_company_assessment.py Ran 3 tests OK.
  • Environment note: on this macOS host the disposable cluster needs LC_ALL set (postmaster became multithreaded during startup); CI on Ubuntu is unaffected. .github/workflows/release-cases.yml gains 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).
  • label is 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