> **2026-10-05:** steps 2 and 4 are superseded by docs/superpowers/specs/2026-10-04-scanner-host-rollout-design.md. clamd runs on its own box, the switch is carlos system antivirus enable, and CARLOS_STORE_SCANNER is deprecated. Steps 5 to 8 and the watch list still stand.

# Rolling object-storage scanning out to Carloku

**Not TDD tasks — an ordered operational sequence.** Do not start it until the
implementation plan (`2026-09-01-object-storage-virus-scanning.md`) is merged
and on `main`. Each step states how to know it worked.

**Spec:** `docs/superpowers/specs/2026-09-01-object-storage-virus-scanning-design.md`

**Tito first throughout**, for the reason the console-as-an-app rollout gives:
smaller blast radius, and Tito's console going down does not take Carloku with
it.

## What is actually being rolled out

An opt-in scanner that is **inert until three separate things are true**:
`CARLOS_STORE_SCANNER` is set on the deployment, a member has requested
scanning for their app, and an operator has granted it. Shipping the binary
changes nothing on its own, which is what makes this rollout low-risk and why
step 1 is safe to do first and sit on.

The failure mode worth naming up front: the ClamAV engine **reads tenant
object bytes**. Nothing reads anything until an operator grants a specific
app, but the capability arrives with the binary, and the sequence below puts
the disclosure before the first grant rather than after it.

---

### 1. Ship the binary with the scanner unset

Deploy `main` to Tito, then Carloku, with `CARLOS_STORE_SCANNER` **unset**.

**How to know it worked:** `carlos store scan status` on any app answers
`engine: none` and the operator doors 503 naming `CARLOS_STORE_SCANNER`. No
scan pass appears in the console log. `carlos store status` is unchanged for
every existing app, and no `scan.json` exists anywhere:

```
aws s3 ls s3://<deployment-bucket>/apps/ --recursive | grep scan.json
```

should print nothing. If it prints anything, stop — a sweep ran when nothing
should have.

### 2. Stand up clamd on the Tito console box

This is the `tito/carlos-infrastructure` half. Per spec §10:

- a `clamav-daemon` systemd unit, socket at `/run/clamav/clamd.ctl`
- `freshclam` on a timer — **a stale signature database is the silent failure
  mode of this whole feature**; a clean verdict from three-month-old
  signatures reads exactly like a fresh one
- `StreamMaxLength` matched to whatever `CARLOS_CLAMD_MAX_BYTES` will be
- the console unit's user added to the `clamav` group
- roughly 1–1.5 GB of RAM for resident signatures, held whether or not
  anything is being scanned — check the box has it rather than assuming
- **resource control on the unit**, which matters more than the headroom:

  ```ini
  [Service]
  MemoryMax=2G          # the kernel kills clamd, never the console
  MemoryHigh=1600M
  CPUQuota=50%
  Nice=10
  ```

  This runs on the CONSOLE box. If clamd drives that into the OOM killer we
  do not lose scanning, we lose deploys, routing and config for every app on
  the deployment. With `MemoryMax` on clamd's own cgroup the kernel kills
  clamd and the console carries on; without it the two compete for the same
  memory and the wrong one can lose.
- **the reload spike, which is the number that catches people.** Current
  ClamAV defaults to `ConcurrentDatabaseReload yes`, holding the old and new
  databases in memory at once so scanning never pauses during a `freshclam`
  update — briefly around doubling it. Either size for ~3 GB of headroom, or
  set `ConcurrentDatabaseReload no` and size for steady state. The second
  trade is nearly free: it blocks scanning during a reload rather than
  doubling memory, and a pass that fails a tick resumes on the next one

**How to know it worked:**

```
systemctl is-active clamav-daemon
sudo -u <console-user> test -w /run/clamav/clamd.ctl && echo reachable
echo -n 'zVERSION' | sudo -u <console-user> socat - UNIX-CONNECT:/run/clamav/clamd.ctl
```

The last prints `ClamAV <engine>/<sig-version>/<sig-date>`. Check the date is
today or yesterday. If `freshclam` has never run, it will not be.

### 3. Grant the console its S3 read and tag permissions

Add `s3:GetObject`, `s3:GetObjectTagging` and `s3:PutObjectTagging` on
`arn:aws:s3:::<BucketPrefix>*` to the Tito console's role. It already lists
those buckets for the economics storage pass; this adds read and tag.

**How to know it worked:** step 5's first sweep completes. There is no useful
intermediate check that does not involve reading a tenant object, and doing
that by hand before a member has opted in is exactly what this feature is
supposed to require consent for.

### 4. Turn the engine on, still with nothing opted in

Set `CARLOS_STORE_SCANNER=clamav`, `CARLOS_CLAMD_SOCKET=/run/clamav/clamd.ctl`
and `CARLOS_CLAMD_MAX_BYTES` on the Tito console. Restart.

The other three have defaults and only need setting deliberately:
`CARLOS_CLAMD_MAX_SIG_AGE` (168h — when `scan status` starts warning),
`CARLOS_STORE_SCAN_BUDGET` (60s of scanning per app per hourly tick) and
`CARLOS_STORE_SCAN_INTERVAL` (24h floor before a *completed* sweep re-runs; a
sweep still in progress ignores it, which is how a bucket bigger than one
budget ever finishes).

Setting `CARLOS_STORE_SCANNER` without `CARLOS_STORE_BACKEND` is refused at
boot, so a typo here fails loudly rather than leaving a scanner with nothing
to scan.

