Add read-only identity conflict inventory audit script
Summary
Adds supabase/audits/identity_conflict_inventory.sql, a standalone read-only audit script. It is not a migration. It runs with psql as one BEGIN READ ONLY transaction that ends in ROLLBACK, so the ticket's baseline needs no schema change, release step or new grant. It returns one jsonb row of aggregate counts, never IDs, names, URLs, ISRCs or provider IDs, for twelve identity-conflict classes:
- duplicated Spotify artist ids across accounts
- accounts with several Spotify ids
- case-variant and all-lowercase ids
- Context identity links in
conflictstatus - one artist subject accepted for several resources
- accepted Context identities that disagree with legacy socials, in either direction
- accepted identities with no legacy Spotify social on either side
- same-name, name-only artists
- roster rows without organization attachment, and the inverse
- one provider
track_idon several ISRCs
Each class carries its proposed handling rule and names recoupable/app#2119 as the enforcement owner. Every rule is quarantine or review, never an automatic merge, delete or backfill. The report includes:
transaction_read_onlybypasses_row_security, because a role subject to RLS would silently undercountmigration_history:present,not_present, ordeniedwhen the role cannot read it
Classes whose tables are absent report not_applicable.
tests/IDENTITY-INVENTORY.md documents:
- each class with its production constraint, conflict-handling rule and compatibility rule
- the api and app writer paths
- the public/private storage classification
- the schema-verification checklist
- an explicit "Live access" section
This advances Done-when items 1 (reproducible aggregate counts and writer paths), 2 (a rule for each constraint), 3 (storage classification) and 4 (missing live access stated).
Corrections to the scoping plan, checked against the code:
- Spotify ids are case-sensitive, so the onboarding expression is used, not
lower(). - "More than one accepted identity per Context resource" cannot happen under
context_one_accepted_identity, so the Context classes measure the directions that index does not cover. song_identifiersmulti-mapping counts onlytrack_id, becausealbum_idlegitimately maps to many songs.- The 2026-07-29 cleanup manifests get no class, because
20260729210000dropped them.
Changes after review (second commit):
- The function migration became an audit script, so no production mutation is needed to take the baseline.
- Disagreement is now counted on the account side as well. An accepted identity whose account carries a different Spotify social is no longer reported as a benign attachment candidate.
- The manifest class was removed.
- The writer-path inventory was corrected. It now covers:
- the catalog-to-roster side effect in
attachCanonicalArtistToAccount - the name-keyed
getPreferredArtistAccountIdsresolver, which usesilikeand creates accounts by name - account-provisioning callers
- MCP paths
- app writers that reach the api only over HTTP, plus the app's direct writers that have no live callers
- the catalog-to-roster side effect in
- The pipeline test now calls
commit_spotify_contextand asserts the refusal.
Refs recoupable/app#2117 (child of epic recoupable/app#2116).
Merge sequencing
docs → database → api → app. This ticket touches only the database repository. The audit is not exposed through HTTP or MCP, so no api, app or docs PR accompanies it. It depends on no open PR and references none of the tables in database#88/#89/#90/#92/#94/#95. Merging it deploys nothing. Running it against a hosted database is a separately approved, read-only operator step.
Verification
- Ran
LC_ALL=C PATH=/opt/homebrew/opt/postgresql@17/bin:$PATH python3 -m unittest discover -s tests -p 'test_identity_conflict_inventory.py': 7 passed, 0 failed, 0 skipped on PostgreSQL 17.7. The same command withpostgresql@15passed 7 of 7 on PostgreSQL 15.10. The suite runs the script file withpsql, the way an operator would. It checks:- exact counts for every seeded class
- counts-only output
- one read-only transaction, with no table, relation or function changes and no leftover setting
- the RLS flag for a reader without bypass, and failure for
anon/authenticated not_applicable/deniedgatingcommit_spotify_contextraisingConflicting artist identityon the duplicated id that the audit counts, with every table unchanged
- Ran the existing suites on PostgreSQL 17:
test_spotify_onboarding.py18 OK,test_research_roster.py27 OK. - Red → green: in a scratch copy where the account-side comparison was disabled, as in the first commit,
test_seeded_conflicts_report_exact_countsfailed with1 != 2on the disagreement class. With the fix it passes. In the first commit, all 7 tests failed before the inventory existed. - Writer paths were found by searching api
c72887aand app025342d, following callers up to routes and handlers. - GitHub Actions on head
0b18af0:identity-inventorypassed on PostgreSQL 15 and 16.credit-charge-receipts,release-cases (16),release-playersandstoragealso passed.onboarding,release-cases (15)and Supabase Preview were still pending when this was written. - Not verified:
- any hosted database: no production counts were read; the 2026-10-08 read-only audit in database PR86 remains the latest live schema verification
- hosted Supabase grants for reading
supabase_migrationsasservice_role(deniedis reported, not raised) - live providers
Out of scope / known gaps
- No enforcement: no constraint, trigger, quarantine table, backfill, merge or deletion (recoupable/app#2119).
- Production counts stay unrecorded until an approved operator runs the script read-only. Results belong in the private operational report.
- The two differing Spotify URL matchers (
onboard_spotify_artistvscommit_spotify_context) are documented but not reconciled. - The catalog-to-roster attach, the name-keyed catalog resolver and the app's unused direct writers are inventoried, not changed. Their handling belongs to #2119.
- The writer-path inventory covers access-layer writers and their callers. Raw SQL elsewhere is covered only where named.
🤖 Generated with Claude Code