# Scanner Host Rollout Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Object-storage virus scanning switched on, with no member granted, on carloku.com and then on the Tito platform, each with clamd on its own box in the home region.

**Architecture:** A new `scanner-host` OpenTofu module builds a clamd box whose TCP port is open only while a self-test has passed against the running clamd. `region-host` gains an opt-in read-and-tag grant for the console. One small Go change records clamd's limit replies as unscanned. Then a release, and an operational sequence per deployment: apply, acceptance checks on the box, console roll, key, wiring, enable, proof.

**Tech Stack:** Go (`internal/clamd`), OpenTofu 1.12 with `tofu test` and `mock_provider`, Debian trixie, ClamAV 1.4, systemd (`systemd-socket-proxyd`), AWS (EC2, IAM, SSM), the `carlos` and `amadan` CLIs.

**Spec:** `docs/superpowers/specs/2026-10-04-scanner-host-rollout-design.md` (draft 6). Read it first; section numbers below (§) refer to it.

## Global Constraints

- **Rulings D1–D8 in spec §2 are fixed.** Do not reopen them.
- `MaxFileSize` and `MaxScanSize` are both `100M`, a constant, not a variable (§3.2).
- The invariant (§3.2): TCP 3310 is open only while a self-test has passed against the clamd process running now. clamd itself never listens on TCP.
- The scanner box's role: Session Manager only, explicit Deny on the four parameter-read actions, no S3, no KMS (§3.1).
- `CARLOS_STORE_SCANNER` stays unset everywhere. It is deprecated.
- The scan tag key never appears in git, tofu state, argv, a terminal or a transcript (§6.1).
- **Nothing live changes without Paul's go for that step.** Every `tofu apply`, console roll, env edit, restart, `antivirus enable`, merge, mirror push and release is preceded by showing him exactly what will run. Tasks 6–12 are marked **GATED**, as are the merge and mirror steps of Task 5.
- Every tofu apply is `-target`ed. Never apply a whole root (flagship has standing `user_data` drift that restarts boxes; Tito's main cannot plan).
- Carloku first, then Tito. Tito waits for #201 phase 2 to have landed.
- User-facing prose (READMEs, `docs/site`, release notes) goes through the `copy-review` skill before it is written to a file. No em dashes in copy: full stop and a new sentence.
- No GitHub issues. Track on an amadan discussion (`amadan` skill for the house rules).
- Go: use the default build cache. Scratch goes under `$TMPDIR/scanner-host/`, never `/tmp`. Delete it at the end.
- AWS profiles: `keymail` is the flagship account (console box `i-092c0c1eea75723cb`, eu-west-1, bucket `carlos-flagship-271376211898`). `titogo` is Tito's (console box `i-025a0b70f3d93304d`, eu-west-1). Assumed-role profiles need the Bash sandbox off for that command.
- Commits end with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.

## Review Focus

1. **An engine upgrade that changes limit behaviour.** A later ClamAV package could answer `OK` for an over-limit archive. Expected: the port closes and stays closed. Pinned by Task 7's forced-fail acceptance check and by the self-test running on every clamd start (Task 3).
2. **A reboot of the scanner box.** Expected: it comes back scanning with no hand. First boot cannot show an ordering cycle. Pinned by Task 7's reboot check.
3. **A signature name that merely contains the limit phrase.** `Win.Heuristics.Limits.Exceeded.Fake FOUND` must stay infected. Pinned by Task 1's tests.
4. **A console restarted on the old binary with the scanner wired.** Expected: wiring stops before any variable is written. Pinned by Task 8's `/proc/<pid>/exe` check and Task 9's precondition.
5. **A rerun of the key step.** Expected: the existing key survives; a second run cannot replace it and orphan every verdict. Pinned by `Overwrite:false` in Task 9.

## File Structure

Platform repo (this worktree, branch `worktree-scanner-host`):

| File | Responsibility |
|---|---|
| `internal/clamd/clamd.go` (modify) | `parseReply`: limit replies become size-exceeded or error |
| `internal/clamd/clamd_test.go` (modify) | tests for the above |
| `infra/modules/region-host/variables.tf`, `main.tf` (modify) | `store_scanning` and the `-store-scan` policy |
| `infra/modules/region-host/tests/store_scanning.tftest.hcl` (create) | plan-time tests for the grant |
| `infra/modules/scanner-host/{versions,variables,main,outputs}.tf` (create) | the box, its group, its role |
| `infra/modules/scanner-host/scanner-init.sh.tftpl` (create) | first-boot script |
| `infra/modules/scanner-host/files/scanner-selftest` (create) | the self-test |
| `infra/modules/scanner-host/files/scanner-selftest.service`, `scanner-tcp.socket`, `scanner-tcp.service` (create) | the gate |
| `infra/modules/scanner-host/tests/scanner_host.tftest.hcl` (create) | plan-time tests |
| `infra/modules/scanner-host/README.md` (create), `infra/modules/README.md`, `infra/modules/region-host/README.md`, `docs/site/cli.md`, `docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md` (modify) | docs |

Other repos: `~/github.com/carlosframework/carloku-infrastructure` (`region-ie.tf`, new `scanner.tf`), `~/github.com/teamtito/carlos-infrastructure` (`object-store.tf`, new `scanner.tf`).

---

### Task 1: clamd client records limit replies as unscanned

**Files:**
- Modify: `internal/clamd/clamd.go` (`parseReply`, near line 268; the `StatusSizeExceeded` comment near line 38)
- Test: `internal/clamd/clamd_test.go`

**Interfaces:**
- Consumes: `replyFixture(t, reply string) Result` (existing test helper), `signatureOf(reply string) string` (existing).
- Produces: no new names. `parseReply` now returns `StatusSizeExceeded` for limit heuristics other than scan time, and `StatusError` for `Heuristics.Limits.Exceeded.MaxScanTime`. `objstore.verdictFor` is unchanged and already maps these to `VerdictSkipped` and `VerdictFailed`.

- [ ] **Step 1: Write the failing tests.** Append to `internal/clamd/clamd_test.go`:

```go
// With AlertExceedsMax on, clamd reports an archive it could only partly scan
// as a FOUND whose signature names the limit. That is not an infection and it
// is not clean: nothing was judged, so it is the same status as an object too
// large to stream. Recording it clean would sign a verdict over bytes nobody
// read; recording it infected would make enforce block large honest archives.
func TestLimitHeuristicIsSizeExceededNotInfected(t *testing.T) {
	for _, limit := range []string{"MaxScanSize", "MaxFileSize", "MaxFiles", "MaxRecursion"} {
		reply := "stream: Heuristics.Limits.Exceeded." + limit + " FOUND\x00"
		res := replyFixture(t, reply)
		if res.Status != StatusSizeExceeded {
			t.Errorf("%s: status = %v, want size-exceeded", limit, res.Status)
		}
		if res.Signature != "" {
			t.Errorf("%s: a limit is not a signature, got %q", limit, res.Signature)
		}
		if !strings.Contains(res.Raw, limit) {
			t.Errorf("%s: the engine's own words did not survive: %q", limit, res.Raw)
		}
	}
}

// Older engines name the limit without saying which one.
func TestBareLimitHeuristicIsSizeExceeded(t *testing.T) {
	res := replyFixture(t, "stream: Heuristics.Limits.Exceeded FOUND\x00")
	if res.Status != StatusSizeExceeded {
		t.Fatalf("status = %v, want size-exceeded", res.Status)
	}
}

// A scan that ran out of TIME depends on load, not on the bytes, so it must be
// retried. StatusError becomes a failed verdict, which every sweep re-issues;
// size-exceeded becomes skipped, which is carried forward until the object
// changes. Filing a timeout under skipped would exclude the object for good.
func TestScanTimeLimitIsAnErrorSoItIsRetried(t *testing.T) {
	res := replyFixture(t, "stream: Heuristics.Limits.Exceeded.MaxScanTime FOUND\x00")
	if res.Status != StatusError {
		t.Fatalf("status = %v, want error", res.Status)
	}
	if !strings.Contains(res.Raw, "MaxScanTime") {
		t.Fatalf("the engine's own words did not survive: %q", res.Raw)
	}
}

// The match is on the SIGNATURE, as a prefix. A real signature that happens to
// carry the phrase elsewhere in its name is still an infection, and so is one
// that only begins with something similar.
func TestLookalikeSignaturesAreStillInfected(t *testing.T) {
	for _, sig := range []string{
		"Win.Heuristics.Limits.Exceeded.Fake",
		"Heuristics.Limits.ExceededButNot",
		"Heuristics.Phishing.Email.SpoofedDomain",
		"Eicar-Signature",
	} {
		res := replyFixture(t, "stream: "+sig+" FOUND\x00")
		if res.Status != StatusInfected {
			t.Errorf("%s: status = %v, want infected", sig, res.Status)
		}
		if res.Signature != sig {
			t.Errorf("signature = %q, want %q", res.Signature, sig)
		}
	}
}
```

- [ ] **Step 2: Run them and watch them fail.**

Run: `go test ./internal/clamd -run 'Limit|Lookalike' -count=1`
Expected: FAIL. `TestLimitHeuristicIsSizeExceededNotInfected`, `TestBareLimitHeuristicIsSizeExceeded` and `TestScanTimeLimitIsAnErrorSoItIsRetried` report `status = infected`. `TestLookalikeSignaturesAreStillInfected` passes already; it is there to stay passing.

- [ ] **Step 3: Implement.** In `internal/clamd/clamd.go`, add beside the timeout constants:

```go
// clamd's names for "I stopped before the end". With AlertExceedsMax yes it
// reports them as FOUND, which parseReply must not read as an infection.
const (
	limitHeuristic         = "Heuristics.Limits.Exceeded"
	limitHeuristicScanTime = "Heuristics.Limits.Exceeded.MaxScanTime"
)

// isLimitHeuristic reports whether sig names a scan limit rather than malware.
// A prefix on the signature, with the dot, so neither a longer first component
// nor a real signature carrying the phrase elsewhere can match.
func isLimitHeuristic(sig string) bool {
	return sig == limitHeuristic || strings.HasPrefix(sig, limitHeuristic+".")
}
```

and replace the `FOUND` case of `parseReply`:

```go
	case strings.HasSuffix(reply, " FOUND"):
		sig := signatureOf(reply)
		switch {
		case sig == limitHeuristicScanTime:
			// Load-dependent, so retried: an error becomes a failed verdict,
			// which every sweep re-issues.
			res.Status = StatusError
		case isLimitHeuristic(sig):
			// Deterministic for these bytes and this config, and nothing was
			// judged: the same fact as an object too large to stream.
			res.Status = StatusSizeExceeded
		default:
			res.Status = StatusInfected
			res.Signature = sig
		}
```

Extend the `StatusSizeExceeded` doc comment with one sentence: `It also covers clamd's limit heuristics (an archive that expands past MaxScanSize, MaxFileSize, MaxFiles or MaxRecursion), which arrive as FOUND when AlertExceedsMax is on.`

- [ ] **Step 4: Run the package.**

Run: `go test ./internal/clamd -count=1` then `go vet ./internal/clamd`
Expected: `ok`, no vet output.

- [ ] **Step 5: Run what consumes it.**

Run: `go test ./internal/objstore -run 'Clam|Scan|Verdict' -count=1`
Expected: `ok` (no behaviour change there; this proves nothing asserted the old mapping).

- [ ] **Step 6: Commit.**

```bash
git add internal/clamd/clamd.go internal/clamd/clamd_test.go
git commit -m "clamd: a limit heuristic is unscanned, and a scan timeout is retried" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```

---

### Task 2: `region-host` grants the console read-and-tag, opt-in

**Files:**
- Modify: `infra/modules/region-host/variables.tf` (after `store_user_path`), `infra/modules/region-host/main.tf` (after `host_store`; and the `host_store` comment)
- Create: `infra/modules/region-host/tests/store_scanning.tftest.hcl`

**Interfaces:**
- Produces: variable `store_scanning` (bool, default `false`); resource `aws_iam_role_policy.host_store_scan[0]` named `"${var.iam_name}-store-scan"`. Task 7 targets `module.host_ie.aws_iam_role_policy.host_store_scan`.

- [ ] **Step 1: Write the failing test.** Create `infra/modules/region-host/tests/store_scanning.tftest.hcl`:

```hcl
# Plan-time tests for the scanning grant. mock_provider means no AWS account
# and no credentials: these assert what the module WOULD create.
mock_provider "aws" {}

override_data {
  target = data.aws_vpcs.default
  values = { ids = ["vpc-0aaaaaaaaaaaaaaaa"] }
}

variables {
  name                  = "carlos-test-ie"
  sg_description        = "carlos test host"
  iam_name              = "carlos-test-host"
  instance_type         = "t4g.small"
  root_volume_size      = 20
  user_data             = "#!/bin/sh\n"
  deployment_bucket_arn = "arn:aws:s3:::carlos-test-platform"
  data_bucket_arn       = "arn:aws:s3:::carlos-test-ie"
  ssm_parameter_arn     = "arn:aws:ssm:eu-west-1:123456789012:parameter/carlos/*"
  store_bucket_prefix   = "carlos-store-test-"
}

run "off_by_default" {
  command = plan
  assert {
    condition     = length(aws_iam_role_policy.host_store_scan) == 0
    error_message = "store_scanning defaults to false and must create nothing"
  }
}

run "on_grants_exactly_read_and_tag" {
  command = plan
  variables {
    store_scanning = true
  }
  assert {
    condition     = length(aws_iam_role_policy.host_store_scan) == 1
    error_message = "store_scanning = true must create the scan policy"
  }
  assert {
    condition     = aws_iam_role_policy.host_store_scan[0].name == "carlos-test-host-store-scan"
    error_message = "the policy is named <iam_name>-store-scan"
  }
  assert {
    condition = toset(jsondecode(aws_iam_role_policy.host_store_scan[0].policy).Statement[0].Action) == toset([
      "s3:GetObject", "s3:GetObjectTagging", "s3:PutObjectTagging",
    ])
    error_message = "exactly the three actions the scanner calls, no more"
  }
  assert {
    condition     = jsondecode(aws_iam_role_policy.host_store_scan[0].policy).Statement[0].Resource == "arn:aws:s3:::carlos-store-test-*/*"
    error_message = "scoped to objects under the store prefix"
  }
}

run "refused_without_a_store_prefix" {
  command = plan
  variables {
    store_scanning      = true
    store_bucket_prefix = ""
  }
  expect_failures = [aws_iam_role_policy.host_store_scan]
}
```

- [ ] **Step 2: Run it and watch it fail.**

```bash
export TF_DATA_DIR="$TMPDIR/scanner-host/tf-region-host"
tofu -chdir=infra/modules/region-host init -backend=false -input=false
tofu -chdir=infra/modules/region-host test
```
Expected: FAIL, `Reference to undeclared resource ... "aws_iam_role_policy" "host_store_scan"`. (The first `init` downloads the AWS provider into `TF_DATA_DIR`, on disk, not `/tmp`. If a mock data source makes an unrelated precondition fail, add an `override_data` for it in this file and say so in the commit; do not weaken the module.)

- [ ] **Step 3: Implement.** In `variables.tf`, after `store_user_path`:

```hcl
variable "store_scanning" {
  description = "Let this host's role READ and TAG objects under store_bucket_prefix, which is what the console's virus scanner needs (s3:GetObject, s3:GetObjectTagging, s3:PutObjectTagging). Off by default: without it the console provisions stores and never reads tenant data. Set it only on the box that runs the console, and only on a deployment that runs a scanner (infra/modules/scanner-host). Requires store_bucket_prefix."
  type        = bool
  default     = false
}
```

In `main.tf`, directly after the `host_store` resource:

```hcl
# ---- object storage scanning (opt-in) --------------------------------------
#
# The one grant that lets this role read tenant object bytes, so it is its own
# policy behind its own switch: a deployment that has not turned scanning on
# plans no change here, and the sentence in host_store above ("it does not
# read tenant data") stays true for it.
#
# Exactly the calls the scanner makes. HeadObject is authorised by GetObject;
# ListObjectsV2 is already in host_store. Store buckets use SSE-S3, so there is
# no KMS statement. PutObjectTagging is how a verdict is written; the store
# user boundary deliberately lacks it, so an app cannot write one itself.
resource "aws_iam_role_policy" "host_store_scan" {
  count = var.store_scanning ? 1 : 0
  name  = "${var.iam_name}-store-scan"
  role  = aws_iam_role.host.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = ["s3:GetObject", "s3:GetObjectTagging", "s3:PutObjectTagging"]
        Resource = "arn:aws:s3:::${var.store_bucket_prefix}*/*"
      },
    ]
  })
  lifecycle {
    precondition {
      condition     = var.store_bucket_prefix != ""
      error_message = "store_scanning needs store_bucket_prefix: with no prefix there are no object stores on this deployment to scan, and the grant would name every bucket in the account."
    }
  }
}
```

In the `host_store` comment, change `No GetObject: the console provisions stores, it does not read tenant data, and the economics sweep sizes buckets with ListBucket.` to add: ` (Scanning is the exception, and it is a separate policy behind store_scanning, below.)`

- [ ] **Step 4: Run the tests and the formatter.**

Run: `tofu -chdir=infra/modules/region-host test` then `tofu fmt -check -recursive infra/modules/region-host`
Expected: `3 passed, 0 failed`; fmt prints nothing.

- [ ] **Step 5: Commit.** Confirm `git status --short infra/` shows no `.terraform` or lock file, then:

```bash
git add infra/modules/region-host/variables.tf infra/modules/region-host/main.tf infra/modules/region-host/tests/store_scanning.tftest.hcl
git commit -m "region-host: store_scanning, the console's opt-in read-and-tag grant" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```

---

### Task 3: the `scanner-host` module

**Files:**
- Create: `infra/modules/scanner-host/versions.tf`, `variables.tf`, `main.tf`, `outputs.tf`, `scanner-init.sh.tftpl`, `files/scanner-selftest`, `files/scanner-selftest.service`, `files/scanner-tcp.socket`, `files/scanner-tcp.service`, `tests/scanner_host.tftest.hcl`

**Interfaces:**
- Consumes: nothing from earlier tasks.
- Produces: module inputs `name`, `ami_id`, `client_security_group_id`, `region`, `instance_type`, `stream_max_bytes`, `memory_max`, `memory_high`, `concurrent_database_reload`, `subnet_id`; outputs `clamd_addr`, `instance_id`, `security_group_id`, `stream_max_bytes`. On the box: `/usr/local/sbin/scanner-selftest`, units `scanner-selftest.service`, `scanner-tcp.socket`, `scanner-tcp.service`, marker `/var/lib/scanner-host/ready`, console-log lines `SCANNER-HOST READY` / `SCANNER-HOST FAILED: <reason>`, and the environment switch `SCANNER_SELFTEST_FORCE_FAIL=1`. Tasks 7, 9, 11 and 12 use these names.

- [ ] **Step 1: Write the failing tests.** Create `infra/modules/scanner-host/tests/scanner_host.tftest.hcl`:

```hcl
# Plan-time tests. mock_provider: no account, no credentials.
mock_provider "aws" {}

override_data {
  target = data.aws_security_group.client
  values = { vpc_id = "vpc-0aaaaaaaaaaaaaaaa" }
}

override_data {
  target = data.aws_vpc.client
  values = { default = true }
}

variables {
  name                     = "carlos-test-scanner"
  ami_id                   = "ami-0123456789abcdef0"
  client_security_group_id = "sg-0123456789abcdef0"
  region                   = "eu-west-1"
}

run "the_fence" {
  command = plan
  assert {
    condition     = length(aws_security_group.scanner.ingress) == 1
    error_message = "exactly one way in"
  }
  assert {
    condition = alltrue([
      for i in aws_security_group.scanner.ingress :
      i.from_port == 3310 && i.to_port == 3310 && i.protocol == "tcp"
      && i.security_groups == toset(["sg-0123456789abcdef0"])
      && try(length(i.cidr_blocks), 0) == 0 && try(length(i.ipv6_cidr_blocks), 0) == 0
    ])
    error_message = "3310 from the client security group only, never a CIDR"
  }
  assert {
    condition     = aws_security_group.scanner.vpc_id == "vpc-0aaaaaaaaaaaaaaaa"
    error_message = "the group lands in the client group's VPC"
  }
}

run "the_box" {
  command = plan
  assert {
    condition     = aws_instance.scanner.metadata_options[0].http_tokens == "required"
    error_message = "IMDSv2 only"
  }
  assert {
    condition     = aws_instance.scanner.root_block_device[0].encrypted
    error_message = "encrypted root volume"
  }
  assert {
    condition     = aws_instance.scanner.instance_type == "t4g.medium"
    error_message = "t4g.medium by default"
  }
  assert {
    # base64 is 4 characters per 3 bytes, so 21800 characters is under 16 KiB.
    condition     = length(base64gzip(local.init_script)) < 21800
    error_message = "EC2 refuses user_data over 16 KiB, and the script is sent gzipped to stay well under it"
  }
}

run "the_role_cannot_read_parameters" {
  command = plan
  assert {
    condition     = jsondecode(aws_iam_role_policy.no_parameters.policy).Statement[0].Effect == "Deny"
    error_message = "an explicit Deny, because the managed SSM policy allows GetParameter* on *"
  }
  assert {
    condition = toset(jsondecode(aws_iam_role_policy.no_parameters.policy).Statement[0].Action) == toset([
      "ssm:GetParameter", "ssm:GetParameters", "ssm:GetParameterHistory", "ssm:GetParametersByPath",
    ])
    error_message = "all four parameter-read actions"
  }
  assert {
    condition     = jsondecode(aws_iam_role_policy.no_parameters.policy).Statement[0].Resource == "*"
    error_message = "on every parameter: this box is allowed none"
  }
}

run "the_engine_config" {
  command = plan
  assert {
    condition     = strcontains(local.init_script, "setkey MaxFileSize 100M") && strcontains(local.init_script, "setkey MaxScanSize 100M")
    error_message = "both limits 100M: equal is what makes a trimmed member trip the scan budget"
  }
  assert {
    condition     = strcontains(local.init_script, "setkey AlertExceedsMax yes")
    error_message = "limits must alert"
  }
  assert {
    condition     = strcontains(local.init_script, "setkey StreamMaxLength 26214400")
    error_message = "StreamMaxLength is stream_max_bytes"
  }
  assert {
    condition     = !strcontains(local.init_script, "setkey TCPSocket") && !strcontains(local.init_script, "setkey TCPAddr")
    error_message = "clamd itself never listens on TCP; the gated proxy does"
  }
  assert {
    condition     = output.stream_max_bytes == 26214400
    error_message = "the console's CARLOS_CLAMD_MAX_BYTES comes from this output"
  }
}

run "small_box_with_concurrent_reload_is_refused" {
  command = plan
  variables {
    instance_type = "t4g.small"
  }
  expect_failures = [aws_instance.scanner]
}

run "small_box_without_concurrent_reload_is_fine" {
  command = plan
  variables {
    instance_type              = "t4g.small"
    concurrent_database_reload = false
  }
  assert {
    condition     = strcontains(local.init_script, "setkey ConcurrentDatabaseReload no")
    error_message = "the reload setting reaches clamd.conf"
  }
}

run "stream_limit_too_small_for_the_fixtures" {
  command = plan
  variables {
    stream_max_bytes = 1048576
  }
  expect_failures = [var.stream_max_bytes]
}

run "stream_limit_above_the_scan_budget" {
  command = plan
  variables {
    stream_max_bytes = 209715200
  }
  expect_failures = [var.stream_max_bytes]
}

run "another_vpc_needs_a_subnet" {
  command = plan
  override_data {
    target = data.aws_vpc.client
    values = { default = false }
  }
  expect_failures = [aws_instance.scanner]
}
```

- [ ] **Step 2: Run it and watch it fail.**

```bash
export TF_DATA_DIR="$TMPDIR/scanner-host/tf-scanner-host"
tofu -chdir=infra/modules/scanner-host init -backend=false -input=false
tofu -chdir=infra/modules/scanner-host test
```
Expected: FAIL at init or test: the module has no configuration yet.

- [ ] **Step 3: `versions.tf` and `variables.tf`.**

`versions.tf`:

```hcl
terraform {
  required_version = ">= 1.10"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 6.0"
    }
  }
}
```

`variables.tf`:

```hcl
variable "name" {
  description = "Name for the instance, its security group, its IAM role and its instance profile (e.g. \"carlos-flagship-scanner\")."
  type        = string
}

variable "ami_id" {
  description = "Debian arm64 AMI. Pass the console box's own image, module.<host>.ami_id, so the two are built from the same base. Ignored after creation: a newer image never replaces the box by itself."
  type        = string
}

variable "client_security_group_id" {
  description = "Security group of the ONE box allowed to reach clamd: the console's. clamd has no authentication at all, so this group is the whole fence. The scanner's own group is created in this group's VPC."
  type        = string
}

variable "region" {
  description = "AWS region the box runs in (e.g. \"eu-west-1\"). The SSM agent package is served per region and the box must fetch its own."
  type        = string
}

variable "instance_type" {
  description = "EC2 instance type (arm64 Graviton). clamd holds about 1.5 GB of signatures resident and briefly about twice that while reloading them, so t4g.medium is the default. A t4g.small works only with concurrent_database_reload = false."
  type        = string
  default     = "t4g.medium"
}

variable "stream_max_bytes" {
  description = "Largest object clamd accepts on a stream (StreamMaxLength), in bytes. Set the console's CARLOS_CLAMD_MAX_BYTES to this module's stream_max_bytes output: the two must be equal. Between 8 MiB and 100 MiB: the box's self-test streams fixtures of about 5 MB, and a whole stream must fit the 100 MiB scan budget."
  type        = number
  default     = 26214400
  validation {
    condition     = var.stream_max_bytes >= 8388608 && var.stream_max_bytes <= 104857600
    error_message = "stream_max_bytes must be between 8388608 (8 MiB) and 104857600 (100 MiB)."
  }
}

variable "memory_max" {
  description = "clamd's cgroup ceiling (systemd MemoryMax). Under memory pressure the kernel kills clamd, never the SSM agent that is the only way onto the box."
  type        = string
  default     = "3G"
}

variable "memory_high" {
  description = "clamd's throttle point (systemd MemoryHigh), about 85% of memory_max."
  type        = string
  default     = "2560M"
}

variable "concurrent_database_reload" {
  description = "Keep scanning while freshclam's new signatures load, at the cost of holding both databases in memory at once. False pauses scanning during each reload and fits a smaller box."
  type        = bool
  default     = true
}

variable "subnet_id" {
  description = "Subnet to launch in. Null (the default) is the default subnet of the default VPC, and is refused unless the client security group lives in the default VPC. The subnet needs a route to the internet: the box fetches signatures and packages."
  type        = string
  default     = null
}
```

- [ ] **Step 4: `main.tf` and `outputs.tf`.**

`main.tf`:

```hcl
# 🤖 A CARLOS scanner host: one box running clamd for a deployment's console.
#
# The console reads each object from S3 and streams it here. This box reads
# nothing for itself: its role has Session Manager and nothing else, so a
# compromised clamd cannot fetch a tenant object or a key.
#
# What makes it more than "install clamav" is in scanner-init.sh.tftpl and
# files/: TCP 3310 is open only while a self-test has passed against the clamd
# process running now. See README.md.

data "aws_security_group" "client" {
  id = var.client_security_group_id
}

data "aws_vpc" "client" {
  id = data.aws_security_group.client.vpc_id
}

locals {
  # Fixed in this release. The self-test's fixtures and expected answers are
  # sized for it; making it a variable means deriving both.
  scan_limit = "100M"

  small_box = can(regex("^t4g\\.(nano|micro|small)$", var.instance_type))

  init_script = templatefile("${path.module}/scanner-init.sh.tftpl", {
    region            = var.region
    stream_max_bytes  = var.stream_max_bytes
    scan_limit        = local.scan_limit
    memory_max        = var.memory_max
    memory_high       = var.memory_high
    concurrent_reload = var.concurrent_database_reload ? "yes" : "no"
    selftest_b64      = base64encode(file("${path.module}/files/scanner-selftest"))
    selftest_unit_b64 = base64encode(file("${path.module}/files/scanner-selftest.service"))
    tcp_socket_b64    = base64encode(file("${path.module}/files/scanner-tcp.socket"))
    tcp_service_b64   = base64encode(file("${path.module}/files/scanner-tcp.service"))
  })
}

resource "aws_security_group" "scanner" {
  name        = var.name
  description = "clamd, reachable from the console box only"
  vpc_id      = data.aws_security_group.client.vpc_id

  # The whole fence. clamd authenticates nobody, so nothing but the console's
  # own group may reach it, and never a CIDR.
  ingress {
    description     = "clamd INSTREAM from the console"
    from_port       = 3310
    to_port         = 3310
    protocol        = "tcp"
    security_groups = [var.client_security_group_id]
  }

  # Signatures (freshclam) and packages (apt). No SSH ingress exists: admin
  # access is SSM Session Manager.
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = { Name = var.name }
}

resource "aws_iam_role" "scanner" {
  name = var.name
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ec2.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy_attachment" "scanner_ssm" {
  role       = aws_iam_role.scanner.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

# AmazonSSMManagedInstanceCore grants ssm:GetParameter* on "*". Left alone,
# this box could read the deployment's scan tag key and its release key. An
# explicit Deny beats the managed Allow and names only the parameter-read
# actions, so Session Manager is untouched. Same shape as region-host's
# host_ssm_parameter_scope, with no parameter allowed at all.
resource "aws_iam_role_policy" "no_parameters" {
  name = "${var.name}-no-parameters"
  role = aws_iam_role.scanner.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Deny"
        Action = [
          "ssm:GetParameter",
          "ssm:GetParameters",
          "ssm:GetParameterHistory",
          "ssm:GetParametersByPath",
        ]
        Resource = "*"
      },
    ]
  })
}

resource "aws_iam_instance_profile" "scanner" {
  name = var.name
  role = aws_iam_role.scanner.name
}

resource "aws_instance" "scanner" {
  ami                    = var.ami_id
  instance_type          = var.instance_type
  subnet_id              = var.subnet_id
  vpc_security_group_ids = [aws_security_group.scanner.id]
  iam_instance_profile   = aws_iam_instance_profile.scanner.name

  # Gzipped: the script carries the self-test and three units, and EC2 caps
  # user_data at 16 KiB. cloud-init unpacks it.
  user_data_base64            = base64gzip(local.init_script)
  user_data_replace_on_change = false

  metadata_options {
    http_endpoint               = "enabled"
    http_tokens                 = "required"
    http_put_response_hop_limit = 1
  }

  root_block_device {
    volume_size = 20
    volume_type = "gp3"
    encrypted   = true
  }

  tags = { Name = var.name }

  # A newer image or an edited script never replaces the box by itself.
  # Changing either, including stream_max_bytes, is an explicit
  # `tofu apply -replace=module.<name>.aws_instance.scanner`. A replaced box
  # has a new private IP: update the console's CARLOS_CLAMD_ADDR.
  lifecycle {
    ignore_changes = [ami, user_data, user_data_base64]

    precondition {
      condition     = var.subnet_id != null || data.aws_vpc.client.default
      error_message = "The client security group is not in the default VPC, so the scanner cannot use the default subnet: its security group and its instance would be in different VPCs. Pass subnet_id naming a subnet in the client group's VPC."
    }

    precondition {
      condition     = !(local.small_box && var.concurrent_database_reload)
      error_message = "A t4g.small or smaller cannot hold two signature databases at once. Either use t4g.medium, or set concurrent_database_reload = false (scanning pauses during each reload)."
    }
  }
}
```

`outputs.tf`:

```hcl
output "clamd_addr" {
  description = "Where the console reaches clamd: set it as CARLOS_CLAMD_ADDR. A private address, reachable only from the client security group. It changes if the box is replaced."
  value       = "${aws_instance.scanner.private_ip}:3310"
}

output "stream_max_bytes" {
  description = "clamd's StreamMaxLength in bytes: set it as CARLOS_CLAMD_MAX_BYTES on the console."
  value       = var.stream_max_bytes
}

output "instance_id" {
  description = "EC2 instance id of the scanner box, for SSM."
  value       = aws_instance.scanner.id
}

output "security_group_id" {
  description = "Security group id of the scanner box."
  value       = aws_security_group.scanner.id
}
```

- [ ] **Step 5: the self-test.** Create `infra/modules/scanner-host/files/scanner-selftest` (mode 0755 in git: `chmod +x` before `git add`):

```bash
#!/bin/bash
# 🤖 scanner-selftest: prove, on THIS engine, that clamd never answers OK for
# an archive it could only partly scan.
#
# scanner-selftest.service runs this every time clamd starts, and the TCP
# listener is bound to that unit, so a failure here keeps port 3310 shut. It
# talks to clamd on its unix socket: the port it guards is not open yet.
#
# Why it exists: with ClamAV's packaged limits, a zip member that expands past
# MaxFileSize is trimmed and the trimmed copy is scanned and answered OK, with
# no limit raised. The box is configured so that cannot happen (both limits
# equal, AlertExceedsMax on). This checks that the configuration still does
# what it did when it was chosen, on whatever version is installed today.
#
# Fixtures are sized for a 100 MiB scan limit. Members are written from a temp
# file so their zip headers are ordinary; streamed members get zip64 headers,
# which clamd does not open at all.
set -u
sock=/run/clamav/clamd.ctl

if [ "${SCANNER_SELFTEST_FORCE_FAIL:-}" = 1 ]; then
  echo "scanner-selftest: FAIL (forced, for the acceptance check)"
  exit 1
fi

d=$(mktemp -d /var/tmp/scanner-selftest.XXXXXX) || exit 1
trap 'rm -rf "$d"' EXIT
echo "LocalSocket $sock" > "$d/clamdscan.conf"

python3 - "$d" <<'PY' || { echo "scanner-selftest: FAIL (could not build fixtures)"; exit 1; }
import io, gzip, os, random, sys, tarfile, zipfile
d = sys.argv[1]
fx = os.path.join(d, 'fx')
os.mkdir(fx)
EICAR = rb'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*'

def block(seed):
    # 1 MiB: a 16 KiB random block repeated. It compresses about 140 to 1 and
    # differs per seed, so clamd's clean cache cannot wave a member through.
    r = random.Random(seed)
    return bytes(r.getrandbits(8) for _ in range(16384)) * 64

def bigfile(name, mb, seed):
    p = os.path.join(d, name)
    blk = block(seed)
    with open(p, 'wb') as f:
        for _ in range(mb):
            f.write(blk)
    return p

def mkzip(name, build):
    with zipfile.ZipFile(os.path.join(fx, name), 'w', zipfile.ZIP_DEFLATED) as z:
        build(z)

open(os.path.join(fx, '01-clean.txt'), 'wb').write(b'hello world\n')
open(os.path.join(fx, '02-eicar.txt'), 'wb').write(EICAR)
big = bigfile('big.bin', 500, 1)
mid = bigfile('mid.bin', 60, 5)
mkzip('03-zip-one-500.zip', lambda z: z.write(big, 'big.bin'))
with zipfile.ZipFile(os.path.join(d, 'inner.zip'), 'w', zipfile.ZIP_DEFLATED) as z:
    z.write(big, 'big.bin')
mkzip('04-zip-nested-500.zip', lambda z: z.write(os.path.join(d, 'inner.zip'), 'inner.zip'))
def many(z):
    for i in range(30):
        z.write(bigfile('m.bin', 20, 100 + i), 'm%02d.bin' % i)
mkzip('05-zip-30x20.zip', many)
with gzip.open(os.path.join(fx, '06-gzip-500.gz'), 'wb') as g:
    blk = block(2)
    for _ in range(500):
        g.write(blk)
with tarfile.open(os.path.join(fx, '07-targz-5x40.tar.gz'), 'w:gz') as t:
    for i in range(5):
        data = block(200 + i) * 40
        ti = tarfile.TarInfo('t%d.bin' % i)
        ti.size = len(data)
        t.addfile(ti, io.BytesIO(data))
def big_then_eicar(z):
    z.write(big, 'big.bin')
    z.writestr('eicar.com', EICAR)
mkzip('08-zip-500-then-eicar.zip', big_then_eicar)
def mid_then_eicar(z):
    z.write(mid, 'mid.bin')
    z.writestr('eicar.com', EICAR)
mkzip('09-zip-60-then-eicar.zip', mid_then_eicar)
mkzip('10-zip-one-60.zip', lambda z: z.write(mid, 'mid.bin'))
PY

# What each fixture must answer. "limit" is the one that matters: an OK there
# is a truncated scan signed clean.
want() {
  case $1 in
    01 | 10) echo ok ;;
    02 | 09) echo infected ;;
    *) echo limit ;;
  esac
}

fail=0
for f in "$d"/fx/*; do
  n=$(basename "$f")
  exp=$(want "${n%%-*}")
  reply=$(clamdscan --config-file="$d/clamdscan.conf" --stream --no-summary "$f" 2>&1 | tail -1)
  case $reply in
    *"Heuristics.Limits.Exceeded"*" FOUND") got=limit ;;
    *" FOUND") got=infected ;;
    *": OK") got=ok ;;
    *) got=error ;;
  esac
  if [ "$got" = "$exp" ]; then
    echo "scanner-selftest: pass  $n  ($got)"
  else
    echo "scanner-selftest: FAIL  $n  want $exp, got $got: $reply"
    fail=1
  fi
done

if [ $fail -eq 0 ]; then
  echo "scanner-selftest: PASS"
else
  echo "scanner-selftest: FAIL"
fi
exit $fail
```

- [ ] **Step 6: the three units.**

`files/scanner-selftest.service`:

```ini
# 🤖 Runs the self-test against the clamd that is running now. BindsTo makes it
# go inactive the moment clamd stops or restarts, and the TCP listener is bound
# to THIS unit, so the port follows the test, not the boot.
[Unit]
Description=Prove this clamd never answers OK for an over-limit archive
BindsTo=clamav-daemon.service
After=clamav-daemon.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/scanner-selftest
TimeoutStartSec=1800

[Install]
WantedBy=multi-user.target
```

`files/scanner-tcp.socket` (`@PRIVATE_IP@` is replaced at first boot):

```ini
# 🤖 clamd on TCP, for the console. Open only behind a passed self-test.
#
# DefaultDependencies=no is load-bearing. A socket unit is ordered before
# sockets.target by default, sockets.target before basic.target, and every
# service (the self-test included) 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 this port shut after a reboot.
# So it opts out, restores what it still needs, and is started by
# multi-user.target and by clamd's Wants=, never by sockets.target.
[Unit]
Description=clamd on TCP, open only behind a passed self-test
DefaultDependencies=no
After=sysinit.target
Requires=scanner-selftest.service
After=scanner-selftest.service
BindsTo=scanner-selftest.service
Conflicts=shutdown.target
Before=shutdown.target

[Socket]
ListenStream=@PRIVATE_IP@:3310

[Install]
WantedBy=multi-user.target
```

`files/scanner-tcp.service`:

```ini
# 🤖 Carries each TCP connection to clamd's unix socket. Bound to the self-test
# like the socket is, so connections in flight are cut when the test's unit
# goes inactive.
[Unit]
Description=Proxy TCP 3310 to clamd's unix socket
Requires=scanner-selftest.service
After=scanner-selftest.service
BindsTo=scanner-selftest.service

[Service]
ExecStart=/usr/lib/systemd/systemd-socket-proxyd /run/clamav/clamd.ctl
DynamicUser=yes
SupplementaryGroups=clamav
PrivateTmp=yes
NoNewPrivileges=yes
```

- [ ] **Step 7: the first-boot script.** Create `infra/modules/scanner-host/scanner-init.sh.tftpl`. It is a `templatefile`: `$${...}` would be needed for a literal bash `${...}`, so the bash below uses `$name` and `$(...)` forms only. Keep it that way.

```bash
#!/bin/bash
# 🤖 First-boot init for a CARLOS scanner host (Debian arm64). Rendered by
# infra/modules/scanner-host. Each step says where it got to on the serial
# console, so `aws ec2 get-console-output` answers "how far did it get".
#
# The order is the design:
#   1. SSM agent, because with no SSH it is the only way in to fix the rest.
#   2. Packages installed but NOT started.
#   3. Config, the self-test and the gate units.
#   4. First signatures, alone, before anything else touches the database.
#   5. Start: clamd, then the self-test, then (only on a pass) the port.
#   6. Verify from the outside of the gate, and say READY or FAILED.
set -euo pipefail

say() { echo "scanner-init: $*" | tee /dev/console; }
fail() { say "SCANNER-HOST FAILED: $*"; exit 1; }
trap 'fail "line $LINENO"' ERR
export DEBIAN_FRONTEND=noninteractive

# ---- 1. the way in ---------------------------------------------------------
say "1/6 SSM agent"
apt-get update -q
apt-get install -qy curl unattended-upgrades
curl -fsSL -o /var/tmp/amazon-ssm-agent.deb \
  "https://s3.${region}.amazonaws.com/amazon-ssm-${region}/latest/debian_arm64/amazon-ssm-agent.deb"
dpkg -i /var/tmp/amazon-ssm-agent.deb
rm -f /var/tmp/amazon-ssm-agent.deb
systemctl enable --now amazon-ssm-agent
systemctl enable --now unattended-upgrades

# ---- 2. packages, not started ----------------------------------------------
# Debian's postinst starts clamav-freshclam and the daemon. Neither may run
# before step 3 has configured it, and freshclam's service would fight the
# foreground run in step 4 for the database lock.
say "2/6 clamav packages"
printf '#!/bin/sh\nexit 101\n' > /usr/sbin/policy-rc.d
chmod +x /usr/sbin/policy-rc.d
apt-get install -qy clamav-daemon clamav-freshclam clamdscan python3 iproute2
rm -f /usr/sbin/policy-rc.d

# ---- 3. config, self-test, gate --------------------------------------------
say "3/6 config and gate"
conf=/etc/clamav/clamd.conf
# clamd has no include directory: replace each key in place.
setkey() {
  sed -i "/^$1[[:space:]]/d" "$conf"
  echo "$1 $2" >> "$conf"
}
setkey StreamMaxLength ${stream_max_bytes}
setkey AlertExceedsMax yes
setkey MaxFileSize ${scan_limit}
setkey MaxScanSize ${scan_limit}
setkey ConcurrentDatabaseReload ${concurrent_reload}
# Debian 13 mounts /tmp as tmpfs: extraction of up to the scan limit per scan
# belongs on disk, not in memory.
setkey TemporaryDirectory /var/tmp
# clamd itself never listens on TCP. The gated proxy does.
sed -i '/^TCPSocket[[:space:]]/d;/^TCPAddr[[:space:]]/d' "$conf"

echo '${selftest_b64}' | base64 -d > /usr/local/sbin/scanner-selftest
chmod 0755 /usr/local/sbin/scanner-selftest
echo '${selftest_unit_b64}' | base64 -d > /etc/systemd/system/scanner-selftest.service
echo '${tcp_service_b64}' | base64 -d > /etc/systemd/system/scanner-tcp.service

tok=$(curl -fsS -X PUT http://169.254.169.254/latest/api/token \
  -H 'X-aws-ec2-metadata-token-ttl-seconds: 300')
ip=$(curl -fsS -H "X-aws-ec2-metadata-token: $tok" \
  http://169.254.169.254/latest/meta-data/local-ipv4)
case $ip in
  [0-9]*.[0-9]*.[0-9]*.[0-9]*) ;;
  *) fail "no private IPv4 from instance metadata: '$ip'" ;;
esac
echo '${tcp_socket_b64}' | base64 -d | sed "s/@PRIVATE_IP@/$ip/" \
  > /etc/systemd/system/scanner-tcp.socket
grep -q "^ListenStream=$ip:3310\$" /etc/systemd/system/scanner-tcp.socket \
  || fail "the listener is not bound to the private address"

mkdir -p /etc/systemd/system/clamav-daemon.service.d
cat > /etc/systemd/system/clamav-daemon.service.d/10-scanner.conf <<EOF
# Every start of clamd, an automatic restart included, asks for the gate again.
[Unit]
Wants=scanner-tcp.socket

# The kernel kills clamd under memory pressure, never the SSM agent.
[Service]
MemoryMax=${memory_max}
MemoryHigh=${memory_high}
Restart=on-failure
EOF
systemctl daemon-reload

# ---- 4. first signatures ---------------------------------------------------
say "4/6 first signatures"
# NotifyClamd would complain that no clamd is running yet. It is not.
sed '/^NotifyClamd/d' /etc/clamav/freshclam.conf > /run/freshclam-first.conf
freshclam --config-file=/run/freshclam-first.conf
rm -f /run/freshclam-first.conf
for db in main daily bytecode; do
  ls /var/lib/clamav/$db.c[vl]d >/dev/null 2>&1 || fail "no $db signature database after freshclam"
done
systemctl enable --now clamav-freshclam

# ---- 5. start, in the order the units enforce ------------------------------
say "5/6 clamd, self-test, listener (several minutes)"
systemctl enable clamav-daemon.service scanner-selftest.service scanner-tcp.socket
systemctl start clamav-daemon.socket clamav-daemon.service
# Blocks until the self-test has finished. Fails if it failed.
systemctl start scanner-tcp.socket \
  || fail "self-test or listener did not start: $(systemctl is-active clamav-daemon.service scanner-selftest.service scanner-tcp.socket | tr '\n' ' ')"

# ---- 6. verify from outside the gate ---------------------------------------
say "6/6 verify"
[ "$(systemctl is-active scanner-selftest.service)" = active ] || fail "self-test is not active"
[ "$(systemctl is-active scanner-tcp.socket)" = active ] || fail "listener is not active"
bound=$(ss -ltnH 'sport = :3310' | awk '{print $4}' | sort -u | tr '\n' ' ')
[ "$bound" = "$ip:3310 " ] || fail "3310 is bound to '$bound', want only $ip:3310"
exec 3<>"/dev/tcp/$ip/3310"
printf 'zVERSION\0' >&3
ver=$(timeout 20 head -c 200 <&3 | tr -d '\0')
exec 3>&-
case $ver in
  ClamAV*) ;;
  *) fail "no VERSION answer through the listener: '$ver'" ;;
esac
mkdir -p /var/lib/scanner-host
echo "$ver" > /var/lib/scanner-host/ready
say "SCANNER-HOST READY $ver"
```

- [ ] **Step 8: Run the tests and the formatter.**

Run: `tofu -chdir=infra/modules/scanner-host test` then `tofu fmt -check -recursive infra/modules/scanner-host` then `bash -n infra/modules/scanner-host/files/scanner-selftest`
Expected: `9 passed, 0 failed`; fmt and `bash -n` print nothing. If an assertion fails because a mocked attribute is null or unknown at plan time (the `ingress` block attributes are the likely one), adjust the assertion's `try(...)`, never the module's behaviour, and say which in the commit.

- [ ] **Step 9: Check the rendered script is valid bash and has no stray template syntax.**

```bash
mkdir -p "$TMPDIR/scanner-host/render"
cat > "$TMPDIR/scanner-host/render/main.tf" <<'EOF'
output "script" {
  value = templatefile("${path.module}/../../../infra/modules/scanner-host/scanner-init.sh.tftpl", {
    region = "eu-west-1", stream_max_bytes = 26214400, scan_limit = "100M",
    memory_max = "3G", memory_high = "2560M", concurrent_reload = "yes",
    selftest_b64 = "QQ==", selftest_unit_b64 = "QQ==", tcp_socket_b64 = "QQ==", tcp_service_b64 = "QQ==",
  })
}
EOF
```

Adjust the relative path so it resolves to this worktree's template (an absolute path is fine), then:

Run: `tofu -chdir="$TMPDIR/scanner-host/render" init -input=false >/dev/null && tofu -chdir="$TMPDIR/scanner-host/render" apply -auto-approve >/dev/null && tofu -chdir="$TMPDIR/scanner-host/render" output -raw script > "$TMPDIR/scanner-host/render/init.sh" && bash -n "$TMPDIR/scanner-host/render/init.sh" && grep -c 'setkey MaxFileSize 100M' "$TMPDIR/scanner-host/render/init.sh"`
Expected: `1`, and no bash syntax error.

- [ ] **Step 10: Commit.** Confirm no `.terraform` directory or lock file is staged.

```bash
chmod +x infra/modules/scanner-host/files/scanner-selftest
git add infra/modules/scanner-host
git commit -m "scanner-host: a clamd box whose port is open only behind a passed self-test" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```

---

### Task 4: docs, through copy review

**Files:**
- Create: `infra/modules/scanner-host/README.md`
- Modify: `infra/modules/README.md` (new `## scanner-host` section), `infra/modules/region-host/README.md` (one paragraph on `store_scanning`), `docs/site/cli.md` (the operator table near line 1088 and the "What `status` will and will not claim" paragraph near line 1071), `docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md` (a dated note at the top)

**Interfaces:**
- Consumes: every name Task 3 produces.

- [ ] **Step 1: Draft, do not write.** Use the `humane-docs` skill for the drafting voice. Content each piece must carry:
  - **`scanner-host/README.md`**: what the box is and that it reads nothing itself; the usage block (`module "scanner"` with `ami_id = module.host_ie.ami_id`, `client_security_group_id = module.host_ie.security_group_id`, `region`); the two outputs the console needs and the variables they become (`clamd_addr` to `CARLOS_CLAMD_ADDR`, `stream_max_bytes` to `CARLOS_CLAMD_MAX_BYTES`) plus `CARLOS_SCAN_TAG_KEY`; replacement changes the private IP (spec §3.6), first; the invariant in one paragraph (§3.2); why the two limits are equal and fixed, with the table from §3.4; what a clean verdict does not cover (zip64 and encrypted members); how to check a box is ready (`cloud-init status --wait`, `/var/lib/scanner-host/ready`, `systemctl is-active scanner-selftest.service scanner-tcp.socket`); the four acceptance checks from §3.3 with their commands (Task 7 has them); sizing; that the subnet needs a route out.
  - **`infra/modules/README.md`**: a short `## scanner-host` section in the style of the `deployment-buckets` one, with the variable table and outputs.
  - **`region-host/README.md`**: `store_scanning` in three sentences: what it grants, that it is off by default, that it goes on the console's box only.
  - **`docs/site/cli.md`**: the operator table is stale. Replace the `CARLOS_STORE_SCANNER` and `CARLOS_CLAMD_SOCKET` rows with `CARLOS_CLAMD_ADDR` (`host:port`, or a socket path beginning `/`) and `CARLOS_SCAN_TAG_KEY` (required once an address is set; signs every verdict), say the switch is `carlos system antivirus enable`, and change "six environment variables" to the true count. In the `status` paragraph add one sentence: an archive that expands past the engine's limits is counted as not scanned, the same as an object too large to stream.
  - **Rollout plan note**, at the top: `> **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.` (Internal plan text, not user-facing: no copy review for this one.)

- [ ] **Step 2: Copy review.** Invoke the `copy-review` skill with every drafted string except the rollout-plan note. Wait for Paul's edits. Apply them verbatim.

- [ ] **Step 3: Write the files** with the approved text.

- [ ] **Step 4: Verify the docs gate.**

Run: `go test ./internal/docsite -count=1`
Expected: `ok`.

- [ ] **Step 5: Commit.**

```bash
git add infra/modules/scanner-host/README.md infra/modules/README.md infra/modules/region-host/README.md docs/site/cli.md docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md
git commit -m "docs: the scanner host, store_scanning, and the scanner's real variables" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```

---

### Task 5: gate, review, discussion, merge

**Files:** none new.

- [ ] **Step 1: Full gate.**

Run: `go build ./... && go vet ./... && go test ./... -count=1`
Expected: all `ok`. If the activator's socket tests fail on path length or `/tmp` fills, use the `-exec` wrapper recorded in memory (`gate-tmp-exec-wrapper`); do not skip packages. Report any failure with its output.

- [ ] **Step 2: Cross-compile, as CI does.**

Run: `GOOS=darwin GOARCH=arm64 go build ./... && GOOS=windows GOARCH=amd64 go build ./...`
Expected: no output.

- [ ] **Step 3: Open the tracking discussion.** Invoke the `amadan` skill, then create a discussion on `carlos/platform` titled `Scanner host: object-storage scanning on Carloku and Tito`, linking the spec, this plan and the branch, and recording the two follow-ups from spec §11 (the zip64 and encrypted-archive disclosure; a configurable scan limit). Note its number; later tasks post progress there.

- [ ] **Step 4: Astra code review.** The session model is Opus, so the reviewer is Astra via `codex` (memory `codex-second-reader` has the working command; set `model_reasoning_effort="high"` and read the header line). Commit a brief as `docs/superpowers/specs/CODEX-REVIEW-BRIEF-SCANNER-HOST-CODE.md` asking it to review `origin/main..HEAD` against the spec, with special attention to: the systemd dependency design in `files/`, the first-boot script's failure paths, the `parseReply` change, and the IAM statements. Commit findings unedited as `CODEX-FINDINGS-SCANNER-HOST-CODE.md`. Verify every citation before accepting a finding (`receiving-code-review` skill). Fix, re-run the affected tests, and repeat until the verdict is accept.

- [ ] **Step 5: Push the branch.** `git push origin worktree-scanner-host`, then `amadan ci status carlos/platform` until green.

- [ ] **Step 6 (GATED): merge.** Show Paul the diffstat and the review verdict. On his go: `amadan branch merge -squash` per the `amadan` skill and memory `carlos-platform-forge`. Record the main SHA in the discussion.

- [ ] **Step 7 (GATED): move the GitHub mirror.** Only a fast-forward:

```bash
git fetch https://github.com/carlosframework/platform.git main
git merge-base --is-ancestor FETCH_HEAD origin/main && git push https://github.com/carlosframework/platform.git origin/main:refs/heads/main
```
Expected: the push succeeds. If the ancestor check fails, stop and tell Paul; never force.

---

### Task 6 (GATED): release v0.24.1

> **2026-10-06:** v0.23.0 had already been cut (3ca43d3) before the scanner merged, so the scanner first shipped in v0.24.0. v0.24.0's first boot failed on AppArmor: freshclam may only read its config under `/etc/clamav`. The fix ships in v0.24.1, which is the pin used below.

Carloku pins modules to release tags, so the modules reach it through a release. The release carries everything on main since v0.22.2, not only this work, so **whether and when to cut it is Paul's call**. Ask before starting.

- [ ] **Step 1:** List what the release contains: `git log --oneline v0.22.2..origin/main`. Show Paul. Wait for his go.
- [ ] **Step 2:** Follow `README.md` "Releasing the platform" exactly (steps 1 to 9 and the public-side verification), with memory `platform-release-recipe` for the traps. Release notes go through `copy-review`. The operators' section says: object-storage scanning needs a scanner host (`infra/modules/scanner-host`) and `store_scanning` on the console's `region-host`; a console must run this release before it is pointed at a scanner host, because older consoles record an over-limit archive as infected.
- [ ] **Step 3:** Push the tag to the GitHub mirror: `git push https://github.com/carlosframework/platform.git v0.24.1`.
- [ ] **Step 4:** Do **not** roll boxes as part of this plan. The box binary is unaffected; only consoles need the new build (Tasks 8 and 12). If Paul wants the fleet on v0.24.1 too, that is the README's last step and his separate go.

---

### Task 7 (GATED): Carloku, the scanner box

**Files:**
- `~/github.com/carlosframework/carloku-infrastructure/region-ie.tf` (modify: `host_ie`'s `?ref=`, add `store_scanning = true`)
- `~/github.com/carlosframework/carloku-infrastructure/scanner.tf` (create)
- `~/github.com/carlosframework/carloku-infrastructure/outputs.tf` (modify)

- [ ] **Step 1: Branch and edit.** In that repo: `git checkout -b scanner-host origin/main`. In `region-ie.tf`, change `host_ie`'s source to `?ref=v0.24.1` (that module block only; the other three regions stay on v0.21.0) and add, after `store_bucket_prefix`:

```hcl
  # Object-storage virus scanning (platform spec 2026-10-04): lets THIS box's
  # role read and tag store objects. The console is the only thing that scans.
  store_scanning = true
```

Create `scanner.tf`:

```hcl
# The flagship's scanner host: clamd for the console's object-storage virus
# scanning, on its own box so its signatures and CPU stay off the control
# plane. Reachable from the IE host's security group only. The module's README
# has the readiness and acceptance checks; run them before wiring the console.
module "scanner" {
  source                   = "git::https://github.com/carlosframework/platform.git//infra/modules/scanner-host?ref=v0.24.1"
  name                     = "carlos-flagship-scanner"
  ami_id                   = module.host_ie.ami_id
  client_security_group_id = module.host_ie.security_group_id
  region                   = "eu-west-1"
}
```

Append to `outputs.tf`:

```hcl
output "scanner_clamd_addr" {
  description = "CARLOS_CLAMD_ADDR for the flagship console."
  value       = module.scanner.clamd_addr
}

output "scanner_stream_max_bytes" {
  description = "CARLOS_CLAMD_MAX_BYTES for the flagship console."
  value       = module.scanner.stream_max_bytes
}

output "scanner_instance_id" {
  description = "The scanner box, for SSM."
  value       = module.scanner.instance_id
}
```

- [ ] **Step 2: Plan, targeted.** (Sandbox off for the `keymail` profile.)

```bash
AWS_PROFILE=keymail tofu init -upgrade -input=false
AWS_PROFILE=keymail tofu plan -input=false -out="$TMPDIR/scanner-host/flagship.plan" \
  -target=module.scanner \
  -target='module.host_ie.aws_iam_role_policy.host_store_scan'
```
Expected: creations only: the scanner's security group, role, policy attachment, `no_parameters` policy, instance profile and instance, plus `host_store_scan[0]`. **No `update in-place`, no `replace`, no `user_data` line on any existing box.** If there is one, stop.

- [ ] **Step 3: Show Paul the plan** (`tofu show "$TMPDIR/scanner-host/flagship.plan"`), and separately the full untargeted plan's summary line so he sees what the ref bump leaves pending on `host_ie`. Wait for his go.

- [ ] **Step 4: Apply that plan file.** `AWS_PROFILE=keymail tofu apply "$TMPDIR/scanner-host/flagship.plan"`. Record the three outputs. Commit the branch, push, open a PR on carloku-infrastructure listing what was applied and what the bump leaves unapplied.

- [ ] **Step 5: Wait for first boot.** Create the SSM helper used from here on:

```bash
mkdir -p "$TMPDIR/scanner-host"
cat > "$TMPDIR/scanner-host/ssm-run.sh" <<'EOF'
#!/bin/sh
# usage: ssm-run.sh <profile> <region> <instance-id> '<command>'
# Runs one shell command on a box and prints its stdout, stderr and status.
set -eu
p=$1; r=$2; i=$3; c=$4
id=$(aws --profile "$p" --region "$r" ssm send-command --instance-ids "$i" \
  --document-name AWS-RunShellScript --parameters "$(jq -nc --arg c "$c" '{commands:[$c]}')" \
  --query Command.CommandId --output text)
aws --profile "$p" --region "$r" ssm wait command-executed --command-id "$id" --instance-id "$i" 2>/dev/null || true
aws --profile "$p" --region "$r" ssm get-command-invocation --command-id "$id" --instance-id "$i" \
  --query '[Status,StandardOutputContent,StandardErrorContent]' --output text
EOF
chmod +x "$TMPDIR/scanner-host/ssm-run.sh"
```

Then poll the serial console until it says one or the other (first boot takes several minutes: packages, about 300 MB of signatures, the self-test):

Run: `aws --profile keymail --region eu-west-1 ec2 get-console-output --instance-id <scanner-id> --latest --output text | grep 'SCANNER-HOST'`
Expected: `SCANNER-HOST READY ClamAV 1.4.x/<sig>/<date>`, with the date today or yesterday. On `SCANNER-HOST FAILED: <reason>`, read the `scanner-init:` lines above it and fix the module (back to Task 3), not the box.

- [ ] **Step 6: Readiness.**

Run: `"$TMPDIR/scanner-host/ssm-run.sh" keymail eu-west-1 <scanner-id> 'cloud-init status --wait; cat /var/lib/scanner-host/ready; systemctl is-active scanner-selftest.service scanner-tcp.socket; journalctl -u scanner-selftest.service --no-pager | tail -14; aa-status 2>/dev/null | grep -c /usr/sbin/clamd; journalctl -k --no-pager | grep -c "apparmor=\"DENIED\".*clamd"'`
Expected: `status: done`; the VERSION line; `active` twice; ten `pass` lines and `scanner-selftest: PASS`; then `1` or more (clamd's AppArmor profile is loaded) and `0` (it denied clamd nothing, so the `/var/tmp` local include took).

Then prove a package reconfigure leaves `clamd.conf` alone (debconf is frozen off; with it on, the postinst would rewrite `StreamMaxLength`):

Run: `... ssm-run.sh keymail eu-west-1 <scanner-id> 'debconf-show clamav-daemon | grep "clamav-daemon/debconf"; dpkg-reconfigure -fnoninteractive clamav-daemon; grep -E "^(StreamMaxLength|MaxFileSize|MaxScanSize|AlertExceedsMax|TemporaryDirectory) " /etc/clamav/clamd.conf; systemctl restart clamav-daemon.service; for i in $(seq 1 60); do [ "$(systemctl is-active scanner-tcp.socket)" = active ] && break; sleep 10; done; echo "after reconfigure: socket $(systemctl is-active scanner-tcp.socket), selftest $(systemctl is-active scanner-selftest.service), $(ss -ltnH "sport = :3310" | wc -l) listeners"'`
Expected: a line ending `clamav-daemon/debconf: false`; these five lines, in any order: `StreamMaxLength 25M`, `AlertExceedsMax yes`, `MaxFileSize 100M`, `MaxScanSize 100M`, `TemporaryDirectory /var/tmp` (init appends them in that order, so expect that one, but any order is a pass); then `after reconfigure: socket active, selftest active, 1 listeners`. The reconfigure moves Debian's `extend.conf` drop-in aside (postinst, debconf off), so the restart also proves clamd starts on the module's own drop-in. Anything else: stop, fix in Task 3, replace the box.

- [ ] **Step 7: Acceptance check, kill.** The port follows the test.

Run: `... ssm-run.sh keymail eu-west-1 <scanner-id> 'systemctl kill -s KILL clamav-daemon.service; sleep 3; echo "after kill: $(ss -ltnH "sport = :3310" | wc -l) listeners, selftest $(systemctl is-active scanner-selftest.service)"; for i in $(seq 1 60); do [ "$(systemctl is-active scanner-tcp.socket)" = active ] && break; sleep 10; done; echo "recovered: socket $(systemctl is-active scanner-tcp.socket), selftest $(systemctl is-active scanner-selftest.service), $(ss -ltnH "sport = :3310" | wc -l) listeners"'`
Expected: `after kill: 0 listeners, selftest` anything but `active`; then `recovered: socket active, selftest active, 1 listeners`. If the port is still open right after the kill, or never reopens, the dependency design is wrong on this systemd: stop, fix in Task 3, replace the box.

- [ ] **Step 8: Acceptance check, forced fail.** A failed test keeps the port shut.

Run, in one command: write `/etc/systemd/system/scanner-selftest.service.d/99-force-fail.conf` containing `[Service]` and `Environment=SCANNER_SELFTEST_FORCE_FAIL=1`; `systemctl daemon-reload`; `systemctl restart clamav-daemon.service`; wait 90 seconds; print `systemctl is-active clamav-daemon.service scanner-selftest.service scanner-tcp.socket` and the listener count; then remove the drop-in, `daemon-reload`, `systemctl restart clamav-daemon.service`, wait until `scanner-tcp.socket` is active (up to 10 minutes), and print the same again.
Expected first print: `active`, `failed`, `inactive` (or `failed`), `0` listeners. Second: `active` three times, `1` listener.

- [ ] **Step 9: Acceptance check, the protocol through the proxy, from the console box.**

Run: `... ssm-run.sh keymail eu-west-1 i-092c0c1eea75723cb 'exec 3<>/dev/tcp/<scanner-private-ip>/3310; printf "zINSTREAM\0\0\0\0\104X5O!P%%@AP[4\\PZX54(P^)7CC)7}\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\$H+H*\0\0\0\0" >&3; timeout 20 head -c 200 <&3 | tr -d "\0"; echo'`
(`\0\0\0\104` is the 68-byte chunk length, octal 104 = 68; the four zero bytes after the payload end the stream.)
Expected: `stream: Eicar-Test-Signature FOUND`. If the nested quoting fights you, send the same bytes with a three-line `python3` socket script instead; the bytes are what matter. Also confirm the fence from somewhere that is not the console box: the same connect attempt from another fleet box times out.

- [ ] **Step 10: Acceptance check, reboot.**

Run: `aws --profile keymail --region eu-west-1 ec2 reboot-instances --instance-ids <scanner-id>`, wait five minutes, then `... ssm-run.sh keymail eu-west-1 <scanner-id> 'systemctl is-active clamav-daemon.service scanner-selftest.service scanner-tcp.socket; ss -ltnH "sport = :3310" | wc -l; journalctl -b --no-pager | grep -ci "ordering cycle"; systemd-analyze verify /etc/systemd/system/scanner-selftest.service /etc/systemd/system/scanner-tcp.socket /etc/systemd/system/scanner-tcp.service 2>&1 | grep -v "^$" | head'`
Expected: `active` three times, `1`, `0`, and nothing from `systemd-analyze verify` about these three units.

- [ ] **Step 11: Record** all four results in the discussion, with the VERSION line.

---

### Task 8 (GATED): Carloku, the console on the new build

- [ ] **Step 1:** A clean checkout of the `v0.24.1` tag (scratch clone under `~/.claude/tmp/carlos-build/`, as memory `platform-roll-recipe` describes). `git status --porcelain` must print nothing.
- [ ] **Step 2:** Show Paul the command. On his go, from `carloku-infrastructure`: `GOFLAGS=-buildvcs=false scripts/roll-console.sh <checkout> flagship`.
Expected: it builds arm64, ships, promotes, adopts, restarts, and its own check prints the running executable as `.../versions/<sha>/console` for the SHA it built. **If that check fails, stop: nothing gets wired.**
- [ ] **Step 3:** `carlos system antivirus status --console https://console.carloku.com`
Expected: it answers, and says no engine is wired. (Wiring is Task 9.)

---

### Task 9 (GATED): Carloku, key and wiring

- [ ] **Step 1: Preconditions, both.** Task 7's readiness and four acceptance checks are recorded as passed. Task 8's executable check passed for the build with the Task 1 change: `git merge-base --is-ancestor <task-1-commit-on-main> v0.24.1` exits 0.

- [ ] **Step 2: Find the console's environment file.**

Run: `... ssm-run.sh keymail eu-west-1 i-092c0c1eea75723cb 'systemctl cat console.service | grep -i EnvironmentFile; systemctl show -p User --value console.service'`
Expected: one `EnvironmentFile=` path (written `<envfile>` below) and the unit's user.

- [ ] **Step 3: Generate the key straight into SSM.** It never touches argv, a file or the terminal:

```bash
openssl rand -hex 32 \
  | jq -Rc '{Name:"/carlos/scan-tag-key",Type:"SecureString",Value:.,Overwrite:false,Description:"Signs every object-storage scan verdict. Losing it makes every stored verdict unverifiable."}' \
  | aws --profile keymail --region eu-west-1 ssm put-parameter --cli-input-json file:///dev/stdin
```
Expected: `"Version": 1`. `Overwrite:false` means a rerun fails with `ParameterAlreadyExists` instead of replacing the key; that failure is correct, leave the key alone.

- [ ] **Step 4: Show Paul** the three variables (the key as a name only), the file, and that the next step restarts the console for a few seconds. Wait for his go.

- [ ] **Step 5: Write the variables on the box.** The box fetches the key itself, so it never crosses the SSM command channel:

```sh
set -eu
f=<envfile>
cp -p "$f" "$f.bak-scan-$(date +%Y%m%d)"
key=$(aws ssm get-parameter --region eu-west-1 --name /carlos/scan-tag-key --with-decryption --query Parameter.Value --output text)
[ ${#key} -eq 64 ] || { echo "key is not 64 hex chars"; exit 1; }
grep -v '^CARLOS_CLAMD_ADDR=\|^CARLOS_CLAMD_MAX_BYTES=\|^CARLOS_SCAN_TAG_KEY=\|^CARLOS_CLAMD_SOCKET=' "$f" > "$f.new"
printf 'CARLOS_CLAMD_ADDR=%s\nCARLOS_CLAMD_MAX_BYTES=%s\nCARLOS_SCAN_TAG_KEY=%s\n' '<scanner_clamd_addr>' '<scanner_stream_max_bytes>' "$key" >> "$f.new"
chmod --reference="$f" "$f.new"; chown --reference="$f" "$f.new"; mv "$f.new" "$f"
grep -c '^CARLOS_CLAMD_ADDR=\|^CARLOS_CLAMD_MAX_BYTES=\|^CARLOS_SCAN_TAG_KEY=' "$f"
grep -c '^CARLOS_STORE_SCANNER=' "$f" || true
```
Expected: `3`, then `0`. Nothing prints the key.

- [ ] **Step 6: Restart and check.**

Run: `... ssm-run.sh keymail eu-west-1 i-092c0c1eea75723cb 'systemctl restart console; sleep 8; systemctl is-active console; readlink /proc/$(systemctl show -p MainPID --value console)/exe; journalctl -u console --since "-1min" --no-pager | grep -i "clamd\|scan\|fatal" | tail -5'`
Expected: `active`; the same `versions/<sha>/console`; no `fatal` line.
**If it is not active:** `cp -p <envfile>.bak-scan-<date> <envfile> && systemctl reset-failed console && systemctl restart console`, confirm `active`, stop, and tell Paul with the journal lines.

- [ ] **Step 7: Status, then enable.**

Run: `carlos system antivirus status --console https://console.carloku.com`
Expected: engine wired, answering, signature date today or yesterday, policy off.
Show Paul. On his go: `carlos system antivirus enable --console https://console.carloku.com`, then `status` again.
Expected: policy on. No app is granted, so the hourly pass does nothing.

---

### Task 10 (GATED): Carloku, the proof

On a throwaway app Paul's account owns. Ask Paul which account to create it under, and confirm before creating and before destroying.

- [ ] **Step 1: Make the app and its store.** Create an app (name `scan-proof-<date>`), then `carlos store create --app <app>` and `carlos store status --app <app>` to learn the bucket name.
- [ ] **Step 2: Build the fixtures locally** by running the fixture half of `infra/modules/scanner-host/files/scanner-selftest` (the `python3` heredoc) into `$TMPDIR/scanner-host/fx`. Upload five with the operator profile: `01-clean.txt`, `02-eicar.txt`, `03-zip-one-500.zip`, `08-zip-500-then-eicar.zip`, `09-zip-60-then-eicar.zip`:
`aws --profile keymail s3 cp "$TMPDIR/scanner-host/fx/<file>" s3://<bucket>/proof/<file>`
- [ ] **Step 3: Request, grant, sweep.**

```
carlos store scan request --app <app> --plaintext
carlos store scan grant --app <app>
carlos store scan sweep --app <app>
carlos store scan status --app <app>
```
Expected (spec §7 steps 1 and 2): a completed sweep; 5 objects; `01` clean; `02` and `09` infected; `03` and `08` skipped and counted unscanned. **If `03` or `08` reads clean, stop the rollout:** `carlos system antivirus disable`, and tell Paul.

- [ ] **Step 4: The tags.** For `01` and `03`: `aws --profile keymail s3api get-object-tagging --bucket <bucket> --key proof/<file>` and `aws --profile keymail s3api head-object --bucket <bucket> --key proof/<file> --query ETag`.
Expected: `carlos-scan`, `carlos-scan-version`, `carlos-scan-at` present; the version tag carries the object's current ETag; `01` says clean, `03` says skipped.

- [ ] **Step 5: A rewrite invalidates the verdict.** Overwrite `proof/01-clean.txt` with different bytes, `sweep`, `status`.
Expected: the fresh count goes up by one; it is re-scanned, not carried forward. **If carried forward, stop the rollout.**

- [ ] **Step 6: Enforce survives the engine going away.** `carlos store scan enforce --app <app> on`. Then, with Paul's go (two more console restarts): on the box, back up `<envfile>`, remove the `CARLOS_CLAMD_ADDR` line, restart; run `carlos store scan enforce --app <app> off`.
Expected: it succeeds with no engine built. Restore the line from the backup, restart, `antivirus status` shows the engine wired again.

- [ ] **Step 7: Teardown.** Destroy the app.
Expected: `aws --profile keymail s3 ls s3://carlos-flagship-271376211898/apps/ --recursive | grep '<app>.*scan.json'` prints nothing, and the app name can be created again at once (create it, then destroy it).

- [ ] **Step 8:** Record every result in the discussion. Scanning stays enabled on Carloku with no member granted.

---

### Task 11 (GATED): Tito, the scanner box and the grant

Do not start until Task 10 has passed and #201 phase 2 has landed (check memory `tito-console-store-boundary` and the teamtito repo). Tito is a third party: every step here is shown to Paul first.

**Files:** `~/github.com/teamtito/carlos-infrastructure/object-store.tf` (modify), `scanner.tf` (create), `outputs.tf` (modify)

- [ ] **Step 1: Branch from the line that can plan.** `git fetch origin`, then branch `scanner-host` from the tip the last targeted apply ran from (`store-boundary-201`, or whatever has superseded it; `main` cannot plan while the Canada box is only on `region-ca`). Say which tip in the PR.

- [ ] **Step 2: Edit.** In `object-store.tf`, add to `host_ie_object_store`'s `Statement` list:

```hcl
      {
        # Object-storage virus scanning (platform spec 2026-10-04): the console
        # reads each object to stream it to the scanner host, and writes the
        # verdict as tags. Inert until a member requests scanning for a store
        # and an operator grants it.
        Sid    = "StoreScan"
        Effect = "Allow"
        Action = ["s3:GetObject", "s3:GetObjectTagging", "s3:PutObjectTagging"]
        Resource = [
          "arn:aws:s3:::carlos-store-tito-*/*",
          "arn:aws:s3:::titogo-uploads-665106592142/*",
        ]
      },
```

Create `scanner.tf` as in Task 7 Step 1 with `name = "carlos-tito-scanner"`, and the three outputs.

- [ ] **Step 3: Check the box can read its key.** In `region-ie.tf`, read `host_ie`'s `ssm_parameter_arn`. If `/carlos/scan-tag-key` falls outside it, the console box could not fetch the key in Task 12; add that one parameter ARN in the smallest way that file allows and include the resource in the targeted plan. Say so to Paul.

- [ ] **Step 4: Plan, targeted.**

```bash
AWS_PROFILE=titogo tofu init -upgrade -input=false
AWS_PROFILE=titogo tofu plan -input=false -out="$TMPDIR/scanner-host/tito.plan" \
  -target=module.scanner -target=aws_iam_role_policy.host_ie_object_store
```
Expected: the scanner's six resources created; `host_ie_object_store` updated in place with exactly the `StoreScan` statement added (read the policy diff: any other difference is drift from #201, stop and ask). No change to any instance.

- [ ] **Step 5:** Show Paul the plan. On his go, apply the plan file, push the branch, open the PR.
- [ ] **Step 6:** Task 7 Steps 5 to 11 on Tito's scanner box (profile `titogo`, console box `i-025a0b70f3d93304d`): first boot, readiness, and all four acceptance checks.

---

### Task 12 (GATED): Tito, console, key, wiring, enable

- [ ] **Step 1:** Roll Tito's console to the same build as Task 8: `GOFLAGS=-buildvcs=false scripts/roll-console.sh <checkout> tito`, with Paul's go. Its executable check must pass. Before rolling, diff Tito's console environment against the flagship's for variables the new build requires (the 2026-09-11 lesson): `... ssm-run.sh` on each console box, `cut -d= -f1 <envfile> | sort`, and compare.
- [ ] **Step 2:** Task 9 Steps 1 to 7 in Tito's account (profile `titogo`, region `eu-west-1`, `/carlos/scan-tag-key`, console `https://carlos.tito.io`). A **new** key: never copy the flagship's.
- [ ] **Step 3:** Stop at `antivirus status` (wired, answering, fresh signatures) and `antivirus enable`. **No throwaway app, no grant, no sweep on Tito** (spec §8).
- [ ] **Step 4:** Record in the discussion.

---

### Task 13: close out

- [ ] **Step 1:** Post the final state to the discussion: both deployments enabled, no store granted, the two follow-ups open.
- [ ] **Step 2:** Update memory: a `scanner-host-rollout` entry (what is live, box ids, where the keys are, the replacement rule, the follow-ups) and a pointer line in `MEMORY.md`; correct `tito-console-store-boundary` if Tito's policy changed.
- [ ] **Step 3:** Delete `$TMPDIR/scanner-host/` and the scratch clone if this plan made it. Remove `.scratch-codex-review.sh` from the worktree. Leave nothing under `/tmp`.
- [ ] **Step 4:** Write the handoff (`handoff` skill).
