ADR-042: Upload Scan Quarantine — clamd Backend, Async Promotion, Content-Identity-Bound Verdicts
On this page
Status: Accepted (2026-08-10)
Issue: #1006 (architecture ratified on-issue 2026-08-10; guard bundling ruled 2026-08-03 on #1265)
Relates to: ADR-008 (amended — see its Amendment 4), ADR-016, ADR-014, ADR-004, ADR-007, ADR-041 (doctrine)
Context
The applicant document pipeline validated size/magic/allowlist/hash/filename but always injected NoopScanner — every upload was marked clean without inspection and served inline to worker browsers with no gate. MIME checks are not malware detection; scanner outages, encrypted containers, and unsupported content need fail-closed states.
Decision
-
Adapter shape: the
Scannertrait stays the seam; backends are compile-time impls selected by typed config (CANOPY_APPLICATIONS__SCANNER_BACKEND = clamav | noop). No plugin machinery. The clamd backend (canopy-scanner-clamd,clamav-clienttransport, OUR strict single-line response parsing) is the default;Heuristics.Encrypted./Heuristics.Limits.Exceededdetections and the INSTREAM size-limit class map toSkipped, neverClean, never plainInfected. -
Fully async lifecycle: every upload is durable at
scan_status='pending'(bound in the store fn — no caller chooses). An idempotent promotion worker (the documents table IS the queue) drivespending → clean | infected | skipped | error, fenced on a per-claim token + a per-row scan generation so lease theft and requeues can never double-settle. Verdict provenance (backend, version, timestamp, detail) is observed with the verdict; a clamav verdict without fresh, parseable provenance never settles (stale definitions defer — scanning fails closed, serving does not depend on scanner availability). -
Content-identity binding: the worker verifies size+sha256 before scanning; the content endpoint re-reads the full object (bounded by the 10 MiB upload cap) and re-verifies sha256 before serving — a replaced object is unservable regardless of scan state. All content responses carry
Cache-Control: no-store. -
Gate law: content GET, accept, and reject refuse
409unless viewable —clean, orskippedcarrying the audited supervisor override. The predicate lives inside the review UPDATEs (no check-then-update race) and the DB enforces the state machine with named CHECK constraints (including accepted-implies-viewable). -
Accountable override (ADR-041 doctrine): releasing quarantined-
skippedcontent requires a verified actor whose roles includesupervisor/admin, enforced at the origin; the free-text reason stays on the row, its SHA-256 digest tamper-binds thescan_overriddenaudit event (ADR-004 — no operator prose in the chain). The noop backend outside development refuses boot withoutCANOPY_APPLICATIONS__ALLOW_INSECURE_SCANNER=true(loud per-boot WARN — the #1265 guard clone). -
Legacy data: every pre-quarantine row carried an unprovable verdict — the migration requeues them all (staggered), clearing acceptances (
ck_docs_accepted_viewablemakes acceptance-of-unscanned unrepresentable). Rows the noop backend settled are requeued by a boot sweep when a real backend takes over. Recovery from terminalerroris the service-only rescan endpoint (+canopy application document-rescan, ADR-007).
Consequences
-
≈seconds of scan latency between upload and worker-actionability (poll cadence + clamd); the worker UI polls state honestly; the applicant sees "being checked".
-
A scanner outage quarantines new uploads (they stay
pending) but never blocks uploads or the serving of already-clean content; backlog age is gauged and the runbook (runbooks/clamav-operations.adoc) owns alerting/recovery. -
Cross-service verification facts resolved from an acceptance later revoked via RESCAN are routed to human review by
acceptance_revoked; the boot-time backend-switch sweep revokes in bulk with a WARN-logged count instead of per-row events (recorded deviation — per-row audit of sweep revocations rides #1416’s revocation-visibility work). Automation is #1416; the override CLI needs a legitimate actor assertion — #1417.