Resolve songwriter name requests to selected professionals
Summary
Adds 20261010213600_context_songwriter_identity_resolution.sql. Context intake (create_context_songwriter_name_request) saves a submitted songwriter name as a workspace-private, unresolved candidate. Before this PR, nothing could connect that candidate to the organization's professional roster (organization_professionals), so the request kept the gap "Songwriter identity not confirmed" indefinitely. This PR lets an operator resolve an ambiguous name to an explicitly selected professional before any enrichment. It never matches by name and never enrolls anyone in a roster.
context_songwriter_identity_resolutions: an organization-private receipt with RLS. It allows one row per request and one per idempotency key, has a composite FK tocontext_requests(id, owner_id), and only allowsresolution_basis = 'operator_confirmed'. Only service_role can select or insert; anon and authenticated are revoked.resolve_context_songwriter_identity(p_actor, p_owner, p_request, p_professional, p_key)(security invoker, emptysearch_path, service_role only):- Takes advisory locks per key and per request.
- Rechecks membership with
FOR SHARE(42501 when membership is revoked or missing). - Reuses the existing readiness checks for the request, including that the submitted-name evidence has not been withdrawn (22023 otherwise).
- Requires the professional to belong to the same workspace (42501, never "not found").
- Returns the stored receipt on an exact replay. A changed key, a second professional for the same request, or a different actor is a 23505 conflict, not an overwrite.
- Updates only the request output: adds
resolvedProfessionalIdand removes the unconfirmed-identity gap. Input, sources and results stay unchanged.
list_context_songwriter_request_targetis replaced with its readiness logic unchanged. It now reportsidentityConfirmed: trueplusprofessionalIdonly when a resolution exists.- CI:
release-cases.ymlalso runs the new suite on PostgreSQL 15 and 16.tests/README.mddocuments coverage and rollout.
Refs recoupable/app#2136 (child of epic recoupable/app#2116).
Merge sequencing
docs recoupable/docs#369 → database (this PR) → api recoupable/api#1022 (resolve_songwriter_identity). Apply after 20260925100000_context_songwriter_name_entry.sql and 20261008050000–20261008050300 (professional roster, all merged). Does not depend on any open PR. No app change in this slice.
Verification
- Ran (disposable local PostgreSQL 17.x,
LC_ALL=C):python3 -m unittest discover -s tests -p 'test_songwriter_identity_resolution.py' -vgave 11 run, 11 passed, 0 failed. That is 10 new cases plus the inherited release-case lifecycle test. - Same suite on local PostgreSQL 15.10: 11 run, 11 passed.
- Regression (PostgreSQL 17):
test_professional_roster.py11 passed,test_company_assessment.py3 passed,test_release_cases.py1 passed. - Red → green: with the migration file removed, the same suite failed 10 of 11 (
function public.resolve_context_songwriter_identity(...) does not exist,relation "public.context_songwriter_identity_resolutions" does not exist). With the migration, all 11 pass. - Covered cases:
- A new resolution links the request to the selected professional, and the target and read reflect it.
- An exact replay and 16 parallel identical calls produce one receipt.
- The same key with a different request, professional or actor conflicts.
- A second professional is rejected rather than overwriting the first.
- A professional from a foreign workspace, or an unknown ID, is denied.
- Revoked or never-granted membership is denied, including on replay.
- When professionals share a name, none is selected until an ID is given.
- Unready requests, withdrawn evidence and invalid keys are rejected.
- Browser roles cannot read or execute.
- A failed receipt insert rolls back the request-output update.
- Applied automatically by the Supabase Preview integration to this PR's hosted preview branch (the
Supabase Previewcheck passes); that is the integration's per-PR behaviour, not a manual apply. It has not been applied to production or any other hosted database. - GitHub CI:
release-cases (15)andrelease-cases (16)pass, which run this suite. - Re-ran after the review round (no database change; HEAD 0d1c66f, local PostgreSQL 17.7,
LC_ALL=C):test_songwriter_identity_resolution.py11/11,test_professional_roster.py11/11,test_company_assessment.py3/3,test_release_cases.py1/1. - Not verified: live providers, production database, and an end-to-end API call against a migrated database.
Out of scope / known gaps
- No roster enrollment, professional creation, name matching, enrichment dispatch, login account, catalog rights or mandate semantics.
- No chat UI. No company-name resolution. The
commit_spotify_contextroster side-effect audit belongs to #2147/#2140. - Un-resolving (withdrawing a resolution) is not supported. A wrong selection currently needs a new songwriter-name request.
- Withdrawing the submitted-name source after a resolution leaves the stored request output (
resolvedProfessionalId, no identity gap) as it was.read_context_requestreturns the stored row for every request kind, so this matches existing behaviour; consumers that act on the identity (list_context_songwriter_request_target, and thereforeplan) recheck the evidence and refuse a withdrawn request. A concurrent resolve and withdraw can only end in the same state as resolve-then-withdraw. - The professional's workspace is enforced by the RPC, not by a composite
(professional_id, owner_id)foreign key, becauseorganization_professionalshas nounique(id, organization_id)yet (open database#90 adds one). The key can be added once that lands.
🤖 Generated with Claude Code