**How to know it worked:** the console log shows the scan pass starting and
then finding nothing to do. `carlos store scan status` on any app now answers
`engine: clamav`, `requested: no`. **No bucket has been read** — the pass
skips every app without an `enabled` block, and there are none.

This is the state to sit in for a day. A scan pass that wakes hourly and
correctly does nothing is the cheapest possible proof that the enumeration and
the gating work.

### 5. First real store: one Tito app you own

Pick an app whose objects are **plaintext** and whose data you own — not a
member's. Then:

```
carlos store scan request --plaintext
carlos store scan grant
carlos store scan sweep
carlos store scan status
```

**How to know it worked:** `status` reports a completed sweep with a
`last complete` timestamp and a signature version matching step 2.

Read the arithmetic carefully, because it is deliberately not the obvious one.
`scanned` and `unscanned` are **not complements of each other against
`objects`** — they are two independent facts:

```
objects   = scanned + skipped + denied + failed
unscanned = skipped + denied + failed          (NOT objects - scanned)
```

An object too large to stream was never judged, so it counts as both `skipped`
and `unscanned`. `unscanned: 0` is therefore only the right expectation on a
store where nothing was skipped, denied or failed — likely on a small
plaintext app, but do not treat a non-zero number as a bug. It is the feature:
the second sentence of the audit answer.

`status` also splits coverage into verdicts reached during this sweep and
verdicts carried forward from an earlier one. On a first sweep everything is
fresh; on a re-sweep of an unchanged bucket everything is carried forward,
which is correct and is why the engine database version is printed as what it
is *now* rather than as a claim about every object beneath it.

Then verify the two properties that are easy to assume and worth proving:

- **Verdicts are on the objects.** `aws s3api get-object-tagging` on any
  scanned key shows `carlos-scan`, `carlos-scan-version` and `carlos-scan-at`,
  and the etag matches the object's current one.
- **A rewrite invalidates the verdict.** Overwrite one object, then run
  `carlos store scan sweep` — that object must be re-scanned rather than
  reading back its old verdict, which shows up as the fresh count going up.
  If it is carried forward instead, the version binding is not working and
  **stop the rollout**: verdicts that outlive the bytes they judged are worse
  than no verdicts, because they are trusted.

  The tag records the object's ETag *and* its last-modified time. Both halves
  matter: a single-part upload's ETag is the content MD5, and chosen-prefix
  MD5 collisions are practical, so an attacker who controls two uploads could
  otherwise hand a payload the ETag of the clean object it replaced.

### 6. Prove the destructive paths before any member sees this

On a throwaway app in Tito:

- `carlos store scan grant`, then destroy the app. Confirm no `scan.json`
  remains under its prefix and **the app name is immediately claimable
  again** — an orphan sidecar makes a name permanently unusable and is the
  failure this feature's teardown path exists to prevent.
- `carlos store scan enforce on`, then unset `CARLOS_STORE_SCANNER` and
  restart. Confirm `carlos store scan enforce off` still works with no engine
  built. This is the deadlock draft 3 of the spec would have shipped; prove it
  is gone on a real deployment, not only in a unit test.

### 7. Disclose before the first member grant

Before granting scanning on any store CARLOS does not own, the member has to
know what they are consenting to:

- the deployment will **read their object bytes** to scan them
- a clean verdict on a **client-side-encrypted** store means nothing, and they
  should request `--client-encrypted` if that is their shape
- verdicts are **eventually consistent** — an object is readable before it is
  scanned, and there is no upload hook

The `scan request` verb prints this, which is the point of making the
attestation an explicit flag. But a member who has not run the verb has not
read it, so an operator granting on someone's behalf must have had the
conversation first.

### 8. Carloku, same sequence

Repeat steps 2 through 6 on Carloku. Then stop and wait: on Carloku the first
grant should be **driven by an actual member request**, not by an operator
enabling it speculatively. This feature exists to answer a compliance
question, and the person with the compliance question is the one who should
ask for it.

## Watch after each grant

- **Signature age.** `scan status` warns past `CARLOS_CLAMD_MAX_SIG_AGE`.
  Treat the warning as an incident, not a notice — every clean verdict written
  while it was stale is worth less than it looks.
- **Sweep duration against the budget.** If a sweep spans many ticks, the
  audit answer's T is as old as the sweep is long. Raise
  `CARLOS_STORE_SCAN_BUDGET` or shorten the interval, and say the real number
  when anyone quotes coverage.
- **`skipped` climbing.** Over-ceiling objects are not scanned and are not
  clean. A store that is mostly skips has not been meaningfully scanned and
  the compliance answer must say so.
- **`denied` appearing at all.** It means the console could not read those
  objects — an SSE-KMS key policy that does not cover it, or an object owned
  by another account in an adopted bucket. It is re-issued every sweep rather
  than cached, so a permissions fix repairs it on the next tick with nothing
  to clean up. A `denied` count that stays flat after a fix means the fix did
  not work.
- **Console box memory**, now carrying clamd's resident signatures — and the
  reload spike, not just the steady state (step 2).

## Rollback

Unset `CARLOS_STORE_SCANNER` and restart. The pass stops, the doors 503, and
every existing verdict tag stays on its object — harmless, and still readable
if the engine comes back. Nothing needs undoing in a bucket.

The one thing that does **not** roll back by unsetting the variable is an
active `enforce` deny, which lives in the app's IAM policy. Clear it first
with `carlos store scan enforce off` (which works with no engine built, by
design) before unsetting, or an app keeps a Deny statement referring to a tag
nothing will ever write again. Harmless in practice — the tag will not appear,
so nothing is denied — but it is a stale statement in a live policy and it
should not be left behind.
