Story 1872: privilege follows the file into every copy we make of it
Why this exists (the catastrophe it prevents). When counsel marks a document privileged work-product, PRAMAAN excludes it from the evidence index (Story-1501). But an Office document (DOCX/PPTX) is not just the bytes the firm uploaded — the pipeline renders a derived/rendered.pdf and extracts a derived/text.txt so we can cite and highlight it (RFD-DOC-002). Those derived copies are our re-representation of the privileged file. The adversary case (PANEL-SYNTHESIS-DOC-002 A9 / R3): if the download gate only knows about the original, a user who is refused the privileged original can simply ask for its rendered PDF instead and pull the same content out a side door — a privilege waiver through the back of the building. And if a derived artifact could be re-ingested as a fresh upload, it would re-enter the system stripped of the privilege/scan lineage that made it safe. This Story closes both doors.
rendered_pdf_key and derived_artifact_key are columns on the same documents.documents row as privilege_flag, so the derived artifacts inherit privilege by construction — flipping the flag on the source instantly covers every derived copy with no second write to keep in sync. The download gate reads that one flag for the derived variants; re-ingest is blocked at the one boundary that turns an S3 key back into a document.Satellite view: gate the derived copies the same way, block the laundering path
The download endpoint GET /app/documents/{id}/download-url grows one optional query parameter, variant ∈ {original, rendered, text}, defaulting to original (the existing behaviour, byte-for-byte). The original keeps its quarantine-only gate — the firm owns the file it uploaded and may always retrieve it. The two derived variants additionally obey the privilege gate: a privileged document refuses its rendered PDF and its extracted text with a typed 403, so there is no privilege-waiver surface via the derived copy. Re-ingest is blocked where it actually happens: the malware-results consumer resolves a document from the GuardDuty object key via firm_and_document_from_object_key, which requires the raw/ segment — a derived/ key resolves to nothing and its PUT event is dropped, so a derived output can never advance a row to a scan-clean state.
The decisions that matter
1. Inheritance is structural, not a second write (AC-a)
There is no separate privilege flag to propagate. The derived artifacts are pointers (rendered_pdf_key, derived_artifact_key) on the source row, so they share its privilege_flag. set_privilege is unchanged: flipping the source covers the derived copies the instant the gate reads the flag. The alternative — copying the flag onto a derived-artifact table — would create exactly the drift (source privileged, copy not) the AC forbids.
2. One gate, split by variant — quarantine for all, privilege for derived (AC-b)
The quarantine check (processing_status not in USABLE_DOCUMENT_STATUSES, Story-1853) runs first for every variant, so a malware/tamper reject is downloadable in no copy — same as the original. The new _download_target_key helper then resolves the key per variant: original returns object_key unchanged; rendered/text first refuse a privileged row with PramaanForbiddenError(code="pramaan.document.derived_privileged") (403), then resolve rendered_pdf_key or {derived_artifact_key}text.txt. A document with no derived artifact yet (a PDF/image, or pre-render) returns a typed 409 pramaan.document.derived_not_available — never a 500, never a leaked key.
3. Re-ingest is blocked at the key-to-document boundary (AC-c)
"Block re-ingest" is enforced where a key becomes a document: firm_and_document_from_object_key already requires the key’s seventh segment to be raw. Because the derived layout is …/documents/{id}/derived/…, a rendered.pdf or text.txt key fails that check and resolves to None; the malware-results consumer logs it as an unmanaged_key and skips it. So a derived artifact’s own GuardDuty PUT event — or any attempt to feed a derived key into the ingest path — never advances a row to a scan-clean, evidence-eligible state. Derived artifacts are outputs, not uploads; they cannot be laundered back in without their privilege/scan lineage. This Story makes that property explicit in the docstring and pins it with a unit test.
4. Contract + observability hygiene
The route keeps its declared response_model, canonical responses= (the existing AUTH_NOT_FOUND_CONFLICT_VALIDATION_SERVER_RESPONSES already covers 403/409), and typed domain errors (HR-1, HR-6, HR-7). The new DocumentDownloadVariant enum and the variant query parameter are reflected in the regenerated openapi/app-api.json. The download boundary is covered by the route’s ADOT T1 auto-instrumented HTTP + S3 spans (pramaan-docs ADR-124 floor); the gate is pure branching with no new boundary, so no manual span is owed. The audit row now stamps the variant in its metadata so a derived-copy download is distinguishable from an original.
rendered_pdf_key is a separate Story; S7 gates whatever the render/extraction workers have written. The original-download path is intentionally not privilege-gated: privilege excludes a document from evidence, but the firm may always download the file it uploaded. Only the evidence-facing derived re-representations are gated — matching DR-9 ("both derived artifacts obey the download/privilege gate").
Verification
The S7/DR-9 properties are pinned at the system boundary — real routes through the ASGI app against the functions test DB, plus a pure-function unit test for the ingest resolver.
| Check | Proves |
|---|---|
test_app_document_privilege_gates_derived_downloads | not-privileged: rendered/text presign to their derived keys (200); after PATCH …/privilege both are refused (403) while the original stays downloadable (200); clearing privilege re-enables both derived copies (AC-a, AC-b) |
test_app_document_quarantine_gates_rendered_same_as_original | a failed (quarantined) document refuses the rendered PDF with 409, the same gate as the original (AC-b, "same as the original") |
test_document_derived_reingest_block.py | a raw/ key resolves to (firm, document); every derived/ key (rendered.pdf / text.txt / pages.json) resolves to None — re-ingest blocked (AC-c) |
All four new checks pass; the existing documents, malware-scan, quarantine-BDD, and contract suites stay green (44 passed) against make test-db. Ruff is clean on all changed files; git diff --check is clean; alembic heads is a single head (no migration — logic only); the OpenAPI snapshot regenerates deterministically and is committed.
One sentence to remember: a privileged document refuses its rendered PDF and extracted text with a typed 403 (no waiver via the derived copy), the original stays downloadable, and a derived artifact can never be re-ingested as a fresh upload — because privilege lives on the one row every copy points back to.