# Virus scanning for the object storage primitive

**Date:** 2026-09-01
**Status:** approved in principle (Paul, 2026-09-01). Items marked **(ruling)**
were decided in conversation; items marked **(decision)** are this spec's
reasoning and can be argued with.

**Revision history.** Three review rounds, fourteen blockers, all fixed.
Draft 3 carried Paul's direction change — **ClamAV primary, GuardDuty an
option, self-hosters included** — after GuardDuty's 25-bucket cap surfaced.
Draft 4 (this one) closes review 3: the enforcement deadlock, verdicts not
bound to the bytes they judged, the untrue self-hoster claim, an audit
sentence that hid unscannable objects, and the ClamAV engine's missing
protocol detail.

## 1. What is being asked, and what can honestly be claimed

Tony asked whether CARLOS can scan objects for viruses. Asked whose problem
that solves, the answer was a **compliance checkbox** — someone wants to know
whether stored files are scanned and the deployment needs a defensible yes.
Coverage and evidence matter; detection latency and enforcement strength do
not. **(ruling)**

**The three sentences an audit response may use, and no stronger ones:**

> As of the last completed sweep, which finished at time T, N objects in this
> bucket carried no recorded verdict, and a further S were recorded as not
> scannable — too large, unreadable, or the engine failed on them.

> Objects scanned clean were scanned by ENGINE against signature database
> VERSION.

> For apps whose objects are encrypted before they reach the store, scanning
> is not applicable and no coverage is claimed — see §2.1.

The first sentence names **two** numbers. Draft 3 named only the unscanned
count, which let a bucket of 12,000 over-size objects report "0 objects
carried no recorded verdict" — technically true under §3's taxonomy and
thoroughly misleading. *Completed* matters too: a sweep in progress is not an
answer, which is why §5 keeps the last complete one separately. And T is as
wide as a sweep takes, so §7.1 gives it a budget and `scan status` prints the
projection when it spans ticks.

This is a point-in-time reconciliation, not a continuous guarantee. It is
still worth more than a scan-on-upload pipeline, whose misses are invisible
while a reconciler's are counted.

### 1.1 Why a reconciler and not a pipeline

CARLOS never sees the PUT. `internal/objstore` is a control plane and says so:

> It never touches the data plane — apps talk to their bucket directly with
> the delivered credentials.

It mints a bucket, creates one IAM user per app whose inline policy is exactly
two statements (`aws.go:129-130`), seals the secret into the app's config, and
steps out. There is no request path to hook because there is no request path.

The lever that survives is that **CARLOS writes the app's IAM policy**, so the
control plane can gate reads on a verdict without being in the data path (§8).

## 2. Scope, and who this is for

**The primary engine is ClamAV on the deployment's own infrastructure.** No
bucket cap, any S3-compatible store, no cloud service to buy. **(ruling)**

**GuardDuty is an option for AWS deployments that would rather buy it.** Not
the default: AWS caps Malware Protection at **25 protected buckets per account
per Region, not adjustable**, and CARLOS mints one bucket per app
(`MintedBucket`, `walk.go:62`), so it runs out at 25 apps.

### 2.1 Who can actually turn this on today

Draft 3 said a self-hoster "sets `CARLOS_STORE_SCANNER=clamav` and runs a
clamd unit — no AWS account." **That is not true today and the correction
matters.**

The store primitive itself is AWS-bound: `cmd/console/main.go:497-498` fatals
on any `CARLOS_STORE_BACKEND` but `"aws"`, and `aws.go:73-75` records that a
non-AWS endpoint "still means IAM-against-real-AWS in v1 — there is nothing
else for it to point at." There is no store for a scanner to scan without AWS
IAM.

So the honest statement: **scanning is available to any deployment that has
the store primitive, and today that still needs an AWS account for IAM.** The
scanner itself is backend-agnostic and will follow a non-AWS provisioner the
moment one lands — that provisioner, promised in `provisioner.go`'s doc
comment, is the actual dependency for a fully self-hosted story, and this spec
does not deliver it. What draft 4 does deliver is a scanner that will not have
to change when it arrives. **(decision)**

### 2.2 Scanning ciphertext is meaningless

CARLOS's stated family default is server-blindness — *"If the server is
compromised, the attacker learns nothing"* (`building-carlos-apps/SKILL.md`
line 60). An app following it encrypts objects **client-side**, before they
reach the store.

