Record immutable context document version history
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 DEFINERand runs as the migration owner.service_rolegetsselectonly. 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_idandtopic. 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 returnsnullfor 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_documentsreader; - cross-owner
null; - the lifecycle fixture
supabase/tests/context_document_versions.sql, which printsPASS:; - forgery resistance (below);
- the documented disable and re-enable;
- the documented full removal followed by re-apply;
- the inherited release-case test.
- legacy backfill and the unchanged
- Forgery resistance.
service_rolecannot insert history, so a forged future revision or a cross-topic citation fails withinsufficient_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_sourcestill records itsclearedrevision. Both recording and reading functions have the expectedSECURITY DEFINERsettings. - Lifecycle fixture. It covers:
- revision 1 → 2 through the unchanged
save_context_metadatawriter; - reuse of identical content without a new revision;
- a brief frozen at revision 1 that reads
superseded=truewhile history still resolves its citedversion/resultId; - a stale-revision
accept_context_resultthat records nothing; - rejection of a pointer move without a new revision and of a backwards revision;
- withdrawal →
clearedrevision, with earlier versions showingsource_state=withdrawn; - exclusive-cursor pagination and limit clamping;
- UPDATE/DELETE on history raising an error;
- duplicate-revision and cross-workspace rejection;
- the
service_rolepath reading history but not writing it; - browser-role denial.
- revision 1 → 2 through the unchanged
- Regression.
test_release_cases.py→ 1 test OK;test_company_assessment.py→ 3 tests OK. - CI on this head.
document-versionspasses 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_timeoutbehavior on productioncontext_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