Resolve songwriter name requests to selected professionals

SI
6d ago
+352-0

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 to context_requests(id, owner_id), and only allows resolution_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, empty search_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 resolvedProfessionalId and removes the unconfirmed-identity gap. Input, sources and results stay unchanged.
  • list_context_songwriter_request_target is replaced with its readiness logic unchanged. It now reports identityConfirmed: true plus professionalId only when a resolution exists.
  • CI: release-cases.yml also runs the new suite on PostgreSQL 15 and 16. tests/README.md documents 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' -v gave 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.py 11 passed, test_company_assessment.py 3 passed, test_release_cases.py 1 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 Preview check 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) and release-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.py 11/11, test_professional_roster.py 11/11, test_company_assessment.py 3/3, test_release_cases.py 1/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_context roster 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_request returns the stored row for every request kind, so this matches existing behaviour; consumers that act on the identity (list_context_songwriter_request_target, and therefore plan) 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, because organization_professionals has no unique(id, organization_id) yet (open database#90 adds one). The key can be added once that lands.

🤖 Generated with Claude Code