**A scanner cannot scan ciphertext.** Every object in such a bucket scans
clean forever, whatever it contains.

Note the distinction draft 3 blurred: this is about **client-side**
encryption. Server-side encryption (SSE-S3, SSE-KMS — on by default for every
AWS bucket since 2023) is transparent to a reader holding the right
credentials and scans perfectly well. Only client-side encryption defeats a
scanner.

**The platform cannot detect which it is, so it asks.** `scan request` takes
an explicit attestation, recorded on the record:

- `--plaintext` — objects are readable by the deployment; scanning is
  meaningful; the §1 sentences apply.
- `--client-encrypted` — objects are ciphertext. The sweep marks the sidecar
  `not_applicable` and does no work, `scan status` prints §1's third sentence
  and **refuses to print the first**, and `enforce` is rejected as inert
  (nothing will ever be tagged infected).

Without a recorded attestation, §1's third sentence has no data behind it and
`scan status` cannot know which answer to give. **(decision)**

### 2.3 What the member is actually consenting to

The ClamAV engine reads tenant object bytes, and the per-app opt-in is where
the app owner consents to that. One honest qualification, because the consent
story would otherwise overclaim:

**The console can already read every store.** It holds `PutUserPolicy` and
`CreateAccessKey` over every store user (`aws.go:282-311`), so it can mint
itself a credential to any tenant bucket at will. §10's `GetObject` grant
makes an existing capability explicit rather than creating a new one. What the
opt-in governs is what the platform **does**, not what it **could** do. Say
that plainly rather than implying scanning is what first gives the platform
reach into a member's bucket. **(decision)**

## 3. The Scanner seam

```go
// Scanner is the scanning-engine seam. Like Provisioner, every method is
// idempotent — "already enabled" and "already gone" are successes.
type Scanner interface {
	EnsureEnabled(ctx context.Context, bucket string) error
	// EnsureDisabled releases what EnsureEnabled took. Teardown calls it
	// BEFORE deleting the bucket (§7.3).
	EnsureDisabled(ctx context.Context, bucket string) error
	// Verdict answers the recorded verdict for one object AT etag. A verdict
	// recorded against a different etag is (VerdictUnknown, nil): the object
	// has been rewritten since it was judged (§3.1).
	Verdict(ctx context.Context, bucket, key, etag string) (Verdict, error)
	// Scan scans one object and records its verdict against its etag. It
	// returns the verdict when the engine is synchronous (ClamAV) and
	// VerdictUnknown when the engine scans asynchronously (GuardDuty).
	Scan(ctx context.Context, bucket, key, etag string) (Verdict, error)
	// Enforcement names the tag this engine writes, so the policy writer can
	// deny reads on it (§8).
	Enforcement() DenyTag
	// Capacity reports buckets still protectable and the cap; (-1,-1) is
	// uncapped. ClamAV is uncapped; GuardDuty is not.
	Capacity(ctx context.Context) (remaining, cap int, err error)
	// Provenance names what did the scanning, for the evidence record.
	Provenance(ctx context.Context) (engine, version string, err error)
}

type Verdict string

const (
	VerdictUnknown  Verdict = ""         // no recorded verdict for these bytes
	VerdictClean    Verdict = "clean"
	VerdictInfected Verdict = "infected"
	VerdictSkipped  Verdict = "skipped"  // too large, unsupported, unextractable
	VerdictDenied   Verdict = "denied"   // the engine could not read the object
	VerdictFailed   Verdict = "failed"   // the engine tried and failed
)

type DenyTag struct{ Key, Value string }
```

`Scan` returning a `Verdict` is draft 4's correction to a subtle counting bug.
ClamAV is synchronous — fetch, stream, tag — so the verdict exists the moment
`Scan` returns. Draft 3's "the next sweep reads the result" was GuardDuty's
shape applied to both, which would have made the **first complete sweep over
every bucket report every object as unscanned**, and with a 24-hour re-sweep
floor the audit sentence would only become true two days in. The sweep counts
a verdict `Scan` hands back.

### 3.1 Verdicts are bound to the bytes they judged

`PutObjectTagging` replaces a tag set unconditionally and knows nothing about
versions, so a verdict tag alone says "some bytes under this key were clean
once." The window: `Scan` fetches v1, the app overwrites with v2, `Scan` tags
clean — and v2 is marked clean having never been read. Narrow, but it is
precisely the window worth timing.

