Store operator-asserted company relationships apart from access

SI
5d ago
+525-0

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 trailing p_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 withdrawn once 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_id counts as input);
    • rejects unknown, foreign or mismatched supersession targets with one message, and a second successor with Company relationship already superseded.
  • list_context_company_relationships returns 50 rows per page by id, rejects foreign cursors, and returns coverage: operator_asserted_relationships_only plus explicit gaps. Each item carries supersedes_id and superseded_by. After the real withdraw_context_source runs, the list returns state: unavailable with 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 former row that supersedes the current row, 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_source and asserts that current_result_id is null. It failed against the previous migration with ERROR: Company relationship denied instead of returning state: 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.
  • 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_ids or account_artist_ids rows, 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 only catalog_interest/current and frontline_roster/former remain in force.
    • Replay with supersedes_id returns 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.
    • anon and authenticated cannot execute the functions or read the table.
  • CI: a test_company_relationships.py step 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:
    • an artist the workspace no longer reaches (for example, a former roster artist whose access link was removed);
    • a party known only by name, which needs identity resolution (#2119) or roster enrollment (#2136).
  • No current uniqueness rule: two un-superseded current rows of the same kind can coexist when an operator asserts both. superseded_by shows 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_ids and account_artist_ids minimally. It is not a full Supabase migration replay.

🤖 Generated with Claude Code