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

  1. Adapter shape: the Scanner trait 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-client transport, OUR strict single-line response parsing) is the default; Heuristics.Encrypted. / Heuristics.Limits.Exceeded detections and the INSTREAM size-limit class map to Skipped, never Clean, never plain Infected.

  2. 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) drives pending → 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).

  3. 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.

  4. Gate law: content GET, accept, and reject refuse 409 unless viewableclean, or skipped carrying 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).

  5. Accountable override (ADR-041 doctrine): releasing quarantined-skipped content requires a verified actor whose roles include supervisor/admin, enforced at the origin; the free-text reason stays on the row, its SHA-256 digest tamper-binds the scan_overridden audit event (ADR-004 — no operator prose in the chain). The noop backend outside development refuses boot without CANOPY_APPLICATIONS__ALLOW_INSECURE_SCANNER=true (loud per-boot WARN — the #1265 guard clone).

  6. Legacy data: every pre-quarantine row carried an unprovable verdict — the migration requeues them all (staggered), clearing acceptances (ck_docs_accepted_viewable makes acceptance-of-unscanned unrepresentable). Rows the noop backend settled are requeued by a boot sweep when a real backend takes over. Recovery from terminal error is 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.

Edit this page · default