So the engine writes the scanned object's **version** into the tag set
alongside the verdict, and `Verdict` compares it against what `ListObjectsV2`
already returns for free on the page being swept. A mismatch is
`VerdictUnknown` — the object gets rescanned, which is the correct outcome for
bytes nobody has looked at. This also closes the forgery story properly: a
re-uploaded object cannot inherit its predecessor's verdict. **(decision —
draft 3 had this only as a Hetzner fallback; it is the primary design.)**

**The version is the ETag AND `LastModified`, not the ETag alone
(implementation review).** For a single-part upload the ETag is the content
MD5, and chosen-prefix MD5 collisions are practical on ordinary hardware. An
attacker who controls both uploads — which is exactly the case enforcement
exists for, an app accepting uploads and serving them back — could give a
payload the ETag of the clean object it replaces and inherit its verdict,
walking through the Deny. `LastModified` moves on every overwrite and is not
the uploader's to choose. Both halves are on the listing page already, so the
stronger binding costs nothing. The tag is `carlos-scan-version`.

## 4. The engines

### 4.1 `clamavScanner` — primary, shipped in v1

Fetch the object, stream it to `clamd`, write `carlos-scan`,
`carlos-scan-version` and `carlos-scan-at` tags — preserving any tag the
scanner does not own, because an ADOPTED bucket's objects can carry tags from
tooling that predates CARLOS. `Capacity` is uncapped.

**Unreadable and vanished objects are per-object verdicts, not pass failures
(implementation review).** An `AccessDenied` on `GetObject` — an SSE-KMS object
outside the console's key policy, a foreign-owned object in an adopted bucket —
returned as an error would abandon the page unwritten, and the next tick would
re-list the same page and fail identically: the bucket's sweep would never
advance past it, forever, while every surface reported a sweep merely "in
progress". It is `VerdictDenied`, which is what that verdict was declared for.
A `NoSuchKey` is "gone": dropped rather than counted, since an object deleted
between the listing and the fetch is neither covered nor a gap.

**The read path already exists.** `sizeProvisionedStore`
(`economicspass.go:990`) lists a tenant's provisioned bucket with
`objstore.S3ClientFor(ctx, p.Region, p.Endpoint)` (`aws.go:95`) and the
console's ambient credentials, from a scheduled pass, with no IAM involved.
Scanning adds `GetObject`, `GetObjectTagging` and `PutObjectTagging` to that
same principal. Stream `GetObject.Body` straight into the socket; never buffer
an object in memory.

**Transport.** `clamd`'s INSTREAM protocol is unauthenticated, so it is never
exposed to a network. The console reaches it over a **unix socket**
(`CARLOS_CLAMD_SOCKET`, default `/run/clamav/clamd.ctl`). No TCP option in v1:
an unauthenticated scanning service reachable over a network is a worse
problem than the one being solved. **(decision)**

**Protocol, stated so it is not invented twice.** Send `zINSTREAM\0`, then
chunks of a 4-byte big-endian length followed by that many bytes, terminated
by a zero-length chunk. Replies are null-terminated: `stream: OK\0` → clean,
`stream: <Signature> FOUND\0` → infected, `INSTREAM size limit exceeded.
ERROR\0` → skipped. The `z` (null-terminated) command form is used throughout,
not the newline form. One connection per object; `IDSESSION` is not used in
v1, and §11 measures whether that costs enough to revisit.

**Size ceiling, checked before the fetch.** `ListObjectsV2` already returns
`Size`, so an object above `CARLOS_CLAMD_MAX_BYTES` is tagged `skipped`
without a `GetObject` — paying egress to fetch bytes clamd will refuse is
pure waste. If the variable is set above clamd's own `StreamMaxLength`, clamd
answers `size limit exceeded ... ERROR` mid-stream; that classifies as
**skipped, not failed**, or the counters lie about what happened.

**Timeouts.** Dial deadline, per-chunk write deadline and reply read deadline
on our side; clamd's `ReadTimeout` and `CommandReadTimeout` on its side. All
configurable, all defaulted, none infinite.

**When clamd is down.** A dial failure aborts that app's pass **without
writing any verdict** and is reported as a pass error to retry next tick. It
must never stamp thousands of objects `failed`: "the engine is unreachable" and
"the engine read this object and failed on it" are different facts and only
the second belongs in a tag.

