Record immutable context document version history

Draft
SI
5d ago
+552-0

Summary

Adds one CE05 increment: accepted context document versions as immutable history behind the existing current pointer. Before this, context_documents kept only the current pointer, so each accept_context_result / withdraw_context_source overwrote which result revision N pointed at. A brief that reads superseded=true could not resolve the version it cited.

20261010212100_context_document_versions.sql adds context_document_versions, one immutable row per document revision: accepted or cleared, plus backfilled for rows derived at migration time. An after-insert/update trigger on context_documents writes the rows, so the existing writers stay unchanged: accept_context_result, withdraw_context_source, save_context_metadata, enrichment completion, and the artist/release/name/brief/material entry functions.

  • Only the trigger writes history. The recording function is SECURITY DEFINER and runs as the migration owner. service_role gets select only. Browser roles get nothing. A before-update/delete trigger makes rows immutable. A pointer that moves without a new revision, or a revision that moves backwards, is rejected.
  • Each version carries its document's subject_id and topic. Foreign keys go to the document and to the result on (id, owner_id, subject_id, topic). A version therefore cannot cite a result from another workspace, subject or topic. This needs one key, context_documents_lineage_key unique(id, owner_id, subject_id, topic), which is skipped if already present.
  • Backfill. Existing documents are backfilled from their current revision. Fresh revision-0 documents get no row.
  • Read. read_context_document_history(p_owner, p_document, p_before, p_limit) returns the document's current pointer and its versions, newest first, clamped to 1–100. Each version includes the cited result's current status and source state (live/withdrawn/removed). It returns null for another workspace's document or an unknown one.
  • Rollback. The first step only disables the recording trigger. That keeps every recorded version and the read, and there is a tested re-enable step that records the current revision of any document that moved in the meantime. Fully dropping the table is reserved for a deployment whose history was never relied on, because re-applying the migration backfills only current revisions. Customer documents, results, sources and briefs are untouched either way.

Refs recoupable/app#2121 (child of epic recoupable/app#2116).

Merge sequencing

Order: docs → database → api → app.

This PR is a draft. Its production apply and merge are gated on the ticket's execution gate: CE01 deployed-schema verification and conflict handling (recoupable/app#2117, currently open as recoupable/database#103), plus table-name finalization after that baseline. The added context_documents key and the revision guard assume every deployed writer matches these migrations, and CE01 exists to verify exactly that. Until then this is a fixture-tested draft migration.

  • Sibling PRs for this ticket: docs recoupable/docs#362 (contract) and api recoupable/api#1004. The api PR must not be released before this migration is applied; until then the action fails with the standard 409.
  • No app PR in this slice.
  • No overlap with database#89/#90/#92/#94/#95: this PR uses its own migration timestamp (per the ticket collision rule) and its own CI workflow, .github/workflows/document-versions.yml.

Verification

  • New suite. export PATH="/opt/homebrew/opt/postgresql@17/bin:$PATH" LC_ALL=en_US.UTF-8; python3 -m unittest discover -s tests -p 'test_context_document_versions.py' → 6 tests OK on local PostgreSQL 17.7. It covers:
    • legacy backfill and the unchanged read_context_documents reader;
    • cross-owner null;
    • the lifecycle fixture supabase/tests/context_document_versions.sql, which prints PASS:;
    • forgery resistance (below);
    • the documented disable and re-enable;
    • the documented full removal followed by re-apply;
    • the inherited release-case test.
  • Forgery resistance. service_role cannot insert history, so a forged future revision or a cross-topic citation fails with insufficient_privilege. Even the owner cannot insert a version citing a result of another topic, or one that relabels a document's topic (foreign_key_violation). withdraw_context_source still records its cleared revision. Both recording and reading functions have the expected SECURITY DEFINER settings.
  • Lifecycle fixture. It covers:
    • revision 1 → 2 through the unchanged save_context_metadata writer;
    • reuse of identical content without a new revision;
    • a brief frozen at revision 1 that reads superseded=true while history still resolves its cited version/resultId;
    • a stale-revision accept_context_result that records nothing;
    • rejection of a pointer move without a new revision and of a backwards revision;
    • withdrawal → cleared revision, with earlier versions showing source_state=withdrawn;
    • exclusive-cursor pagination and limit clamping;
    • UPDATE/DELETE on history raising an error;
    • duplicate-revision and cross-workspace rejection;
    • the service_role path reading history but not writing it;
    • browser-role denial.
  • Regression. test_release_cases.py → 1 test OK; test_company_assessment.py → 3 tests OK.
  • CI on this head. document-versions passes on PG 15 and 16, along with release-cases, release-players, credit-charge-receipts, onboarding and storage.
  • Red → green. With the migration file absent, the suite fails because the table, the read function and the trigger do not exist. On the first revision of this PR (66839c9), independent review showed that a forged service-role row could cite a result of another topic and could block withdraw_context_source. The new forgery test encodes that probe and passes on this head.
  • Repository automation. The Supabase GitHub integration applied this branch to its own hosted preview branch ("Supabase Preview" check). That is automatic, not an agent action. No production or shared database was touched.
  • Not verified: PostgreSQL 15/16 locally (CI covers them), production database apply, real backfill volume and lock_timeout behavior on production context_documents, hosted API/MCP behavior, live providers.

Out of scope / known gaps

  • This does not complete CE05. Still open: deletion/retention rules for history rows (deferred with the CE04 lifecycle work), recipe/model/prompt version references, source-to-subject link tables (database#90), original-file receipts (#94/#95), and the blockers #2117/#2118/#2119/#2120.
  • No correction/withdrawal workflow that writes new revisions (#2124). Its writes will be recorded automatically by the trigger.
  • Lineage is not human review, rights approval or deletion policy.

🤖 Generated with Claude Code