carlos / platform Public

spec: draft 3 — ClamAV primary, GuardDuty optional, self-hosters included

Direction change from Paul: the 25-bucket GuardDuty cap (per account per
region, not adjustable) is a poor fit for a bucket-per-app platform, so
ClamAV becomes the primary engine and GuardDuty an option behind the
seam. The feature is offered to self-hosters rather than being a
Carloku-only control: set CARLOS_STORE_SCANNER=clamav, run a clamd unit,
done.

Draft 2's review found four more holes, all fixed here:

- The compliance artifact was destroyed every cycle. One *Sweep served
  both "in progress" and "complete", and After == "" meant both
  "finished" and "start fresh", so the tick after completion overwrote
  the audit answer with page-one partials. Current and LastComplete are
  now separate.
- Grant could not build a DenyTag: it takes a Provisioner and has no
  Scanner to ask. The record now carries the tag itself, not a bool.
- The retry rule could resurrect a torn-down app. A conditional write
  against a key Teardown just deleted reads as ErrCASConflict, and a
  blind retry writes create-only. Guarded.
- storagePass does not run hourly; it returns once the day's object
  exists. The scan pass gets its own ticker on the StartInfraPass shape.

Two things the reviews did not find. Evidence moves to an
apps/<acct>/<app>/scan.json sidecar with a single writer, which dissolves
the CAS choreography draft 2 needed to write rollups into a record three
doors also write. And scanning ciphertext is meaningless: the family
default is server-blindness, so for an encrypted store every object
scans clean forever. The spec now says that plainly and gives the audit
response a second true sentence rather than letting someone enable
scanning on an encrypted bucket and believe the result.

Enforcement follows internal/mail/walk.go's "C2" fix, which solved this
identical bug: EnsureUser runs unconditionally before the idempotence
short-circuit, so one convergence path rewrites the policy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E1vDqqMC44bNatFmt6F2Ce
Paul Campbell pushed by paul@keymail.dev a165cfc6bdd1b46a498ce5aab9abfa0a08531d5f parent 896d829
1 file changed, +449 −453
  • docs/superpowers/specs/2026-09-01-object-storage-virus-scanning-design.md +449 −453
