carlos / platform Public

objstore: the re-review's blocker — denied verdicts must not be sticky

A fix from the previous round introduced this one. Classifying an
unreadable object as VerdictDenied stopped the sweep stalling, but the
verdict was written as a tag AND read back as an answer — so the object
was never retried. Its bytes do not change, so its version never
changes, so a permissions fix would never take. The spec says the
opposite in as many words: "failed and denied are re-issued every sweep
— both are usually transient or a permissions fix away".

The SSE-KMS case is the one that bites. Tagging needs no KMS access, so
the denied tag lands cleanly on exactly the objects that can never be
read, and they stay denied for good. Glacier is the same shape: a
restore does not change LastModified. denied and failed now read back
as unknown; the tag stays as an audit trace of what happened last time.

Three more from the same review:

**tag() blind-wrote when its own tag READ failed** — destroying exactly
the foreign tags the read-merge-write exists to protect, on the one
path where we know least about what is there. It fails the page now and
lets the next tick retry.

**The ten-tag cap turned a working scan into a permanent stall.** An
object with eight foreign tags produces an eleven-tag write, which S3
refuses, which abandons the page unwritten every tick forever — the
exact stall the denied classification was added to remove. The verdict
is recorded for the sweep and the write skipped.

**Fresh/Reused split Objects, not Scanned**, so "N of M scanned, of
those X judged now" printed visibly wrong arithmetic the moment one
object was skipped.

Also: a record with an engine enabled and no attestation is refused
rather than swept. Unreachable now that the grant door checks, but
sweeping reads tenant bytes and the attestation is the consent for
that — guessing "probably plaintext" is not this walk's call.

The sweep-level version binding had no test: mutating Version() back to
ETag passed everything, because the test lister never set LastModified.
It does now, and that mutation fails.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJkJkxZZLgW8X8A5tk69Sq
Paul Campbell pushed by paul@keymail.dev 6e13700a540f86a0f161eefecb94acbb42b01051 parent 300e727
6 files changed, +272 −26
  • docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md +1 −1
  • docs/superpowers/plans/2026-09-01-object-storage-virus-scanning.md +2 −2
  • internal/objstore/clamav.go +54 −18
  • internal/objstore/clamav_test.go +93 −0
  • internal/objstore/sweep.go +24 −2
  • internal/objstore/sweep_test.go +98 −3
