D88 — Documents vault malware scanning: quarantine-by-default lifecycle with a MalwareScanner seam (ClamAV adapter, env-gated) (RESOLVED, extends D43/D57)
Architecture decision record, relocated verbatim from the retired single-file
docs/tenancy/decision log. Status, thematic clusters, and how to record a new ADR: the decision log index.
The vault admitted uploads with only the (inert-in-practice) synchronous ContentPreScanner
tripwire between the client's bytes and the provider forward. Decision: a quarantine-first scan
lifecycle, config-gated OFF (documents.scanning.enabled) so the shipped behaviour is unchanged
until a scanner is deployed:
- Lifecycle: a new
scan_statuscolumn (DocumentScanStatus:pending|clean|rejected) ORTHOGONAL tostatus(capture/forward/shred). Scanning enabled ⇒ every upload (proxied store AND direct confirm) commits quarantined (pending): not downloadable, not forwardable — the singleDocumentServeGaterefuses every byte-serving path (download-URL issue, streaming/redirect serve, forward read) with distinct coded 409s (DOCUMENT_QUARANTINED/DOCUMENT_SCAN_REJECTED/DIRECT_UPLOAD_INCOMPLETE), so a new serve path cannot forget a state. The queuedScanDocumentJob(documents queue, retries + backoff) reads the verified bytes through the bus (GetDocumentForScanningQuery— the ONE read path allowed to touch quarantined bytes) and records the verdict via the idempotentRecordDocumentScanVerdictCommand: clean promotes + releases the deferred provider forward (scan-before-forward replaces store-then- forward when scanning is on); rejected is terminal — verdict signature stamped inscan_verdict,DocumentScanRejecteddomain event +documents.scan_rejectedaudit line, and the blob RETAINED in quarantine for forensics (encrypted at rest, unreachable through any serve). A scanner ERROR throws — no verdict, the job retries, the document STAYS quarantined (fail-closed; exhausted retries emit thedocuments.scan_failedaudit alert and the job is re-drivable). Existing rows are grandfatheredclean. Scanning disabled ⇒ documents are admittedcleanat store/confirm and the forward dispatches immediately (the pre-D88 behaviour, proven by the untouched existing tests). The client resource now surfacesscan_statusso the SPA can show "being scanned" / "refused" without probing for 409s. - Scanner choice — ClamAV adapter now, managed service as a swap-in. Evaluated: a managed
scanner (e.g. GuardDuty Malware Protection for S3 / a bucket-triggered lambda) is operationally
lighter at scale but couples the verdict path to one cloud + an async eventing pipeline the app
doesn't have; ClamAV (clamd INSTREAM over TCP) is provider-agnostic, runs as a sidecar anywhere,
and slots directly into the synchronous-job shape. Decision: ship the
MalwareScannerseam with theClamAvMalwareScannerINSTREAM adapter (env-gated deploy config —documents.scanning.clamav.{host,port,timeout_seconds}; nothing stood up locally, tests bind a fake; the response parsing is unit-tested, fail-closed on ERROR/unknown). A managed-service adapter implements the same interface and binds in its place; a bucket-side scanner that quarantines via object tags would integrate at the confirm/verdict seam later. - Scanner-boundary guard: a stream the scanner refuses (e.g. past clamd's
StreamMaxLength) yields NO verdict on every retry — permanent quarantine, fail-closed but silent. Two controls: the DEPLOY INVARIANT that clamd'sStreamMaxLengthMUST exceeddocuments.max_bytes(documented on both config knobs — clamd's own default, 25M, sits exactly at the default cap), anddocuments.scanning.max_scannable_bytes(default =max_bytes): with scanning enabled, an upload/confirm whose actual size exceeds it is refused as a clear 422DOCUMENT_REJECTEDat admission time instead of stranding in quarantine. - Deliberately NOT built: a separate physical quarantine BUCKET, scheduled re-scans of the
standing corpus, and operator surfaces for the rejected queue (the audit line + event are the
hooks; both deferrals tracked in
docs/tracking/document-vault-retention-and-rtbf.md). On the quarantine bucket, the trade-off is owned honestly: quarantine is a metadata state and a second bucket adds a cross-bucket move + IAM surface — but the isolation this decision ships is app-gate-only. A quarantined DIRECT-path blob has no envelope encryption and no IAM boundary of its own; a separate quarantine bucket WOULD add an IAM-level GET boundary (credentials scoped away from the serving bucket) that theDocumentServeGatealone does not. That residual — quarantined direct-path bytes are protected by the application gate, not by storage-level isolation — is accepted and owned here the same way D87 owns the encryption trade-off, and is revisited if a real scanner deployment or a compromise-of-app-credentials threat model demands the storage-level boundary.