**Re-scan policy.** `failed` and `denied` are re-issued every sweep — both are
usually transient or a permissions fix away. `skipped` is not re-issued unless
the ETag changed or the ceiling moved. `clean` and `infected` are re-issued
only on ETag mismatch (§3.1).

**Provenance.** `zVERSION\0` answers e.g. `ClamAV 1.4.1/27500/Mon Sep 1
08:00:00 2026` — engine version, signature database version, database date.
`Sweep.Signature` records all three verbatim. `scan status` warns when the
database date is older than `CARLOS_CLAMD_MAX_SIG_AGE` (default 7 days),
because a clean result from stale signatures should not read like a fresh one.

**Operational facts self-hosters need up front**, and §10 must carry: clamd
holds roughly 1–1.5 GB of signatures resident; `freshclam` must run on a timer
or the database rots silently; and the console's unit user must be in the
socket's group (`/run/clamav/clamd.ctl` is group `clamav`).

### 4.2 `guarddutyScanner` — optional, AWS only, gated

`EnsureEnabled` creates a Malware Protection plan with tagging on; `Verdict`
reads `GuardDutyMalwareScanStatus`; `Capacity` reports against the cap.

Verified against AWS documentation rather than assumed:

- **`SendObjectMalwareScan` is real**, takes an S3 object path (optionally a
  version), and is the documented on-demand path for objects that predate the
  plan. It needs `AmazonGuardDutyFullAccess_v2` on the caller and is excluded
  from the free tier, so the sweep pays per unknown object per pass.
- **The object ceiling is 100 GB** and the bucket cap is 25, both from the
  quotas table, both non-adjustable.

**The gate that must be measured before any GuardDuty work starts.** AWS
states tagging "must be enabled before an object gets uploaded" or GuardDuty
"will not be able to add tags to the scanned object." Every object in an
existing store predates its plan. If an on-demand scan of such an object is
not tagged, `Verdict` never converges on any existing store, and this engine
needs an EventBridge consumer reading scan results instead of tags — a
different architecture. **Measure it against a real bucket first.** ClamAV is
unaffected either way, which is why it is the primary engine.

## 5. Evidence: a sidecar, not the store record

Two objects, two writers, no contention. **(decision — draft 2 put everything
in `store.json` and needed a page of CAS choreography to survive it.)**

**`store.json` gains a `scanning` block, written only by the doors:**

```go
type ScanRequested struct {
	At        time.Time `json:"at"`
	By        string    `json:"by"`
	Encrypted bool      `json:"encrypted"` // §2.2 attestation
}

type ScanEnabled struct {
	Engine string    `json:"engine"` // "clamav" | "guardduty"
	At     time.Time `json:"at"`
	By     string    `json:"by"`
}

type ScanBlock struct {
	Requested *ScanRequested `json:"requested,omitempty"`
	Enabled   *ScanEnabled   `json:"enabled,omitempty"`
	// Enforce holds the tag itself, not a bool: Grant takes a Provisioner
	// and has no Scanner to ask, so the record carries what the policy
	// writer needs (§8).
	Enforce *DenyTag `json:"enforce,omitempty"`
}
```

**`apps/<acct>/<app>/scan.json` holds the rollups, written only by the sweep:**

```go
type ScanSidecar struct {
	Schema        int    `json:"schema"`
	Account       string `json:"account"` // §5.1
	App           string `json:"app"`
	NotApplicable bool   `json:"not_applicable,omitempty"` // §2.2
	Current       *Sweep `json:"current,omitempty"`
	LastComplete  *Sweep `json:"last_complete,omitempty"` // the audit answer
}

type Sweep struct {
	StartedAt time.Time `json:"started_at"`
	At        time.Time `json:"at"`
	Engine    string    `json:"engine"`
	Signature string    `json:"signature,omitempty"`
	Objects   int64     `json:"objects"`
	Scanned   int64     `json:"scanned"`
	Infected  int64     `json:"infected"`
	Skipped   int64     `json:"skipped"`
	Denied    int64     `json:"denied"`
	Failed    int64     `json:"failed"`
	Unscanned int64     `json:"unscanned"`
	Bytes     int64     `json:"bytes"`
	After     string    `json:"after,omitempty"`
}
```

