carlos / platform Public

plan: draft 4 — cleared to implement from

Review 3's verdict was yes, subject to three under-specifications in the
seam task 10 exports and one misdirected error message.

The message first, because it is the kind of thing that ships. I had
pointed scanNoEngine — which fires only when the Scanner is nil, a
wiring state — at the fix for a disabled record. An operator whose
record is already enabled would have been told to run enable, and the
two 503s would have read the same, which is precisely what
TestScanStatusReportsDisabledDistinctFromUnreachable exists to prevent.
Wiring names CARLOS_CLAMD_ADDR; policy names the command.

Drained could not feed the meter: §8 counts bytes scanned on demand and
the struct had no size, and an item answered from an existing tag was
scanned by the sweep — which the metering test says must not be billed
to the tenant — with no way to tell the two apart. Size and Scanned
added.

DrainApp now owns Observe and Release outright. Draft 3 had it take the
index and also return listedEmpty "so console can call Release", which
would have needed console to read Observe before the listing — an
ordering it cannot see, and exactly the ordering the generation counter
protects.

Also: a third fixture arms a scanner by hand rather than through either
helper, so task 5's list was off by one; grant must stamp
webhook_key_id or the first rotation restarts every app for nothing; the
deployment-wide queue depth had no owner after task 6 was correctly
ruled out; and the reorder left one stale cross-reference.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 42b3663289031f46e7ecb3e60f65758b93f7a85f parent 6c39a61
1 file changed, +44 −15
  • docs/superpowers/plans/2026-09-02-scan-on-demand.md +44 −15
