Save unreleased recordings and planned releases with internal IDs

SI
5d ago
+639-0

Summary

Adds 20261010214100_context_unreleased_music_entry.sql. With it, an account can save an unreleased recording or a planned release before any external identifier exists, and nothing is invented. The migration is additive. It widens the context_resources.resource_kind and context_subjects.kind checks with recording_input / planned_release_input resources and unreleased_recording / planned_release subjects. Each subject sits on an opaque urn:recoup:* resource, so no private title leaks into a global row. The ISRC-backed recording arm of context_subjects_check is untouched, so every current ISRC consumer keeps working (Done-when 1). It also adds four service-role functions:

  • create_context_unreleased_recording_request: title, optional working title, lifecycle (unknown when omitted) and planned date. The ISRC is stored as unknown.
  • create_context_planned_release_request: lifecycle (unknown when omitted), products/formats and promotional or pre-save links. Store IDs are fixed at {spotify, apple_music, upc: unknown}, and any caller key for ISRC, UPC or store IDs is rejected (Done-when 2). The tracklist references recordings already saved in the workspace, with positions taken from WITH ORDINALITY, so one recording can sit on several planned releases (Done-when 3, partial). References are checked only when the request is first saved, so an identical replay returns the saved request even after a referenced recording is withdrawn. The referenced recordings' sources and versions are share-locked before the check, as accept_context_result does, so a concurrent withdrawal waits for the save.
  • list_context_unreleased_recording_request_target and list_context_planned_release_request_target: workspace-scoped target reads that hide withdrawn sources. The fixture uses them as the read path; no API code calls them yet.

Both entries are saved as customer_assertion evidence. The request stays partial with explicit gaps.

Refs recoupable/app#2141 (child of epic recoupable/app#2116).

Merge sequencing

docs (recoupable/docs#370) → database (this PR) → api (recoupable/api#1023). Apply this migration before deploying the API actions. Another sibling database PR that rewrites context_subjects_check or the kind checks must keep unreleased_recording / planned_release, just as this PR keeps every current kind, including video and material. This PR depends on no open PR.

Verification

  • Ran on disposable local PostgreSQL 17.7 (LC_ALL=en_US.UTF-8), head c501fa1:
    • python3 -m unittest discover -s tests -p 'test_unreleased_music.py' → 3 tests OK (fixture PASS: marker, the inherited release-case test, and the new two-session lock test).
    • python3 -m unittest discover -s tests -p 'test_release_cases.py' → 1 test OK.
    • python3 -m unittest discover -s tests -p 'test_company_assessment.py' → 3 tests OK.
  • The fixture covers:
    • Saves through the service role, title normalization, replay and the changed-input idempotency conflict.
    • Rejection of caller-supplied isrc, store_ids and upc, plus invalid lifecycle (including JSON null), date, format and link inputs and more than 20 links.
    • Explicit unknown identifiers in the result and its coverage, with no private title in the global resource; an omitted lifecycle stored as unknown for both entries.
    • One recording on two planned releases, with tracklist positions; an ISRC-backed recording with current evidence is accepted too.
    • Rejection of an unknown recording, a recording from another workspace, a duplicate slot and a withdrawn recording on a new key; an identical replay of an existing planned release still returns it after the withdrawal.
    • Target reads run as service_role, denied (P0002) to another workspace and after source withdrawal; an earlier release snapshot is not rewritten.
    • insert … kind='recording' without an ISRC still violates the check, and an unreleased_recording subject cannot carry song_isrc.
    • anon and authenticated cannot execute any of the four functions; service_role can execute all four.
  • Red → green:
    • Without the migration the fixture fails ("function … does not exist").
    • With only the optional-lifecycle change applied to the previous migration, the fixture fails with Planned release references an unknown recording at the identical replay after withdrawal; it passes with the reordered check.
    • With the share lock removed, the lock test fails (AssertionError not raised: the withdrawal commits immediately); with it, the withdrawal times out on the lock and succeeds after the save rolls back.
  • CI: added the test_unreleased_music.py line to .github/workflows/release-cases.yml (PostgreSQL 15/16 matrix). Its result on this head is not recorded here.
  • Not verified: no hosted or production database (no migration was applied anywhere remote), no hosted deployment, no live providers.

Out of scope / known gaps

  • Later identifier assignment (candidate, accepted or conflicting ISRC/UPC/store ID linked to an ISRC-backed recording or a Spotify release) and its conflicting-ID tests are slice 2 of #2141. Because lifecycle is expressed through separate subject kinds, slice 2 needs a typed link from an unreleased_recording / planned_release subject to the identifier-backed subject; it should consume the external-ID observations from #2142 and #2143 rather than duplicate them.
  • No composition, ownership, roster or rights linkage. Saving a title asserts none of these.
  • No audio or file intake (#2140) and no campaign linkage (#2138).

🤖 Generated with Claude Code