diff --git a/docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md b/docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md
index 49673fdc2..e26f04610 100644
--- a/docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md
+++ b/docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md
@@ -108,7 +108,7 @@ step 2, and a `last complete` timestamp. Then verify the two properties that
are easy to assume and worth proving:
- **Verdicts are on the objects.** `aws s3api get-object-tagging` on any
- scanned key shows `carlos-scan`, `carlos-scan-etag` and `carlos-scan-at`,
+ scanned key shows `carlos-scan`, `carlos-scan-version` and `carlos-scan-at`,
and the etag matches the object's current one.
- **A rewrite invalidates the verdict.** Overwrite one object, re-run
`scan status` — that object must count as unscanned until the next sweep
diff --git a/docs/superpowers/plans/2026-09-01-object-storage-virus-scanning.md b/docs/superpowers/plans/2026-09-01-object-storage-virus-scanning.md
index f31ffbdff..f02c1f6db 100644
--- a/docs/superpowers/plans/2026-09-01-object-storage-virus-scanning.md
+++ b/docs/superpowers/plans/2026-09-01-object-storage-virus-scanning.md
@@ -218,7 +218,7 @@ type DenyTag struct {
// the bytes it judged.
const (
TagVerdict = "carlos-scan"
- TagETag = "carlos-scan-etag"
+ TagETag = "carlos-scan-version"
TagAt = "carlos-scan-at"
)
```
@@ -1278,7 +1278,7 @@ func TestClamAVSkipsOversizeWithoutFetching(t *testing.T) { /* stub s3API, asser
// A verdict is written with the etag it judged, so a later rewrite reads as
// unknown rather than inheriting the old verdict.
-func TestClamAVTagsVerdictWithETag(t *testing.T) { /* assert PutObjectTagging carries carlos-scan-etag */ }
+func TestClamAVTagsVerdictWithETag(t *testing.T) { /* assert PutObjectTagging carries carlos-scan-version */ }
// clamd being unreachable is a PASS error, not thousands of failed tags.
// "The engine is unreachable" and "the engine read this object and failed on
diff --git a/internal/objstore/clamav.go b/internal/objstore/clamav.go
index a31246fbe..8a5470f48 100644
--- a/internal/objstore/clamav.go
+++ b/internal/objstore/clamav.go
@@ -148,6 +148,19 @@ func (c *clamavScanner) Verdict(ctx context.Context, bucket, key, version string
if verdict == "" || taggedVersion == "" {
return VerdictUnknown, nil
}
+ // denied and failed are RE-ISSUED every sweep, never carried forward.
+ //
+ // Both are transient by nature — a permissions fix, a Glacier restore, an
+ // engine that was having a bad day — and the tag is an audit trace of what
+ // happened last time, not a verdict to inherit. Reading them back as
+ // answers would make them permanent: the object is not rewritten, so its
+ // version never changes, so it would never be retried and the repair would
+ // never take. An SSE-KMS denial is the case that bites, because the
+ // tagging call needs no KMS access and therefore succeeds.
+ switch Verdict(verdict) {
+ case VerdictDenied, VerdictFailed:
+ return VerdictUnknown, nil
+ }
if taggedVersion != version {
// The bytes changed under the verdict. Not a failure — just unknown
// again, which is exactly what makes the next sweep re-scan it.
@@ -182,12 +195,12 @@ func (c *clamavScanner) Scan(ctx context.Context, bucket, key, version string, s
// account in an adopted bucket, would stall that store's coverage
// forever while every surface reported a sweep merely "in progress".
if isDenied(err) {
- if terr := c.tag(ctx, bucket, key, version, VerdictDenied); terr != nil {
- // Tagging it is best effort: if we cannot read the object we
- // may well not be able to tag it either. The verdict still
- // stands for this sweep's count.
- return VerdictDenied, nil
- }
+ // Tagging is best effort and its failure is deliberately ignored:
+ // if we may not read the object we may well not be able to tag it,
+ // and the verdict still stands for this sweep's count. The tag is
+ // only an audit trace here — Verdict re-issues denied rather than
+ // reading it back, so nothing depends on this landing.
+ _ = c.tag(ctx, bucket, key, version, VerdictDenied)
return VerdictDenied, nil
}
// Deleted between the listing and the fetch. Routine on a bucket
@@ -260,30 +273,53 @@ func (c *clamavScanner) tag(ctx context.Context, bucket, key, version string, v
{Key: s2aws(TagAt), Value: s2aws(time.Now().UTC().Format(time.RFC3339))},
}
// Read-merge-write. One extra call per object per LIFETIME, not per sweep:
- // an object whose verdict still matches its ETag never reaches Scan again.
- if existing, err := c.s3.GetObjectTagging(ctx, &awss3.GetObjectTaggingInput{
+ // an object whose verdict still matches its version never reaches Scan
+ // again.
+ existing, err := c.s3.GetObjectTagging(ctx, &awss3.GetObjectTaggingInput{
Bucket: &bucket,
Key: &key,
- }); err == nil {
- for _, t := range existing.TagSet {
- if !isScanTag(aws2s(t.Key)) {
- set = append(set, t)
- }
+ })
+ if err != nil {
+ // A tag set we cannot read must NOT be written over. PutObjectTagging
+ // replaces the whole set, so proceeding here would destroy exactly the
+ // foreign tags this merge exists to protect — silently, and on the one
+ // path where we know least about what is there. The sweep read this
+ // same object's tags moments ago through Verdict, so a failure now is
+ // transient: fail the page and let the next tick retry.
+ if isDenied(err) || isGone(err) {
+ return nil // nothing we could ever write, and not a pass failure
+ }
+ return fmt.Errorf("objstore: read tags before writing %s/%s: %w", bucket, key, err)
+ }
+ for _, t := range existing.TagSet {
+ if !isScanTag(aws2s(t.Key)) {
+ set = append(set, t)
}
}
- // A tag set we cannot read is not a reason to refuse to record a verdict:
- // the verdict is the point, and S3 caps the set at ten anyway.
- _, err := c.s3.PutObjectTagging(ctx, &awss3.PutObjectTaggingInput{
+
+ // S3 caps an object at ten tags. An object already carrying eight foreign
+ // ones cannot also hold our three, and erroring here would abandon the
+ // page unwritten every single tick — the exact permanent stall the denied
+ // classification was added to remove. Record the verdict for this sweep
+ // and move on; the object is simply re-scanned next time rather than
+ // blocking every object behind it.
+ if len(set) > maxObjectTags {
+ return nil
+ }
+
+ if _, err := c.s3.PutObjectTagging(ctx, &awss3.PutObjectTaggingInput{
Bucket: &bucket,
Key: &key,
Tagging: &s3types.Tagging{TagSet: set},
- })
- if err != nil {
+ }); err != nil {
return fmt.Errorf("objstore: tag %s/%s: %w", bucket, key, err)
}
return nil
}
+// maxObjectTags is S3's per-object tag limit.
+const maxObjectTags = 10
+
// isScanTag is the set this scanner owns and may overwrite. Everything else on
// the object belongs to whoever put it there.
func isScanTag(key string) bool {
diff --git a/internal/objstore/clamav_test.go b/internal/objstore/clamav_test.go
index 21b042a09..b0ee1490a 100644
--- a/internal/objstore/clamav_test.go
+++ b/internal/objstore/clamav_test.go
@@ -411,6 +411,9 @@ func TestClamAVUnreadableObjectIsDeniedNotAPassFailure(t *testing.T) {
if v != VerdictDenied {
t.Fatalf("verdict = %q, want denied", v)
}
+ // The tag is an audit trace, not an answer: the next sweep
+ // re-issues denied rather than reading it back. See
+ // TestClamAVDeniedAndFailedAreReIssuedNotInherited.
if wrote[TagVerdict] != string(VerdictDenied) {
t.Fatalf("tags = %v", wrote)
}
@@ -498,3 +501,93 @@ func TestClamAVPreservesForeignTags(t *testing.T) {
t.Fatalf("the scanner did not overwrite its own stale etag tag: %v", wrote)
}
}
+
+// denied and failed are RE-ISSUED every sweep, never carried forward.
+//
+// Both are transient — a permissions fix, a Glacier restore, an engine having
+// a bad day. Reading them back as answers would make them permanent: the
+// object is not rewritten, so its version never changes, so it would never be
+// retried and the repair would never take. The SSE-KMS case is the one that
+// bites, because tagging needs no KMS access and so the tag does land.
+func TestClamAVDeniedAndFailedAreReIssuedNotInherited(t *testing.T) {
+ for _, stuck := range []Verdict{VerdictDenied, VerdictFailed} {
+ t.Run(string(stuck), func(t *testing.T) {
+ s3 := &stubScanS3{t: t}
+ s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) {
+ return taggingOutput(TagVerdict, string(stuck), TagVersion, "v1"), nil
+ }
+ c := &clamavScanner{s3: s3, engine: &fakeEngine{}}
+
+ v, err := c.Verdict(context.Background(), "b", "k", "v1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if v != VerdictUnknown {
+ t.Fatalf("verdict = %q, want unknown — %q must be retried, not inherited", v, stuck)
+ }
+ })
+ }
+
+ // clean and infected ARE carried forward: they are answers about bytes
+ // that have not changed.
+ for _, keep := range []Verdict{VerdictClean, VerdictInfected, VerdictSkipped} {
+ t.Run(string(keep), func(t *testing.T) {
+ s3 := &stubScanS3{t: t}
+ s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) {
+ return taggingOutput(TagVerdict, string(keep), TagVersion, "v1"), nil
+ }
+ c := &clamavScanner{s3: s3, engine: &fakeEngine{}}
+ v, err := c.Verdict(context.Background(), "b", "k", "v1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if v != keep {
+ t.Fatalf("verdict = %q, want %q carried forward", v, keep)
+ }
+ })
+ }
+}
+
+// A tag set we cannot READ must never be written over. PutObjectTagging
+// replaces the whole set, so writing blind here destroys exactly the foreign
+// tags the merge exists to protect — on the one path where we know least about
+// what is there.
+func TestClamAVDoesNotBlindWriteWhenTagReadFails(t *testing.T) {
+ s3 := &stubScanS3{t: t} // putTagging unset: any write fails the test
+ s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
+ return objectBody("hello"), nil
+ }
+ s3.getTagging = func(*awss3.GetObjectTaggingInput) (*awss3.GetObjectTaggingOutput, error) {
+ return nil, apiErr("SlowDown")
+ }
+ c := &clamavScanner{s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20}
+
+ if _, err := c.Scan(context.Background(), "b", "k", "v1", 5); err == nil {
+ t.Fatal("an unreadable tag set was written over anyway")
+ }
+}
+
+// An object already carrying eight foreign tags cannot also hold our three —
+// S3 caps an object at ten. Erroring there would abandon the page unwritten
+// every tick forever, which is the exact permanent stall the denied
+// classification was added to remove. Record the verdict, skip the write.
+func TestClamAVSkipsTagWriteRatherThanStallAtTheTagLimit(t *testing.T) {
+ var foreign []string
+ for i := 0; i < 8; i++ {
+ foreign = append(foreign, "own-"+string(rune('a'+i)), "v")
+ }
+ s3 := &stubScanS3{t: t} // putTagging unset: an 11-tag write fails the test
+ s3.getObject = func(*awss3.GetObjectInput) (*awss3.GetObjectOutput, error) {
+ return objectBody("hello"), nil
+ }
+ s3.allowTagRead(foreign...)
+ c := &clamavScanner{s3: s3, engine: &fakeEngine{res: clamd.Result{Status: clamd.StatusClean}}, maxBytes: 1 << 20}
+
+ v, err := c.Scan(context.Background(), "b", "k", "v1", 5)
+ if err != nil {
+ t.Fatalf("a heavily tagged object stalled the sweep: %v", err)
+ }
+ if v != VerdictClean {
+ t.Fatalf("verdict = %q, want clean — it was scanned, just not recorded", v)
+ }
+}
diff --git a/internal/objstore/sweep.go b/internal/objstore/sweep.go
index aa834015d..53f589fa0 100644
--- a/internal/objstore/sweep.go
+++ b/internal/objstore/sweep.go
@@ -148,6 +148,17 @@ func RunSweep(ctx context.Context, s release.Store, lister ObjectLister, sc Scan
return markNotApplicable(ctx, s, acct, app)
}
+ // An engine enabled with no attestation on record is refused rather than
+ // swept. The grant door will not create that state any more, so this is
+ // unreachable in practice — but sweeping reads tenant object bytes, and
+ // the attestation is the consent for that. Guessing "probably plaintext"
+ // is not a decision this walk gets to make on the member's behalf.
+ if rec.Scanning.Requested == nil {
+ return nil, fmt.Errorf("objstore: %s/%s: scanning is enabled but no attestation is on record "+
+ "— the member runs `carlos store scan request` to say whether these objects are readable",
+ acct, app)
+ }
+
if sc == nil {
return nil, fmt.Errorf("objstore: %s/%s has scanning enabled but no engine is configured "+
"(set CARLOS_STORE_SCANNER)", acct, app)
@@ -228,8 +239,10 @@ func RunSweep(ctx context.Context, s release.Store, lister ObjectLister, sc Scan
if err != nil {
return nil, err
}
- fresh++
- } else {
+ if isCoverage(v) {
+ fresh++
+ }
+ } else if isCoverage(v) {
reused++
}
side.Current.count(v, o.Size)
@@ -267,6 +280,15 @@ func RunSweep(ctx context.Context, s release.Store, lister ObjectLister, sc Scan
}
}
+// isCoverage is a verdict that counts as Scanned. Fresh and Reused split THAT
+// number, not the object count: an object that was skipped or denied was never
+// judged at all, so calling its verdict "fresh" would make the printed
+// arithmetic — "N of M scanned, of those X judged now" — visibly wrong the
+// moment a single object is too large.
+func isCoverage(v Verdict) bool {
+ return v == VerdictClean || v == VerdictInfected
+}
+
// count folds one object's verdict into the rollup.
//
// Scanned and Unscanned are not complements, and the arithmetic here is the
diff --git a/internal/objstore/sweep_test.go b/internal/objstore/sweep_test.go
index 0b7975a5a..2ea2ade96 100644
--- a/internal/objstore/sweep_test.go
+++ b/internal/objstore/sweep_test.go
@@ -20,11 +20,22 @@ type memLister struct {
p *MemProvisioner
pageSize int
calls int
+ // modified is each key's last-modified time. It is half of the version a
+ // verdict binds to, so a lister that left it zero would make every sweep
+ // test blind to that half.
+ modified map[string]time.Time
// before runs at the top of each ListPage, so a test can make the world
// change underneath a sweep at the one moment that matters.
before func()
}
+func (l *memLister) touch(key string, at time.Time) {
+ if l.modified == nil {
+ l.modified = map[string]time.Time{}
+ }
+ l.modified[key] = at
+}
+
func (l *memLister) ListPage(_ context.Context, bucket, startAfter string, _ int32) ([]ScanObject, bool, error) {
l.calls++
if l.before != nil {
@@ -50,10 +61,15 @@ func (l *memLister) ListPage(_ context.Context, bucket, startAfter string, _ int
for _, k := range keys {
body := l.p.Buckets[bucket][k]
sum := md5.Sum(body)
+ mod := l.modified[k]
+ if mod.IsZero() {
+ mod = time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
+ }
objs = append(objs, ScanObject{
- Key: k,
- ETag: hex.EncodeToString(sum[:]),
- Size: int64(len(body)),
+ Key: k,
+ ETag: hex.EncodeToString(sum[:]),
+ Modified: mod,
+ Size: int64(len(body)),
})
}
return objs, truncated, nil
@@ -471,3 +487,82 @@ func (g *goneScanner) Scan(_ context.Context, _, key, _ string, _ int64) (Verdic
func (g *goneScanner) Provenance(context.Context) (string, string, error) {
return "clamav", "test/0/never", nil
}
+
+// The sweep binds verdicts to the object VERSION, not the ETag alone.
+//
+// Same bytes, later last-modified: an overwrite that happened to produce the
+// same content hash. A chosen-prefix MD5 collision is exactly this shape, and
+// it is the case enforcement exists for — an app that accepts uploads and
+// serves them back. Passing only the ETag here would let the payload inherit
+// the clean verdict of the object it replaced, and every existing sweep test
+// would still have passed.
+func TestSweepRescansWhenOnlyLastModifiedChanges(t *testing.T) {
+ ctx := context.Background()
+ s, p, sc, bucket := sweepFixture(t, 1)
+ l := &memLister{p: p}
+
+ first, err := RunSweep(ctx, s, l, sc, "bab", "titogo", time.Minute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first.LastComplete.Fresh != 1 {
+ t.Fatalf("setup: fresh = %d, want 1", first.LastComplete.Fresh)
+ }
+
+ // The bytes are untouched — so the ETag is identical — but the object was
+ // rewritten.
+ _ = bucket
+ l.touch(objectName(0), time.Date(2026, 9, 2, 0, 0, 0, 0, time.UTC))
+
+ second, err := RunSweep(ctx, s, l, sc, "bab", "titogo", time.Minute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if second.LastComplete.Fresh != 1 || second.LastComplete.Reused != 0 {
+ t.Fatalf("fresh=%d reused=%d — an overwritten object inherited its old verdict",
+ second.LastComplete.Fresh, second.LastComplete.Reused)
+ }
+}
+
+// Fresh and Reused split SCANNED, not the object count. An object that was
+// skipped was never judged, so counting its verdict as "judged during this
+// sweep" makes the printed arithmetic — "N of M scanned, of those X judged
+// now" — visibly wrong the moment one object is too large.
+func TestSweepFreshAndReusedSplitScannedNotObjects(t *testing.T) {
+ s, p, sc, _ := sweepFixture(t, 3)
+ sc.ScanAs(objectName(1), VerdictSkipped)
+
+ side, err := RunSweep(context.Background(), s, &memLister{p: p}, sc, "bab", "titogo", time.Minute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := side.LastComplete
+ if got.Fresh+got.Reused != got.Scanned {
+ t.Fatalf("fresh(%d)+reused(%d) = %d, but scanned = %d of %d objects",
+ got.Fresh, got.Reused, got.Fresh+got.Reused, got.Scanned, got.Objects)
+ }
+ if got.Fresh != 2 {
+ t.Fatalf("fresh = %d, want 2 — the skipped object was never judged", got.Fresh)
+ }
+}
+
+// An engine enabled with no attestation on record is refused, not swept.
+// Sweeping reads tenant object bytes and the attestation is the consent for
+// that; guessing "probably plaintext" is not this walk's decision to make.
+func TestSweepRefusesWhenNoAttestationIsOnRecord(t *testing.T) {
+ ctx := context.Background()
+ s, p, sc, _ := sweepFixture(t, 1)
+
+ rec, etag, err := Get(ctx, s, "bab", "titogo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec.Scanning.Requested = nil
+ if _, err := Put(ctx, s, "bab", "titogo", rec, etag); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := RunSweep(ctx, s, &memLister{p: p}, sc, "bab", "titogo", time.Minute); err == nil {
+ t.Fatal("a store with no attestation on record was swept anyway")
+ }
+}