# Scanner host: getting object-storage scanning running on Carloku and Tito

**Status:** draft 6. Closes Astra review 1 (`CODEX-FINDINGS-SCANNER-HOST.md`,
findings 1–5, all accepted), review 2 (`CODEX-FINDINGS-SCANNER-HOST-R2.md`,
findings 6–8: 7 and 8 accepted, 6 declined with the reason in §4.3) and
review 3 (`CODEX-FINDINGS-SCANNER-HOST-R3.md`, finding 9, accepted and tested
against a real engine, §3.4) and review 4 (`CODEX-FINDINGS-SCANNER-HOST-R4.md`,
findings 10 and 11, both accepted; 10 reshaped how the box listens, §3.2) and
review 5 (`CODEX-FINDINGS-SCANNER-HOST-R5.md`, finding 12, accepted). Review 5
found the gating design sound at spec level once 12 is fixed, and left restart
timing, connection cut-off and EICAR transport to the real-box acceptance
checks in §3.3. Accepted findings are marked **(codex N)**.
**Builds on:** `2026-09-01-object-storage-virus-scanning-design.md` (the sweep,
verdict tags, enforcement) and `2026-09-02-scan-on-demand-design.md` §9 (clamd
off the console box, reachable over TCP).
**Supersedes:** steps 2 and 4 of `../plans/2026-09-01-carloku-scanning-rollout.md`
(clamd on the console box, `CARLOS_STORE_SCANNER` as the switch). Its steps 5–8
and its watch list still stand.

---

## 1. Where things stand

The code is shipped and live. Both consoles run builds that carry the sweep,
`carlos store scan request|status|grant|revoke|sweep|enforce` and
`carlos system antivirus status|enable|disable` (Carloku `7edd4b33`, Tito
`fe2088e3`). `CARLOS_CLAMD_ADDR` already takes `host:port` as well as a socket
path (`2eaf2aae`, in every release since v0.18.0).

What neither deployment has is a clamd to talk to, permission for the console
to read and tag store objects, or a scan tag key. This spec supplies those three
things, plus one small client change (§4.3) that stops a partly scanned archive
being signed clean. **(codex 4)**

## 2. Decisions

| # | Decision | Why |
|---|---|---|
| D1 | clamd runs on its own box, not the console box | Its ~1.5 GB of resident signatures (about 3 GB during a reload) leave the control plane. Carloku's console box is a `t4g.large` that also serves IE apps. |
| D2 | One box per deployment, in the home region only | Only the console talks to clamd, and every store bucket is in the one region `CARLOS_STORE_REGION` names (`cmd/console/main.go:546-585`). A box elsewhere would add cross-region transfer, not remove it. |
| D3 | Console to clamd is plain TCP inside the VPC, closed by security group | clamd has no TLS and no authentication. Same-VPC, single-source security group is the same trust model as the console's own S3 traffic. TLS (stunnel or a dialler change) or Tailscale are later options, not v1. **(Paul, 2026-10-04: "A is fine for now")** |
| D4 | The console keeps fetching from S3 and streams to clamd | Scan-on-demand §9.4: tenant-data credentials stay on one box. The scanner box gets no S3 or parameter grants. |
| D5 | Both pieces are platform modules | Carloku consumes the platform the way a self-hoster does. Hand-written root resources would be a workaround and would duplicate clamd's setup per root. |
| D6 | Carloku first, then Tito | Tito is a third party, and its console was only unpinned on 2026-10-03 with #201 phase 2 pending. Prove it on our own flagship first. |
| D7 | Scope ends at "engine wired, switched on, no member granted" | Rollout plan steps 7–8: a member grant follows a member's request and the disclosure, never an operator's speculation. |
| D8 | An object clamd only partly scanned, with no malware signature matched, is recorded **unscanned**, never clean, and never infected on the strength of the limit alone | A signed clean verdict over bytes nobody read is the failure the verdict design exists to prevent; flagging large legitimate archives infected would make enforce block them. Unscanned fits the existing audit arithmetic (`unscanned = skipped + denied + failed`). A real signature match is positive evidence and stays **infected** even when the scan was partial (§4.3). **(Paul, 2026-10-04, on codex 4)** |