`Current` and `LastComplete` are separate because draft 2's single `*Sweep`
overloaded `After == ""` to mean both "complete" and "start fresh", so the
tick after a sweep finished overwrote the audit answer with page-one partials.
A sweep accumulates in `Current`; on its last page it moves to `LastComplete`
and `Current` clears. A completed sweep does not restart before
`CARLOS_STORE_SCAN_INTERVAL` (default 24h), so `LastComplete` stays stable
long enough to quote.

`Schema` is refused when unknown, the rule `ReadAppTombstone` already applies
(`trash.go:76-80`).

### 5.1 Why the sidecar carries its own account

`accounts migrate` copies every key under the source prefix
(`accounts.go:587-597`), so a sidecar would silently follow an app to a new
account carrying rollups for a bucket named after the old one. The reader
refuses a body whose `Account`/`App` disagree with its path, exactly as
`AppTombstone` does.

(`store.json` has the same latent problem — its bucket and IAM user are named
after the original account — but that predates this spec and is noted, not
fixed here.)

### 5.2 The PARSER LIST hazard

`objstore.go` records that `release.PurgeApp` parses `grant` and `provisioned`
with its own local struct and refuses box-side auto-purge when either is
present. Additive changes are safe (`trash.go:214-217` ignores unknown keys),
so `scanning` is fine, but those two names must not move. The full consumer
list for this shape is `objstore.Record`, `PurgeApp`'s local struct, and
`handleAPIStoreStatus`'s map (`apistore.go:130-141`). §13 adds the additive
subtest, which `TestPurgeGuardSeesOurRecord` does not cover today.

## 6. Who turns it on

- **The member requests** — `carlos store scan request --plaintext` or
  `--client-encrypted` (§2.2). Records the ask and the attestation, spends
  nothing.
- **An operator grants** — `carlos store scan grant`. Calls the cloud account
  and starts a meter, so it sits behind `CARLOS_STORE_OPERATORS`, the gate
  `apistore.go` applies to store grants for the same reason, checked **after**
  the member gate so a 403 never confirms which apps exist.

`CARLOS_STORE_SCANNER` unset means no engine is built and `grant`/`sweep` 503
naming the variable. The **member's** request door still records, following
the create door's deliberate precedent (`apistore.go:145-152`): a member
cannot see whether their deployment has a backend, and refusing them teaches
them nothing they can act on.

**`revoke` and `enforce off` never 503**, and this is draft 4's fix for a
deadlock. Draft 3 had §8 refuse a repair when the scanner was nil and
`Enforce` was set, while §6 had the operator doors 503 without an engine — so
unsetting `CARLOS_STORE_SCANNER` on a deployment with any enforcing record
left the store unrepairable and the deny unclearable without hand-editing the
bucket. Both are **record-only doors that work with no engine built**, which
is the whole point of keeping the `DenyTag` in the record.

## 7. The sweep

1. Read `store.json`. No `scanning.enabled`, or `Requested.Encrypted` — write
   the sidecar `not_applicable` if needed and return. Skip trashed apps
   (`deleted.json`, `trash.go:34-36`): a trashed store is destroyed within 30
   days and scanning it bills for data on its way out.
2. Read `scan.json`; resume from `Current.After` or start fresh.
3. List a page with `ListObjectsV2` using `StartAfter` — not a
   `ContinuationToken`, whose cross-process lifetime is unspecified and, on
   Ceph, unmeasured. A key is a portable resume point. The page gives `Size`
   and `ETag` for free, which §3.1 and §4.1 both need.
4. For each key, `Verdict(key, etag)`; count it; `Scan` when unknown and count
   the verdict it returns.
5. **Re-read `store.json`, then write the sidecar** (§7.2), setting `After` to
   the page's last key. On the final page move `Current` to `LastComplete`.

**Honest semantics.** A key added lexically before the resume point mid-sweep
is missed by that sweep and caught by the next. A key deleted after being
counted inflates `Objects` for that pass. Neither breaks a point-in-time
reconciliation.

### 7.1 The driver and its budget

Its own goroutine on the `StartInfraPass` shape (`infrapass.go:49`), started
from `serve()` in `cmd/console/main.go` alongside `StartEconomicsPass` and
`StartInfraPass` (`main.go:326-330`) — **never from `NewServer`**, the rule
`economicspass.go:35-38` states. Not inside `economicsPass`, whose single
goroutine already runs census, statements and billing and already worries
about a slow step delaying the others.

