Story 1504: copied_from_document_id + S3 CopyObject (FN-LINEAGE)
Why this exists (the catastrophe it prevents). A litigation document — an affidavit, an order, a bank statement — sometimes belongs in a second matter. The tempting cheap answer is to let two matters point at the same bytes: one S3 object, two database rows referencing it. That answer quietly destroys the one property a legal-evidence product cannot ship without: each matter must be independently exportable, erasable, and retainable. If matter A and matter B share a byte object and a court orders matter A erased, you cannot delete the object without silently gutting matter B; if matter B is under legal hold, you cannot honour A’s erasure at all. Whose retention window governs a shared object? There is no honest answer. pramaan-docs ADR-126 D4 closes this by decree: cross-matter reuse is an explicit copy, never a shared reference. This Story (roster id FN-LINEAGE) implements that copy — a server-side S3 CopyObject into the destination matter’s own prefix, plus a single lineage edge recording where the copy came from.
The mental model: photocopy, not a library loan. Relating a document to another matter is not lending the original out (a shared reference that two matters fight over). It is walking to the copier, making a fresh physical copy, and filing that copy in the second matter’s own folder — with a sticky note on it saying “copied from document X.” Two folders, two pieces of paper, one provenance note. Either folder can be shredded without touching the other.
🛰 Satellite: the whole shape of the change
One nullable column, one new write path, one audit verb. Nothing about how documents are uploaded, listed, or downloaded changed.
POST /app/documents/{doc_id}/copy-to-matter { targetMatterNumber }
|
| RLS scopes the source read to the caller's firm (foreign source -> 404)
v
source must be status=uploaded (no bytes yet -> 409)
source.matter != target (same matter -> 400)
|
v
S3 CopyObject firms/{firm}/matters/{SOURCE}/documents/{src}/file
-> firms/{firm}/matters/{TARGET}/documents/{new}/file (same bucket, server-side)
|
v
INSERT documents row { matter_id = TARGET, status = uploaded,
copied_from_document_id = src, <- the lineage edge
content_hash / size / content_type copied }
|
v
emit document.copied (scope = target matter) -> returns the new DocumentListItem
✈️ Airplane: the files that moved
alembic/versions/b1d3f5a7c9e2_0105_documents_copied_from_lineage.py(new) — addsdocuments.copied_from_document_id(nullableuuid), a self-referential FKfk_documents_copied_from_document(ondelete=RESTRICT), and the firm-leading indexix_documents_firm_copied_from. Down-revision isa7c9e2d4f6b1(0104), so this is the new head 0105.src/domains/document/entity.py— the matchingcopied_from_document_idfield (self-FK todocuments.documents.id) and the firm-leading index in__table_args__. NULL for originally-uploaded documents.src/domains/document/service.py— the newcopy_to_matter()method, a shared_build_object_key()helper (single source of truth for the key shape, now used by both upload and copy), and_copy_object()(the server-side S3 call, stubbable in tests like_presign).src/domains/document/dtos/document.py—DocumentCopyToMatterRequest(the destinationtargetMatterNumber) and a newcopiedFromDocumentIdfield onDocumentListItemso the register can render provenance.src/app_api/router_documents.py—POST /app/documents/{doc_id}/copy-to-matter(operationIdappCopyDocumentToMatter),response_model=DocumentListItem, canonical error responses.src/shared/audit/registry.py— the newcopiedverb (categoryrecords_files,filesnapshot), so the actiondocument.copiedresolves.src/shared/audit/coverage.py— the new mutating route is registered todocument.copied, satisfying the route-coverage watchdog.openapi/app-api.json— regenerated viamake openapi; admin snapshot unchanged (this is an app-only surface).
🔬 Microscopic: the four decisions inside the copy
1. Why a self-FK with RESTRICT, not SET NULL
copied_from_document_id points at the origin document in the same table. The delete behaviour is deliberately RESTRICT: documents are never hard-deleted in this product (their lifecycle is archive / erasure / retention, per the Lifecycle danger zone), so provenance must not be silently severed the way SET NULL would do. If an origin row ever were removed while a copy still cites it, the database refuses — the lineage edge is load-bearing, not decorative.
2. Why one _build_object_key() helper feeds both paths
The destination key is built by the same helper the upload path uses, so the copy and the upload can never fork the layout:
firms/{firm_id}/matters/{matter_id}/documents/{document_id}/{safe_filename}
The copy reuses the source’s already-sanitized original_filename, so its key matches the upload key shape exactly — only the matter and document segments change. The raw/ vs derived/ durability split that ADR-126 D2 freezes is owned by the upload key-shape story, not FN-LINEAGE; centralising the key in one helper means the day that split lands, both paths inherit it for free.
3. Why firm_id stays invariant — this is an ownership change, not an isolation change
A copy moves a document between two matters of the same firm. firm_id — the only tenant-isolation boundary (ADR-126 D5) — never changes, so RLS, the bucket’s firm prefix, and the same-bucket server-side copy all hold. The guard source.matter_id == target_matter.id rejects a no-op same-matter copy with 400; a source belonging to another firm is invisible under RLS, so the read 404s before any S3 call is attempted.
4. Why there is no manual span here
pramaan-docs ADR-124 requires every new boundary to be either instrumented or explicitly exemption-documented. The closed ManualSpanName registry has no name for a document-copy operation, and inventing one would be a registry change governed by the ADR, not by a functions Story. So this boundary rides the auto-instrumentation floor (T1): the route’s auto HTTP span plus the auto-instrumented S3-client span on copy_object cover it. That choice is written into the copy_to_matter docstring as a deliberate exemption, not an omission. Span attributes carry only metadata; document bytes never touch a span.
Two rows, two keys, one edge — verified against real data
The defining assertion of ADR-126 D4 is that “the same file in two matters” is genuinely two independent objects, not a shared one. The test proves it end-to-end against the real router and a real (empty-first-run) DB: after a copy, the destination matter’s register lists the new row with copiedFromDocumentId set, the origin matter still lists only its own rows (the copy never leaks back), and the CopyObject was issued exactly once with the destination key under the target matter’s prefix.
Acceptance Evidence
| Acceptance criterion | Test / check |
|---|---|
| Column + migration | 0105_documents_copied_from_lineage applies forward and backward; alembic check reports no drift (entity matches migration) |
CopyObject path on cross-matter relate | test_app_documents_copy_to_matter_sets_lineage_and_copyobject asserts exactly one CopyObject, same bucket, destination key under the target matter’s /documents/{new}/ prefix |
| Lineage pointer set | same test: response and destination-register copiedFromDocumentId == source; copy is a first-class row in the target matter; origin register unchanged (no shared reference) |
| Guards: same matter / not-yet-uploaded source / foreign source | same test (400 same-matter, 409 pending source, no extra copy) + test_app_documents_copy_foreign_source_is_not_found (RLS 404, zero CopyObject) |
| Audit trail | document.copied verb registered; the copy emits one row scoped to the destination matter with the new document id; route is in the audit coverage manifest |
| Contract synced | make openapi regenerated; openapi/app-api.json advertises the route, DocumentCopyToMatterRequest, and copiedFromDocumentId; admin snapshot untouched |
| Walkthrough doc included | this document |
Full suite green against an isolated Docker test DB (980 passed); ruff clean, make openapi idempotent, and the migration round-trips cleanly with no autogenerate drift.
One-sentence recall. Relating a document to another matter is a photocopy with a provenance note — an S3 CopyObject into the destination matter’s own prefix plus a copied_from_document_id edge — so each matter stays independently exportable and erasable, never a shared object two matters fight over.