**Out of scope:** the unmerged scan-on-demand API (`worktree-store-scan-on-demand`),
TLS to clamd, alerting on signature age, GuardDuty, per-region scanning. If
store buckets ever become per-region, scanning would carry tenant bytes out of
their region; the answer then is a regional scanner that fetches for itself,
which reopens D4. That is a new decision, not more boxes.

## 3. The `scanner-host` module (`infra/modules/scanner-host`)

### 3.1 Resources

- **`aws_instance`**: `ami_id` from the caller (the caller passes
  `module.<host>.ami_id`, as Tito's `svc-pdf.tf` does), `instance_type` default
  `t4g.medium`, 20 GiB encrypted gp3 root, IMDSv2 required
  (`http_tokens = "required"`), `user_data_replace_on_change = false`,
  `lifecycle { ignore_changes = [ami, user_data] }`, so a newer image or an
  edited script never silently replaces the box; changing either (including
  `stream_max_bytes`) is an explicit `-replace`. Optional `subnet_id`; null
  means the default subnet, as `region-host` does, and a precondition then
  requires the client security group's VPC to be the default VPC.
- **`aws_security_group`**: ingress TCP 3310 from `client_security_group_id`
  **only**. No other ingress, no SSH. Egress open, for `freshclam` and apt. It
  lands in the client security group's VPC (`vpc_id` from a data source on that
  group), so the two can never end up in different VPCs.
- **IAM role and instance profile**: `AmazonSSMManagedInstanceCore` attached,
  for Session Manager. That managed policy grants `ssm:GetParameter*` on `*`,
  which on its own would let the box read `/carlos/scan-tag-key` and
  `/carlos/release-key`. So the role also carries an explicit Deny on
  `ssm:GetParameter`, `GetParameters`, `GetParameterHistory` and
  `GetParametersByPath` on `*`, the same shape as `region-host`'s
  `host_ssm_parameter_scope` (`main.tf:418-450`) with no allowed parameter at
  all. Session Manager's channel actions are untouched. No bucket, no KMS. The
  box cannot fetch tenant data or the key even if clamd were compromised.
  **(codex 1)**

### 3.2 How the box listens: the invariant

**TCP 3310 is open only while a self-test has passed against the clamd process
that is running now.** Not "the boot script checked once": a failed test, a
clamd restart, or a ClamAV package upgrade closes the port until the test
passes again. The console's own checks cannot stand in for this, because
`antivirus status` and `enable` only ask clamd for `VERSION`
(`internal/objstore/clamav.go:108`, `internal/console/apisystem.go:85,133`),
and an engine that signs truncated scans clean answers `VERSION` perfectly
well. **(codex 10)**

So clamd itself never listens on TCP. Three units:

- **`clamav-daemon`**, Debian's own unit and socket activation, **untouched**
  except a drop-in for `MemoryMax` (variable, default `3G`), `MemoryHigh` at
  about 85% of it, and `Restart=on-failure`. It listens on the unix socket
  `/run/clamav/clamd.ctl` only; `clamd.conf` has no `TCPSocket` and no
  `TCPAddr`. That is why the earlier replacement unit is gone: socket
  activation handing clamd only the unix socket is now exactly what is wanted.
  (Finding 2's problem no longer arises.) A drop-in `Wants=scanner-tcp.socket`
  makes every start of clamd, including an automatic restart, ask for the
  gate below to be re-run.
- **`scanner-selftest.service`**, `Type=oneshot`, `RemainAfterExit=yes`,
  `BindsTo=clamav-daemon.service`, `After=clamav-daemon.service`. Runs
  `/usr/local/sbin/scanner-selftest` against the unix socket. Being bound to
  clamd, it goes inactive the moment clamd stops or restarts.
- **`scanner-tcp.socket`** and **`scanner-tcp.service`**: the socket listens on
  `<private IPv4>:3310` (from IMDSv2, written into the unit at first boot;
  never `0.0.0.0`) and hands connections to `systemd-socket-proxyd
  /run/clamav/clamd.ctl`, running as a dedicated user in group `clamav`. Both
  carry `Requires=scanner-selftest.service`, `After=` it and `BindsTo=` it. So
  the port cannot open before the test passes, and it closes, cutting
  connections in flight, when the test's unit goes inactive.

  The socket unit sets **`DefaultDependencies=no`**. A socket unit is ordered
  `Before=sockets.target` by default, `sockets.target` comes before
  `basic.target`, and every service (the self-test included) comes after
  `basic.target`; a socket that must also come *after* the self-test closes
  that loop, and systemd breaks an ordering cycle by dropping a job, which
  could leave the port shut after a reboot. So the unit opts out, restores what
  it still needs by hand (`After=sysinit.target`, `Conflicts=shutdown.target`,
  `Before=shutdown.target`), and is started by `WantedBy=multi-user.target` and
  clamd's `Wants=`, never by `sockets.target`. **(codex 12)**

`clamd.conf` keys, each rewritten idempotently (clamd has no include
directory):

   - `StreamMaxLength <stream_max_bytes / 1 MiB>M`. Written in whole MiB, not
     bytes: Debian's debconf round trip strips an `M` and appends one, so a bare
     byte count would become a 25 TiB limit on the first configure (final
     review I1). `clamav-daemon/debconf` is also set false after install, so
     later upgrades leave `clamd.conf` alone.
   - `AlertExceedsMax yes`. With the default `no`, an archive that expands past
     `MaxScanSize`, `MaxFileSize`, `MaxFiles` or `MaxRecursion` is partly
     scanned and answered `OK`. With `yes` clamd answers
     `Heuristics.Limits.Exceeded.<limit> FOUND`, which §4.3 records as
     unscanned (D8). **(codex 4)**
   - `MaxFileSize 100M` and `MaxScanSize 100M`: **the same value, and fixed in
     this release.** `AlertExceedsMax` alone is not enough. ClamAV's zip
     extractor trims a member that expands past `MaxFileSize` and scans the
     trimmed copy without raising any limit (`libclamav/unzip.c:222-226`,
     1.4.3). With the two limits equal, the trimmed copy is as large as the
     whole scan budget, so scanning it always trips `MaxScanSize`, which does
     alert. Tested, §3.4. **(codex 9)** It is a constant, not a variable,
     because the self-test's fixtures and expected answers are sized for
     100 MiB; a configurable limit means deriving both from it, which is a
     follow-up (§11). **(codex 11)**
   - `ConcurrentDatabaseReload` from a variable, default `yes`.

### 3.3 `user_data`

A templated script (`scanner-init.sh.tftpl`), `set -eu`, each step logged to
the serial console so `aws ec2 get-console-output` shows how far it got.

1. **SSM agent first.** The AMI is the base Debian image `region-host` boots
   from (`outputs.tf:26`), not a configured host, and Debian ships no SSM agent.
   Install the arm64 `amazon-ssm-agent` `.deb` from the regional bucket and
   enable it, exactly as `host-init.sh.tftpl` does. With SSH closed this is the
   only way onto the box, so it goes before anything that can fail. A
   `region` variable feeds the URL. Also `unattended-upgrades`, as region hosts
   have. **(codex 5)**
2. **Install without starting.** Write a `policy-rc.d` that exits 101, then
   `apt-get install clamav-daemon clamav-freshclam clamdscan python3`, then
   remove the `policy-rc.d`. Debian's postinst would otherwise start
   `clamav-freshclam` (`clamav-freshclam.postinst.in:333`) and the daemon
   before they are configured. **(codex 3)**
3. **Write `clamd.conf`'s keys, the drop-in, the self-test script and the
   three units** of §3.2. `systemctl daemon-reload`.
4. **First signatures.** `freshclam` once in the foreground, with no
   `clamav-freshclam` service running (step 2 kept it stopped), so the two never
   contend for the lock or the update log. Refuse to continue unless `main`,
   `daily` and `bytecode` (`.cvd` or `.cld`) exist. Then enable and start
   `clamav-freshclam` (hourly checks by default). **(codex 3)**
5. **Enable and start** `clamav-daemon`, `scanner-selftest.service` and
   `scanner-tcp.socket`. The ordering in §3.2 does the rest: clamd loads, the
   self-test runs, and only on a pass does the port open.
6. **Verify, and say so.** `systemctl is-active scanner-selftest.service
   scanner-tcp.socket` both `active`; `zVERSION` answers on
   `<private ip>:3310`; `ss -ltn` shows 3310 bound to the private IP only.
   Write `/var/lib/scanner-host/ready` and print `SCANNER-HOST READY` plus the
   `zVERSION` line. Any failure prints `SCANNER-HOST FAILED: <which check>` and
   exits non-zero, with the port closed by construction.

**The self-test** (`/usr/local/sbin/scanner-selftest`) builds the §3.4
fixtures with `python3` in a temp directory (members written from a temp file,
so their zip headers are ordinary rather than zip64, which clamd would skip),
streams each to the unix socket with `clamdscan --stream`, and requires the
class of reply in the table's last column. **An over-limit fixture answering
`OK` is a failure.** It re-proves the behaviour on whatever ClamAV version the
image or a later upgrade installs, rather than trusting what 1.4.3 did.

**Acceptance checks for the module**, run on the first real box before any
console is wired (§6.2), and recorded in the README:

- kill clamd: 3310 closes at once, and reopens only after the self-test has
  re-run and passed;
- force the self-test to fail (an environment override in a drop-in, used only
  for this check): clamd comes up, 3310 **stays closed**, the unit shows
  failed; remove the override and restart: it opens;
- an INSTREAM scan of EICAR through `<private ip>:3310` from the console box
  answers `Eicar-Test-Signature FOUND`, so the proxy carries the protocol;
- **reboot the box**: it comes back with the self-test passed and 3310 open,
  `systemd-analyze verify` on the three units is clean, and `journalctl -b`
  has no `ordering cycle` line. First boot cannot show this, because
  `basic.target` and `sockets.target` are already reached by the time
  `user_data` runs. **(codex 12)**

### 3.4 What the limits do, measured

Run 2026-10-05 against Debian trixie's clamd (ClamAV 1.4.3), `StreamMaxLength
25M`, `AlertExceedsMax yes`, every fixture streamed with `clamdscan --stream`.
Members are a repeating 16 KiB random block, distinct per member, so the clean
cache cannot short-circuit them. "Limit" means a
`Heuristics.Limits.Exceeded.*` reply.

| Fixture | `MaxFileSize 25M`, `MaxScanSize 100M` | Both 100M (this spec) | Self-test requires |
|---|---|---|---|
| small clean file | OK | OK | OK |
| EICAR | infected | infected | infected |
| zip, one member expanding to 500 MiB | **OK** (trimmed, silent) | Limit | Limit |
| zip, a zip inside holding that member | **OK** (silent) | Limit | Limit |
| zip, 30 distinct 20 MiB members | Limit | Limit | Limit |
| gzip of 500 MiB | Limit | Limit | Limit |
| tar.gz, 5 × 40 MiB | Limit | Limit | Limit |
| bzip2, xz, tar.xz of the same | Limit | Limit | not in self-test |
| zip, 500 MiB member then EICAR | infected | Limit | Limit |
| zip, 60 MiB member then EICAR | infected | infected | infected |
| zip, one 60 MiB member | **OK** (trimmed, silent) | OK (fully scanned) | OK |

The first column is finding 9, observed: three fixtures signed clean over
content nobody read. The second is what this spec configures.

**Known and not fixed: zip64 local headers.** A zip member written with zip64
local headers is not extracted at all by 1.4.3 (`local header - stream out of
file`), and EICAR inside one reads `OK` under every config. That is parser
coverage, not a limit, and no clamd setting changes it. Encrypted archives are
the same class. So a clean verdict means "clamd found nothing in what it could
open", never "this object is safe", and the disclosure a member reads before
opting in (rollout plan step 7) should say so in those words. Recorded in §11
as a follow-up; it does not block this rollout, because it is true of the
engine wherever it runs.

### 3.5 Variables and outputs

| Variable | Default | Notes |
|---|---|---|
| `name` | — | Prefixes every resource |
| `ami_id` | — | Debian arm64, from `region-host` |
| `client_security_group_id` | — | The console box's group |
| `instance_type` | `t4g.medium` | `t4g.small` only with `concurrent_database_reload = false` |
| `stream_max_bytes` | `26214400` | Must equal the console's `CARLOS_CLAMD_MAX_BYTES`. Validation: a whole number of MiB, between 8 MiB and 100 MiB, so the self-test's fixtures (about 5 MB compressed) always fit the stream and a whole stream always fits the scan budget **(codex 11)** |
| `memory_max` | `"3G"` | clamd's cgroup ceiling |
| `concurrent_database_reload` | `true` | |
| `subnet_id` | `null` | |
| `region` | — | For the SSM agent download URL |

Validation: `instance_type` beginning `t4g.small`/`t4g.micro`/`t4g.nano` with
`concurrent_database_reload = true` is refused with a message naming the reload
spike.

Outputs: `clamd_addr` (`"<private_ip>:3310"`), `instance_id`,
`security_group_id`, `stream_max_bytes`.

### 3.6 Replacement

A replaced box gets a new private IP, so `CARLOS_CLAMD_ADDR` must be updated by
hand. Accepted: replacements are explicit (`-replace`) and the failure is loud
(sweeps fail per tick, `scan status` and `antivirus status` say so, the console
stays up). The README says this in its first section.

## 4. The console's read-and-tag grant

### 4.1 `region-host` (Carloku)

- New variable `store_scanning` (bool, default `false`). A precondition refuses
  `true` with an empty `store_bucket_prefix`.
- When `true`, a **separate** `aws_iam_role_policy`, `${iam_name}-store-scan`:
  `s3:GetObject`, `s3:GetObjectTagging`, `s3:PutObjectTagging` on
  `arn:aws:s3:::${store_bucket_prefix}*/*`. These are exactly the calls the
  scanner makes (`HeadObject` is authorised by `GetObject`; `ListObjectsV2` is
  already granted by `host_store`). Store buckets use SSE-S3, so no KMS grant.
- Separate, so an opted-out deployment plans zero changes, and so the existing
  `host_store` comment ("No GetObject: the console provisions stores, it does not
  read tenant data") stays true. That comment gains one line pointing at
  `store_scanning`.
- The store-user boundary is untouched. Apps still cannot tag their own objects.

### 4.2 Tito (`object-store.tf`)

A new statement `StoreScan` in `host_ie_object_store` with the same three
actions on `arn:aws:s3:::carlos-store-tito-*/*` and
`arn:aws:s3:::titogo-uploads-665106592142/*`. Tito keeps its own store policy
(#201 settled this), so `store_scanning` is not used there.

### 4.3 The client change: limits exceeded is unscanned (D8)

`internal/clamd/clamd.go` `parseReply` (`:273`) maps any `FOUND` to
`StatusInfected`. With `AlertExceedsMax yes`, a limit hit arrives as
`stream: Heuristics.Limits.Exceeded.MaxScanSize FOUND` (likewise `.MaxFileSize`,
`.MaxFiles`, `.MaxRecursion`, `.MaxScanTime`). Add two cases **before** the
`FOUND` case, both keeping the raw reply:

- signature exactly `Heuristics.Limits.Exceeded.MaxScanTime` becomes
  `StatusError`. A timeout depends on load, not on the bytes, so it must be
  retried. `verdictFor` maps `StatusError` to `VerdictFailed`, which
  `clamav.go:249-251` re-issues every sweep rather than carrying forward.
  **(codex 7)**
- any other signature beginning `Heuristics.Limits.Exceeded` becomes
  `StatusSizeExceeded`. These limits are deterministic for given bytes and
  config. `clamav.go:374-383` maps it to `VerdictSkipped`, so it lands in
  `skipped` and `unscanned`, never `clean` or `infected`, and like an oversize
  object it is carried forward until the bytes change.

**Declined: codex 6** (a malware match hiding an earlier limit hit). When
clamd reports a real match, the client records infected, whatever limit was
also hit. That is correct under D8: the object is known to contain malware,
whatever else went unread. Setting `HeuristicScanPrecedence yes` to surface the
limit instead would turn a known infection into `unscanned`, which is worse.
When nothing matches, clamd answers with the limit heuristic, so the case D8
exists for is covered. Which of the two an archive gets depends on what clamd
reaches before the budget runs out (§3.4: a 500 MiB member ahead of EICAR
answers the limit, a 60 MiB member ahead of it answers EICAR). Both are honest
and neither is clean. §7 step 2 tests both.

Tests in `clamd_test.go`: `MaxScanTime` maps to error; each of the other four
limit names maps to size-exceeded;
`Eicar-Signature FOUND` still maps to infected; a signature that merely
contains the phrase elsewhere (`Win.Heuristics.Limits.Exceeded.Fake`) still
maps to infected, so the match is a prefix on the signature, not a substring of
the reply. The docs for `carlos store scan status` that explain `skipped` gain
one sentence naming over-limit archives.

Because this is console code, **both consoles roll to a build containing it
before they are wired** (§6.2). A console without it, wired to a clamd with
`AlertExceedsMax yes`, would record over-limit archives as infected.

## 5. Release and consumption

- The module change, `scanner-host` and the §4.3 client change land on amadan main through a branch and
  `amadan branch merge -squash`, then the GitHub mirror is pushed forward
  (fast-forward checked first), then **v0.24.1** is cut per the README's
  "Releasing the platform" recipe and pushed to the mirror.
- **Carloku** (`carloku-infrastructure`): move **only `host_ie`'s** `?ref=` to
  v0.24.1, set `store_scanning = true`, add `module "scanner"` with
  `client_security_group_id = module.host_ie.security_group_id`. The other three
  regions stay on v0.21.0. Every apply is `-target`ed
  (`module.host_ie.aws_iam_role_policy.host_store_scan`, `module.scanner`), and
  the plan is read for any `user_data` line before it runs, because a
  `user_data` change stops and starts a box. Any other `host_ie` diff the
  v0.21.0 to v0.24.1 bump shows is listed in the PR and not applied.
- **Tito** (`teamtito/carlos-infrastructure`): a branch from the
  `store-boundary-201` line (main cannot plan while the Canada box is only on
  `region-ca`), `module "scanner"` at `?ref=v0.24.1`, the `StoreScan`
  statement. Targeted applies. Tito pins `region-host` to a SHA; that pin does
  not move.

## 6. Wiring the console

### 6.1 The scan tag key

- 32 random bytes, hex. Generated once per deployment, never in git, tofu state
  or a transcript: generated and written in one pipeline.
- Durable copy: SSM SecureString `/carlos/scan-tag-key` in the deployment's
  account (inside the `/carlos/*` path the box can already read). Losing it makes
  every stored verdict unverifiable.
- `CARLOS_SCAN_TAG_KEY_PREVIOUS` stays unset; rotation exists and is not needed.

### 6.2 The console's environment

**Two preconditions, both checked before any variable is written.**

*The scanner box is ready.* Over SSM on the scanner instance: `cloud-init
status --wait` reports `done` with no error; `/var/lib/scanner-host/ready`
exists; `systemctl is-active scanner-selftest.service scanner-tcp.socket` says
`active` twice; and the module's acceptance checks (§3.3) have been run on
this box. Nothing the console can ask tells a tested engine from an untested
one, so this is checked on the box, on **each** deployment, Tito included.
**(codex 10)**

*The console **process** runs a build containing §4.3.* Promoting only moves the channel
pointer (`internal/release/release.go:718`); adoption is separate and can defer
(`adopt.go:956`); and `x-carlos-version` comes from the edge's route state
(`internal/edge/edge.go:2192`), not from the running process. So neither a
promote nor the header proves it. The check is the one `roll-console.sh`
already makes (`scripts/roll-console.sh:93-97`): over SSM, `readlink
/proc/$(systemctl show -p MainPID --value console)/exe` must resolve to
`versions/<sha>/console` for the exact SHA built. Roll with `roll-console.sh`
(it ships, promotes, adopts, restarts and runs that check); if the check fails,
**stop**: nothing is wired. Boxes are untouched. On Tito, the same from Tito's
bucket. **(codex 8)**

First confirm over SSM, read-only, which file `console.service` loads. Then,
after a dated backup of that file:

```
CARLOS_CLAMD_ADDR=<module.scanner.clamd_addr>
CARLOS_CLAMD_MAX_BYTES=<module.scanner.stream_max_bytes>
CARLOS_SCAN_TAG_KEY=<from /carlos/scan-tag-key>
```

`systemctl restart console`, then repeat the `/proc/<pid>/exe` check (same SHA,
new PID), and `carlos system antivirus status` answers with the engine wired.
If it fails to start: restore the backup, `systemctl reset-failed console &&
systemctl restart console` (a start-limited unit ignores try-restart; seen
2026-09-11).

`CARLOS_STORE_SCANNER` stays unset. It is deprecated, and the switch is the
record (`system/antivirus.json`), so the restart wires the engine without
turning it on.

### 6.3 Switching on

`carlos system antivirus status`: engine wired, clamd answering, signature date
today or yesterday. Then `carlos system antivirus enable`. With no app granted
the hourly pass runs and correctly does nothing. Sit in that state until §7
passes.

## 7. Proving it on Carloku

On a throwaway app we own, with a plaintext store:

1. Upload one clean object and the EICAR test file. `scan request --plaintext`,
   `grant`, `sweep`. **Pass:** `scan status` shows a completed sweep, one clean,
   one infected; both objects' tags verify under this deployment's key and carry
   the object's current ETag.
2. Upload three archives from the §3.4 fixture set and `sweep`:
   - the zip with one member expanding to 500 MiB. **Pass:** `skipped` and
     `unscanned`, never `clean` or `infected`; its tag carries no clean
     verdict. **(codex 4, codex 9)**
   - the zip with that member followed by EICAR. **Pass:** `skipped` and
     `unscanned` (clamd stops at the limit before reaching EICAR).
   - the zip with a 60 MiB member followed by EICAR. **Pass:** `infected`
     (D8: a real match stands).

   If any of the three reads `clean`, stop.
3. Overwrite the clean object, `sweep`. **Pass:** it is re-scanned, not carried
   forward. **If it is carried forward, stop the rollout:** a verdict that
   outlives its bytes is worse than none.
4. `enforce on`. Remove `CARLOS_CLAMD_ADDR`, restart. **Pass:** `enforce off`
   still works with no engine built. Restore the address, restart.
5. Destroy the app. **Pass:** no `scan.json` remains under its prefix and the
   name is claimable at once.

Then stop. Scanning stays enabled; no member store is granted.

## 8. Tito

After §7 passes and after #201 phase 2 has landed (so two changes do not edit
Tito's console environment on the same day): §5's Tito apply, the Tito console
roll and the scanner-box readiness check (§6.2's two preconditions), §6 in
Tito's account, `antivirus status`, `enable`. **Tito's proof stops at status and
enable.** §7 exercises platform code identical to Carloku's, and a throwaway app
in a customer's deployment needs the customer's say-so.

## 9. Docs

- `infra/modules/scanner-host/README.md`: what it is, the three outputs the
  console needs, replacement (§3.6), the invariant (§3.2), the limits and why they are equal (§3.4), the acceptance checks (§3.3), and the sizing rule.
- `infra/modules/region-host/README.md`: `store_scanning`.
- A dated note at the top of `../plans/2026-09-01-carloku-scanning-rollout.md`:
  steps 2 and 4 are superseded by this spec, `CARLOS_STORE_SCANNER` is
  deprecated.
- An amadan discussion on `carlos/platform` tracking the rollout.

## 10. Cost

One `t4g.medium` plus 20 GiB gp3 per deployment: about $25 a month each, $50
for the pair.

## 11. What would change this design

- `t4g.medium` turning out too small for the reload spike in practice: raise
  the instance type, or set `concurrent_database_reload = false`.
- A member asking for scanning on a client-encrypted store: no change here, the
  existing `--client-encrypted` path handles it.
- Store buckets becoming per-region: see §2, out of scope.
- **zip64 and encrypted archive members are not opened by clamd** (§3.4), so a
  clean verdict never covers them. Follow-ups, each needing its own decision:
  say so in the `scan request` disclosure (user-facing copy, so copy review);
  consider `AlertEncrypted yes` with a mapping to unscanned, the way §4.3 maps
  limits.
- Making the scan limit configurable: the self-test's fixture sizes and
  expected answers must then be derived from it (codex 11). Fixed at 100 MiB
  until someone needs otherwise.
- Raising clamd's limits later: objects already recorded `skipped` under the
  old limits are carried forward until their bytes change, as oversize objects
  are today. A forced re-sweep of skipped objects is a follow-up if anyone
  raises a limit, not part of this rollout.