Draft 2 wanted to ride `storagePass`; that was wrong. `storagePass` returns at
`economicspass.go:137-140` once the day's object exists, so it iterates once
per UTC day.

**Enumeration.** The pass finds enabled stores the way `storagePass` does —
`ListDirs("apps/")`, per-account `ListDirs`, per-app `objstore.Get`
(`economicspass.go:150-181`). Hourly, that is 24× that pass's listing cost.
Accepted for v1 and named here so it is a known cost rather than a surprise;
an index is the fix if it bites.

**Budget, with the arithmetic stated.** `CARLOS_STORE_SCAN_BUDGET` bounds work
per app per tick by time (default 60s). An hourly tick at 60s is 24 minutes of
scanning per app per day; at a few hundred objects a minute over INSTREAM,
a million-object bucket takes months, not days. **So the default is honest
only for small and medium stores**, and `scan status` prints the projected
completion whenever a sweep spans more than one tick. A deployment with large
stores raises the budget or shortens the tick, and §1's T widens accordingly.
Apps are swept sequentially in the one goroutine; when N apps × budget exceeds
the interval the ticker simply runs back to back, which is correct behaviour
and not an error.

**Two drivers, one recorder.** The `scan sweep` verb and the ticker can both
list a page before either writes. For ClamAV that costs a duplicate fetch; for
GuardDuty on-demand it is double-billed. The guarantee is **at most one driver
records**, not that two never run — draft 3 overstated it.

### 7.2 The pre-write re-read

Before every sidecar write, re-read `store.json` and stand down if it is gone
or `scanning.enabled` is nil.

This closes a hole the retry guard alone does not. A **fresh** sweep's first
write is create-only by construction — no sidecar exists, etag `""`. Sequence:
the sweep reads an enabled `store.json`, lists a page over some seconds,
`Teardown` runs and `PurgeApp` deletes the whole prefix
(`trash.go:229-238`), and the sweep then writes `scan.json` create-only, which
succeeds. The result is an orphan object under a purged app, and that orphan
is expensive: the app-name claim gate refuses a name whose prefix holds any
key (`server.go:2876-2890`), rename is refused (`appsettings.go:468-479`), and
box-side auto-purge never reaches it because the tombstone went last.

A one-round-trip tolerance is the same one `deleteRecordIfUnchanged`
(`walk.go:519-540`) already accepts.

**The retry guard still applies to replaces:** on a lost CAS, continue only if
`Current` still exists and its `After` matches where this page started;
otherwise stand down. A conditional write against a deleted key reads as
`ErrCASConflict` (`release/store.go:521-534`; `DirStore` matches at `:260`),
so a blind retry would write create-only and resurrect the sidecar.

### 7.3 Teardown disables first

`Teardown` (`walk.go:390-538`) empties the bucket, deletes it, deletes the
user, unsets config, then deletes the record. Nothing today disables a
scanner, so a GuardDuty plan and its managed EventBridge rule would outlive
the bucket, counting against the §2 cap, with the record naming them gone.

`Teardown` takes a `Scanner`, calls `EnsureDisabled` before `DeleteBucket`,
and deletes the sidecar. A nil scanner with an enabled record refuses, as a
nil provisioner already does (`walk.go:417`). The destroy door's pre-check
(`appsettings.go:827-833`) tests only `StoreProvisioner` and needs the
matching scanner check, or the refusal surfaces as a generic "Could not
destroy" flash. Both callers (`appsettings.go:845`, `ownership.go:417`) update
with the signature.

### 7.4 Engine drift

Both cases refuse rather than guess. A record naming another engine: the sweep
refuses for that app, because verdicts in the bucket were written under a tag
key the current engine does not read, and recounting them as unscanned would
destroy the coverage history — an operator revokes and re-grants deliberately.
Engine `guardduty` with `Deployment.Endpoint != ""`: refused at boot, since
GuardDuty cannot protect a non-S3 bucket.

## 8. Enforcement

`ScanBlock.Enforce` — nil by default — adds a Deny statement: deny
`s3:GetObject` where `s3:ExistingObjectTag/<Key>` equals `<Value>`. It stores
the tag rather than a bool because `Grant` (`walk.go:133`) takes a
`Provisioner` and has no `Scanner` to ask.

