Story 1853: quarantine download gate — a file the scanner flagged as malware can never be handed back as bytes

Why this exists (the catastrophe it prevents). When counsel uploads a document, GuardDuty scans the object on PUT. If it reports a threat, the malware-results worker “quarantines” the document — but in PRAMAAN today (AGENT4 adversary review A3, verified live) quarantine is just processing_status = 'failed' plus an audit note. The malicious bytes still sit in the WORM bucket. The danger the adversary raised: if the document download presign endpoint is not gated on status, a user (or a compromised session) can ask for a presigned GET URL for the quarantined object and pull the malware straight back out — a door the security pipeline thinks it welded shut, still open. DP-23 corrects DP-05: malware must be un-downloadable, not merely un-indexed.

The server is the authoritative gate. DocumentService.download_url refuses a presigned GET for every document whose processing_status is not in the malware-clean USABLE_DOCUMENT_STATUSES set — failed (quarantined), needs_review (unscannable), and pending (unconfirmed) are all refused with a typed, non-500 error before any S3 presign is signed. The quarantined row is never dropped: it stays listed as failed with its audit trail, so the security action is accounted, not silent.

Satellite view: the gate already exists — this Story proves it and closes the test gap

The presign refusal landed with the upload state machine in Story-522 (PR #395): download_url checks processing_status not in USABLE_DOCUMENT_STATUSES and raises PramaanConflictError(code="pramaan.document.not_downloadable") — a 409, not a 500, and crucially before the _presign call, so no URL is ever signed for malicious bytes. The AGENT4 review that opened this Story predated that gate. So the genuine remaining gap for DP-23 was not the gate — it was an executable acceptance proof that pins the property end to end at the system boundary, including the accounting half (the file is still listed), so a future refactor cannot silently re-open the door.

Per the repo’s “done = system boundary, not component boundary” rule: this proof drives the real malware-results gate (apply_scan_result(THREATS_FOUND)) against a real DB and then hits the real download and list routes through the ASGI app — not a hand-set fixture status. A green unit test against a seeded row would have hidden whether the quarantine path itself persists an accountable row.

The decisions that matter

1. One gate, keyed to the malware-clean status set — not a per-state special case

The refusal is the existing single predicate processing_status not in USABLE_DOCUMENT_STATUSES ({uploaded, stored, extracting, indexed}). This is the root-cause shape: every non-clean state — the malware reject, the unscannable park, the unconfirmed upload — is refused by construction, so a new reject state added later is gated automatically. The AC’s “any malware-flagged state” is satisfied without enumerating states at the call site.

2. The refusal is typed and lands before the presign

The gate raises a PramaanConflictError with the stable code pramaan.document.not_downloadable. Because it runs before get_settings() and _presign, a quarantined request never touches S3 and never produces a downloadUrl field — the response is the typed error envelope (error.code), so there are literally no bytes and no URL to leak.

3. Quarantine stays accounted — never a silent drop (DP-14)

apply_scan_result(THREATS_FOUND) sets the row to failed, records a status-transition event with the threat detail, and emits a document.quarantined audit row. The row keeps its default availability = live, so list_documents (which filters only on firm/matter/availability) still returns it with processingStatus = "failed". The matter register shows the quarantined file honestly; the security action is on the record, not a vanish.

4. No production code changed — so no contract churn

This Story adds a pytest-bdd acceptance scenario and registers its markers; it touches no route, DTO, or trust-boundary payload. The OpenAPI snapshots are therefore unchanged. The endpoint already complies with the API-contract hard rules (declared response_model, canonical responses=, typed domain error) and is covered by the route’s ADOT T1 auto-instrumented HTTP + S3 spans, so no new observability span is owed.

Verification

The DP-23 property is pinned as a pytest-bdd scenario that runs the real quarantine path and the real routes against the functions test DB (system boundary).

CheckProves
tests/features/document_quarantine_download.feature — DP-23the Gherkin acceptance scenario, tagged @security @refusal @DP-23 @DP-14, mapping the desk feature verbatim
test_dp23_quarantined_document_cannot_be_downloaded — Givena real THREATS_FOUND scan quarantines an uploaded document to failed (the row persists, never dropped)
… When/Then refusalGET /app/documents/{id}/download-url returns 409 with error.code == "pramaan.document.not_downloadable" and no downloadUrl — no presigned bytes
… Then accountingGET /app/matters/{n}/documents still lists the document with processingStatus == "failed" (DP-14: accounted, not silent)

The new BDD scenario passes; the existing documents and malware-scan integration suites stay green (27 passed, 1 pre-existing skip) against make test-db. Ruff is clean on all changed files (the repo’s pre-existing lint debt is untouched and outside this scope); git diff --check is clean; the OpenAPI snapshots are unchanged (no route/DTO touched).

App follow-up flagged (not fixed here). The firm-app document drawer enables its download button for the failed status (pramaan-app document-detail-drawer.tsx:178). That is a non-authoritative client mirror: the server gate proven here refuses the request regardless, so there is no exposure. Tightening the drawer affordance to match is a separate app-side change and is out of scope for this functions Story.

One sentence to remember: a quarantined or scan-failed document is refused a presigned download server-side with a typed 409 before any URL is signed, while the row stays listed as failed — so malware can never be downloaded, and the security action is always accounted for.