diff --git a/docs/superpowers/specs/2026-09-01-object-storage-virus-scanning-design.md b/docs/superpowers/specs/2026-09-01-object-storage-virus-scanning-design.md
index 6cd33980f..d87083a44 100644
--- a/docs/superpowers/specs/2026-09-01-object-storage-virus-scanning-design.md
+++ b/docs/superpowers/specs/2026-09-01-object-storage-virus-scanning-design.md
@@ -2,214 +2,231 @@
**Date:** 2026-09-01
**Status:** approved in principle (Paul, 2026-09-01). Tony raised the
-question; Paul chose the shape from four options and accepted opt-in,
-default-off. Items marked **(ruling)** were decided in conversation and are
-not cheap to overturn; items marked **(decision)** are this spec's own
-reasoning and can be argued with.
+question. Items marked **(ruling)** were decided in conversation; items marked
+**(decision)** are this spec's reasoning and can be argued with.
-**Revision:** draft 2. Draft 1 was reviewed and six blockers were found; every
-one is corrected below. Draft 1 claimed the deny statement needed no migration
-(false — §8), that GuardDuty covers a bucket's existing objects (false — §4),
-and it never named the sweep's driver (§7) or GuardDuty's bucket cap (§2.1).
-The compliance claim in §1 has been weakened to what is actually true.
+**Revision history.** Draft 1 was reviewed and six blockers found. Draft 2
+fixed them and was reviewed again, finding four more. Draft 3 (this one) fixes
+those and carries a **direction change from Paul, 2026-09-01**: after the
+25-bucket GuardDuty cap surfaced, **ClamAV becomes the primary engine and
+GuardDuty an option**, and the feature is offered to self-hosters rather than
+being a Carloku-only control. **(ruling)**
-## 1. What is actually being asked
+## 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 (a customer,
-an auditor, a procurement form) wants to know whether stored files are
-scanned, and the deployment needs a defensible yes. **(ruling)**
-
-That answer, not the phrase "virus scanning", determines the design. Coverage
-and evidence matter; detection latency and enforcement strength do not. The
-question a deployment must answer is not "how fast did we catch it" but
-**"how many objects lack a verdict?"**, and the answer has to be a number
-somebody can check.
-
-**What this design can honestly claim, stated precisely.** Not "every object
-was scanned" — that is a proof no design here can deliver. What it delivers
-is: *as of the last completed sweep at time T, N objects in this bucket
-carried no verdict.* That is a point-in-time reconciliation, not a
-continuous guarantee, and it is worth more than a scan-on-upload pipeline can
-offer, because a pipeline's misses are invisible while a reconciler's are
-counted. Anyone writing the audit response should use that sentence and not a
-stronger one.
-
-## 2. The constraint that rules out the obvious hook
-
-`internal/objstore` is a control plane and says so in its own package
-comment:
+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)**
+
+So the question to answer is not "how fast did we catch it" but **"how many
+objects lack a recorded verdict?"**, and the answer must be a number someone
+can check.
+
+**The two sentences the 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.
+
+> For apps that store encrypted objects, scanning is not applicable — see §2.1.
+
+Three words in the first sentence are load-bearing. *Completed* — a sweep in
+progress is not an answer, which is why §5 keeps the last complete one
+separately. *Recorded* — an absent verdict has causes other than "unscanned"
+(a tag ceiling, a permissions failure, an engine that could not read the
+object), and the count must not silently assert otherwise. *T* — the window is
+as wide as a sweep takes, so §7.1 gives it a budget rather than leaving it
+open-ended.
+
+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.
-The engine mints a bucket, creates one IAM user per app whose inline policy is
-exactly two statements (`s3:ListBucket` on the bucket; `s3:GetObject`,
-`s3:PutObject`, `s3:DeleteObject` under it, no conditions —
-`internal/objstore/aws.go:129-130`), seals the secret into the app's config,
-and steps out. Every verb is an idempotent, re-runnable walk over bucket
-state (`Grant`, `Rotate`, `RotateFinish`, `Teardown` in `walk.go`).
+It mints a bucket, creates one IAM user per app whose inline policy is exactly
+two statements (`s3:ListBucket` on the bucket; `s3:GetObject`, `s3:PutObject`,
+`s3:DeleteObject` under it, no conditions — `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.
-**CARLOS never sees the PUT.** There is no request path to hook, because there
-is no request path. Any design claiming to scan "on upload" is either buying
-that from the backend or inserting a new component into the data plane.
+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
+cashes that in.
-The lever that survives: **CARLOS writes the app's IAM policy**, so the
-control plane can gate access to an object by its verdict without reading a
-byte. §8 is where that is cashed in, and §8 is also where draft 1 was wrong
-about how.
+## 2. Scope, and who this is for
-### 2.1 GuardDuty protects at most 25 buckets per account per Region
+**The primary engine is ClamAV, running on the deployment's own
+infrastructure.** It has no bucket cap, works against any S3-compatible store,
+and is available to any deployment including self-hosted ones. **(ruling)**
-This is a hard architectural constraint and it was missed in draft 1.
+**GuardDuty is an option for AWS deployments that prefer to buy it**, kept
+behind the seam. It is not the default, for a reason worth recording: 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`). A bucket-per-app platform hits that ceiling at 25 apps. §4.2
+covers what the engine must therefore do.
-AWS's quota table gives **Maximum protected buckets: 25, per account in each
-Region, not adjustable.** CARLOS mints one bucket per app (`MintedBucket`,
-`walk.go:62`). So on a single-account deployment the GuardDuty engine covers
-**at most 25 apps per Region**, and the 26th `scan grant` fails.
+### 2.1 Scanning ciphertext is meaningless, and this must be said out loud
-This does not sink the design, for one reason that follows directly from the
-ruling in §1: the feature is opt-in and default-off, and the population that
-opts in is the population with a compliance requirement — not every app on the
-deployment. Twenty-five is plausibly years of headroom for that population.
-But it must be surfaced rather than discovered:
+CARLOS's stated family default is server-blindness — *"If the server is
+compromised, the attacker learns nothing"* (`building-carlos-apps/SKILL.md`).
+An app following that default stores encrypted objects.
-- `scan grant` counts the deployment's protected buckets before enabling, and
- refuses at the cap with the count and the number in the message, naming the
- quota. A refusal that says "25 of 25 buckets protected in eu-west-1; this is
- a non-adjustable AWS quota" is actionable; a raw AWS error is not.
-- `store scan status` for the deployment reports the count against the cap, so
- an operator sees the ceiling approaching rather than hitting it.
-- The long-term answer past 25 is the ClamAV engine (§4.2), which has no such
- cap. That is the honest reason the seam exists rather than a bare GuardDuty
- integration. **(decision)**
+**A virus scanner cannot scan ciphertext.** Every object in such a bucket
+scans clean, forever, whatever it contains. Two consequences the design must
+carry rather than let someone discover after enabling it:
-## 3. Shape: a Scanner seam beside Provisioner
+- **`scan status` must never present a clean result for an encrypted store as
+ though it meant something.** The compliance answer for those apps is the
+ second sentence in §1, not the first.
+- **The ClamAV engine reads tenant object bytes.** For a server-blind app that
+ is both useless and a trust regression, and it is the strongest argument for
+ the per-app opt-in already chosen in §6: the app owner is the only party who
+ knows whether their objects are plaintext, and the right party to consent to
+ the deployment reading them.
-`Provisioner` is the existing backend seam, and its doc comment already
-promises this move:
+The platform cannot reliably detect encryption, so it does not try to guess.
+`scan request` states plainly what the member is consenting to, and `scan
+status` repeats it. **(decision)**
-> v1 ships the AWS implementation and this package's in-memory fake. A
-> Hetzner/R2/Minio provisioner arrives here later without touching a walk.
+## 3. The Scanner seam
-Scanning gets the same treatment. **(decision)**
+`Provisioner`'s doc comment already promises this move ("A Hetzner/R2/Minio
+provisioner arrives here later without touching a walk"). Scanning mirrors it.
```go
// Scanner is the scanning-engine seam. Like Provisioner, every method is
// idempotent — "already enabled" and "already gone" are successes — because
-// the sweep walk that drives one must be re-runnable after a half-failure.
+// the sweep that drives one must be re-runnable after a half-failure.
type Scanner interface {
- // EnsureEnabled turns scanning on for a bucket. Engines that scan by
- // fetching have nothing to configure and answer nil.
EnsureEnabled(ctx context.Context, bucket string) error
- // EnsureDisabled turns it off and releases whatever EnsureEnabled took.
- // Teardown calls this BEFORE deleting the bucket (§7.2).
+ // EnsureDisabled releases whatever 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. Unscanned is
- // (VerdictUnknown, nil) — a state, not an error, the same idiom
- // objstore.Get uses for an absent record.
+ // Verdict answers the recorded verdict for one object. No verdict is
+ // (VerdictUnknown, nil) — a state, not an error.
Verdict(ctx context.Context, bucket, key string) (Verdict, error)
- // Scan causes one object to be scanned and its verdict recorded. It is
- // how the sweep closes a coverage gap, so every engine implements it for
- // real: the GuardDuty engine by asking for an on-demand scan, the ClamAV
- // engine by fetching and scanning. It may return before a verdict exists
- // (GuardDuty scans asynchronously); the next sweep reads the result.
+ // Scan causes one object to be scanned and its verdict recorded. It may
+ // return before a verdict exists; the next sweep reads the result.
Scan(ctx context.Context, bucket, key string) error
- // Enforcement names the object tag this engine writes, so the IAM policy
- // writer can deny reads on it (§8). Engine-specific by necessity: the
- // key and value differ per engine.
- Enforcement() (tagKey, infectedValue string)
- // Capacity reports how many buckets this engine can still protect on
- // this deployment, and the cap. (-1, -1) means uncapped (§2.1).
+ // Enforcement names the tag this engine writes, so the policy writer can
+ // deny reads on it (§8). Engine-specific by necessity.
+ Enforcement() DenyTag
+ // Capacity reports buckets still protectable and the cap; (-1, -1) is
+ // uncapped. ClamAV is uncapped; GuardDuty is not (§2).
Capacity(ctx context.Context) (remaining, cap int, err error)
+ // Provenance names what did the scanning — engine and signature version —
+ // for the evidence record (§5).
+ Provenance(ctx context.Context) (engine, version string, err error)
}
type Verdict string
const (
- VerdictUnknown Verdict = "" // no verdict recorded yet
+ VerdictUnknown Verdict = "" // no recorded verdict
VerdictClean Verdict = "clean"
VerdictInfected Verdict = "infected"
- VerdictSkipped Verdict = "skipped" // too large, unsupported, or unextractable
- VerdictFailed Verdict = "failed" // the engine tried and could not
+ 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 }
```
`Verdict` hides that each engine reads its own tag key, so the sweep never
-branches on engine name. `Enforcement` is the one place that difference has to
-surface, because a Deny statement must name a literal tag key and value —
-draft 1 claimed the walk never branches on engine and then wrote an
-engine-agnostic Deny, which is not possible. Naming the seam is the fix.
-
-**Draft 1 had `Scan` return `ErrScannedByBackend` for the GuardDuty engine.**
-That sentinel is deleted. It encoded a belief that turned out to be false
-(§4.1) and it made the sweep's gap permanently uncloseable.
-
-## 4. The two engines
-
-### 4.1 `guarddutyScanner` (AWS)
-
-`EnsureEnabled` creates a Malware Protection plan on the bucket with object
-tagging on. `Verdict` reads GuardDuty's `GuardDutyMalwareScanStatus` tag.
-`EnsureDisabled` deletes the plan.
-
-**GuardDuty scans newly uploaded objects only.** Objects already in the bucket
-when the plan is created are never scanned by the plan, and would sit at
-`Unscanned` forever — which would mean the number in §1 never reaches zero on
-any bucket enabled after first use, i.e. every existing store. `Scan`
-therefore calls **`SendObjectMalwareScan`** for the key, which is the
-documented on-demand path for exactly this. That single call is what turns the
-sweep from a counter into a reconciler.
-
-Two consequences worth stating rather than discovering:
-
-- **Tagging must be on before the first upload for the steady-state path to
- work.** AWS is explicit: enable tagging after an object is uploaded and
- GuardDuty cannot tag that object. For an existing store this is what the
- on-demand path above is for; for a new store, `scan grant` should precede
- first use.
-- **GuardDuty is itself event-driven and at-least-once.** The argument in §1
- against event pipelines applies to it too. The sweep is what covers its
- misses, which is the reason the sweep exists even on the engine that
- advertises automatic scanning.
+branches on engine. `Enforcement` is where that difference must surface,
+because a Deny statement names a literal key and value.
+
+`Provenance` is new in draft 3 and exists for the audit answer: "scanned with
+what?" deserves a reply. The sweep records the engine and its signature
+database version, so a pass run against three-month-old signatures is visible
+rather than indistinguishable from a current one.
+
+## 4. The engines
+
+### 4.1 `clamavScanner` — primary, shipped in v1
+
+`EnsureEnabled`/`EnsureDisabled` are no-ops; there is nothing on the bucket to
+configure. `Scan` fetches the object, streams it to `clamd`, and writes the
+verdict as a `carlos-scan` object tag. `Verdict` reads it back. `Capacity` is
+uncapped. `Provenance` returns the ClamAV engine and signature-database
+version from clamd's `VERSION` command.
+
+**The read path already exists in this codebase**, which is what makes this the
+cheap option rather than the expensive one. `sizeProvisionedStore`
+(`economicspass.go:990`) already lists a tenant's provisioned bucket directly
+with `objstore.S3ClientFor` — region plus optional endpoint override, no IAM
+involved — from a scheduled console pass. The sweep needs the same client with
+`GetObject` added.
+
+**Transport.** `clamd`'s INSTREAM protocol is unauthenticated, so it is not
+exposed to a network. The console reaches clamd over a **unix socket** on the
+same box (`CARLOS_CLAMD_SOCKET`, default `/run/clamav/clamd.ctl`). A TCP
+option is deliberately not offered in v1: an unauthenticated scanning service
+reachable over a network is a worse problem than the one being solved.
+**(decision)**
+
+**Size ceiling.** clamd's `StreamMaxLength` defaults to 25 MB and objects above
+it cannot be streamed. Such objects record `VerdictSkipped` with the reason,
+and `Sweep.Skipped` counts them — a skipped object is not a clean one and the
+evidence must not blur them. The ceiling is configurable
+(`CARLOS_CLAMD_MAX_BYTES`) and reported by `scan status`, because an auditor
+told "12,000 clean" deserves to know 400 were too large to look at.
+
+**Signature freshness.** `freshclam` keeps the database current and is the
+operator's responsibility. §5 records the version per sweep so a stale
+database is visible in the evidence rather than silently producing clean
+results.
+
+**Egress.** Every unscanned object is fetched once. Same-region reads are free
+on AWS and on Hetzner; a cross-region deployment pays. `scan status` reports
+bytes fetched per sweep.
+
+### 4.2 `guarddutyScanner` — optional, AWS only
+
+`EnsureEnabled` creates a Malware Protection plan with object tagging on;
+`Verdict` reads `GuardDutyMalwareScanStatus`; `EnsureDisabled` deletes the
+plan; `Capacity` reports against the 25-bucket cap.
+
+**Three constraints, all of which the engine must surface rather than hide:**
+
+- **The cap.** `scan grant` counts protected buckets first and refuses at the
+ ceiling with the count and the quota named — "25 of 25 protected in
+ eu-west-1; this is a non-adjustable AWS quota" — never a raw AWS error.
+- **It scans new uploads only.** Objects already present when the plan is
+ created are not scanned by it. `Scan` therefore calls
+ `SendObjectMalwareScan`, the documented on-demand path. On-demand scans are
+ excluded from the free tier, so the sweep pays per unknown object per pass;
+ §7.1's budget bounds that.
+- **Tagging must precede upload, and whether on-demand escapes that is
+ UNVERIFIED.** AWS states: "You must enable this option before an object gets
+ uploaded. If you enable tagging after an object gets uploaded … GuardDuty
+ will not be able to add tags to the scanned object." Every object in an
+ existing store predates the plan. **If on-demand scans of such objects are
+ not tagged, `Verdict` never converges on any existing store and this engine
+ needs an EventBridge consumer reading `statusReasons` instead of tags — a
+ different architecture.** This must be measured against a real bucket before
+ any GuardDuty implementation work starts. It is the first item in §11 and it
+ is a gate, not a risk.
Priced per object evaluated plus per GB scanned ($0.09/GB in us-east-1 after
-the 2025-02 reduction), plus S3 object-tagging cost, billed to the
-deployment's own cloud account. Objects over 100 GB are not scanned. That
-price tag is load-bearing for §6.
-
-### 4.2 `clamavScanner` — specified, not shipped in v1
-
-`EnsureEnabled`/`EnsureDisabled` are no-ops. `Scan` fetches the object,
-streams it to a `clamd` worker, and writes a `carlos-scan` tag. `Verdict`
-reads it back. `Capacity` is uncapped, which is what makes it the answer to
-§2.1.
-
-**It is out of v1 scope, because the backend it exists for does not exist
-yet.** `cmd/console/main.go:497` rejects every `CARLOS_STORE_BACKEND` but
-`"aws"` with a `log.Fatal`, and with `CARLOS_STORE_ENDPOINT` set the IAM half
-is still real AWS (`aws.go:73-75`) writing policies that name
-`arn:aws:s3:::<bucket>`, which a Ceph gateway ignores. Shipping a ClamAV
-engine before a non-AWS provisioner exists would be shipping a wing without an
-aeroplane. **(decision)**
-
-v1 ships the seam, the GuardDuty engine, and a `MemScanner` fake — which is
-precisely the "capability to build upon" that was asked for. The ClamAV engine
-lands with, or after, a Hetzner provisioner.
-
-When it does land, note the honest cost: on that engine CARLOS reads tenant
-data, giving up the property in §2. Operators should be told that in the
-documentation, not left to infer it.
-
-## 5. The record is the compliance artifact
-
-`objstore.Record` gains one additive block. Its shape mirrors the record's
-existing three-actor idiom — separate pointer blocks, each null-or-present —
-rather than draft 1's flat struct with `omitempty` timestamps, which does not
-work: `omitempty` has no effect on a `time.Time`, so a zero time serialises as
-`0001-01-01T00:00:00Z` and "not enabled" becomes indistinguishable from
-"enabled at the zero time" without an `IsZero()` check the JSON does not
-suggest.
+the 2025-02 reduction) plus tagging cost, on the deployment's account. Objects
+over 100 GB are not scanned.
+
+## 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 discipline to survive it.)**
+
+**`store.json` gains a small `scanning` block, written only by the doors:**
```go
type ScanRequested struct {
@@ -218,7 +235,7 @@ type ScanRequested struct {
}
type ScanEnabled struct {
- Engine string `json:"engine"` // "guardduty" | "clamav"
+ Engine string `json:"engine"` // "clamav" | "guardduty"
At time.Time `json:"at"`
By string `json:"by"` // the operator
}
@@ -226,323 +243,302 @@ type ScanEnabled struct {
type ScanBlock struct {
Requested *ScanRequested `json:"requested,omitempty"`
Enabled *ScanEnabled `json:"enabled,omitempty"`
- Sweep *Sweep `json:"sweep,omitempty"`
- Enforce bool `json:"enforce,omitempty"` // §8, default false
+ // Enforce is the deny in force, nil when off. It holds the tag itself,
+ // not a bool: Grant takes a Provisioner and has no Scanner to ask, so
+ // the record must carry 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"`
+ App string `json:"app"`
+ Current *Sweep `json:"current,omitempty"` // in progress
+ LastComplete *Sweep `json:"last_complete,omitempty"` // the audit answer
}
type Sweep struct {
StartedAt time.Time `json:"started_at"`
- At time.Time `json:"at"` // last page written
+ At time.Time `json:"at"`
+ Engine string `json:"engine"`
+ Signature string `json:"signature,omitempty"` // §3 Provenance
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"`
- After string `json:"after,omitempty"` // resume key, "" = complete
+ Bytes int64 `json:"bytes"`
+ After string `json:"after,omitempty"` // resume key
}
```
-Draft 1 reused `*Declared` for the member's request. It carries `EnvPrefix`,
-which is meaningless for scanning and would serialise as `""`; `ScanRequested`
-replaces it.
-
-`Sweep` is the deliverable. `carlos store scan status` renders it as one line:
-
-```
-12,481 objects · 12,479 scanned · 2 skipped · 0 infected · 0 unscanned
-last complete sweep 2026-09-01T14:02Z
-```
-
-A sweep still in progress says so and prints the previous complete one, because
-a partial count is not an audit answer.
-
-**A hazard this spec must not walk into.** `objstore.go` carries a PARSER LIST
-comment: `release.PurgeApp` parses `grant` and `provisioned` out of these bytes
-with its own local struct — release cannot import objstore, because objstore
-imports release — and refuses the box-side auto-purge when either block is
-present. Additive changes are safe; those two names are not free to move. A
-`scanning` key is additive and `trash.go:213-216` ignores unknown keys, so this
-is safe. `TestPurgeGuardSeesOurRecord` pins the two names but never writes a
-`scanning` key, so the additive claim is not currently asserted — §13 adds the
-subtest that asserts it.
-
-## 6. Who turns it on: the same three authors
-
-The store record has three blocks for three actors — the member declares, an
-operator grants, the console records what it provisioned. Scanning mirrors it.
-**(decision, accepted 2026-09-01)**
-
-- **The member requests.** `carlos store scan request` records the ask. It
- spends nothing and provisions nothing.
-- **An operator grants.** `carlos store scan grant` calls out to the cloud
- account and starts a per-GB meter. That is exactly the test `apistore.go`
- already applies: "Granting is the one verb that spends money in the
- DEPLOYMENT's own cloud account, so it needs an address on
- `CARLOS_STORE_OPERATORS` as well." Same gate, same ordering — **after** the
- member gate, so a 403 never confirms to a stranger which accounts and apps
- exist.
-
-Default off at two levels. `CARLOS_STORE_SCANNER` unset means no engine is
-built at boot, no sweep runs, and the **operator** door answers 503 naming the
-variable. And with an engine configured, an app is unscanned until requested
-and granted.
-
-**The member's request door does not 503 when the scanner is unset.** Draft 1
-had it do so; that contradicts the precedent the store's own create door sets
-deliberately (`apistore.go:145-152`): a member cannot see whether their
-deployment has a backend, and refusing them would teach them nothing they can
-act on. The request is recorded either way and `scan status` reports
-`engine: none`, which is the same shape `store create` already uses for a
-deployment with no backend.
-
-## 7. The sweep walk
-
-`Sweep` joins `Grant`, `Rotate` and `Teardown` in `walk.go`, with their
-contract: check-then-act, re-runnable after a failure at any point.
-
-1. Read the record. No `scanning.enabled` — return without touching the
- backend. An ungranted app costs a sweep nothing.
-2. List the bucket page by page with `ListObjectsV2`, resuming with
- `StartAfter` set to `Sweep.After`. **Not a `ContinuationToken`**: draft 1
- cited `EmptyBucket` as precedent for a persisted token, but `EmptyBucket`
- (`aws.go:200-221`) persists nothing — it restarts from the first page and is
- resumable only because deletes are idempotent. A continuation token's
- lifetime across processes is unspecified and, on Ceph, unmeasured. A key is
- a portable resume point.
-3. For each key, ask `Verdict` and count it into the right bucket. If unknown,
- call `Scan`, which asks GuardDuty for an on-demand scan (§4.1). The verdict
- arrives asynchronously; this sweep counts the object as unscanned and the
- next one counts it as scanned. That is the reconciliation loop closing.
-4. **Write the rollup after every page**, carrying `After`. Not once at the
- end: a crash between pages would otherwise lose the whole pass, and a
- resume that re-counted from a stale rollup would double-count. Counters
- reset when a sweep starts fresh (`After == ""`).
-
-**CAS discipline.** Draft 1 said a lost race is the caller's to surface. That
-is right for a single-shot verb and wrong for a multi-minute walk writing once
-per page against a record the create, grant and rotate doors also write — it
-would fail constantly. Sweep owns `scanning.sweep` outright and follows the
-pattern `Rotate` already uses (`walk.go:304-327`): on conflict, re-read, drop
-only its own block onto the fresh record, and retry once. Anything else is the
-caller's.
-
-**Honest semantics, stated because they will be asked about.** A key added
-lexically before the resume point during a sweep is missed by that sweep and
-caught by the next. A key deleted after being counted inflates `Objects` for
-that pass. Neither is a correctness bug in a point-in-time reconciliation, and
-§1's sentence is worded to survive both.
-
-### 7.1 The driver
-
-Draft 1 never said what runs `Sweep`, which left the single most operationally
-consequential question to the implementer.
-
-It runs as a scheduled pass beside `storagePass` (`economicspass.go:136`),
-which already wakes hourly and already iterates every provisioned store — so
-the iteration, the bucket resolution and the cadence all exist. The scan pass
-takes one page per app per tick, so a large bucket makes progress every hour
-without any single tick running long.
-
-It is **not** driven inline from a console door. `Verdict` is one
-`GetObjectTagging` per object; 12,481 objects is 12,481 round trips against a
-door bounded by `storeGrantTimeout` at 60s (`apistore.go:339`). The operator
-verb `scan sweep` starts a pass, or advances it by one page, and says which —
-it never blocks on a whole bucket.
-
-### 7.2 Teardown must disable before it deletes
+**`Current` and `LastComplete` are separate, and this is the fix for draft 2's
+worst bug.** There, one `*Sweep` served both and `After == ""` meant both
+"complete" and "start fresh" — so the tick after a sweep finished overwrote the
+completed counts with page-one partials, destroying the compliance artifact
+every cycle. Now a sweep accumulates in `Current`; on its last page it moves to
+`LastComplete` and `Current` clears. `scan status` reads `LastComplete` for the
+audit line and mentions `Current` only as progress.
+
+The separation also removes all of draft 2's CAS choreography. The sweep is
+the only writer of `scan.json`, so a lost race means another sweep is running
+and this one stands down — it does not merge blocks into a record three doors
+are also writing. `Teardown` deletes the sidecar; the trash purge sweeps it.
+
+**The PARSER LIST hazard, still live.** `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:213-216` ignores unknown keys), so `scanning` is fine, but
+`grant` and `provisioned` must not move. §13 adds the subtest that asserts the
+additive claim, which `TestPurgeGuardSeesOurRecord` does not currently cover.
+
+## 6. Who turns it on
+
+Three actors, mirroring the store's own shape. **(decision, accepted)**
+
+- **The member requests** — `carlos store scan request`. Records the ask,
+ spends nothing, and states what §2.1 says about plaintext and about the
+ deployment reading their objects.
+- **An operator grants** — `carlos store scan grant`. Calls the cloud account
+ and starts a meter, so it sits behind `CARLOS_STORE_OPERATORS`, the same
+ 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 at boot and the operator
+doors 503 naming the variable. The **member's** request door still records —
+following the store 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. `scan status` reports `engine: none`.
+
+**Self-hosters** set `CARLOS_STORE_SCANNER=clamav` and run a clamd unit. That
+is the whole opt-in: no AWS account, no cloud service, no per-GB meter.
+
+## 7. The sweep
+
+1. Read `store.json`. No `scanning.enabled` — return without touching the
+ backend or the sidecar.
+2. Read `scan.json`. Resume from `Current.After` when set, else start a fresh
+ `Current` with zeroed counters.
+3. List a page with `ListObjectsV2` using `StartAfter` — not a
+ `ContinuationToken`, whose lifetime across processes is unspecified and, on
+ Ceph, unmeasured. A key is a portable resume point.
+4. For each key ask `Verdict`, count it, and call `Scan` when unknown.
+5. Write the sidecar with `After` set to the last key of the page. On the final
+ page, move `Current` to `LastComplete` and clear `Current`.
+
+**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, and §1's sentence is worded to survive both.
+
+**Re-sweep interval.** A completed sweep does not immediately restart.
+`CARLOS_STORE_SCAN_INTERVAL` (default 24h) is the floor, so `LastComplete`
+stays stable long enough to be quoted.
+
+### 7.1 The driver, and its budget
+
+Its own goroutine on the `StartInfraPass` pattern (`infrapass.go:49`): run
+once at boot, then hourly, ticking independently. **Not inside
+`economicsPass`** — that pass runs census, statements and billing in one
+goroutine and already worries about a slow step delaying the others.
+
+Draft 2 claimed the scan pass could ride `storagePass`. That was wrong:
+`storagePass` returns at `economicspass.go:138-140` once the day's object
+exists, so it iterates once per UTC day, not hourly. The scan pass iterates
+provisioned-and-enabled stores itself.
+
+**Budget.** One page of 1,000 keys per app per tick would take 42 days for a
+million-object bucket, which would make T in §1 a six-week window.
+`CARLOS_STORE_SCAN_BUDGET` bounds work per app per tick by time (default 60s),
+not by pages, so a fast store completes in one tick and a slow one still
+progresses. `scan status` reports the projected completion when a sweep spans
+more than one tick, because an audit answer six weeks stale should say so.
+
+**Mutual exclusion.** The `scan sweep` verb and the ticker are two drivers of
+one sidecar. The verb advances the sweep synchronously for one budget and
+returns; the ticker skips any app whose `Current.At` is newer than the tick
+started. A sweep is never run concurrently with itself.
+
+### 7.2 Retry guard
+
+If the sidecar write loses a CAS race, re-read and continue **only if**
+`Current` still exists and its `After` matches the value this page started
+from. Otherwise stand down: another driver is ahead.
+
+The guard exists for a specific failure draft 2 would have shipped. A
+conditional write against a key `Teardown` has just deleted is classified as
+`ErrCASConflict` (`release/store.go:521-534`); a blind re-read returns nothing,
+and a blind retry writes create-only — resurrecting a sidecar for an app that
+no longer exists. A sidecar write for a torn-down app must fail, not retry.
+
+### 7.3 Teardown disables first
`Teardown` (`walk.go:390-496`) empties the bucket, deletes it, deletes the
-user, unsets the config and then deletes the record — which is the only place
-`scanning.enabled.engine` is written down. Nothing in draft 1 called
-`EnsureDisabled`, so a torn-down store would leave its Malware Protection plan
-and the managed EventBridge rule behind it alive, counting against the
-25-bucket cap in §2.1, with the record that named them gone. That is precisely
-the read-first-delete-last failure the walk's ordering exists to prevent.
-
-`Teardown` takes a `Scanner` and calls `EnsureDisabled` before `DeleteBucket`.
-A nil scanner with an enabled record **refuses**, the same way a nil
-provisioner already refuses at `walk.go:417` — a teardown that cannot reach the
-scanner must not proceed to delete the only record of what it should have
-disabled. Both destroy callers (`appsettings.go:845`, `ownership.go:417`) pass
-only a `Provisioner` today and must be updated with the signature.
-
-### 7.3 Engine drift
-
-`scanning.enabled.engine` is stamped at grant and the deployment's configured
-engine can change under it. Two cases, both refusals rather than guesses:
-
-- **Record says one engine, deployment now runs another.** The sweep refuses
- for that app and says so; the verdicts in the bucket were written by an
- engine whose tag key the current one does not read, and silently recounting
- them as unscanned would destroy the coverage history. An operator revokes and
- re-grants deliberately.
+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:828-833`) tests only `StoreProvisioner` today and needs the
+matching scanner check, or the refusal surfaces as a generic "Could not
+destroy" flash. Both destroy callers (`appsettings.go:845`,
+`ownership.go:417`) update with the signature.
+
+### 7.4 Engine drift
+
+`scanning.enabled.engine` is stamped at grant and the deployment's engine can
+change under it. Both cases refuse rather than guess:
+
+- **Record names another engine.** The sweep refuses for that app. Verdicts in
+ the bucket were written under a tag key the current engine does not read, and
+ silently recounting them as unscanned would destroy the coverage history. An
+ operator revokes and re-grants deliberately.
- **Engine is `guardduty` and `Deployment.Endpoint != ""`.** GuardDuty cannot
- protect a bucket that is not in S3. This is refused at boot, where a
- misconfiguration is one loud failure rather than a per-app mystery later.
-
-## 8. Enforcement: tags by default, deny behind a per-app flag
-
-Ship verdicts as tags and let apps decide. **(decision)**
-
-`ScanBlock.Enforce`, default false, adds a Deny statement to the app's inline
-policy: deny `s3:GetObject` where `s3:ExistingObjectTag/<tagKey>` equals the
-engine's infected value, both supplied by `Scanner.Enforcement()`. The
-compliance answer does not need it — §1 ruled enforcement strength is not what
-is being asked — and a deny that misfires takes a live app's reads down.
-
-**Draft 1's migration claim was false and is the reason this section changed.**
-It said grant and rotate rewrite the policy unconditionally, so flipping the
-flag would be picked up by the next walk. In fact `PutUserPolicy` is reachable
-only through `EnsureUser` (`aws.go:302`), which is called from exactly one line
-(`walk.go:184`), and `Grant` returns early before it whenever the store is
-healthy (`walk.go:169-177`):
-
-```go
-if rec.Provisioned != nil && rec.Provisioned.Endpoint == d.Endpoint {
- missing, err := EnvDelivered(...)
- if len(missing) == 0 { return rec, nil }
-}
-```
-
-`Rotate` never calls it at all. So on every healthy provisioned store —
-which is all of them — flipping `Enforce` would have changed nothing, forever.
-
-The correction, and it is an interface change: **`EnsureUser` gains the deny
-state.**
-
-```go
-EnsureUser(ctx context.Context, name, path, bucketARN string, deny *DenyTag) (arn string, err error)
-
-type DenyTag struct{ Key, Value string } // nil = no deny statement
-```
-
-One policy writer, not two, so the base policy and the enforced policy cannot
-drift. `Grant` passes the record's current `Enforce` state, so a repair walk
-can never silently drop an operator's enforcement. The scan walk calls
-`EnsureUser` directly when `Enforce` changes — it is idempotent and the user
-already exists. `MemProvisioner` mirrors the signature; every existing caller
-and test updates with it.
-
-**Anti-forgery.** AWS's documented tag-based access control uses a *bucket*
-policy with `NotPrincipal` on the GuardDuty role plus a statement denying
-`s3:PutObjectTagging` on the scan tag key, so a caller cannot mark its own
-object clean. This design's identity-policy deny is a valid and simpler
-variant, and it is safe **only because the app user has no `PutObjectTagging`
-today** (`aws.go:130`). That is now load-bearing: **`s3:PutObjectTagging` must
-never be added to the Allow statement.** §9's widening adds
+ protect a non-S3 bucket. Refused at boot, where it is one loud failure rather
+ than a per-app mystery.
+
+## 8. Enforcement
+
+`ScanBlock.Enforce` — nil by default — adds a Deny statement to the app's
+inline policy: deny `s3:GetObject` where `s3:ExistingObjectTag/<Key>` equals
+`<Value>`. It stores the `DenyTag` itself rather than a bool because `Grant`
+takes a `Provisioner` and has no `Scanner` to ask; the record carries what the
+policy writer needs, and engine drift becomes visible in the record instead of
+being silently recomputed.
+
+**How the policy actually gets rewritten — the bug draft 1 and 2 both had.**
+`PutUserPolicy` is reachable only through `EnsureUser` (`aws.go:302`), called
+from exactly one line (`walk.go:184`), and `Grant` returns early before it for
+any healthy store (`walk.go:169-177`). `Rotate` never writes a policy. So a
+flag flip would have been permanently inert.
+
+**This repo has already fixed this exact bug once, and the fix is the
+precedent to follow.** `internal/mail/walk.go:654-670` (marked "C2 — review
+follow-up") moved `EnsureUser` to run **unconditionally, before the idempotence
+short-circuit**, with a comment explaining 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 current
+ `Enforce`. The short-circuit continues to gate only the expensive mint and
+ deliver, which is what it was for.
+- `scan enforce on|off` writes the record and re-runs the grant walk. One
+ convergence path, no second policy writer.
+- **A nil scanner with `Enforce` set refuses the repair** rather than
+ rewriting the policy without the deny. Dropping enforcement silently is the
+ failure this section exists to prevent.
+
+`MemProvisioner` mirrors the signature and gains policy state, which it lacks
+today (`MemUser` is ARN plus keys), so §13's policy tests have something to
+assert against.
+
+**Anti-forgery.** AWS's documented tag-based access control uses a bucket
+policy denying reads unless the tag says clean, plus a statement stopping a
+caller retagging its own object. This design's identity-policy deny is a
+weaker variant in a way a compliance reader must not be allowed to miss:
+**AWS's blocks unscanned objects; this one blocks only known-infected ones.**
+That is the right trade under §1's ruling, and it must be stated, not implied.
+It is safe only because the app user has no `s3:PutObjectTagging`
+(`aws.go:130`) and therefore cannot mark its own object clean — so
+**`s3:PutObjectTagging` must never be added to the Allow statement.** §9 adds
`s3:GetObjectTagging` and nothing else.
## 9. Surface
-`carlos store` has four sub-verbs — `create`, `status`, `grant`, `rotate`
-(`store.go`). Scanning adds a nested family, reusing the existing verb names
-so the actor of each is obvious from the word:
-
| command | actor | effect |
|---|---|---|
-| `carlos store scan request` | member | records that this app wants scanning |
-| `carlos store scan status` | member | prints the §5 line |
+| `carlos store scan request` | member | records the ask; states §2.1 |
+| `carlos store scan status` | member | the §1 line, plus engine and signature |
| `carlos store scan grant` | operator | enables the engine on the bucket |
-| `carlos store scan revoke` | operator | disables it |
-| `carlos store scan sweep` | operator | starts or advances a sweep |
+| `carlos store scan revoke` | operator | disables it; clears `Enforce` |
+| `carlos store scan sweep` | operator | advances a sweep by one budget |
| `carlos store scan enforce on\|off` | operator | flips the §8 Deny |
-Draft 1 used `enable`/`on` for the member/operator pair, which is opaque about
-who does what; `request`/`grant` matches `create`/`grant` on the store itself.
-
-API mode only, like the rest of `store.go`, for the reason its header gives:
-these verbs drive walks that call out to the deployment's cloud account, an
-authority a headless box cannot exercise.
-
-`store status` gains the scan line, so the existing verb does not become a
-half-truth. `docs/site/cli.md` gains the six verbs under its `### carlos
-store` section (line 393) — the docsite gate only checks top-level headings,
-so this is a house-rule obligation, not something CI will catch.
-
-## 10. The infra half
-
-Belongs in `tito/carlos-infrastructure`:
-
-- **The GuardDuty scanning role.** Enabling a plan requires an IAM role
- GuardDuty assumes — EventBridge managed rule, `GetObject`,
- `PutObjectTagging`, and KMS where the bucket is encrypted — passed at plan
- creation, so the console additionally needs `iam:PassRole` on it. One
- per-deployment role scoped to `arn:aws:s3:::<BucketPrefix>*` covers every
- minted bucket without an edit per app; a role scoped bucket-by-bucket would
- need updating on every `scan grant`.
-- **Console permissions** to create, read and delete Malware Protection plans,
- and to read the resulting tags.
-- **`clamd` placement** — deferred with §4.2, but when it lands: its own unit
- on a box with a current signature database, reached over an authenticated
- transport. `clamd`'s INSTREAM protocol is unauthenticated TCP, so the
- transport is a decision that spec must make, not inherit.
-
-Note the honest correction to draft 1: moving `clamd` out of the console moves
-the *signature database*, not the tenant bytes — the sweep still fetches the
-object into the console process to stream it. Only a design where the worker
-holds its own read credential and the console never sees the bytes would move
-the blast radius, and that is the shape §4.2's spec should choose.
-
-## 11. Risks to verify during implementation
-
-- **GuardDuty tag semantics.** Confirm the tag key, every value it can take,
- and whether skipped, too-large, access-denied and failed objects are tagged
- at all. An untagged object is indistinguishable from an unscanned one, which
- would make `Unscanned` overcount forever. Map every documented status onto
- the §3 verdict set before writing the counter.
-- **`SendObjectMalwareScan` permissions and rate.** The on-demand path in §4.1
- is what closes the coverage gap; confirm the permission it needs, whether it
- is throttled, and what it costs per call, because the sweep may issue one per
- unscanned object.
-- **The bucket cap in practice.** Confirm how `Capacity` can read the current
- protected-bucket count, so §2.1's refusal is real rather than a guess.
-- **Hetzner object tagging** (deferred with §4.2). `s3compat`'s quirks table
- exists because Ceph RGW already differed from S3 once in a way that broke
- every conditional write. Measure `PutObjectTagging`/`GetObjectTagging`
- before relying on them; the fallback is a verdict sidecar object under a
- reserved prefix.
+`request`/`grant` mirrors `create`/`grant` on the store itself, so the actor is
+readable from the verb. 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`), so this is a house-rule obligation, not one CI
+catches.
+
+## 10. The infra half (`tito/carlos-infrastructure`)
+
+- **clamd.** A systemd unit per console box with `freshclam` on a timer, a
+ unix socket the console can reach, and `StreamMaxLength` matched to
+ `CARLOS_CLAMD_MAX_BYTES`. This is the primary path and the only one a
+ self-hoster needs.
+- **Console S3 permissions.** `GetObject` and `PutObjectTagging` on
+ `arn:aws:s3:::<BucketPrefix>*`. It already lists those buckets
+ (`sizeProvisionedStore`); scanning adds read and tag.
+- **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 edit per app.
+
+## 11. Gates and risks
+
+**Gate — measure before building the GuardDuty engine.** Whether
+`SendObjectMalwareScan` tags an object uploaded before tagging was enabled
+(§4.2). If it does not, that engine needs an EventBridge consumer instead of
+tags. This blocks GuardDuty work only; ClamAV is unaffected.
+
+**Risks to verify during implementation:**
+
+- **Hetzner object tagging.** `s3compat`'s quirks table exists because Ceph RGW
+ already differed from S3 once 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 object key and etag.
+- **Verdict mapping.** Map every GuardDuty status — including `ACCESS_DENIED`
+ and `UNSUPPORTED` — onto §3's set before writing the counters, and decide
+ whether `Scan` is re-issued for `failed` and `unknown` on every pass, since
+ on-demand calls are billed each time.
+- **clamd throughput.** One connection per object with a 25 MB ceiling sets the
+ real budget in §7.1. Measure before promising a completion window.
## 12. Out of scope
-- **The ClamAV engine** (§4.2) — specified, deferred until a non-AWS
- provisioner exists.
-- Scanning the deployment's own control-plane bucket, release artifacts, or
- instance replicas. This covers app stores only. Whether the compliance
- question reaches further is for whoever is asking it.
-- Quarantine as object movement. Verdicts are tags; nothing is copied aside or
+- Scanning the deployment's control-plane bucket, release artifacts, or
+ instance replicas. App stores only.
+- 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. The sweep is a reconciler.
-- Notifying members of an infected object. Visible through `scan status`; a
- push path can come later.
+- Scan-on-upload latency guarantees.
+- Notifying members of an infected object. Visible in `scan status`; a push
+ path can come later.
## 13. Testing
-- **Contract test for `Scanner`**, beside `contract_test.go`'s existing one for
- `Provisioner`, run against `MemScanner` and the GuardDuty engine.
- Idempotence is the property: enable twice, disable twice, scan twice.
-- **Sweep resumption.** A sweep interrupted mid-bucket and re-run reaches the
- same rollup without double-counting; a fresh sweep resets counters.
-- **Sweep CAS.** A declare or rotate landing mid-sweep does not fail the sweep,
- and does not lose the other writer's block.
-- **The purge guard, asserted.** A new subtest writes a record carrying a
- `scanning` block and proves `release.PurgeApp` still refuses on `grant`/
- `provisioned` and ignores the new key — the claim §5 makes and
- `TestPurgeGuardSeesOurRecord` does not currently cover.
+- **`Scanner` contract test** over `MemScanner` and each real engine:
+ enable twice, disable twice, scan twice, verdict on an absent object. 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, not an addition to an existing pattern.
+- **Sweep resumption** reaches the same rollup without double-counting; a
+ fresh sweep resets counters.
+- **`LastComplete` survives** the tick after completion — draft 2's bug,
+ asserted.
+- **Retry guard** stands down rather than resurrecting a sidecar for a
+ torn-down app.
+- **Purge guard, additive.** A record carrying `scanning` still makes
+ `release.PurgeApp` refuse on `grant`/`provisioned` and ignore the new key.
- **Policy round-trip.** `EnsureUser` with `deny == nil` produces today's exact
- two-statement document, byte for byte, so enforcement-off stores are
- unchanged; with a `DenyTag` it produces three statements and no
- `PutObjectTagging`.
-- **Enforcement actually applies.** Flipping `Enforce` on a healthy provisioned
- store changes the live policy — the regression draft 1 would have shipped.
-- **Cap refusal.** At the quota, `scan grant` refuses with the count and the
- cap, and enables nothing.
-- **Teardown disables first.** A torn-down store with scanning granted calls
- `EnsureDisabled` before `DeleteBucket`, and a nil scanner with an enabled
- record refuses the same way a nil provisioner does (`walk.go:417`).
-- **Gate order.** A non-operator hitting the operator door gets the grant
- door's byte-identical posture, member gate first.
-- **Inert deployment.** With `CARLOS_STORE_SCANNER` unset the operator doors
- 503 naming the variable, the member request door still records, and no sweep
- touches a backend.
+ two-statement document byte for byte; with a `DenyTag`, three statements and
+ no `PutObjectTagging`.
+- **Enforcement converges.** Flipping `Enforce` on a healthy provisioned store
+ changes the live policy — the regression drafts 1 and 2 would have shipped —
+ and a nil scanner with `Enforce` set refuses.
+- **Teardown disables first**, deletes the sidecar, and refuses on a nil
+ scanner with scanning enabled.
+- **Gate order and inert deployment.** Operator doors 503 with
+ `CARLOS_STORE_SCANNER` unset; the member request door still records.
+- **Skipped is not clean.** An object over the ceiling counts in `Skipped` and
+ `scan status` shows it separately.