Save unreleased recordings and planned releases with internal IDs
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 asunknown.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 fromWITH 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, asaccept_context_resultdoes, so a concurrent withdrawal waits for the save.list_context_unreleased_recording_request_targetandlist_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 (fixturePASS: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_idsandupc, plus invalid lifecycle (including JSONnull), date, format and link inputs and more than 20 links. - Explicit
unknownidentifiers in the result and its coverage, with no private title in the global resource; an omitted lifecycle stored asunknownfor 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 anunreleased_recordingsubject cannot carrysong_isrc.anonandauthenticatedcannot execute any of the four functions;service_rolecan 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 recordingat 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.pyline 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
recordingor a Spotifyrelease) 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 anunreleased_recording/planned_releasesubject 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