diff --git a/docs/superpowers/plans/2026-09-02-scan-on-demand.md b/docs/superpowers/plans/2026-09-02-scan-on-demand.md
index 61784beaf..a0dd8036a 100644
--- a/docs/superpowers/plans/2026-09-02-scan-on-demand.md
+++ b/docs/superpowers/plans/2026-09-02-scan-on-demand.md
@@ -1,6 +1,6 @@
# Scan on demand: implementation plan
-**Status:** draft 3 (closes plan reviews 1 and 2).
+**Status:** draft 4 — cleared to implement from (closes plan reviews 1-3).
**Spec:** `docs/superpowers/specs/2026-09-02-scan-on-demand-design.md` (draft 7,
cleared after six review rounds).
**Branch:** `worktree-store-scan-on-demand`, stacked on the landed scanner work.
@@ -147,9 +147,17 @@ plus the comment sweep in `objstore.go:86`, `scanner.go:89`, `server.go:522-523`
- [ ] `TestRunSweepRefusesWhenDeploymentDisabled` — `sweep.go:163` still reads
`if sc == nil … (set CARLOS_STORE_SCANNER)`.
- [ ] `TestScanStatusReportsDisabledDistinctFromUnreachable` (`apiscan.go:71-81`).
-- [ ] Update the two tests pinning the old wording: `apiscan_test.go:84` and
- `:353` both assert `strings.Contains(msg, "CARLOS_STORE_SCANNER")`.
-- [ ] `apiscan.go:45`'s message names `carlos system antivirus enable`.
+- [ ] **Keep the two 503s distinct.** `scanNoEngine` (`apiscan.go:43-46`) fires
+ only when `StoreScanner == nil` — a *wiring* state — so it names
+ **`CARLOS_CLAMD_ADDR`**. The new refusal, for a record that says disabled,
+ names **`carlos system antivirus enable`**. Draft 3 pointed the first at the
+ second's fix, which would tell an operator whose record is already enabled to
+ enable it, and would collapse the two states
+ `TestScanStatusReportsDisabledDistinctFromUnreachable` exists to separate.
+- [ ] Update the two tests pinning the old wording — `apiscan_test.go:85` and
+ `:353` — to assert `CARLOS_CLAMD_ADDR`. Both run under `scanArmed`, which
+ after this task writes an enabled record and then nils the scanner: "enabled,
+ not wired", not "disabled".
**This task breaks roughly twenty-five existing tests, and every one of them
is a fixture that arms a scanner with no deployment record.** Draft 2 caught
@@ -161,9 +169,13 @@ Absent record = **not enabled**, so:
calls in `sweep_test.go`; without the record every one errors at the new
refusal.
- `scanArmed` (`apiscan_test.go:24`) writes one too. `/grant` is expected to
- return 200 at `apiscan_test.go:105,168,210,283,330,343`, and
+ return 200 at `apiscan_test.go:105,168,283,330,343`, and
`TestScanGrantRefusedWithoutMemberRequest` (`:315`) expects 409 but would meet
the new 503 first.
+- **`TestScanEnforceWritesTheDenyToThePolicy` (`apiscan_test.go:204`) is the
+ third fixture**, and uses neither helper: it arms via `storeArmed` plus a
+ hand-set `cfg.StoreScanner` at `:207`, then expects `/grant` 200 at `:210`.
+ Switch it to `scanArmed` or give it its own record.
**Where the check goes in `RunSweep`:** replace the `sc == nil` arm at
`sweep.go:162-165`, *after* the `Enabled == nil` early return at `:128` — put it
@@ -313,7 +325,9 @@ func (i *QueueIndex) Depth() map[AppRef]int
type Drained struct {
Key, Version, ETag string
Verdict Verdict
- Recorded bool
+ Recorded bool // the tag was written
+ Scanned bool // Scan actually ran, vs answered from a tag
+ Size int64
Engine, Signature string
CallbackPath string
}
@@ -321,9 +335,18 @@ func DrainApp(ctx, s, sc, idx, acct, app string, cap int) (done []Drained, liste
```
**The return type is not incidental.** Console needs per-item outcomes for the
-long-poll wake (13), the callbacks (16) and the per-tick usage fold (17), and it
-needs `listedEmpty` to call `Release` correctly (§7.1). Without `Apps()` and
-`Depth()` console reaches back for the mutex the split exists to hide.
+long-poll wake (13), the callbacks (16) and the per-tick usage fold (17).
+`Size` and `Scanned` are there for the meter specifically: §8 counts *bytes*
+scanned on demand, and an item answered from an existing tag was scanned by the
+**sweep**, which `TestScanUsageCountsOnDemandOnly` says must not be attributed
+to the tenant. Without `Scanned` the drain cannot tell the meter which is which.
+
+**`DrainApp` owns `Observe` and `Release`.** It takes `idx` and calls both;
+`listedEmpty` is returned for the caller's information, not as an instruction.
+The alternative — console calling them — requires reading `Observe` *before* the
+listing, an ordering console cannot see, and that ordering is exactly what
+§7.1's generation counter protects. Without `Apps()` and `Depth()` console
+reaches back for the mutex the split exists to hide.
- [ ] `TestQueueIDSortsByTime` — hand-rolled, no ULID dependency.
- [ ] `TestEnqueueWritesObjectBeforeBump` — a 202 between the two invites a
@@ -380,8 +403,8 @@ Absence is `os.ErrNotExist` (`trash.go:64`), never an error.
## Task 12: the drain goroutine and the boot rebuild
**The boot rebuild had no task in draft 1**, though the spec calls it "the only
-thing that collects a stranded item, so it is not optional" and task 11's age
-test would have tested a collector nobody called.
+thing that collects a stranded item, so it is not optional" and task 9's
+`TestResultObjectCollectedByAge` would have tested a collector nobody called.
**Files:** `internal/console/scandrain.go` (new) + test.
@@ -448,7 +471,10 @@ app `^[a-z][a-z0-9-]{0,31}$` (`release.go:242`).
hook), `apisystem.go`, root `system.go`, `authcli.go`.
- [ ] `TestGrantDeliversWebhookKey` — sealed via `appconfig.Apply`
- (`generation.go:119`), gated by `storeSealKey` (`apistore.go:425`).
+ (`generation.go:119`), gated by `storeSealKey` (`apistore.go:425`). **Grant
+ stamps `webhook_key_id` as well as rotation does**, or the first
+ `rotate-webhook-key` after any grant re-seals and restarts every app under an
+ unchanged master.
- [ ] `TestRotateWebhookKeySkipsUnchangedApps` — **not "re-seals identical
plaintext".** `appconfig.Seal` uses a fresh ephemeral key and nonce on every
call (`internal/appconfig/crypto.go:85-87`), so the ciphertext always differs
@@ -488,8 +514,9 @@ hook), `apisystem.go`, root `system.go`, `authcli.go`.
## Task 17: metering, surfaces and docs
**Files:** `internal/usage/scan.go` + **`scan_test.go` (new)**,
-`internal/console/scandrain.go` (the per-tick fold), `apiscan.go`, root
-`scan.go` and `store.go`, `docs/site/cli.md`,
+`internal/console/scandrain.go` (the per-tick fold), `apiscan.go`,
+**`apisystem.go`**, root `scan.go`, **root `system.go`**, root `store.go`,
+`docs/site/cli.md`,
`docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md`.
- [ ] `TestScanUsageCountsOnDemandOnly` — sweep scans are not attributed to a
@@ -498,7 +525,9 @@ hook), `apisystem.go`, root `system.go`, `authcli.go`.
- [ ] `TestStoreScanStatusReportsDeploymentState` — a per-app record saying
`enabled: clamav` while the deployment switch is off is a new way for the
status output to lie.
-- [ ] `TestPrintScanStatusShowsQueueDepth`.
+- [ ] `TestPrintScanStatusShowsQueueDepth` (per app) and
+ **`TestPrintSystemAntivirusStatusShowsQueueDepth`** (deployment-wide, §4.4).
+ Task 6 lands before the drain exists and cannot surface either.
`usage/scan/<date>.json`, CAS-merged as `UpsertEdgeDay` (`edge.go:85-116`);
meter names frozen once merged (`usage.go:4-5`). Rollout plan step 4 becomes