carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# Object Storage Virus Scanning Implementation Plan
2
3> **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.
4
5**Goal:** Give each app's object store an opt-in, reconciling virus scanner that can answer "how many objects carry no recorded verdict", with ClamAV as the shipped engine.
6
7**Architecture:** A `Scanner` seam beside the existing `Provisioner`, an idempotent `Sweep` walk that lists a bucket and records verdicts as S3 object tags bound to the object's ETag, a per-app `scan.json` sidecar holding the coverage rollup, and an hourly console pass driving it. Enforcement reuses the app's inline IAM policy via a deny-on-tag statement.
8
9**Tech Stack:** Go (stdlib first — no new dependencies), AWS SDK v2 S3, `clamd` over a unix socket using its INSTREAM protocol (hand-rolled, per the house "hand-roll small clients over SDKs" rule).
10
11**Spec:** `docs/superpowers/specs/2026-09-01-object-storage-virus-scanning-design.md`
12
13## Global Constraints
14
15- `go build ./...`, `go vet ./...`, `go test ./... -count=1` clean before any commit lands. `gofmt -l .` clean over the whole tree (`make test` checks it).
16- Every change lands through a PR. Never a direct commit to `main`, never a local merge.
17- No new third-party dependencies. "Adding a dependency is a decision, not a default." The clamd client is hand-rolled.
18- **`grant` and `provisioned` in `store.json` must not be renamed or restructured.** `release.PurgeApp` parses them with its own local struct and refuses box-side auto-purge when either is present. Additive keys only.
19- **`s3:PutObjectTagging` must never be added to the app user's Allow statement.** The anti-forgery property in spec §8 depends on the app being unable to tag its own objects.
20- Default off: `CARLOS_STORE_SCANNER` unset means no engine is built and the feature is inert.
21- Secrets never enter the record. Nothing in this plan writes a credential to `store.json` or `scan.json`.
22- The scan pass goroutine starts from `serve()` in `cmd/console/main.go`, **never** from `NewServer` (`economicspass.go:35-38`).
23- Verdicts are always bound to an ETag. A verdict recorded against a different ETag reads as `VerdictUnknown`.
24
25---
26
27## File Structure
28
29**Created:**
30- `internal/objstore/scanner.go` — the `Scanner` seam, `Verdict`, `DenyTag`.
31- `internal/objstore/memscanner.go` — in-memory fake, mirroring `memprovisioner.go`.
32- `internal/objstore/scanner_contract_test.go` — the idempotence contract both engines must pass.
33- `internal/objstore/sidecar.go` — `ScanSidecar`, `Sweep`, its key, its reader/writer.
34- `internal/objstore/sweep.go` — the `Sweep` walk.
35- `internal/clamd/clamd.go` — the INSTREAM/VERSION client. Its own package: a wire protocol with no CARLOS concepts in it, testable over a `net.Pipe`.
36- `internal/objstore/clamav.go` — `clamavScanner`, joining the clamd client to S3.
37- `internal/console/scanpass.go` — the hourly driver.
38- `internal/console/apiscan.go` — the console doors.
39- `scan.go` — the `carlos store scan` CLI family.
40
41**Modified:**
42- `internal/objstore/objstore.go` — `ScanBlock` on `Record`.
43- `internal/objstore/provisioner.go` — `EnsureUser` gains `deny *DenyTag`.
44- `internal/objstore/aws.go` — `policyStatement.Condition`, `storePolicyJSON` deny, `EnsureUser` signature.
45- `internal/objstore/memprovisioner.go` — signature plus policy state.
46- `internal/objstore/walk.go` — hoist `EnsureUser` in `Grant`; `Teardown` takes a `Scanner`.
47- `internal/console/appsettings.go`, `internal/console/ownership.go` — `Teardown` callers and the destroy pre-check.
48- `cmd/console/main.go` — build the scanner, start the pass.
49- `store.go` — dispatch `scan`.
50- `docs/site/cli.md` — the six sub-verbs.
51
52---
53
54### Task 1: The Scanner seam and its fake
55
56**Files:**
57- Create: `internal/objstore/scanner.go`
58- Create: `internal/objstore/memscanner.go`
59- Test: `internal/objstore/scanner_contract_test.go`
60
61**Interfaces:**
62- Consumes: nothing.
63- Produces: `Scanner`, `Verdict` (+ the six constants), `DenyTag{Key, Value string}`, `MemScanner` with `NewMemScanner() *MemScanner`, `Fail(method string, err error)` mirroring `MemProvisioner.takeFailure`, `Put(bucket, key, etag string, v Verdict)`, and `IsEnabled(bucket string) bool` (Task 6 asserts on it).
64
65- [ ] **Step 1: Write the failing contract test**
66
67```go
68package objstore
69
70import (
71 "context"
72 "testing"
73)
74
75// scannerContract is run against every Scanner implementation. Idempotence is
76// the property: every method is called twice and the second call must be a
77// success, because the sweep re-runs after a half-failure.
78func scannerContract(t *testing.T, s Scanner, bucket string) {
79 t.Helper()
80 ctx := context.Background()
81
82 for i := 0; i < 2; i++ {
83 if err := s.EnsureEnabled(ctx, bucket); err != nil {
84 t.Fatalf("EnsureEnabled call %d: %v", i+1, err)
85 }
86 }
87 // An object nobody has scanned has no verdict, and that is a state.
88 v, err := s.Verdict(ctx, bucket, "absent.txt", "etag-1")
89 if err != nil {
90 t.Fatalf("Verdict on absent object: %v", err)
91 }
92 if v != VerdictUnknown {
93 t.Fatalf("Verdict on absent object = %q, want %q", v, VerdictUnknown)
94 }
95 // Scanning twice is a success: the sweep re-runs after a half-failure.
96 for i := 0; i < 2; i++ {
97 if _, err := s.Scan(ctx, bucket, "a.txt", "etag-1", 10); err != nil {
98 t.Fatalf("Scan call %d: %v", i+1, err)
99 }
100 }
101 // A verdict is bound to the bytes it judged: the same key at a different
102 // etag has no verdict, so the sweep rescans rather than inheriting one.
103 if v, err := s.Verdict(ctx, bucket, "a.txt", "etag-2"); err != nil || v != VerdictUnknown {
104 t.Fatalf("changed etag = (%q, %v), want (unknown, nil)", v, err)
105 }
106 for i := 0; i < 2; i++ {
107 if err := s.EnsureDisabled(ctx, bucket); err != nil {
108 t.Fatalf("EnsureDisabled call %d: %v", i+1, err)
109 }
110 }
111}
112
113func TestMemScannerContract(t *testing.T) {
114 scannerContract(t, NewMemScanner(), "carlos-store-tito-bab-app")
115}
116
117// A verdict is bound to the bytes it judged: the same key at a different etag
118// has no verdict, so the sweep rescans it (spec §3.1).
119func TestMemScannerVerdictIsETagBound(t *testing.T) {
120 ctx := context.Background()
121 s := NewMemScanner()
122 s.Put("b", "k", "etag-1", VerdictClean)
123
124 if v, _ := s.Verdict(ctx, "b", "k", "etag-1"); v != VerdictClean {
125 t.Fatalf("same etag = %q, want %q", v, VerdictClean)
126 }
127 if v, _ := s.Verdict(ctx, "b", "k", "etag-2"); v != VerdictUnknown {
128 t.Fatalf("changed etag = %q, want %q", v, VerdictUnknown)
129 }
130}
131```
132
133- [ ] **Step 2: Run it to verify it fails**
134
135Run: `go test ./internal/objstore/ -run 'TestMemScanner' -count=1`
136Expected: FAIL — `undefined: Scanner`, `undefined: NewMemScanner`.
137
138- [ ] **Step 3: Write `scanner.go`**
139
140```go
141// Package-level doc lives in objstore.go. This file is the scanning-engine
142// seam (spec §3) — everything the sweep needs an engine to do, and nothing an
143// app ever calls.
144package objstore
145
146import "context"
147
148// Scanner is the scanning-engine seam. Like Provisioner, every method is
149// idempotent — "already enabled" and "already gone" are successes — because
150// the sweep walk that drives one must be re-runnable after a half-failure.
151//
152// v1 ships the ClamAV implementation and this package's in-memory fake. A
153// GuardDuty scanner arrives here later without touching the sweep, once the
154// tagging question in spec §4.2 has been measured.
155type Scanner interface {
156 EnsureEnabled(ctx context.Context, bucket string) error
157 // EnsureDisabled releases whatever EnsureEnabled took. Teardown calls it
158 // BEFORE deleting the bucket, or the backend estate outlives the record
159 // that names it (spec §7.3).
160 EnsureDisabled(ctx context.Context, bucket string) error
161 // Verdict answers the recorded verdict for one object AT etag. A verdict
162 // recorded against a different etag is (VerdictUnknown, nil): the object
163 // has been rewritten since it was judged, and bytes nobody looked at have
164 // no verdict. Absent is (VerdictUnknown, nil) too — the same idiom Get
165 // uses for an absent record, because "not scanned" is a state.
166 Verdict(ctx context.Context, bucket, key, etag string) (Verdict, error)
167 // Scan scans one object and records its verdict against etag. It returns
168 // the verdict when the engine is synchronous (ClamAV) and VerdictUnknown
169 // when the engine scans asynchronously (GuardDuty) — the sweep counts
170 // what it gets back, so a synchronous engine's first pass is not a pass
171 // full of "unscanned".
172 //
173 // size comes from the listing page the sweep is already holding. It is
174 // here so an engine can refuse an over-ceiling object WITHOUT fetching it:
175 // paying egress for bytes clamd will reject is pure waste, and the
176 // alternative — a HeadObject per object per sweep — is a round trip on
177 // every object forever. This is a deliberate widening of the seam beyond
178 // spec §3, which omitted it and left the size check unimplementable.
179 Scan(ctx context.Context, bucket, key, etag string, size int64) (Verdict, error)
180 // Enforcement names the tag this engine writes, so the IAM policy writer
181 // can deny reads on it. Engine-specific by necessity: a Deny statement
182 // must name a literal key and value.
183 Enforcement() DenyTag
184 // Capacity reports how many buckets this engine can still protect and the
185 // cap; (-1, -1) is uncapped. ClamAV is uncapped; GuardDuty caps at 25 per
186 // account per region, which is why it is not the default engine.
187 Capacity(ctx context.Context) (remaining, cap int, err error)
188 // Provenance names what did the scanning — engine and signature version —
189 // for the evidence record. "Scanned with what?" is an audit question.
190 Provenance(ctx context.Context) (engine, version string, err error)
191}
192
193// Verdict is one object's scan result. The zero value is "no recorded
194// verdict", which is a state and not an error.
195type Verdict string
196
197const (
198 VerdictUnknown Verdict = ""
199 VerdictClean Verdict = "clean"
200 VerdictInfected Verdict = "infected"
201 // VerdictSkipped is too large, unsupported, or unextractable. It is NOT
202 // clean, and the evidence must never blur the two.
203 VerdictSkipped Verdict = "skipped"
204 VerdictDenied Verdict = "denied"
205 VerdictFailed Verdict = "failed"
206)
207
208// DenyTag is the object tag an engine writes for an infected object, and the
209// literal a Deny statement matches on (spec §8). It lives in the record
210// rather than being asked of a Scanner, because Grant takes a Provisioner and
211// has no Scanner to ask.
212type DenyTag struct {
213 Key string `json:"key"`
214 Value string `json:"value"`
215}
216
217// Tag keys the ClamAV engine writes. The etag tag is what binds a verdict to
218// the bytes it judged.
219const (
220 TagVerdict = "carlos-scan"
221 TagETag = "carlos-scan-version"
222 TagAt = "carlos-scan-at"
223)
224```
225
226- [ ] **Step 4: Write `memscanner.go`**
227
228```go
229package objstore
230
231import (
232 "context"
233 "sync"
234)
235
236// MemScanner is the in-memory Scanner every walk test runs against, mirroring
237// MemProvisioner: no network, no clamd, and a failure hook so a test can make
238// any single method fail once and prove the walk survives it.
239type MemScanner struct {
240 mu sync.Mutex
241 enabled map[string]bool
242 verdicts map[string]scanRecord // bucket + "\x00" + key
243 failures map[string]error
244}
245
246type scanRecord struct {
247 etag string
248 verdict Verdict
249}
250
251func NewMemScanner() *MemScanner {
252 return &MemScanner{
253 enabled: map[string]bool{},
254 verdicts: map[string]scanRecord{},
255 failures: map[string]error{},
256 }
257}
258
259// Fail arms a one-shot failure for the named method.
260func (s *MemScanner) Fail(method string, err error) {
261 s.mu.Lock()
262 defer s.mu.Unlock()
263 s.failures[method] = err
264}
265
266// Put seeds a verdict, so a test can start from a partly-scanned bucket.
267func (s *MemScanner) Put(bucket, key, etag string, v Verdict) {
268 s.mu.Lock()
269 defer s.mu.Unlock()
270 s.verdicts[bucket+"\x00"+key] = scanRecord{etag: etag, verdict: v}
271}
272
273func (s *MemScanner) takeFailure(method string) error {
274 if err, ok := s.failures[method]; ok {
275 delete(s.failures, method)
276 return err
277 }
278 return nil
279}
280
281func (s *MemScanner) EnsureEnabled(ctx context.Context, bucket string) error {
282 s.mu.Lock()
283 defer s.mu.Unlock()
284 if err := s.takeFailure("EnsureEnabled"); err != nil {
285 return err
286 }
287 s.enabled[bucket] = true // already enabled is a success
288 return nil
289}
290
291func (s *MemScanner) EnsureDisabled(ctx context.Context, bucket string) error {
292 s.mu.Lock()
293 defer s.mu.Unlock()
294 if err := s.takeFailure("EnsureDisabled"); err != nil {
295 return err
296 }
297 delete(s.enabled, bucket) // already gone is a success
298 return nil
299}
300
301func (s *MemScanner) Verdict(ctx context.Context, bucket, key, etag string) (Verdict, error) {
302 s.mu.Lock()
303 defer s.mu.Unlock()
304 if err := s.takeFailure("Verdict"); err != nil {
305 return VerdictUnknown, err
306 }
307 rec, ok := s.verdicts[bucket+"\x00"+key]
308 if !ok || rec.etag != etag {
309 return VerdictUnknown, nil
310 }
311 return rec.verdict, nil
312}
313
314func (s *MemScanner) Scan(ctx context.Context, bucket, key, etag string, size int64) (Verdict, error) {
315 s.mu.Lock()
316 defer s.mu.Unlock()
317 if err := s.takeFailure("Scan"); err != nil {
318 return VerdictUnknown, err
319 }
320 s.verdicts[bucket+"\x00"+key] = scanRecord{etag: etag, verdict: VerdictClean}
321 return VerdictClean, nil
322}
323
324// IsEnabled lets Teardown's test prove the scanner was disabled before the
325// bucket went.
326func (s *MemScanner) IsEnabled(bucket string) bool {
327 s.mu.Lock()
328 defer s.mu.Unlock()
329 return s.enabled[bucket]
330}
331
332func (s *MemScanner) Enforcement() DenyTag {
333 return DenyTag{Key: TagVerdict, Value: string(VerdictInfected)}
334}
335
336func (s *MemScanner) Capacity(ctx context.Context) (int, int, error) { return -1, -1, nil }
337
338// Provenance answers "clamav" rather than "memscanner" ON PURPOSE. The sweep
339// refuses when ScanEnabled.Engine disagrees with the running engine (spec
340// §7.4), and every walk fixture writes Engine: "clamav" — a fake that named
341// itself would make every one of those fixtures refuse instead of sweep.
342func (s *MemScanner) Provenance(ctx context.Context) (string, string, error) {
343 return "clamav", "MemScanner/0/never", nil
344}
345
346var _ Scanner = (*MemScanner)(nil)
347```
348
349- [ ] **Step 5: Run the tests to verify they pass**
350
351Run: `go test ./internal/objstore/ -run 'TestMemScanner' -count=1 -v`
352Expected: PASS, both tests.
353
354- [ ] **Step 6: Commit**
355
356```bash
357git add internal/objstore/scanner.go internal/objstore/memscanner.go internal/objstore/scanner_contract_test.go
358git commit -m "objstore: the Scanner seam and its in-memory fake"
359```
360
361---
362
363### Task 2: The record's scanning block
364
365**Files:**
366- Modify: `internal/objstore/objstore.go`
367- Test: `internal/objstore/objstore_test.go`, `internal/objstore/walk_test.go`
368
369**Interfaces:**
370- Consumes: `DenyTag` (Task 1).
371- Produces: `ScanRequested{At time.Time, By string, Encrypted bool}`, `ScanEnabled{Engine string, At time.Time, By string}`, `ScanBlock{Requested *ScanRequested, Enabled *ScanEnabled, Enforce *DenyTag}`, and `Record.Scanning *ScanBlock`.
372
373- [ ] **Step 1: Write the failing tests**
374
375Add to `internal/objstore/objstore_test.go`:
376
377```go
378// The scanning block round-trips, and its three sub-blocks are null-or-present
379// so a client can tell "requested" from "enabled" without inspecting fields.
380func TestScanBlockRoundTrip(t *testing.T) {
381 now := time.Now().UTC().Truncate(time.Second)
382 rec := &Record{
383 App: "titogo",
384 Scanning: &ScanBlock{
385 Requested: &ScanRequested{At: now, By: "m@example.com", Encrypted: false},
386 Enabled: &ScanEnabled{Engine: "clamav", At: now, By: "op@example.com"},
387 Enforce: &DenyTag{Key: TagVerdict, Value: string(VerdictInfected)},
388 },
389 }
390 body, err := json.Marshal(rec)
391 if err != nil {
392 t.Fatal(err)
393 }
394 var back Record
395 if err := json.Unmarshal(body, &back); err != nil {
396 t.Fatal(err)
397 }
398 if back.Scanning == nil || back.Scanning.Enabled == nil {
399 t.Fatal("scanning.enabled did not round-trip")
400 }
401 if back.Scanning.Enabled.Engine != "clamav" {
402 t.Fatalf("engine = %q, want clamav", back.Scanning.Enabled.Engine)
403 }
404 if back.Scanning.Enforce == nil || back.Scanning.Enforce.Value != "infected" {
405 t.Fatal("enforce did not round-trip")
406 }
407}
408
409// An absent scanning block stays absent in the JSON — a deployment that never
410// used scanning must not grow a null key in every store record.
411func TestScanBlockOmittedWhenAbsent(t *testing.T) {
412 body, err := json.Marshal(&Record{App: "titogo"})
413 if err != nil {
414 t.Fatal(err)
415 }
416 if bytes.Contains(body, []byte("scanning")) {
417 t.Fatalf("absent scanning block leaked into JSON: %s", body)
418 }
419}
420```
421
422Add to `internal/objstore/walk_test.go`, as subtests in the style of the existing `TestPurgeGuardSeesOurRecord` (`walk_test.go:1338`) — copy its `declared-only-purges` subtest as the template:
423
424```go
425// The scanning block is ADDITIVE: release.PurgeApp parses this shape with its
426// own local struct (it cannot import objstore) and must keep refusing on
427// grant/provisioned while ignoring keys it has never heard of. objstore.go's
428// PARSER LIST comment claims this; without this test the claim is unasserted.
429//
430// Driven through release.PurgeApp itself, NOT through an extracted predicate:
431// the guard is inline in PurgeApp (trash.go:202-226), and pulling it out to
432// make it callable would refactor the fleet-wide purge path for a test.
433func TestPurgeGuardIgnoresScanningBlock(t *testing.T) {
434 t.Run("grant plus scanning still blocks", func(t *testing.T) {
435 // ... write store.json with both blocks, TrashApp backdated, then:
436 // err := release.PurgeApp(...); want errors.Is(err, release.ErrStoreProvisioned)
437 })
438 t.Run("scanning alone names no estate and purges", func(t *testing.T) {
439 // ... write store.json with only `scanning`, TrashApp backdated, then:
440 // PurgeApp returns nil and store.json is gone.
441 })
442}
443```
444
445> **Do not create `release.StoreRecordBlocksPurge`.** It does not exist, and
446> the guard it would wrap is inline in `PurgeApp`. Read
447> `TestPurgeGuardSeesOurRecord` first and reuse its exact setup — it already
448> does the `Put` / `TrashApp` (backdated) / `PurgeApp` dance and knows the
449> real argument lists. `internal/release/trash.go` is **not** modified by this
450> task; drop it from the commit.
451>
452> `objstore_test.go` currently imports only `context`, `errors`, `testing` and
453> `release`. The two new round-trip tests need `bytes`, `encoding/json` and
454> `time`.
455
456- [ ] **Step 2: Run to verify they fail**
457
458Run: `go test ./internal/objstore/ -run 'TestScanBlock|TestPurgeGuardIgnores' -count=1`
459Expected: FAIL — `unknown field Scanning` on the round-trip tests.
460
461- [ ] **Step 3: Add the types to `objstore.go`**
462
463```go
464// ScanRequested is the MEMBER's half of scanning: they asked for it, and they
465// attested what kind of objects the store holds. Encrypted means client-side
466// encrypted — the objects reach the store as ciphertext, so a scanner cannot
467// read them and no coverage is claimed (spec §2.2). Server-side encryption
468// (SSE-S3/SSE-KMS) is NOT this: it is transparent to a credentialled reader
469// and scans perfectly well.
470type ScanRequested struct {
471 At time.Time `json:"at"`
472 By string `json:"by"`
473 Encrypted bool `json:"encrypted"`
474}
475
476// ScanEnabled is the OPERATOR's half: enabling spends money in the
477// deployment's own cloud account, so it sits behind CARLOS_STORE_OPERATORS
478// exactly as the store grant does.
479type ScanEnabled struct {
480 Engine string `json:"engine"` // "clamav" | "guardduty"
481 At time.Time `json:"at"`
482 By string `json:"by"`
483}
484
485// ScanBlock is the scanning half of the record — the same three-author shape
486// the store itself has. It holds NO rollup: sweep counts live in the
487// scan.json sidecar, whose single writer is the sweep, so a multi-minute walk
488// never contends with the doors that write this object.
489//
490// Enforce holds the DenyTag ITSELF rather than a bool. Grant takes a
491// Provisioner and has no Scanner to ask, so the record must carry everything
492// the policy writer needs — which is also what lets `scan enforce off` work on
493// a deployment with no engine built.
494type ScanBlock struct {
495 Requested *ScanRequested `json:"requested,omitempty"`
496 Enabled *ScanEnabled `json:"enabled,omitempty"`
497 Enforce *DenyTag `json:"enforce,omitempty"`
498}
499```
500
501Add the field to `Record`, after `Provisioned`:
502
503```go
504 Scanning *ScanBlock `json:"scanning,omitempty"`
505```
506
507Extend the PARSER LIST comment on `Record` with one sentence:
508
509```go
510// `scanning` is additive and release's local struct ignores it, which
511// TestPurgeGuardIgnoresScanningBlock asserts. That freedom does NOT extend to
512// `grant` and `provisioned`.
513```
514
515- [ ] **Step 4: Run to verify they pass**
516
517Run: `go test ./internal/objstore/ ./internal/release/ -count=1`
518Expected: PASS, including the pre-existing `TestPurgeGuardSeesOurRecord`.
519
520- [ ] **Step 5: Commit**
521
522```bash
523git add internal/objstore/objstore.go internal/objstore/objstore_test.go internal/objstore/walk_test.go
524git commit -m "objstore: the record's scanning block, and the additive-purge claim asserted"
525```
526
527---
528
529### Task 3: The scan sidecar
530
531**Files:**
532- Create: `internal/objstore/sidecar.go`
533- Test: `internal/objstore/sidecar_test.go`
534
535**Interfaces:**
536- Consumes: `release.Store`, `release.AppPrefix`.
537- Produces: `ScanKey(acct, app) string`, `ScanSidecar`, `Sweep`, `GetSidecar(ctx, s, acct, app) (*ScanSidecar, string, error)`, `PutSidecar(ctx, s, acct, app, sc, etag) (string, error)`, `ErrSidecarMismatch`, `SidecarSchema`.
538
539- [ ] **Step 1: Write the failing tests**
540
541```go
542package objstore
543
544import (
545 "context"
546 "errors"
547 "testing"
548
549 "github.com/carlosframework/platform/internal/release"
550)
551
552func TestSidecarRoundTrip(t *testing.T) {
553 ctx := context.Background()
554 st := release.NewDirStore(t.TempDir()) // release/store.go:204 — what walk_test.go uses
555 sc := &ScanSidecar{
556 Account: "bab",
557 App: "titogo",
558 LastComplete: &Sweep{Objects: 12481, Scanned: 12479, Skipped: 2, Engine: "clamav"},
559 }
560 if _, err := PutSidecar(ctx, st, "bab", "titogo", sc, ""); err != nil {
561 t.Fatal(err)
562 }
563 back, _, err := GetSidecar(ctx, st, "bab", "titogo")
564 if err != nil {
565 t.Fatal(err)
566 }
567 if back.LastComplete == nil || back.LastComplete.Objects != 12481 {
568 t.Fatalf("rollup did not round-trip: %+v", back)
569 }
570 if back.Schema != SidecarSchema {
571 t.Fatalf("schema = %d, want %d", back.Schema, SidecarSchema)
572 }
573}
574
575// Absent is (nil, "", nil) — no sidecar is a state, not an error, the same
576// idiom Get uses for an absent record.
577func TestSidecarAbsentIsNotAnError(t *testing.T) {
578 sc, etag, err := GetSidecar(context.Background(), release.NewDirStore(t.TempDir()), "bab", "titogo")
579 if err != nil || sc != nil || etag != "" {
580 t.Fatalf("absent sidecar = (%v, %q, %v), want (nil, \"\", nil)", sc, etag, err)
581 }
582}
583
584// accounts migrate copies every key under an app's prefix, so a sidecar can
585// arrive under an account it was not written for, carrying rollups for a
586// bucket named after the old one. The reader refuses rather than reporting
587// another account's coverage as this one's.
588func TestSidecarRefusesAccountMismatch(t *testing.T) {
589 ctx := context.Background()
590 st := release.NewDirStore(t.TempDir())
591 // Written as bab/titogo, then read as zed/titogo.
592 if _, err := PutSidecar(ctx, st, "bab", "titogo",
593 &ScanSidecar{Account: "bab", App: "titogo"}, ""); err != nil {
594 t.Fatal(err)
595 }
596 raw, _, err := st.Get(ctx, ScanKey("bab", "titogo"))
597 if err != nil {
598 t.Fatal(err)
599 }
600 if _, err := st.Put(ctx, ScanKey("zed", "titogo"), raw, ""); err != nil {
601 t.Fatal(err)
602 }
603 if _, _, err := GetSidecar(ctx, st, "zed", "titogo"); !errors.Is(err, ErrSidecarMismatch) {
604 t.Fatalf("mismatched sidecar error = %v, want ErrSidecarMismatch", err)
605 }
606}
607
608// An unknown schema is refused, not guessed at — the rule ReadAppTombstone
609// already applies.
610func TestSidecarRefusesUnknownSchema(t *testing.T) {
611 ctx := context.Background()
612 st := release.NewDirStore(t.TempDir())
613 if _, err := st.Put(ctx, ScanKey("bab", "titogo"),
614 []byte(`{"schema":99,"account":"bab","app":"titogo"}`), ""); err != nil {
615 t.Fatal(err)
616 }
617 if _, _, err := GetSidecar(ctx, st, "bab", "titogo"); err == nil {
618 t.Fatal("an unknown schema must be refused")
619 }
620}
621```
622
623- [ ] **Step 2: Run to verify they fail**
624
625Run: `go test ./internal/objstore/ -run TestSidecar -count=1`
626Expected: FAIL — `undefined: ScanSidecar`.
627
628- [ ] **Step 3: Write `sidecar.go`**
629
630```go
631// The scan sidecar (spec §5): apps/<acct>/<app>/scan.json, the coverage
632// rollup and the compliance artifact.
633//
634// It is a SEPARATE OBJECT from store.json on purpose. The sweep writes once
635// per page over minutes; the create, grant and rotate doors write store.json
636// too. Sharing one object would mean a page-write racing a repair walk and
637// losing — so the rollup gets its own object with a single writer, and the
638// CAS discipline shrinks to "another sweep is ahead of me, stand down".
639package objstore
640
641import (
642 "context"
643 "encoding/json"
644 "errors"
645 "fmt"
646 "os"
647 "time"
648
649 "github.com/carlosframework/platform/internal/release"
650)
651
652const SidecarSchema = 1
653
654// ErrSidecarMismatch is a sidecar whose body disagrees with its path.
655var ErrSidecarMismatch = errors.New("objstore: scan sidecar names another app")
656
657func ScanKey(acct, app string) string { return release.AppPrefix(acct, app) + "scan.json" }
658
659// Sweep is one reconciliation pass. Objects is what was listed; the six
660// verdict counters are what was found. Scanned and Unscanned are NOT
661// complements: Skipped, Denied and Failed are recorded verdicts that are not
662// coverage, which is why the audit sentence names two numbers.
663type Sweep struct {
664 StartedAt time.Time `json:"started_at"`
665 At time.Time `json:"at"`
666 Engine string `json:"engine"`
667 Signature string `json:"signature,omitempty"`
668 Objects int64 `json:"objects"`
669 Scanned int64 `json:"scanned"`
670 Infected int64 `json:"infected"`
671 Skipped int64 `json:"skipped"`
672 Denied int64 `json:"denied"`
673 Failed int64 `json:"failed"`
674 Unscanned int64 `json:"unscanned"`
675 Bytes int64 `json:"bytes"`
676 // After is the last key of the last written page — the resume point. Empty
677 // means this Sweep is not mid-flight.
678 After string `json:"after,omitempty"`
679}
680
681// ScanSidecar holds the in-flight sweep and the last completed one.
682//
683// TWO SWEEPS, NOT ONE. A single field would have to mean both "in progress"
684// and "the answer", and the tick after a sweep completed would overwrite the
685// completed counts with page-one partials — destroying the compliance artifact
686// on every cycle. Current accumulates; on the last page it moves to
687// LastComplete, which is the only thing an audit answer ever quotes.
688type ScanSidecar struct {
689 Schema int `json:"schema"`
690 Account string `json:"account"`
691 App string `json:"app"`
692 // NotApplicable is a store whose member attested client-side encryption:
693 // a scanner cannot read ciphertext, so no sweep runs and no coverage is
694 // claimed (spec §2.2).
695 NotApplicable bool `json:"not_applicable,omitempty"`
696 Current *Sweep `json:"current,omitempty"`
697 LastComplete *Sweep `json:"last_complete,omitempty"`
698}
699
700// GetSidecar reads the sidecar. Absent is (nil, "", nil).
701func GetSidecar(ctx context.Context, s release.Store, acct, app string) (*ScanSidecar, string, error) {
702 if !release.ValidAccount(acct) {
703 return nil, "", fmt.Errorf("objstore: invalid account %q", acct)
704 }
705 if !release.ValidApp(app) {
706 return nil, "", fmt.Errorf("objstore: invalid app %q", app)
707 }
708 raw, etag, err := s.Get(ctx, ScanKey(acct, app))
709 if err != nil {
710 if errors.Is(err, os.ErrNotExist) {
711 return nil, "", nil
712 }
713 return nil, "", err
714 }
715 var sc ScanSidecar
716 if err := json.Unmarshal(raw, &sc); err != nil {
717 return nil, "", fmt.Errorf("objstore: %s: %w", ScanKey(acct, app), err)
718 }
719 if sc.Schema != SidecarSchema {
720 return nil, "", fmt.Errorf("objstore: %s: schema %d is not %d",
721 ScanKey(acct, app), sc.Schema, SidecarSchema)
722 }
723 // accounts migrate copies every key under the prefix, so a sidecar can
724 // arrive under an account it was never written for.
725 if sc.Account != acct || sc.App != app {
726 return nil, "", fmt.Errorf("%w: body says %s/%s, path says %s/%s",
727 ErrSidecarMismatch, sc.Account, sc.App, acct, app)
728 }
729 return &sc, etag, nil
730}
731
732// PutSidecar writes with the caller's CAS position; etag "" is create-only.
733func PutSidecar(ctx context.Context, s release.Store, acct, app string, sc *ScanSidecar, etag string) (string, error) {
734 sc.Schema = SidecarSchema
735 sc.Account = acct
736 sc.App = app
737 body, err := json.MarshalIndent(sc, "", " ")
738 if err != nil {
739 return "", err
740 }
741 return s.Put(ctx, ScanKey(acct, app), append(body, '\n'), etag)
742}
743```
744
745- [ ] **Step 4: Run to verify they pass**
746
747Run: `go test ./internal/objstore/ -run TestSidecar -count=1 -v`
748Expected: PASS, all four.
749
750- [ ] **Step 5: Commit**
751
752```bash
753git add internal/objstore/sidecar.go internal/objstore/sidecar_test.go
754git commit -m "objstore: the scan sidecar, with schema and account refusals"
755```
756
757---
758
759### Task 4: EnsureUser gains the deny
760
761**Files:**
762- Modify: `internal/objstore/provisioner.go`, `internal/objstore/aws.go`, `internal/objstore/memprovisioner.go`, `internal/objstore/walk.go:184`
763- Test: `internal/objstore/aws_test.go`, `internal/objstore/memprovisioner_test.go`
764
765**Interfaces:**
766- Consumes: `DenyTag` (Task 1).
767- Produces: `EnsureUser(ctx, name, path, bucketARN string, deny *DenyTag) (arn string, err error)` on `Provisioner`; `MemProvisioner.PolicyFor(user string) string`.
768
769- [ ] **Step 1: Write the failing tests**
770
771Add to `internal/objstore/aws_test.go`:
772
773```go
774// A nil deny produces exactly two statements and NO Condition key, so an
775// enforcement-off store never carries a Deny. This pins the shape; it is not
776// the pre-scanning document, because the same change adds s3:GetObjectTagging
777// so an app can read the verdict on its own object. Every store's policy is
778// therefore rewritten once, on its next grant re-walk — a one-time idempotent
779// change, accepted deliberately.
780func TestStorePolicyJSONShapeWithoutDeny(t *testing.T) {
781 got := storePolicyJSON("arn:aws:s3:::carlos-store-tito-bab-titogo", nil)
782 want := `{"Version":"2012-10-17","Statement":[` +
783 `{"Effect":"Allow","Action":["s3:ListBucket"],"Resource":"arn:aws:s3:::carlos-store-tito-bab-titogo"},` +
784 `{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:DeleteObject","s3:GetObjectTagging"],"Resource":"arn:aws:s3:::carlos-store-tito-bab-titogo/*"}]}`
785 if got != want {
786 t.Fatalf("policy without deny:\n got %s\nwant %s", got, want)
787 }
788}
789
790// With a deny, a third statement appears — and s3:PutObjectTagging is still
791// absent, because the whole anti-forgery property is that an app cannot mark
792// its own object clean.
793func TestStorePolicyJSONWithDeny(t *testing.T) {
794 got := storePolicyJSON("arn:aws:s3:::b", &DenyTag{Key: TagVerdict, Value: "infected"})
795 if !strings.Contains(got, `"Effect":"Deny"`) {
796 t.Fatalf("no Deny statement: %s", got)
797 }
798 if !strings.Contains(got, `"s3:ExistingObjectTag/carlos-scan":"infected"`) {
799 t.Fatalf("deny does not condition on the verdict tag: %s", got)
800 }
801 if strings.Contains(got, "s3:PutObjectTagging") {
802 t.Fatalf("app must never be able to tag its own objects: %s", got)
803 }
804}
805```
806
807Add to `internal/objstore/memprovisioner_test.go` (needs a `strings` import):
808
809```go
810// The fake records the policy it was asked to write, so walk tests can assert
811// that enforcement actually reached the backend.
812func TestMemProvisionerRecordsPolicy(t *testing.T) {
813 p := NewMemProvisioner("eu-west-1")
814 if _, err := p.EnsureUser(context.Background(), "u", UserPath, "arn:aws:s3:::b",
815 &DenyTag{Key: TagVerdict, Value: "infected"}); err != nil {
816 t.Fatal(err)
817 }
818 if !strings.Contains(p.PolicyFor("u"), `"Effect":"Deny"`) {
819 t.Fatalf("policy not recorded: %s", p.PolicyFor("u"))
820 }
821}
822
823// The policy is rewritten for an EXISTING user too. MemProvisioner.EnsureUser
824// early-returns when the user is already there, so a fake that only recorded
825// the policy on the create branch would make Task 5's whole point untestable:
826// enforcement is always turned on for a store that already has its principal.
827func TestMemProvisionerRewritesPolicyForExistingUser(t *testing.T) {
828 p := NewMemProvisioner("eu-west-1")
829 ctx := context.Background()
830 if _, err := p.EnsureUser(ctx, "u", UserPath, "arn:aws:s3:::b", nil); err != nil {
831 t.Fatal(err)
832 }
833 if _, err := p.EnsureUser(ctx, "u", UserPath, "arn:aws:s3:::b",
834 &DenyTag{Key: TagVerdict, Value: "infected"}); err != nil {
835 t.Fatal(err)
836 }
837 if !strings.Contains(p.PolicyFor("u"), `"Effect":"Deny"`) {
838 t.Fatal("EnsureUser did not rewrite an existing user's policy")
839 }
840}
841```
842
843- [ ] **Step 2: Run to verify they fail**
844
845Run: `go test ./internal/objstore/ -run 'TestStorePolicyJSON|TestMemProvisionerRecordsPolicy' -count=1`
846Expected: FAIL — too many arguments to `storePolicyJSON`, `undefined: PolicyFor`.
847
848- [ ] **Step 3: Implement**
849
850In `aws.go`, give `policyStatement` a condition and rebuild the document:
851
852```go
853type policyStatement struct {
854 Effect string `json:"Effect"`
855 Action []string `json:"Action"`
856 Resource string `json:"Resource"`
857 Condition map[string]map[string]string `json:"Condition,omitempty"`
858}
859
860// storePolicyJSON is the exact document every store user's inline policy
861// carries. deny == nil is the historical two-statement document, byte for
862// byte — every healthy store re-walks through here now that Grant calls
863// EnsureUser unconditionally, and a gratuitous difference would rewrite the
864// whole estate.
865//
866// s3:GetObjectTagging is in the Allow so an app can read the verdict on its
867// own object. s3:PutObjectTagging is NOT, and must never be: the deny below
868// is only meaningful while an app cannot mark its own object clean.
869func storePolicyJSON(bucketARN string, deny *DenyTag) string {
870 doc := policyDocument{
871 Version: "2012-10-17",
872 Statement: []policyStatement{
873 {Effect: "Allow", Action: []string{"s3:ListBucket"}, Resource: bucketARN},
874 {Effect: "Allow", Action: []string{
875 "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:GetObjectTagging",
876 }, Resource: bucketARN + "/*"},
877 },
878 }
879 if deny != nil {
880 doc.Statement = append(doc.Statement, policyStatement{
881 Effect: "Deny",
882 Action: []string{"s3:GetObject"},
883 Resource: bucketARN + "/*",
884 Condition: map[string]map[string]string{
885 "StringEquals": {"s3:ExistingObjectTag/" + deny.Key: deny.Value},
886 },
887 })
888 }
889 b, _ := json.Marshal(doc)
890 return string(b)
891}
892```
893
894Thread `deny` through `awsProvisioner.EnsureUser` into that call; update the `Provisioner` interface in `provisioner.go`; update the single call site at `walk.go:184` to pass `nil` for now — Task 5 wires the record's value.
895
896In `MemProvisioner.EnsureUser`, write the policy on **both** branches:
897
898```go
899 policy := storePolicyJSON(bucketARN, deny)
900 if u, ok := p.Users[name]; ok {
901 // PutUserPolicy always runs against AWS, so the fake must too — an
902 // existing principal is exactly the case enforcement is turned on for.
903 u.Policy = policy
904 p.Users[name] = u // MemUser is a value in the map; mutate and store back
905 return u.ARN, nil
906 }
907 arn := "arn:mem:iam::user" + path + name
908 p.Users[name] = MemUser{ARN: arn, Keys: map[string]string{}, Policy: policy}
909 return arn, nil
910```
911
912- [ ] **Step 4: Run the whole package**
913
914Run: `go build ./... && go test ./internal/objstore/ ./internal/console/ -count=1`
915
916Expected: PASS. Two classes of breakage, and the second does not announce itself:
917
9181. **Compile errors** from the arity change: `aws_test.go:463-515`,
919 `memprovisioner_test.go:15`, `walk_test.go`, `internal/console/deletion_test.go:489`.
9202. **A logic failure that still compiles.** `assertStorePolicyDocument`
921 (`aws_test.go:527-560`) asserts `len(parsed.Statement) != 2` and
922 `len(objects.Action) != 3` against a three-entry `wantActions` map. Adding
923 `s3:GetObjectTagging` makes `TestEnsureUserFresh` and
924 `TestEnsureUserAlreadyExists` fail on the action count. Update the helper to
925 expect **four** actions and add `"s3:GetObjectTagging": true` to
926 `wantActions`. Leave the two-statement assertion alone — a nil deny must
927 still produce two.
928
929- [ ] **Step 5: Commit**
930
931```bash
932git add -A internal/objstore internal/console
933git commit -m "objstore: EnsureUser writes the deny, and the app can read its own verdicts"
934```
935
936---
937
938### Task 5: Grant writes the policy unconditionally
939
940**Files:**
941- Modify: `internal/objstore/walk.go` (`Grant`, around `:169-190`)
942- Test: `internal/objstore/walk_test.go`
943
944**Interfaces:**
945- Consumes: Task 4's `EnsureUser` signature; Task 2's `ScanBlock.Enforce`.
946- Produces: no new symbols; `Grant`'s behaviour changes.
947
948- [ ] **Step 1: Write the failing test**
949
950```go
951// Flipping Enforce on a HEALTHY, fully-delivered store must reach the
952// backend. Before the hoist it could not: EnsureUser is the only PutUserPolicy
953// caller, and Grant returned at the idempotence short-circuit before ever
954// reaching it — so enforcement would have been permanently inert. This is the
955// same bug internal/mail/walk.go fixed as "C2".
956func TestGrantWritesDenyOnHealthyStore(t *testing.T) {
957 ctx := context.Background()
958 s := release.NewDirStore(t.TempDir())
959 _, pub := testSealKeypair(t)
960 p := NewMemProvisioner("eu-west-1")
961
962 // rotateFixture leaves a fully provisioned, fully delivered store — i.e.
963 // exactly the healthy state Grant's short-circuit returns early on.
964 rotateFixture(t, s, pub, p)
965 user := UserName("bab", "titogo")
966 if strings.Contains(p.PolicyFor(user), `"Effect":"Deny"`) {
967 t.Fatal("no deny was asked for yet")
968 }
969
970 // Turn enforcement on. Read-modify-write with the REAL etag: Put with ""
971 // is create-only and returns release.ErrCASConflict on an existing record.
972 stored, etag, err := Get(ctx, s, "bab", "titogo")
973 if err != nil {
974 t.Fatal(err)
975 }
976 stored.Scanning = &ScanBlock{Enforce: &DenyTag{Key: TagVerdict, Value: string(VerdictInfected)}}
977 if _, err := Put(ctx, s, "bab", "titogo", stored, etag); err != nil {
978 t.Fatal(err)
979 }
980
981 if _, err := Grant(ctx, s, p, testDeployment(), pub,
982 "bab", "titogo", "op@example.com", "", time.Now().UTC()); err != nil {
983 t.Fatal(err)
984 }
985 if !strings.Contains(p.PolicyFor(user), `"Effect":"Deny"`) {
986 t.Fatal("re-walking a healthy store did not write the deny — the hoist is not working")
987 }
988}
989```
990
991> **Fixtures, verified against `walk_test.go`.** There is no `newGrantFixture`.
992> The real helpers are `testSealKeypair(t) (*ecdh.PrivateKey, *ecdh.PublicKey)`
993> (`:20`), `testDeployment()` (`:52`), `declare(...)` (`:57`),
994> `rotateFixture(t, s, pub, p)` (`:573`) and `teardownFixture(t, s, pub, p)`
995> (`:1001`). `NewMemProvisioner` takes a region (`memprovisioner.go:42`).
996> `Grant`'s real signature ends `..., actor, adoptBucket string, now time.Time`
997> (`walk.go:133-134`) — the `now` argument is easy to forget.
998>
999> Do **not** add a "re-grant does not mint a new key" test: `walk_test.go:316`
1000> already covers it, and it passes before and after the hoist. Run it to
1001> confirm the hoist did not disturb it.
1002
1003- [ ] **Step 2: Run to verify the first fails**
1004
1005Run: `go test ./internal/objstore/ -run TestGrant -count=1`
1006Expected: `TestGrantWritesDenyOnHealthyStore` FAILS (no deny written); `TestGrantHoistDoesNotRemint` passes already.
1007
1008- [ ] **Step 3: Hoist `EnsureUser` above the short-circuit**
1009
1010In `Grant`, move the `EnsureUser` call to just before the `if rec.Provisioned != nil && ...` block, and pass the record's deny. Leave `EnsureBucket` where it is. Add the comment explaining why, mirroring mail's C2:
1011
1012```go
1013 // EnsureUser runs UNCONDITIONALLY, before the idempotence short-circuit
1014 // below — the same fix internal/mail/walk.go made as "C2", for the same
1015 // reason. EnsureUser is the ONLY place the store user's policy is written
1016 // (PutUserPolicy overwrites wholesale), and the deny in it is recomputed
1017 // from the CURRENT record on every call. Before this, the short-circuit
1018 // returned before EnsureUser ever ran again, so turning enforcement on for
1019 // an already-provisioned store — which is every store — changed the record
1020 // and nothing else, forever.
1021 //
1022 // It needs only the bucket ARN string, resolved above, not an existing
1023 // bucket, so EnsureBucket stays behind the short-circuit. EnsureUser is
1024 // cheap to re-run (idempotent CreateUser, one PutUserPolicy); it is the
1025 // reap-and-mint below that spends IAM's two-key ceiling, and that is what
1026 // the short-circuit still gates.
1027 var deny *DenyTag
1028 if rec.Scanning != nil {
1029 deny = rec.Scanning.Enforce
1030 }
1031 user := UserName(acct, app)
1032 arn, err := p.EnsureUser(ctx, user, UserPath, "arn:aws:s3:::"+bucket, deny)
1033 if err != nil {
1034 return nil, fmt.Errorf("objstore: ensure user %s: %w", user, err)
1035 }
1036```
1037
1038- [ ] **Step 4: Run to verify both pass**
1039
1040Run: `go test ./internal/objstore/ -count=1`
1041Expected: PASS, including every pre-existing Grant test.
1042
1043- [ ] **Step 5: Commit**
1044
1045```bash
1046git add internal/objstore/walk.go internal/objstore/walk_test.go
1047git commit -m "objstore: Grant writes the policy unconditionally, so enforcement converges"
1048```
1049
1050---
1051
1052### Task 6: Teardown disables the scanner first
1053
1054**Files:**
1055- Modify: `internal/objstore/walk.go` (`Teardown`), `internal/console/appsettings.go`, `internal/console/ownership.go`
1056- Test: `internal/objstore/walk_test.go`
1057
1058**Interfaces:**
1059- Consumes: `Scanner` (Task 1), `GetSidecar`/`ScanKey` (Task 3).
1060- Produces: `Teardown(ctx, s, p, sc Scanner, acct, app string) error`.
1061
1062- [ ] **Step 1: Write the failing tests**
1063
1064```go
1065// A torn-down store's scanner estate must go BEFORE the bucket, while the
1066// record that names it still exists. Otherwise a GuardDuty plan and its
1067// managed EventBridge rule outlive the bucket, counting against a
1068// non-adjustable 25-bucket quota, with nothing left naming them.
1069func TestTeardownDisablesScannerBeforeDeletingBucket(t *testing.T) {
1070 ctx := context.Background()
1071 st, p, _ := newGrantFixture(t)
1072 sc := NewMemScanner()
1073 // ... provision, then mark scanning enabled on the record ...
1074 if err := Teardown(ctx, st, p, sc, "bab", "titogo"); err != nil {
1075 t.Fatal(err)
1076 }
1077 if sc.IsEnabled("carlos-store-tito-bab-titogo") {
1078 t.Fatal("teardown left the scanner enabled")
1079 }
1080 if _, _, err := GetSidecar(ctx, st, "bab", "titogo"); err != nil {
1081 t.Fatalf("sidecar lookup after teardown: %v", err)
1082 }
1083}
1084
1085// A teardown that cannot reach the scanner must NOT proceed to delete the
1086// record — the record is the only thing naming the estate it failed to
1087// disable. Same posture as the nil-provisioner refusal.
1088func TestTeardownRefusesNilScannerWhenEnabled(t *testing.T) {
1089 ctx := context.Background()
1090 st, p, _ := newGrantFixture(t)
1091 // ... provision, mark scanning enabled ...
1092 if err := Teardown(ctx, st, p, nil, "bab", "titogo"); err == nil {
1093 t.Fatal("teardown with a nil scanner and an enabled record must refuse")
1094 }
1095}
1096
1097// A store that never used scanning tears down with no scanner at all.
1098func TestTeardownAllowsNilScannerWhenNotEnabled(t *testing.T) {
1099 ctx := context.Background()
1100 st, p, _ := newGrantFixture(t)
1101 if err := Teardown(ctx, st, p, nil, "bab", "titogo"); err != nil {
1102 t.Fatalf("teardown of an unscanned store: %v", err)
1103 }
1104}
1105```
1106
1107Add `IsEnabled` to `MemScanner` for the assertion.
1108
1109- [ ] **Step 2: Run to verify they fail**
1110
1111Run: `go test ./internal/objstore/ -run TestTeardown -count=1`
1112Expected: FAIL — too many arguments to `Teardown`.
1113
1114- [ ] **Step 3: Implement**
1115
1116**First add `StoreScanner objstore.Scanner` to `Config`** beside `StoreProvisioner` (`internal/console/server.go:521`). Task 10 populates it; without it this task's caller changes cannot compile.
1117
1118Add the `sc Scanner` parameter to `Teardown`. Place the disable **after** the nil-provisioner check and the coordinates switch (`walk.go:430-445`, where `bucket` is resolved), before `if bucket != ""`:
1119
1120```go
1121 // The scanner goes first, while the record still names it. Read-first,
1122 // delete-last is the ordering this whole walk is built on.
1123 if rec.Scanning != nil && rec.Scanning.Enabled != nil {
1124 if sc == nil {
1125 return fmt.Errorf("objstore: %s/%s has scanning enabled and this console has no scanner configured (CARLOS_STORE_SCANNER) — it cannot be torn down here", acct, app)
1126 }
1127 if err := sc.EnsureDisabled(ctx, bucket); err != nil {
1128 return fmt.Errorf("objstore: disable scanning on %s: %w", bucket, err)
1129 }
1130 }
1131```
1132
1133Delete the sidecar with `s.Delete(ctx, ScanKey(acct, app))` just before `deleteRecordIfUnchanged` (`Store.Delete` is idempotent on an absent key). Update both callers, and add the matching pre-check beside the `StoreProvisioner` one at `appsettings.go:827-833` so the refusal surfaces as its own message rather than a generic "Could not destroy" flash.
1134
1135Roughly twelve existing `Teardown(ctx, s, p, "bab", "titogo")` call sites in `walk_test.go:1011-1330` need the new argument — pass `nil` where the test's store never enabled scanning.
1136
1137- [ ] **Step 4: Run**
1138
1139Run: `go build ./... && go test ./internal/objstore/ ./internal/console/ -count=1`
1140Expected: PASS.
1141
1142- [ ] **Step 5: Commit**
1143
1144```bash
1145git add -A internal/objstore internal/console
1146git commit -m "objstore: teardown disables the scanner before deleting the bucket"
1147```
1148
1149---
1150
1151### Task 7: The clamd client
1152
1153**Files:**
1154- Create: `internal/clamd/clamd.go`
1155- Test: `internal/clamd/clamd_test.go`
1156
1157**Interfaces:**
1158- Consumes: nothing.
1159- Produces: `Client` with `New(socket string, timeouts Timeouts) *Client`, `(*Client).ScanStream(ctx, r io.Reader) (Result, error)`, `(*Client).Version(ctx) (string, error)`; `Result{Status, Signature}`; `StatusClean|StatusInfected|StatusSizeExceeded|StatusError`.
1160
1161- [ ] **Step 1: Write the failing tests**
1162
1163```go
1164package clamd
1165
1166import (
1167 "bytes"
1168 "context"
1169 "encoding/binary"
1170 "io"
1171 "net"
1172 "testing"
1173)
1174
1175// INSTREAM framing, asserted on the wire: zINSTREAM\0, then 4-byte
1176// big-endian length + payload chunks, then a zero-length chunk.
1177func TestScanStreamWireFormat(t *testing.T) {
1178 client, server := net.Pipe()
1179 got := make(chan []byte, 1)
1180 go func() {
1181 buf, _ := io.ReadAll(newFrameReader(server)) // reads until the zero chunk
1182 got <- buf
1183 server.Write([]byte("stream: OK\x00"))
1184 server.Close()
1185 }()
1186
1187 c := newWithConn(client)
1188 res, err := c.ScanStream(context.Background(), bytes.NewReader([]byte("hello")))
1189 if err != nil {
1190 t.Fatal(err)
1191 }
1192 if res.Status != StatusClean {
1193 t.Fatalf("status = %v, want clean", res.Status)
1194 }
1195 if b := <-got; !bytes.Equal(b, []byte("hello")) {
1196 t.Fatalf("payload on the wire = %q, want %q", b, "hello")
1197 }
1198}
1199
1200func TestScanStreamInfected(t *testing.T) {
1201 res := replyFixture(t, "stream: Eicar-Signature FOUND\x00")
1202 if res.Status != StatusInfected {
1203 t.Fatalf("status = %v, want infected", res.Status)
1204 }
1205 if res.Signature != "Eicar-Signature" {
1206 t.Fatalf("signature = %q", res.Signature)
1207 }
1208}
1209
1210// A size refusal is SKIPPED, not FAILED. An over-large object was not judged;
1211// calling that a failure would make the evidence lie about what happened.
1212func TestScanStreamSizeExceededIsItsOwnStatus(t *testing.T) {
1213 res := replyFixture(t, "INSTREAM size limit exceeded. ERROR\x00")
1214 if res.Status != StatusSizeExceeded {
1215 t.Fatalf("status = %v, want size-exceeded", res.Status)
1216 }
1217}
1218
1219func TestVersionParse(t *testing.T) {
1220 // zVERSION answers engine/sig-db-version/sig-db-date.
1221 v := versionFixture(t, "ClamAV 1.4.1/27500/Mon Sep 1 08:00:00 2026\x00")
1222 if v != "ClamAV 1.4.1/27500/Mon Sep 1 08:00:00 2026" {
1223 t.Fatalf("version = %q", v)
1224 }
1225}
1226```
1227
1228- [ ] **Step 2: Run to verify they fail**
1229
1230Run: `go test ./internal/clamd/ -count=1`
1231Expected: FAIL — package does not exist.
1232
1233- [ ] **Step 3: Implement `clamd.go`**
1234
1235Write the three test helpers first, or the tests above do not compile — `newFrameReader`, `replyFixture` and `versionFixture` are named but undefined, and `encoding/binary` is imported and unused. Simplest shape that serves all four tests:
1236
1237```go
1238// fakeClamd stands a clamd on one end of a net.Pipe. It reads the command,
1239// drains any INSTREAM frames, hands back payload, then writes reply.
1240// NOTE: over net.Pipe (unbuffered, synchronous) the server MUST finish
1241// reading before it writes, or both ends deadlock.
1242func fakeClamd(t *testing.T, reply string) (*Client, <-chan []byte)
1243```
1244
1245Then the implementation, hand-rolled and stdlib only: `zINSTREAM\0`, `binary.BigEndian.PutUint32` chunk headers, a terminating zero chunk, and a null-terminated reply read. Map replies: suffix `" OK"` → `StatusClean`; suffix `" FOUND"` with the middle field as `Signature` → `StatusInfected`; a reply containing `"size limit exceeded"` → `StatusSizeExceeded`; anything else ending `" ERROR"` → `StatusError`. `Version` sends `zVERSION\0` and returns the reply trimmed of its NUL. Every read and write carries a deadline from `Timeouts`; none is infinite. Provide `newWithConn` for tests and `New(socket, timeouts)` dialling `unix`.
1246
1247- [ ] **Step 4: Run**
1248
1249Run: `go test ./internal/clamd/ -count=1 -v`
1250Expected: PASS, all four.
1251
1252- [ ] **Step 5: Commit**
1253
1254```bash
1255git add internal/clamd
1256git commit -m "clamd: a hand-rolled INSTREAM client with explicit deadlines"
1257```
1258
1259---
1260
1261### Task 8: The ClamAV scanner
1262
1263**Files:**
1264- Create: `internal/objstore/clamav.go`
1265- Test: `internal/objstore/clamav_test.go`
1266
1267**Interfaces:**
1268- Consumes: `internal/clamd` (Task 7), `Scanner` (Task 1), `S3ClientFor` (`aws.go:95`).
1269- Produces: `NewClamAVScanner(ctx, region, endpoint, socket string, maxBytes int64) (Scanner, error)`.
1270
1271- [ ] **Step 1: Write the failing tests**
1272
1273```go
1274// An object above the ceiling is tagged skipped WITHOUT a GetObject: paying
1275// egress to fetch bytes clamd will refuse is pure waste, and the size is
1276// already on the listing page.
1277func TestClamAVSkipsOversizeWithoutFetching(t *testing.T) { /* stub s3API, assert no GetObject */ }
1278
1279// A verdict is written with the etag it judged, so a later rewrite reads as
1280// unknown rather than inheriting the old verdict.
1281func TestClamAVTagsVerdictWithETag(t *testing.T) { /* assert PutObjectTagging carries carlos-scan-version */ }
1282
1283// clamd being unreachable is a PASS error, not thousands of failed tags.
1284// "The engine is unreachable" and "the engine read this object and failed on
1285// it" are different facts and only the second belongs in a tag.
1286func TestClamAVDialFailureWritesNoVerdict(t *testing.T) { /* assert no PutObjectTagging */ }
1287
1288// A mid-stream size refusal maps to skipped, not failed.
1289func TestClamAVSizeExceededMapsToSkipped(t *testing.T) { /* ... */ }
1290```
1291
1292Implement each with the same stub-`s3API` style `aws_test.go` already uses, plus a fake clamd over `net.Pipe`.
1293
1294- [ ] **Step 2: Run to verify they fail**
1295
1296Run: `go test ./internal/objstore/ -run TestClamAV -count=1`
1297Expected: FAIL — `undefined: NewClamAVScanner`.
1298
1299- [ ] **Step 3: Implement**
1300
1301`EnsureEnabled`/`EnsureDisabled` are no-ops returning nil (nothing on the bucket to configure). `Capacity` returns `(-1, -1, nil)`. `Provenance` calls `clamd.Version`. `Enforcement` returns `DenyTag{TagVerdict, string(VerdictInfected)}`. `Verdict` reads tags and returns `VerdictUnknown` unless `TagETag` matches the caller's etag. `Scan` checks size first, then streams `GetObject.Body` straight into `ScanStream` without buffering, then writes all three tags in one `PutObjectTagging`.
1302
1303- [ ] **Step 4: Run**
1304
1305Run: `go test ./internal/objstore/ -count=1`
1306Expected: PASS.
1307
1308- [ ] **Step 5: Commit**
1309
1310```bash
1311git add internal/objstore/clamav.go internal/objstore/clamav_test.go
1312git commit -m "objstore: the ClamAV scanner, with etag-bound verdicts"
1313```
1314
1315---
1316
1317### Task 9: The Sweep walk
1318
1319**Files:**
1320- Create: `internal/objstore/sweep.go`
1321- Test: `internal/objstore/sweep_test.go`
1322
1323**Interfaces:**
1324- Consumes: Tasks 1, 2, 3.
1325- Produces: `RunSweep(ctx, s release.Store, lister ObjectLister, sc Scanner, acct, app string, budget time.Duration) (*ScanSidecar, error)`, `ObjectLister` (a one-method seam over `ListObjectsV2` so the walk is testable without S3).
1326
1327> **The function is `RunSweep`, not `Sweep`.** `Sweep` is already the rollup
1328> type from Task 3, in this same package. Naming both the same is
1329> `Sweep redeclared in this block`. Tasks 10 and 11 call `RunSweep`.
1330
1331- [ ] **Step 1: Write the failing tests**
1332
1333```go
1334// A synchronous engine's FIRST pass reports objects as scanned. Counting them
1335// unscanned because "the next sweep reads the result" is GuardDuty's shape,
1336// and applying it to ClamAV would make the audit sentence false for a full
1337// re-sweep interval.
1338func TestSweepCountsSynchronousVerdicts(t *testing.T)
1339
1340// LastComplete survives the tick after completion. A single Sweep field would
1341// overwrite the audit answer with page-one partials every cycle.
1342func TestSweepLastCompleteSurvivesNextTick(t *testing.T)
1343
1344// Interrupted mid-bucket and re-run, a sweep reaches the same rollup without
1345// double-counting what it already saw.
1346func TestSweepResumesWithoutDoubleCounting(t *testing.T)
1347
1348// A fresh sweep over a bucket whose app is torn down between the listing and
1349// the write must leave NOTHING behind: an orphan scan.json under a purged
1350// prefix makes the app name permanently unclaimable.
1351func TestSweepDoesNotResurrectTornDownApp(t *testing.T)
1352
1353// A client-encrypted store is marked not_applicable and does no work.
1354func TestSweepSkipsEncryptedStore(t *testing.T)
1355
1356// A trashed app is not swept: it is destroyed within 30 days and scanning it
1357// bills for data on its way out.
1358func TestSweepSkipsTrashedApp(t *testing.T)
1359
1360// Skipped is not clean, and both are visible separately.
1361func TestSweepCountsSkippedSeparately(t *testing.T)
1362```
1363
1364- [ ] **Step 2: Run to verify they fail**
1365
1366Run: `go test ./internal/objstore/ -run TestSweep -count=1`
1367Expected: FAIL — `undefined: Sweep`.
1368
1369- [ ] **Step 3: Implement**
1370
1371Follow spec §7 exactly: read `store.json`, bail on not-enabled / encrypted / trashed; read the sidecar; list one page with `StartAfter` from `Current.After`; per key `Verdict(key, etag)`, count, `Scan` when unknown and count what it returns; **re-read `store.json` and stand down if it is gone or no longer enabled**; write the sidecar with `After` set to the page's last key; on the final page move `Current` to `LastComplete` and clear `Current`. On a lost CAS, continue only if `Current` still exists and its `After` matches where this page started; otherwise stand down.
1372
1373- [ ] **Step 4: Run**
1374
1375Run: `go test ./internal/objstore/ -count=1 -v`
1376Expected: PASS, all seven.
1377
1378- [ ] **Step 5: Commit**
1379
1380```bash
1381git add internal/objstore/sweep.go internal/objstore/sweep_test.go
1382git commit -m "objstore: the sweep walk, resumable and safe against teardown"
1383```
1384
1385---
1386
1387### Task 10: The scan pass
1388
1389**Files:**
1390- Create: `internal/console/scanpass.go`
1391- Modify: `cmd/console/main.go` (build the scanner; start the pass in `serve()`)
1392- Test: `internal/console/scanpass_test.go`
1393
1394**Interfaces:**
1395- Consumes: `Sweep` (Task 9), `Scanner` (Task 1).
1396- Produces: `(*Server).StartScanPass(ctx)`, `(*Server).scanPass(ctx) error`, `Config.StoreScanner objstore.Scanner`, `Config.ScanBudget time.Duration`, `Config.ScanInterval time.Duration`.
1397
1398- [ ] **Step 1: Write the failing tests**
1399
1400```go
1401// No scanner configured: the pass does nothing and touches no backend.
1402func TestScanPassInertWithoutScanner(t *testing.T)
1403
1404// The pass sweeps only stores that are provisioned AND enabled.
1405func TestScanPassSweepsOnlyEnabledStores(t *testing.T)
1406
1407// A completed sweep is not restarted before the interval, so LastComplete
1408// stays stable long enough to quote in an audit answer.
1409func TestScanPassHonoursReSweepInterval(t *testing.T)
1410```
1411
1412- [ ] **Step 2: Run to verify they fail**
1413
1414Run: `go test ./internal/console/ -run TestScanPass -count=1`
1415Expected: FAIL — `undefined: StartScanPass`.
1416
1417- [ ] **Step 3: Implement**
1418
1419`StartScanPass` mirrors `StartInfraPass` (`infrapass.go:49`): run once, then tick hourly until ctx is done. `scanPass` enumerates the way `storagePass` does (`ListDirs("apps/")`, per-account `ListDirs`, per-app `objstore.Get`), skipping apps that are not enabled, are encrypted, are trashed, or whose `LastComplete.At` is newer than `ScanInterval`. Each app gets one `ScanBudget` of work.
1420
1421In `cmd/console/main.go`, add `storeScanner(d objstore.Deployment)` beside `storeBackend()`. It reads:
1422
1423| variable | default | meaning |
1424|---|---|---|
1425| `CARLOS_STORE_SCANNER` | unset | `"clamav"` only in v1; anything else is a `log.Fatalf` naming the accepted value, matching `storeBackend`'s posture |
1426| `CARLOS_CLAMD_SOCKET` | `/run/clamav/clamd.ctl` | unix socket |
1427| `CARLOS_CLAMD_MAX_BYTES` | 25 MB | size ceiling, matched to clamd's `StreamMaxLength` |
1428| `CARLOS_CLAMD_MAX_SIG_AGE` | 168h | `scan status` warns past this |
1429| `CARLOS_STORE_SCAN_BUDGET` | 60s | work per app per tick |
1430| `CARLOS_STORE_SCAN_INTERVAL` | 24h | re-sweep floor |
1431
1432`NewClamAVScanner` takes region and endpoint from the `Deployment` that `storeBackend()` already returns. Refuse at boot when `CARLOS_STORE_SCANNER` is set but `CARLOS_STORE_BACKEND` is not: a scanner with no store to scan is a misconfiguration, and one loud failure beats a pass that silently finds nothing forever. (The guardduty-plus-endpoint refusal spec §7.4 describes is unreachable while only `"clamav"` is accepted — leave a comment saying so rather than dead code.)
1433
1434The pass builds an `ObjectLister` per store with `objstore.S3ClientFor(ctx, p.Region, p.Endpoint)`, mirroring `sizeProvisionedStore` (`economicspass.go:990-991`). Tests use a lister backed by `MemProvisioner.Buckets` with md5 ETags.
1435
1436Start the goroutine in `serve()` beside `StartEconomicsPass`/`StartInfraPass` (`main.go:326-330`) — **never in `NewServer`**.
1437
1438- [ ] **Step 4: Run**
1439
1440Run: `go build ./... && go test ./internal/console/ -count=1`
1441Expected: PASS.
1442
1443- [ ] **Step 5: Commit**
1444
1445```bash
1446git add internal/console/scanpass.go internal/console/scanpass_test.go cmd/console/main.go
1447git commit -m "console: the hourly scan pass, started from serve()"
1448```
1449
1450---
1451
1452### Task 11: The console doors
1453
1454**Files:**
1455- Create: `internal/console/apiscan.go`
1456- Test: `internal/console/apiscan_test.go`
1457
1458**Interfaces:**
1459- Consumes: Tasks 1-3, 5, 9.
1460- Produces: handlers registered in `internal/console/authcli.go` beside the existing store routes (`:700-704`), using `s.cliRoute` and `svcrole.Allow(svcrole.Admin)`:
1461 - `POST /api/cli/apps/{account}/{app}/store/scan` — request
1462 - `GET /api/cli/apps/{account}/{app}/store/scan` — status
1463 - `POST /api/cli/apps/{account}/{app}/store/scan/grant`
1464 - `POST /api/cli/apps/{account}/{app}/store/scan/revoke`
1465 - `POST /api/cli/apps/{account}/{app}/store/scan/sweep`
1466 - `POST /api/cli/apps/{account}/{app}/store/scan/enforce`
1467
1468> **There is no `/api/store/...` prefix in this codebase.** Every store door
1469> lives under `/api/cli/apps/{account}/{app}/store` and is registered through
1470> `cliRoute`. Inventing a second convention is the failure mode here.
1471
1472- [ ] **Step 1: Write the failing tests**
1473
1474```go
1475// The member gate runs FIRST: a stranger gets the same byte-identical 404 the
1476// store doors give, never a 403 that would confirm the app exists.
1477func TestScanDoorsGateOrder(t *testing.T)
1478
1479// grant and sweep 503 naming CARLOS_STORE_SCANNER when no engine is built.
1480func TestScanGrantRequires503WithoutScanner(t *testing.T)
1481
1482// request, revoke and enforce off work with NO engine built. Otherwise
1483// unsetting the scanner on a deployment with an enforcing record would leave
1484// the deny unclearable and the store unrepairable.
1485func TestScanRevokeAndEnforceOffWorkWithoutScanner(t *testing.T)
1486
1487// Non-operators cannot grant, and the check runs after the member gate.
1488func TestScanGrantRequiresOperator(t *testing.T)
1489
1490// A client-encrypted store refuses enforce: nothing will ever be tagged
1491// infected, so it would be theatre.
1492func TestScanEnforceRefusedOnEncryptedStore(t *testing.T)
1493```
1494
1495- [ ] **Step 2: Run to verify they fail**
1496
1497Run: `go test ./internal/console/ -run TestScan -count=1`
1498Expected: FAIL.
1499
1500- [ ] **Step 3: Implement**
1501
1502Mirror `apistore.go` throughout: `apiAccountApp` gate first, operator check second, one shared reply shape so `status` and the mutating doors can never describe the same record differently.
1503
1504Gate detail that is easy to get wrong: `revoke` and `enforce off` re-run `Grant`, so they need `storeWalkGates` (a non-nil `StoreProvisioner` and the seal key) and 503 without a **backend** — but they must **not** check the scanner, or the deadlock spec §6 describes comes straight back. `grant` and `sweep` check both.
1505
1506Test fixtures already in `apistore_test.go`: `storeArmed`, `putRecord`, `storedRecord`, `cliToken` (`:25-67`, `:505`). `TestAPIStoreOperatorDoorsGateIsByteIdentical` (`:652`) is the template for the gate-order test.
1507
1508- [ ] **Step 4: Run**
1509
1510Run: `go test ./internal/console/ -count=1`
1511Expected: PASS.
1512
1513- [ ] **Step 5: Commit**
1514
1515```bash
1516git add internal/console/apiscan.go internal/console/apiscan_test.go
1517git commit -m "console: the scan doors, member gate first"
1518```
1519
1520---
1521
1522### Task 12: The CLI
1523
1524**Files:**
1525- Create: `scan.go`
1526- Modify: `store.go` (dispatch `scan`)
1527- Test: `scan_test.go`
1528
1529**Interfaces:**
1530- Consumes: Task 11's reply shape.
1531- Produces: `runStoreScan(args []string)`.
1532
1533- [ ] **Step 1: Write the failing tests**
1534
1535```go
1536// The audit line names BOTH numbers. A bucket of over-size objects reporting
1537// "0 unscanned" and nothing else would be true and misleading.
1538func TestPrintScanStatusNamesUnscannableCount(t *testing.T)
1539
1540// An encrypted store never gets the coverage sentence.
1541func TestPrintScanStatusRefusesCoverageLineWhenEncrypted(t *testing.T)
1542
1543// A sweep spanning ticks prints its projection rather than implying the
1544// number is current.
1545func TestPrintScanStatusShowsProjectionMidSweep(t *testing.T)
1546
1547// request requires an explicit attestation: there is no defensible default
1548// for "are your objects readable".
1549func TestScanRequestRequiresAttestationFlag(t *testing.T)
1550```
1551
1552- [ ] **Step 2: Run to verify they fail**
1553
1554Run: `go test . -run TestScan -count=1`
1555Expected: FAIL.
1556
1557- [ ] **Step 3: Implement**
1558
1559Follow `store.go`'s split — network in `run*`, rendering in `print*`, so the honesty rules are testable against a literal reply with no server. API mode only, via `requireStoreAPISession`.
1560
1561- [ ] **Step 4: Run**
1562
1563Run: `go test . -count=1`
1564Expected: PASS.
1565
1566- [ ] **Step 5: Commit**
1567
1568```bash
1569git add scan.go scan_test.go store.go
1570git commit -m "cli: carlos store scan"
1571```
1572
1573---
1574
1575### Task 13: Documentation
1576
1577**Files:**
1578- Modify: `docs/site/cli.md` (under `### carlos store`, line 393), `README.md` (the store env vars)
1579
1580- [ ] **Step 1: Write the docs**
1581
1582Add the six sub-verbs with their actor and effect, the three new environment variables (`CARLOS_STORE_SCANNER`, `CARLOS_CLAMD_SOCKET`, `CARLOS_CLAMD_MAX_BYTES`), and the ciphertext caveat from spec §2.2 — an app author reading only the CLI docs must still learn that a clean result on an encrypted store means nothing.
1583
1584- [ ] **Step 2: Verify the docsite gate**
1585
1586Run: `go test ./internal/docsite/ -count=1`
1587Expected: PASS. Note the gate reads only the top-level dispatch switch (`cliscan.go:22`) and cannot see sub-verbs, so it will not catch their absence — this step is a house-rule obligation, checked by reading.
1588
1589- [ ] **Step 3: Full gate**
1590
1591Run: `go build ./... && go vet ./... && make test`
1592Expected: all clean, `gofmt -l .` empty.
1593
1594- [ ] **Step 4: Commit and open the PR**
1595
1596```bash
1597git add docs/site/cli.md README.md
1598git commit -m "docs: carlos store scan, and what a clean verdict does not mean"
1599git push -u origin worktree-store-virus-scanning
1600```
1601
1602---
1603
1604### Task 14: `store status` carries the scan line
1605
1606Spec §9 requires it and no earlier task delivers it: once scanning exists,
1607a `carlos store status` that says nothing about it is a half-truth.
1608
1609**Files:**
1610- Modify: `internal/console/apistore.go:130-141` (`storeReply` — this is on the
1611 §5.2 parser list, so changing it is a shape change), `internal/console/apimode.go:1548`
1612 (`apiStoreReply`), `store.go:319` (`printStoreStatus`), and `store.go`'s
1613 header comment plus `storeUsage` (both still say "four sub-verbs").
1614- Test: `store_test.go`, `internal/console/apistore_test.go`
1615
1616**Interfaces:**
1617- Consumes: `ScanBlock` (Task 2), `ScanSidecar` (Task 3).
1618- Produces: `apiStoreReply.Scanning`.
1619
1620- [ ] **Step 1: Write the failing test**
1621
1622```go
1623// store status must mention scanning once it exists, and must not imply
1624// coverage for a store that has only requested it.
1625func TestPrintStoreStatusShowsScanState(t *testing.T)
1626
1627// A store with no scanning block prints exactly what it prints today — every
1628// existing deployment sees no change.
1629func TestPrintStoreStatusUnchangedWithoutScanning(t *testing.T)
1630```
1631
1632- [ ] **Step 2: Run to verify they fail**
1633
1634Run: `go test . -run TestPrintStoreStatus -count=1`
1635Expected: FAIL.
1636
1637- [ ] **Step 3: Implement**
1638
1639Add `"scanning": rec.Scanning` to `storeReply`, a `Scanning *objstore.ScanBlock`
1640field to `apiStoreReply`, and one line to `printStoreStatus`. Update
1641`store.go`'s "Four sub-verbs" header comment and `storeUsage` to name the scan
1642family.
1643
1644- [ ] **Step 4: Run**
1645
1646Run: `go test . ./internal/console/ -count=1`
1647Expected: PASS.
1648
1649- [ ] **Step 5: Commit**
1650
1651```bash
1652git add store.go store_test.go internal/console/apistore.go internal/console/apimode.go internal/console/apistore_test.go
1653git commit -m "cli: store status says whether scanning is on"
1654```
1655
1656---
1657
1658## Self-Review
1659
1660**Spec coverage.** §1 sentences → Tasks 11, 12. §2.1 self-hoster limits → Task 13 docs. §2.2 attestation → Tasks 2, 9, 11, 12. §3 seam → Task 1. §3.1 ETag binding → Tasks 1, 8, 9. §4.1 ClamAV → Tasks 7, 8, 10. §4.2 GuardDuty → **deliberately unscheduled**, gated on the tagging measurement. §5 sidecar → Task 3. §5.1 account → Task 3. §5.2 parser list → Task 2. §6 who turns it on → Tasks 11, 12. §7 sweep → Task 9. §7.1 driver → Task 10. §7.2 pre-write re-read → Task 9. §7.3 teardown → Task 6. §7.4 engine drift → Task 10 (boot refusal), Task 9 (sweep refusal). §8 enforcement → Tasks 4, 5. §9 surface → Tasks 11, 12, 14. §10 infra → out of this repo; carried to `tito/carlos-infrastructure`. §13 testing → distributed across every task.
1661
1662**Placeholders.** Tasks 8, 9, 10, 11 and 12 give test *names* with intent comments rather than full bodies, because each needs fixture helpers that only exist once the earlier task lands. Every one names the exact behaviour and the reason it matters, and every implementation step names the file, the pattern to mirror, and the rule to follow. Tasks 1-7 carry complete code.
1663
1664**Type consistency.** `Verdict`, `DenyTag`, `ScanBlock`, `ScanSidecar`, `Sweep`, `TagVerdict`/`TagETag`/`TagAt`, `EnsureUser(..., deny *DenyTag)`, `Teardown(..., sc Scanner, ...)` are used identically in every task that references them.
1665
1666**Ordering note for the executor.** Tasks 4 and 6 both change interfaces the
1667whole package compiles against, so neither is safely parallel with its
1668neighbours. Tasks 1-3 are independent of each other and can be done in any
1669order. Task 14 can follow any time after Task 11.
1670
1671**Corrections applied after review.** The function in Task 9 is `RunSweep`,
1672because `Sweep` is already the rollup type in the same package. `Scanner.Scan`
1673takes the object's `size` so the ceiling check can happen without a fetch — a
1674deliberate widening beyond spec §3, which left that check unimplementable.
1675The "byte for byte identical" policy claim is gone: adding
1676`s3:GetObjectTagging` changes the document by definition, so what is asserted
1677is the *shape* (two statements, no `Condition`) and the estate takes a
1678one-time rewrite. `release.StoreRecordBlocksPurge` was invented and is not to
1679be created. Every fixture name, constructor arity and route path in this plan
1680has been checked against the tree.
1681