**Grant writes the deny from the record whatever the scanner is.** A nil
scanner never blocks a repair and never silently drops the deny — the record
holds everything the policy writer needs, which is why it holds a tag.

**How the policy gets rewritten — the bug drafts 1 and 2 both had.**
`PutUserPolicy` is reachable only through `EnsureUser` (`aws.go:302`), called
from one line (`walk.go:184`), and `Grant` returns early before it for any
healthy store (`walk.go:169-177`). `Rotate` never writes a policy. A flag flip
would have been permanently inert.

**This repo already fixed this exact bug once.** `internal/mail/walk.go:653-671`
("C2 — review follow-up") hoisted `EnsureUser` above the idempotence
short-circuit, with a comment recording that the short-circuit had left the
policy naming only the first domain forever. The same move here:

- `EnsureUser` gains `deny *DenyTag` and stays the single policy writer.
- `Grant` calls it **before** the short-circuit, passing the record's
  `Enforce`. It needs only the bucket ARN string, resolved at `walk.go:143-151`
  before the guard, so `EnsureBucket` stays behind the guard and the
  record-before-create ordering survives (the grant block Put at
  `walk.go:160-165` still precedes it).
- `scan enforce on|off` and `scan revoke` write the record and re-run the
  grant walk. One convergence path, no second policy writer. Clearing the
  record alone would leave the deny live until something re-walked.

This makes every `store grant` re-run do three IAM calls on a healthy store
where it previously did none. That is the intended trade and the plan should
expect it.

`policyStatement` (`aws.go:112-116`) has no `Condition` field today; it gains
one, marshalled `omitempty`, so a nil deny emits no `Condition` key at all.

**The document does change once, estate-wide, and pretending otherwise was a
contradiction in draft 4.** Adding `s3:GetObjectTagging` to the Allow — which
apps need to read their own verdicts — changes the bytes by definition, so
"identical byte for byte when the deny is nil" cannot also be true. The
correct requirement, and what §13 tests, is narrower: **with a nil deny the
document has exactly two statements and no `Condition`**, so an
enforcement-off store never carries a Deny. Every store's policy is rewritten
on its next grant re-walk to gain the new action. That is a one-time,
idempotent change, it costs three IAM calls per re-walk (already accepted
above), and `assertStorePolicyDocument` in `aws_test.go` must be updated to
expect four actions rather than three — it asserts the count, so it fails as a
logic error rather than a compile error and is easy to miss.

`MemProvisioner` mirrors the signature and gains policy state, which `MemUser`
lacks (`memprovisioner.go:12-15`). Note its `EnsureUser` early-returns for an
existing user, so the policy must be written on **both** branches or
enforcement never reaches a store that already has its principal — which is
every store this feature will ever be turned on for.

**Anti-forgery, stated at its real strength.** AWS's documented tag-based
access control denies reads *unless* the tag says clean, so unscanned objects
are blocked too. **This design denies only known-infected objects; unscanned
ones stay readable.** That is the right trade under §1's ruling and it must be
said, not implied, to anyone quoting this in a compliance answer. It is safe
only because the app user has no `s3:PutObjectTagging` (`aws.go:130`) and so
cannot mark its own object clean — **that must never be added to the Allow
statement.** The app gains `s3:GetObjectTagging` only, so it can read its own
verdicts; the console gains `GetObject`, `GetObjectTagging` and
`PutObjectTagging` (§10).

For a `--client-encrypted` store, `enforce` is rejected: nothing will ever be
tagged infected, so it would be theatre.

## 9. Surface

| command | actor | effect |
|---|---|---|
| `carlos store scan request --plaintext\|--client-encrypted` | member | records the ask and the §2.2 attestation |
| `carlos store scan status` | member | the §1 sentences, engine, signature, projection |
| `carlos store scan grant` | operator | enables the engine on the bucket |
| `carlos store scan revoke` | operator | disables it, clears `Enforce`, re-walks |
| `carlos store scan sweep` | operator | advances a sweep by one budget |
| `carlos store scan enforce on\|off` | operator | flips the §8 Deny, re-walks |

`request`/`grant` mirrors `create`/`grant` on the store itself. API mode only,
like the rest of `store.go`.

`store status` gains the scan line. `docs/site/cli.md` gains the six verbs
under `### carlos store` (line 393) — the docsite gate reads only the dispatch
switch (`cliscan.go:22`) and cannot see sub-verbs, so this is a house-rule
obligation.

