Store operator-asserted company relationships apart from access
Summary
Adds 20261010213700_context_company_relationships.sql, an additive, forward-only migration. It stores workspace-private, operator-asserted company business relationships separately from access. The table context_company_relationships records a relationship that a saved company subject (from create_context_company_name_request) holds toward one of three counterparties: the workspace itself, an artist the workspace can already reach, or one of its organization_professionals. Relationship kinds are frontline_roster, catalog_interest, publishing, distribution, management, services and other, and status is current or former. Unknown dates stay null. The table has no ownership, share, rights or mandate column. Rows are never updated or deleted. A change is a new row with supersedes_id, which must point to a row in the same workspace with the same company, counterparty and kind. A composite foreign key enforces the same workspace and company, a unique constraint lets each row be superseded only once, and a per-target advisory lock serializes competing writes. Two RPCs, callable only by the service role, read and write the table:
record_context_company_relationship(with a trailingp_supersedes uuid default null):- checks the actor with the existing
authorize_context_case_actor; - requires the company subject to be reachable through this workspace's own company-input document and customer assertion, and refuses new rows with
Company source withdrawnonce the source is withdrawn; - requires the counterparty to be reachable already;
- denies unknown or foreign subjects, artists and professionals with one generic error;
- replays an exact idempotent input and rejects changed input as a conflict (
supersedes_idcounts as input); - rejects unknown, foreign or mismatched supersession targets with one message, and a second successor with
Company relationship already superseded.
- checks the actor with the existing
list_context_company_relationshipsreturns 50 rows per page by id, rejects foreign cursors, and returnscoverage: operator_asserted_relationships_onlyplus explicit gaps. Each item carriessupersedes_idandsuperseded_by. After the realwithdraw_context_sourceruns, the list returnsstate: unavailablewith no rows.
This covers the ticket's Done-when items:
- Company identity is kept apart from membership and access.
- Frontline roster is kept apart from catalog interest, including former roster. Leaving the roster is a
formerrow that supersedes thecurrentrow, so the two never contradict each other. - Tests show that third-party research infers no ownership, and relationships stay private to the workspace.
Refs recoupable/app#2137 (child of epic recoupable/app#2116).
Merge sequencing
docs recoupable/docs#371 → database (this PR) → api recoupable/api#1024. Apply this migration before deploying the API consumer, which sends p_supersedes.
It depends only on merged migrations: company-name intake 20260925110000, professional roster 20261008050000 and release-case actor authorization 20261008160000. It does not modify read_context_company_baseline (open database#89) or any context_evidence_* object (open database#90), so those PRs can merge independently. The migration filename follows the epic's collision rule.
Sibling conflict to resolve at merge time: open recoupable/database#113 adds tests/reference_model_adoption.json. Its company-relationships entry is marked not_adopted, with check {kind: absent_table, pattern: "relationship|catalog_interest"}. public.context_company_relationships breaks that check, so whichever of #113 and this PR merges second must flip that entry to adopted, or the adoption check fails.
Verification
- Ran on local disposable PostgreSQL 17.7 with
LC_ALL=en_US.UTF-8:python3 -m unittest discover -s tests -p 'test_company_relationships.py': 14 passed, 0 failed (13 relationship cases plus the inherited release-case lifecycle test). The same suite on PostgreSQL 15.10 also gave 14 passed.test_release_cases.py: 1 passed.test_company_assessment.py: 3 passed (regression).
- Red → green, review round:
- The withdrawal test now calls the real
withdraw_context_sourceand asserts thatcurrent_result_idis null. It failed against the previous migration withERROR: Company relationship deniedinstead of returningstate: unavailable. - The two new supersession tests failed because the signature had no supersession argument.
- The frontline test failed because items had no
supersedes_id. - With the migration change, all 14 pass.
- The withdrawal test now calls the real
- Red → green, first round: with the migration body replaced by
select 1;, the suite failed every new test (function or relation does not exist). - What the cases cover:
- Third-party company research creates 0 relationship,
artist_organization_ids,account_organization_idsoraccount_artist_idsrows, and no account. - Frontline roster current, catalog interest current and frontline roster former are 3 distinct rows.
- Leaving the roster supersedes the current row. The earlier row is returned unchanged except for
superseded_by, and onlycatalog_interest/currentandfrontline_roster/formerremain in force. - Replay with
supersedes_idreturns the saved row; dropping it conflicts. A second successor is refused. Unknown, foreign, other-company, other-counterparty and other-kind targets get one rejection. - 8 parallel supersessions of one row leave exactly 1 successor.
- Workspace and professional counterparties are accepted. Exact replay returns the saved row, changed input conflicts, and 16 parallel retries store 1 row.
- Foreign actors, subjects, artists and professionals are denied without hints. Another workspace's withdrawn company is still a generic denial, not
unavailable. - A personal workspace uses its own artist access.
- Input validation passes. 51-row pagination works and rejects foreign cursors.
anonandauthenticatedcannot execute the functions or read the table.
- Third-party company research creates 0 relationship,
- CI: a
test_company_relationships.pystep is in.github/workflows/release-cases.yml(PostgreSQL 15/16 matrix). - Not verified: the hosted or production database (the migration was not applied anywhere), Supabase preview branches, hosted API behaviour and live providers. Concurrency was tested only with local parallel psql sessions.
Out of scope / known gaps
- Relationship direction is fixed: the company holds the relationship toward the counterparty. The reverse direction cannot be recorded yet (for example, the workspace holding a catalog interest in the company).
- Counterparties must be reachable in the workspace when the row is recorded. These cannot be recorded yet:
- No
currentuniqueness rule: two un-superseded current rows of the same kind can coexist when an operator asserts both.superseded_byshows what was replaced. - Folding relationships into the company baseline read (database#89 / api#981) is a follow-up after both merge.
- Not in scope: company-to-company relationships without a workspace anchor, rights interests and evidence links (database#90).
- The test fixture models
artist_organization_idsandaccount_artist_idsminimally. It is not a full Supabase migration replay.
🤖 Generated with Claude Code