## 10. The infra half (`tito/carlos-infrastructure`)

- **clamd**, per console box: a systemd unit, `freshclam` on a timer, a unix
  socket the console's unit user can reach (group `clamav`),
  `StreamMaxLength` matched to `CARLOS_CLAMD_MAX_BYTES`, and ~1–1.5 GB RAM for
  resident signatures. This is the primary path and all a self-hoster needs
  beyond the store primitive itself.
- **Console S3 permissions**: `GetObject`, `GetObjectTagging`,
  `PutObjectTagging` on `arn:aws:s3:::<BucketPrefix>*`, alongside the listing
  it already does.
- **GuardDuty, only if used**: a scanning role GuardDuty assumes (EventBridge
  managed rule, `GetObject`, `PutObjectTagging`, KMS where encrypted), passed
  at plan creation — so the console needs `iam:PassRole` on it plus
  `guardduty:SendObjectMalwareScan`. Scope the role to
  `arn:aws:s3:::<BucketPrefix>*` so it needs no per-app edit.

## 11. Gates and risks

**Gate — measure before building the GuardDuty engine**: whether
`SendObjectMalwareScan` tags an object uploaded before tagging was enabled
(§4.2). Blocks GuardDuty only.

**Risks to verify during implementation:**

- **Hetzner object tagging.** `s3compat`'s quirks table exists because Ceph
  RGW already differed from S3 in a way that broke every conditional write.
  Measure `PutObjectTagging`/`GetObjectTagging` before relying on them; the
  fallback is per-object verdicts in the sidecar keyed by key and ETag.
- **clamd throughput.** One connection per object with a size ceiling sets the
  real budget in §7.1. Measure before promising any completion window, and
  revisit `IDSESSION` if per-connection cost dominates.
- **Enumeration cost** at hourly cadence (§7.1).

## 12. Out of scope

- Scanning the deployment's control-plane bucket, release artifacts, or
  instance replicas. App stores only.
- A non-AWS store provisioner (§2.1) — the dependency for a fully self-hosted
  story, not delivered here.
- Quarantine as movement. Verdicts are tags; nothing is copied aside or
  deleted. Deleting a member's data on a scanner's say-so is a policy decision
  nobody has made.
- Scan-on-upload latency guarantees.
- Notifying members of an infected object.

## 13. Testing

- **`Scanner` contract test** over `MemScanner` and each real engine: enable
  twice, disable twice, scan twice, verdict on an absent object, verdict on a
  changed ETag. Note `contract_test.go` today is `TestDeliveredContractMinio`,
  a delivered-credential wire test — there is no `Provisioner` idempotence
  contract to sit beside, so this is a new file.
- **Version binding.** An object rewritten after being tagged reads
  `VerdictUnknown` and is rescanned (§3.1).
- **Synchronous counting.** A ClamAV sweep's *first* pass reports objects as
  scanned, not unscanned.
- **`LastComplete` survives** the tick after completion — draft 2's bug.
- **Resurrection, both paths.** Fresh sweep with no sidecar, `Teardown` plus
  `PurgeApp` between list and write: no `scan.json` remains and the app name
  is claimable. And the retry path on a replace.
- **Migration.** A sidecar whose `Account` disagrees with its path is refused.
- **Purge guard, additive.** A record carrying `scanning` still makes
  `PurgeApp` refuse on `grant`/`provisioned` and ignore the new key.
- **Policy round-trip.** `EnsureUser` with `deny == nil` produces exactly two
  statements and no `Condition` key; with a `DenyTag`, three statements, a
  `Condition` on the verdict tag, and no `PutObjectTagging` in any of them.
- **Enforcement converges** on a healthy provisioned store, and `enforce off`
  works with no engine built (§6's deadlock, asserted).
- **Teardown** disables first, deletes the sidecar, refuses on a nil scanner.
- **Encrypted attestation.** A `--client-encrypted` store is swept
  `not_applicable`, `scan status` refuses §1's first sentence, `enforce` is
  rejected.
- **clamd down** produces a pass error and zero written verdicts.
- **Skipped is not clean**, and an over-ceiling object is tagged without a
  `GetObject`.
- **Trashed apps are skipped.**
- **Gate order and inert deployment.** `grant`/`sweep` 503 without an engine;
  `request`, `revoke` and `enforce off` still work.
