carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Scan on demand: implementation plan
2
3**Status:** draft 4 — cleared to implement from (closes plan reviews 1-3).
4**Spec:** `docs/superpowers/specs/2026-09-02-scan-on-demand-design.md` (draft 7,
5cleared after six review rounds).
6**Branch:** `worktree-store-scan-on-demand`, stacked on the landed scanner work.
7
8Seventeen tasks, each its own commit, each TDD.
9
10---
11
12## Global constraints
13
14- `go build ./...`, `go vet ./...`, `gofmt -l .` clean before every commit.
15- **No new third-party dependencies.** Signing is `crypto/hmac` + `crypto/sha256`.
16 **There is no ULID library in this repo and none is being added** — queue ids
17 are hand-rolled: a big-endian millisecond timestamp plus random bytes,
18 base32-encoded, with a test that they sort by time.
19- Prefix Go commands with `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache`.
20- Five root-package tests (`TestAccountsList*`, `TestResolveMode*`) fail on this
21 machine for an environmental reason confirmed at `8103270`. Not a regression.
22- **`s3:PutObjectTagging` must never enter the app's policy.**
23- `grant` and `provisioned` in `store.json` stay frozen.
24
25### The mistake this design keeps making
26
27Three review rounds caught the same class of bug: **a new durable fact about an
28object, introduced without carrying the object version across the seam.** The
29ETag binding, then `Status` versus `Verdict`, then the result object. Tasks 7,
309 and 13 carry the tests that exist because of it.
31
32### Helpers that already exist — use them, do not invent
33
34`storeArmed` (`apistore_test.go:505`), `scanArmed` (`apiscan_test.go:24`),
35`putRecord`/`storedRecord` (`apistore_test.go:60,48`), `cliToken`
36(`authcli_test.go:474`), `newConsoleFixture` (`server_test.go:44`),
37`sweepFixture`/`memLister`/`memLister.before` (`sweep_test.go:88,19,206`),
38`stubScanS3`/`fakeEngine`/`taggingOutput` (`clamav_test.go:24,55,264`),
39`rotateFixture`/`testSealKeypair`/`testDeployment` (`walk_test.go:573,20,52`),
40`closedDoors` (`svcroutes_test.go:83`), **`serviceToken`
41(`svcfixture_test.go:14`)** for the role tests, **`brokenStore`
42(`apiledger_test.go:658`)** for read-error tests, `refusingLister`/`withLister`
43(`scanpass_test.go:36,64`).
44
45---
46
47## Task 1: `svcrole.Service`, and per-app scope
48
49**Files:** `internal/svcrole/svcrole.go`, `internal/console/svcprincipal.go`,
50`apiservices.go`, `svcpage.go`, root `services.go`.
51**Tests:** `svcrole_test.go`, `apiservices_test.go`.
52
53- [ ] `TestServiceRoleIsMintable` — `Parse` round-trips, `All()` includes it, so
54 the mint door and the form pick it up unchanged.
55- [ ] `TestServiceCredentialCannotBeAccountScoped` — in `NewServicePrincipal`
56 (`svcprincipal.go:170-172`), **not** a handler: two mint doors exist
57 (`apiservices.go:211`, `svcpage.go:299`) and a handler check leaves one open.
58 New sentinel `ErrAccountScopedService`, with its own 400 arm in both —
59 `apiservices.go:211-215` matches only `ErrServiceNameTaken` today.
60- [ ] Confirm `TestInstanceRoleMatchesTodaysAppTokenReach`
61 (`svcroutes_test.go:262-291`) still passes rather than writing a second one.
62
63**Gotcha:** `services.go` hardcodes the role list **three times** (`:33`, `:157`,
64`:164`).
65
66**Commit:** `svcrole: a Service role for apps calling platform services`
67
68---
69
70## Task 2: the deployment settings record
71
72**Files:** `internal/release/system.go` + test.
73
74- [ ] `TestSystemKeyShape`, `TestAntivirusSchemaRefusesUnknown` (schema 2 is an
75 error, not a guess — this record decides whether tenant bytes get read),
76 `TestPutAntivirusCreateOnlyThenReplace`.
77
78**Commit:** `release: system/<name>.json, a typed deployment settings record`
79
80---
81
82## Task 3: clamd over TCP
83
84**Moved ahead of the wiring task.** If wiring lands first,
85`CARLOS_CLAMD_ADDR=host:port` is accepted and then dialled as a unix path —
86`clamd.go:130` is the only `"unix"` literal and is unchanged until this commit.
87
88**Files:** `internal/clamd/clamd.go` + test.
89
90- [ ] `TestNewDialsTCPWhenAddrIsHostPort`, `TestNewDialsUnixWhenAddrBeginsWithSlash`.
91- [ ] The existing INSTREAM tests pass unchanged over TCP — framing, deadlines
92 and parsing are transport-independent (`clamd.go:76-79,173-187`).
93
94`New(addr, timeouts)` keeps its arity, so no caller moves.
95
96**Commit:** `clamd: dial tcp or unix, decided by the address`
97
98---
99
100## Task 4: wiring, migration and the seed
101
102**Files:** `cmd/console/main.go`, `internal/console/server.go`, `scanpass.go`.
103**Tests:** `scanpass_test.go`, `cmd/console/main_test.go`.
104
105- [ ] `TestScannerWiringAcceptsDeprecatedVar` / `...FatalsOnUnknownEngine` /
106 `...FatalsOnConflictingAddrAndSocket`. **Refactor a pure
107 `scannerWiring(getenv func(string) string) (addr string, seed bool, err error)`**
108 so the fatal path is testable; `main.go` calls `log.Fatal` on its error.
109 `main_test.go` exists today with only `TestMain`/`TestMigrateAppVerb`.
110- [ ] `TestScanPassSeedsRecordOnFirstTick` — create-only, `by: "migration from
111 CARLOS_STORE_SCANNER"`.
112- [ ] `TestScanPassSeedDoesNotOverrideExistingRecord`.
113- [ ] `TestScanPassSeedIsSilentOnReadError` — use `brokenStore`. Seeding on a
114 failed read would enable scanning on a deployment that had disabled it.
115- [ ] `TestScanPassDoesNothingWhenDisabled` — `refusingLister`.
116
117**Gotchas.** The seed goes at the top of the first tick, not `storeScannerFor`,
118which runs at `main.go:262` before `openStore()` at `:269`; `StartScanPass` runs
119`scanPass` before waiting on the ticker (`scanpass.go:43-49`), so it happens at
120boot in practice. `ErrCASConflict` is success.
121
122**This task breaks five existing tests and must fix them in the same commit:**
123`TestScanPassSweepsOnlyEnabledStores`, `...HonoursReSweepInterval`,
124`...ContinuesPastOneAppsFailure`, `...AlwaysContinuesAnInFlightSweep`,
125`...ResweepsAfterReattestationToPlaintext` (`scanpass_test.go:93,126,165,196,241`)
126all drive `scanPass` and expect a sweep. `enableStore` (`scanpass_test.go:44`)
127writes an enabled `system/antivirus.json`. Give the record to
128`TestScanPassStillSkipsEncryptedStore` (`:275`) as well — it currently passes
129for the wrong reason, since `refusingLister` is never consulted whether or not
130encryption is the cause.
131
132**Commit:** `console: antivirus policy moves to a record, wiring stays in env`
133
134---
135
136## Task 5: the doors and `RunSweep` read the record
137
138**The plan's first draft omitted this entirely, so `disable` did not disable.**
139The tick went quiet while `carlos store scan sweep` still read tenant bytes.
140
141**Files:** `internal/console/apiscan.go`, `scanpass.go`, `internal/objstore/sweep.go`,
142plus the comment sweep in `objstore.go:86`, `scanner.go:89`, `server.go:522-523`,
143`main.go:772`.
144
145- [ ] `TestScanGrantRefusedWhenDeploymentDisabled` (`apiscan.go:252`).
146- [ ] `TestSweepNowRefusedWhenDeploymentDisabled` (`scanpass.go:198`).
147- [ ] `TestRunSweepRefusesWhenDeploymentDisabled` — `sweep.go:163` still reads
148 `if sc == nil … (set CARLOS_STORE_SCANNER)`.
149- [ ] `TestScanStatusReportsDisabledDistinctFromUnreachable` (`apiscan.go:71-81`).
150- [ ] **Keep the two 503s distinct.** `scanNoEngine` (`apiscan.go:43-46`) fires
151 only when `StoreScanner == nil` — a *wiring* state — so it names
152 **`CARLOS_CLAMD_ADDR`**. The new refusal, for a record that says disabled,
153 names **`carlos system antivirus enable`**. Draft 3 pointed the first at the
154 second's fix, which would tell an operator whose record is already enabled to
155 enable it, and would collapse the two states
156 `TestScanStatusReportsDisabledDistinctFromUnreachable` exists to separate.
157- [ ] Update the two tests pinning the old wording — `apiscan_test.go:85` and
158 `:353` — to assert `CARLOS_CLAMD_ADDR`. Both run under `scanArmed`, which
159 after this task writes an enabled record and then nils the scanner: "enabled,
160 not wired", not "disabled".
161
162**This task breaks roughly twenty-five existing tests, and every one of them
163is a fixture that arms a scanner with no deployment record.** Draft 2 caught
164this for task 4 and then repeated the omission in the task it added to fix it.
165Absent record = **not enabled**, so:
166
167- `enableScanning` (`teardown_scan_test.go:16`) writes an enabled
168 `system/antivirus.json`. It backs 17 `sweepFixture` calls and 20 `RunSweep`
169 calls in `sweep_test.go`; without the record every one errors at the new
170 refusal.
171- `scanArmed` (`apiscan_test.go:24`) writes one too. `/grant` is expected to
172 return 200 at `apiscan_test.go:105,168,283,330,343`, and
173 `TestScanGrantRefusedWithoutMemberRequest` (`:315`) expects 409 but would meet
174 the new 503 first.
175- **`TestScanEnforceWritesTheDenyToThePolicy` (`apiscan_test.go:204`) is the
176 third fixture**, and uses neither helper: it arms via `storeArmed` plus a
177 hand-set `cfg.StoreScanner` at `:207`, then expects `/grant` 200 at `:210`.
178 Switch it to `scanArmed` or give it its own record.
179
180**Where the check goes in `RunSweep`:** replace the `sc == nil` arm at
181`sweep.go:162-165`, *after* the `Enabled == nil` early return at `:128` — put it
182before and `TestSweepIgnoresStoreWithoutScanning` changes meaning.
183
184Also reword `walk.go:466`, which still tells an operator to set
185`CARLOS_STORE_SCANNER` and is pinned by `teardown_scan_test.go:72`.
186
187**Commit:** `console: disable actually disables`
188
189---
190
191## Task 6: `system antivirus` doors and CLI
192
193**Files:** `internal/console/apisystem.go` (new), `authcli.go`,
194`svcroutes_test.go` (closedDoors), root `system.go` (new), `apimode.go`,
195root `store.go` (dispatch only).
196
197- [ ] `TestSystemAntivirusNeedsOperator` — `storeOperator(addr)`
198 (`apistore.go:383`). Operators are addresses (`server.go:558-571`); a service
199 actor is `service:`/`app:` (`apicli.go:89-100`) and can never match, which is
200 why the role gate is not the authority here.
201- [ ] `TestSystemAntivirusEnableRefusesWithoutScanner` — 503.
202- [ ] `TestSystemAntivirusStatusReportsWiredWithNoRecord`.
203- [ ] `TestPrintSystemAntivirusStatus` — against a literal reply.
204
205Registered `svcrole.Set{}` and added to `closedDoors`; no bespoke
206"refuses service principals" test is needed —
207`TestEveryClosedDoorRefusesAServiceCredential` (`svcroutes_test.go:640`) already
208dials every closed door with a live Admin credential.
209
210**Gotcha:** no `{account}` segment, so `apiPrincipalAccount`
211(`apicli.go:176-187`) cannot resolve it. `GET /api/cli/apps` (`authcli.go:471`)
212is the person-only precedent.
213
214**Commit:** `console: carlos system antivirus`
215
216---
217
218## Task 7: `Scanner.Status`, and `Scan` reports whether it recorded
219
220**Files:** `internal/objstore/scanner.go`, `clamav.go`, `memscanner.go`, `sweep.go`.
221**Tests:** `scanner_contract_test.go`, `clamav_test.go`, `sweep_test.go`.
222
223- [ ] `TestStatusReturnsObjectSoCallerCanScan` — the `ScanObject` is populated
224 on the no-tag path. `Scan` needs version and size and the drain has no
225 listing page, so a verdict-only `Status` forces a `HeadObject` outside the
226 scanner — what keeping the token inside it forbids.
227- [ ] `TestStatusDoesNotApplyTheReIssueRule` — a stored `failed`/`denied` comes
228 back as itself. `Verdict` keeps mapping them to `Unknown`
229 (`clamav.go:151-163`) so the sweep retries; that is retry policy, and leaving
230 it in `Status` makes a failed scan read `pending` forever.
231- [ ] `TestStatusOnDeniedHead` — `(VerdictDenied, ScanObject{}, nil)`.
232- [ ] `TestStatusOnMissingObject` — `ErrObjectGone`.
233- [ ] `TestScanReportsUnrecorded` — the ten-tag cap (`clamav.go:306-308`).
234- [ ] `TestSweepCountsUnchangedByRecordedFlag`.
235
236**Gotchas.** `tag()` returns `nil` silently at `clamav.go:291,307`; it must
237report whether it wrote or `recorded` is a lie. `Scan`'s denied path
238(`clamav.go:203`) discards `tag`'s result — `recorded` is `false` there. The new
239return touches **12 call sites in `clamav_test.go` and one in the contract test**
240(`scanner_contract_test.go:33`) — and, unnamed in draft 2, **`goneScanner.Scan`
241(`sweep_test.go:481`)**, which overrides the old signature and must change in
242the same commit. It embeds `MemScanner` by value, so `Status` arrives for free.
243`MemScanner.Verdict` (`memscanner.go:89-100`) does not apply the re-issue rule
244today, so it gains it; `MemScanner` also gains `PutObject` and
245`Unrecordable(key)`. `scanS3API` and `stubScanS3` gain `HeadObject`.
246
247Large, and necessarily atomic — the signature change cannot be split.
248
249**Commit:** `objstore: Status answers about an object, Verdict decides retries`
250
251---
252
253## Task 8: `release.PageLister`
254
255**Files:** `internal/release/store.go` + test.
256
257- [ ] `TestDirStorePageListerOrdersAndBounds` — `ListMod` is already key-sorted
258 (`release/store.go:290`).
259- [ ] `TestS3StorePageLister` — **against an `httptest` handler**, asserting
260 `start-after`, `max-keys` and the prefix on the `ListObjectsV2` query and
261 returning two pages. Draft 2 said "RGW-gated, like every other S3Store test";
262 that was wrong. `store_rgw_test.go:94,151,184` are **not** env-gated — they
263 stand up an in-process fake (`newRGWFake`, `:27`, then `NewS3Store`,
264 `:99-101`), which is exactly the seam draft 2 claimed did not exist. Gating
265 this test would leave the drain's `StartAfter`/`MaxKeys` path unexercised in
266 CI, which is the one path that matters here.
267
268An **optional interface** type-asserted at the call site as `SizeLister`
269(`release/store.go:181-185`) is at `economicspass.go:141` — not a method on
270`release.Store`, which has eleven implementations.
271
272**Commit:** `release: PageLister, an optional bounded listing`
273
274---
275
276## Task 9: the result object
277
278**Moved ahead of the queue core.** Draft 2 had the queue core first, so
279`DrainApp` would have landed with nowhere to put an unrecorded verdict — the
280exact §5.2.1 bug the result object exists to prevent. Task 9 is independent of
281everything else; task 10 needs it.
282
283**Files:** `internal/objstore/scanresult.go` (new) + test.
284
285**Produces:** `WriteScanResult`, `ReadScanResult(ctx, s, acct, app, key,
286currentVersion)` — absent on version mismatch, so no caller compares versions
287itself.
288
289- [ ] `TestResultObjectIsVersionBound` — **review 5's blocker, the third
290 appearance of this bug class.** A result written for v1 must not answer for
291 v2. Unit-level: `ReadScanResult` with a different `currentVersion` is absent.
292- [ ] `TestResultObjectReplacesOnSecondWrite` — read-then-CAS. `Put` has only
293 create-only and replace (`release/store.go:37-45`); a create-only second write
294 returns `ErrCASConflict` and silently drops the v2 verdict.
295- [ ] `TestResultObjectNotWrittenUnderPurgedPrefix` — the `store.json` re-read
296 guard (`sweep.go:358-367`), using `memLister.before`. A stranded queue item is
297 drained next tick; a stranded **result** object is found by nothing, blocks the
298 claim gate (`server.go:2921`), and — because `materialize.go:333-345,377-382`
299 counts any key and restores a trashed row — **resurrects the app** on the next
300 boot.
301- [ ] `TestResultObjectCollectedByAge` — with a `ModLister`
302 (`release/store.go:161`) fallback that skips and logs once, as `storagePass`
303 does (`economicspass.go:141-145`).
304
305**Commit:** `objstore: the scan result object, version-bound and guarded`
306
307---
308
309## Task 10: the queue core
310
311**Files:** `internal/objstore/queue.go` (new) + test.
312
313**Produces:** `QueueKey`, `QueueItem`, `NewQueueID`, **`Enqueue(ctx, s, idx,
314acct, app, item)`** — which draft 2 dropped from Produces while keeping the test
315that needs it — plus:
316
317```go
318type QueueIndex struct{ … } // exported: console holds the goroutine
319func (i *QueueIndex) Bump(acct, app string)
320func (i *QueueIndex) Observe(acct, app string) uint64
321func (i *QueueIndex) Release(acct, app string, observed uint64, listedEmpty bool) bool
322func (i *QueueIndex) Apps() []AppRef // round-robin needs enumeration
323func (i *QueueIndex) Depth() map[AppRef]int
324
325type Drained struct {
326 Key, Version, ETag string
327 Verdict Verdict
328 Recorded bool // the tag was written
329 Scanned bool // Scan actually ran, vs answered from a tag
330 Size int64
331 Engine, Signature string
332 CallbackPath string
333}
334func DrainApp(ctx, s, sc, idx, acct, app string, cap int) (done []Drained, listedEmpty bool, err error)
335```
336
337**The return type is not incidental.** Console needs per-item outcomes for the
338long-poll wake (13), the callbacks (16) and the per-tick usage fold (17).
339`Size` and `Scanned` are there for the meter specifically: §8 counts *bytes*
340scanned on demand, and an item answered from an existing tag was scanned by the
341**sweep**, which `TestScanUsageCountsOnDemandOnly` says must not be attributed
342to the tenant. Without `Scanned` the drain cannot tell the meter which is which.
343
344**`DrainApp` owns `Observe` and `Release`.** It takes `idx` and calls both;
345`listedEmpty` is returned for the caller's information, not as an instruction.
346The alternative — console calling them — requires reading `Observe` *before* the
347listing, an ordering console cannot see, and that ordering is exactly what
348§7.1's generation counter protects. Without `Apps()` and `Depth()` console
349reaches back for the mutex the split exists to hide.
350
351- [ ] `TestQueueIDSortsByTime` — hand-rolled, no ULID dependency.
352- [ ] `TestEnqueueWritesObjectBeforeBump` — a 202 between the two invites a
353 client to stop retrying an item nothing points at.
354- [ ] `TestIndexEntrySurvivesEnqueueDuringDrain` — the generation counter.
355- [ ] `TestIndexEntrySurvivesPartialDrain` — **the test that would have caught
356 review 3's blocker.** More items than the cap: the entry stays.
357- [ ] `TestIndexEntryReleasedOnlyOnEmptyListing` — "not truncated" was vacuous;
358 `S3Store.ListMod` never truncates (`release/store.go:555-569`).
359- [ ] `TestDrainAppWritesResultWhenUnrecorded` and
360 `TestDrainAppDeletesResultWhenLaterRecorded` — §5.2.1's two drain duties,
361 which no task owned in draft 2.
362- [ ] `TestDrainAppDeletesItemsForTrashedApp` — **deletes, not skips.** Draft 2
363 named it "Skips" after the sweep's behaviour (`sweep.go:135-141`), but §5.1.1
364 and §7.5 have the drain *delete*; a test named "skips" gets implemented as
365 skips and the items sit for thirty days.
366- [ ] `TestDrainAppDeletesOnNotPermitted` — `not_granted`, `not_requested`,
367 `encrypted`, `not_enabled` — versus `TestDrainAppKeepsOnReversible` —
368 `disabled`, engine drift, unreachable engine, read error, each up to the TTL.
369 §7.5's two lists, separately, because "re-checks the consent chain" does not
370 say which side an item falls on.
371- [ ] `TestDrainAppDedupesByKey`.
372
373Absence is `os.ErrNotExist` (`trash.go:64`), never an error.
374
375**Commit:** `objstore: the scan queue, its index and its drain`
376
377---
378
379## Task 11: the enqueue door
380
381**Files:** `internal/console/apiscan.go`, `authcli.go`,
382`internal/console/scanhook.go` (new — the path validator only).
383
384- [x] One test per refusal: `not_granted`, `not_requested`, `encrypted`,
385 `not_enabled`, `disabled`, 429.
386- [x] `TestEnqueueRefusesWithoutAttestation` — **the consent hole review 3
387 found.** The sweep refuses without the member's attestation
388 (`sweep.go:151-160`) and grant refuses without a request
389 (`apiscan.go:268-285`); a door checking only the deployment switch went
390 straight through it.
391- [x] `TestEnqueueRequiresServiceRole` — `serviceToken`; `Admin` reaches it by
392 construction (`svcrole.go:109`), which the test states rather than fights.
393- [x] **`TestCallbackPathValidation`** — the §6.2 hostile table lands **here**,
394 not in the webhook task: absolute URLs, `//evil/x`, `/\evil/x`,
395 `@`-authority, traversal, raw and encoded CRLF, fragments, >1KB. Draft 1
396 accepted the field in this task and validated it four commits later, so
397 commits in between stored hostile paths.
398
399**Commit:** `console: the scan enqueue door, behind the consent chain`
400
401**Landed** as `console: the scan enqueue door, behind the consent chain`.
402Two things came out differently from this plan, both worth carrying forward:
403
404- The refusal mapping was moved OUT of the handler into `scanRefusalAnswer`.
405 Two of its arms are unreachable through the door — a trashed app is a 404 at
406 `apiAccountApp` before the chain is consulted, and an undefined refusal name
407 cannot be sent — and both survived mutation testing through HTTP for that
408 reason alone. Task 12's drain has the same pair of unreachable conclusions
409 (§7.5's keep-or-delete) and should be written as a table from the start
410 rather than as a switch inside the goroutine.
411- `objstore.queuePermitted` is now exported as `QueuePermitted`, because the
412 door is in another package and §5.1.1 forbids a second derivation.
413
414`TestCallbackPathValidation` was already landed with the validator; what this
415task added on top is the door-level proof that the door CALLS it, which is a
416different claim and the one that keeps hostile paths out of the bucket.
417
418
419---
420
421## Task 12: the drain goroutine and the boot rebuild
422
423**The boot rebuild had no task in draft 1**, though the spec calls it "the only
424thing that collects a stranded item, so it is not optional" and task 9's
425`TestResultObjectCollectedByAge` would have tested a collector nobody called.
426
427**Files:** `internal/console/scandrain.go` (new) + test.
428
429- [x] `TestBootRebuildFindsStrandedItem` — an item written with no index entry
430 is drained after a rebuild.
431- [x] `TestBootRebuildCollectsExpiredItemsAndResults` — both prefixes, 24h TTL.
432- [x] `TestDrainRunsWhileSweepIsBlocked` — **observable**, not nominal:
433 `TestDrainIsItsOwnGoroutine` as named would pass against anything. Park
434 `scanPass` in a blocking lister and assert an item still drains. `scanPass`
435 sweeps apps sequentially for a full budget each (`scanpass.go:86-103`), so a
436 queue phase behind it starves for up to N × 60s.
437- [x] `TestDrainRoundRobinFairness` — one app's flood does not delay another's
438 item beyond one tick.
439- [x] `TestDrainAgeSweepsResultObjects` — per visited app, so a console that
440 never restarts does not accumulate them.
441- [x] `TestQueueDepthReported` — §4.4 and §10 both want it; **it is delivered
442 here** and surfaced in task 17. Task 6 lands before this and cannot surface
443 it.
444
445Implements: 30s ticker, 50 items per app per tick, in-process notify for
446waiters, deleting the result object when a later scan records a real verdict.
447
448**Commit:** `console: the scan drain, its own goroutine and its own clock`
449
450---
451
452**Landed** as `console: the scan drain, its own goroutine and its own clock`.
453
454Two lines in the tick are documented as NOT being the guarantee they look
455like, because mutation testing showed neither changes anything observable: the
456deployment-switch check and the engine check are both about avoiding work,
457since `DrainApp` re-reads consent and refuses on its own. The first is asserted
458the only way it can be — by counting listings — and the second is marked
459unassertable in the comment rather than left looking tested.
460
461`TestDrainRoundRobinFairness` also pinned down something the spec left
462implicit: an app's index entry is released on the first tick that lists
463NOTHING, not on the tick that empties it, because a consumed page reports "not
464empty". Task 17's queue-depth surface should say "apps with work" rather than
465"apps with items", or it will read as off-by-one for one tick.
466
467---
468
469## Task 13: the status door and long-poll
470
471**Files:** `internal/console/apiscan.go` + test.
472
473- [x] `TestStatusDoorReadsTagThenResultObject`.
474- [x] `TestStatusDoorRefusesStaleResultObject` — the door-level half of task
475 9's unit test.
476- [x] `TestStatusDoorReportsFailedAndDenied` — as themselves, never `pending`.
477- [x] `TestStatusDoorGoneForMissingObject`.
478- [x] `TestLongPollWakesOnDrain`, `TestLongPollCapsWaiters` — global **and**
479 per-app.
480
481**Commit:** `console: the scan status door, long-polling and honest`
482
483---
484
485**Landed** as `console: the scan status door, long-polling and honest`.
486Eleven mutations, all caught. Two gaps are recorded rather than papered over:
487
488- [ ] **`at`, `engine` and `signature` are missing from the TAG answer.** §5.2's
489 example shows `{"state":"clean","at":"...","engine":"clamav","signature":"..."}`,
490 but the `Scanner.Status` seam §5.2 itself specifies returns the verdict and
491 the object and nothing else. The door reports provenance where it genuinely
492 has it — a result object records all three — and says NOTHING in the tag
493 branch, because the only value available there is this deployment's CURRENT
494 engine, which may not be the one that judged the object. That would be a
495 guess dressed as a record, in the one feature whose whole purpose is not
496 reporting coverage it does not have. Closing this means widening `Status` to
497 return the tag's provenance, which is a change to a reviewed interface and
498 belongs in its own task rather than smuggled into this one.
499- The register-before-read ordering in the long-poll is not asserted: catching
500 the wrong order needs a drain to land between two adjacent statements and
501 there is no seam to hold it there. Noted in the code. Losing that race costs
502 one expired wait, never a wrong answer — the door re-reads on its timer and
503 never answers from the notification itself.
504
505---
506
507## Task 14: callback signing (pure)
508
509**Revised after review 7**, which found five blockers in tasks 14-17 before any
510of it was written. The additions below are all its findings.
511
512**Files:** `internal/console/scansign.go` (new — signing is its own file, not
513more of the path validator) + test.
514
515- [x] `TestSignatureRejectsTamperedBody`, `TestSignatureIsPerApp` (app A's does
516 not verify for app B), `TestSignatureRejectsStaleTimestamp`,
517 `TestTwoMasterRotationVerifies`.
518- [x] **`VerifyCallback(key, header, body, now)` — the reference verifier**, and
519 it is the deliverable §11.3 was missing. "A stale timestamp is refused" and
520 "both masters verify" are properties of the APP's verifier, which is tenant
521 code; without a reference implementation the tests test nothing anybody runs.
522 The docs show verifier examples implementing the same MAC and timestamp checks; they accept unknown signature fields.
523- [x] `TestSignatureIsOverRawBodyBytes` — an app that re-serialises the JSON
524 before verifying gets a different byte string and fails. Said in the docs.
525- [x] `TestVerifierToleratesTwoSignaturesAndUnknownKeyIds` — the rotation
526 window depends on it.
527- [x] `TestPreviousEqualToCurrentIsRefused` — a rotation that rotated to
528 nothing, refused exactly as `NewTagSigner` refuses it.
529
530Per-app key `HMAC(master, "carlos-scan-webhook-v1" + 0x00 + sqid + "/" + app +
531"/" + gen)` derived at sign time — nothing extra stored, and without it one
532deployment secret lets app A forge callbacks to app B. Delimiter is
533injection-safe: sqid `^[a-z]{3,16}$` (`qualified.go:56`), app
534`^[a-z][a-z0-9-]{0,31}$` (`release.go:242`), verified in the repo. The domain
535label and the per-app `gen` are review 7's; so is the key id's distinct label.
536
537`TestTargetURLBuiltStructurally` is dropped — it landed with the validator as
538`TestCallbackTargetIsBuiltStructurally`.
539
540**Commit:** `console: per-app callback signing, and the verifier apps will run`
541
542---
543
544## Task 15: key delivery and rotation
545
546**Files:** `cmd/console/main.go` and `server.go` (both masters into `Config`),
547`internal/console/apiscan.go` (`regrantForScanning`, `apiscan.go:492`, is the
548hook), `apisystem.go`, root `system.go`, `authcli.go`.
549
550- [x] `TestGrantDeliversWebhookKey` — sealed via `appconfig.Apply`
551 (`generation.go:119`), gated by `storeSealKey` (`apistore.go:425`). **Grant
552 stamps `webhook_key_id` as well as rotation does**, or the first
553 `rotate-webhook-key` after any grant re-seals and restarts every app under an
554 unchanged master.
555- [x] `TestRotateWebhookKeySkipsUnchangedApps` — **not "re-seals identical
556 plaintext".** `appconfig.Seal` uses a fresh ephemeral key and nonce on every
557 call (`internal/appconfig/crypto.go:85-87`), so the ciphertext always differs
558 and Sync would restart the unit every time; the console holds only the public
559 key and cannot compare by opening the old value. Rotation records a **key
560 id** in the record — `ScanBlock` gains `webhook_key_id`, additive and safe
561 under the PARSER LIST rule — and skips any app already carrying the current
562 one. The test asserts the skip.
563- [x] `TestRotateWebhookKeyDoesNotDropPrevious` — dropping `_PREVIOUS` is a
564 separate operator step, because "done" means sealed, not converged
565 (`server.go:566-569` makes this point for store rotation).
566- [x] **`TestGrantFoldsBothSecretsIntoOneApply`** — a first grant writes the
567 store credential and the callback secret. Two `Apply` calls are two
568 generation bumps and therefore two unit restarts for one operator action.
569- [x] **`TestRevokeUnsetsTheCallbackSecret`** — a key outliving the consent it
570 was issued under is the same mistake as a result object outliving it.
571- [x] **`TestRotateIsBudgetedAndResumable`** — "paced" needs a mechanism, and
572 it is `scan sweep`'s: one budget per call, an answer saying how many apps
573 remain, the CLI loops. No background job, no pacing state; idempotence by key
574 id is what makes a re-run safe.
575- [x] **`TestNoMasterRefusesACallbackPath`** — with no `CARLOS_SCAN_WEBHOOK_KEY`
576 the enqueue door refuses `callback_path` with 503 `callbacks_not_configured`.
577 A 202 would promise a delivery that cannot happen. (The door already accepts
578 the field, so this is a fix, not only an addition.)
579- [x] **`TestPerAppKeyGenerationRotatesOneApp`** — `webhook_gen` in `ScanBlock`;
580 a tenant that leaks its key must not need a deployment-wide rotation.
581- [x] **`TestReclaimedAppNameGetsADifferentKey`** — and this one needs
582 `webhook_gen` to be RANDOM rather than a counter. `PurgeApp` deletes
583 `store.json` with the rest of the app prefix (`trash.go:229-241`), so a
584 counter from zero restarts at zero for whoever claims the name next and
585 derives the identical key. Mint a random positive int below 2^53 at grant
586 and again on each per-app rotation. Zero stays legal: records predating this
587 have no field, their apps hold keys derived with zero, and
588 `rotate-webhook-key` is the backfill.
589
590**The app-side variable is NOT `CARLOS_SCAN_WEBHOOK_KEY`.** Same name for the
591master and the derived child is a copy-paste footgun that ends with a master in
592a tenant environment. Follow `EnvNames(prefix)` (`walk.go:598`):
593`<PREFIX>_SCAN_CALLBACK_SECRET`.
594
595**Multi-console ordering, which the spec now states:** every console must be
596restarted with `KEY=new, PREVIOUS=old` BEFORE `rotate-webhook-key` runs, or a
597console still on the old master signs only under old while apps another console
598re-sealed hold only new.
599
600**Commit:** `console: webhook key delivery and rotation`
601
602---
603
604## Task 16: delivery
605
606**Files:** `internal/console/scandeliver.go` (new — the worker pool),
607`scandrain.go`, `server.go`.
608
609- [x] **`TestDeliveryDoesNotBlockTheTick`** — review 7's blocker. Delivery in
610 the drain's goroutine puts a tenant's hanging endpoint in the path of every
611 other tenant's verdict: 50 items times a bounded retry schedule, inside a
612 tick that sweeps apps sequentially. That is the starvation §7.2 and §7.3
613 exist to prevent and a trivial tenant-triggered DoS. A bounded worker pool
614 fed from `done` after `scanNotify`, a bounded queue (overflow drops and logs
615 — best effort by construction), a per-app in-flight cap, retries scheduled by
616 the pool.
617- [x] `TestCallbackGoesToPlatformHostOnly` — never a custom domain.
618- [x] `TestCallbackRefusesRedirects` — Go follows them by default and a tenant
619 302 turns a signed POST into a GET at an internal address
620 (`sesevents.go:143-158` is the precedent).
621- [x] **`TestTransportDoesNotSkipTLSVerification`** — a tested ABSENCE, because
622 TLS name verification is the second lock that makes DNS rebinding irrelevant
623 (§6.2). Needs `Config.CallbackTransport http.RoundTripper` (nil = production)
624 as the test seam; name it, or the test cannot intercept an https POST.
625- [x] **`TestNeverPostsUnsigned`** — with no signer there is no request.
626- [x] `TestCallbackRetriesAreBounded` — 2xx success; 3xx and 4xx not retried;
627 5xx/429/timeout retried, three attempts at 1s/4s/16s; 10s client timeout;
628 response body drained through a 4KiB `LimitReader` and discarded.
629- [x] `TestDroppedCallbackStillReadableFromStatusDoor` — the §13 promise that
630 makes lazy retries acceptable.
631- [x] `TestCallbackBodyCarriesRecorded` — or a callback saying `clean` and a
632 door saying `recorded:false` disagree.
633- [x] **`TestCallbackBodyOmitsTheVersionToken`** — §11.7. The body carries
634 account, app, key, the bare ETag, verdict, recorded, and provenance; never
635 the opaque version.
636- [x] **`TestNoPlatformHostIsNotAnError`** — an app with no ready route has
637 nowhere to be called back yet; it polls. Refuse at the door rather than
638 building `https:///hooks`.
639
640**Commit:** `console: callback delivery, bounded and backstopped by the door`
641
642---
643
644## Task 17: metering, surfaces and docs
645
646**Files:** `internal/usage/scan.go` + **`scan_test.go` (new)**,
647`internal/console/scandrain.go` (the per-tick fold), `apiscan.go`,
648**`apisystem.go`**, root `scan.go`, **root `system.go`**, root `store.go`,
649`docs/site/cli.md`,
650`docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md`.
651
652- [x] `TestScanUsageCountsOnDemandOnly` — sweep scans are not attributed to a
653 tenant who did not ask for them.
654- [x] `TestScanUsageFoldsPerTick` — not per object. `Drained.Scanned` and
655 `Size` already exist for this; the fold accumulates per account across
656 `drainScanApp` calls and writes once at the end of `scanDrainTick`.
657- [x] `TestStoreScanStatusReportsDeploymentState` — a per-app record saying
658 `enabled: clamav` while the deployment switch is off is a new way for the
659 status output to lie.
660- [ ] `TestPrintScanStatusShowsQueueDepth` (per app, `objstore.QueueDepth`) and
661 **`TestPrintSystemAntivirusStatusShowsQueueDepth`** (deployment-wide,
662 `Server.ScanQueueDepth`, §4.4). Both landed in task 12; this surfaces them.
663 Note the deployment number counts APPS WITH WORK, and an app's entry survives
664 one extra tick after its last item drains — say "apps with work", not "apps
665 with items", or it reads as off by one.
666
667**Docs must carry, and this is review 7's list:**
668- *If no callback arrives within your own timeout, poll the status door.*
669 Delivery is best effort and may be lost or duplicated; the callback is an accelerator, not the
670 delivery mechanism.
671- The idempotency tuple: `(account, app, key, etag, verdict)`.
672- The signature is over the RAW BODY BYTES.
673- `store scan grant` and `rotate-webhook-key` RESTART apps as a side effect.
674- The corrected platform-host sentence (§6.2): an app with no ready route has
675 no platform host yet — not "an app with only its own domain".
676
677`usage/scan/<date>.json`, CAS-merged as `UpsertEdgeDay` (`edge.go:85-116`);
678meter names frozen once merged (`usage.go:4-5`), so they are named in this task
679and not later. Rollout plan step 4 becomes `CARLOS_CLAMD_ADDR` + `enable`;
680rollback becomes `disable`, no restart.
681
682**Commit:** `console+cli: scan metering, surfaces and docs`
683
684---
685
686---
687
688## Where this stands (2026-09-08)
689
690Tasks 1-17 are implemented on `worktree-store-scan-on-demand`. 14-17 were
691written by codex (gpt-5.6-sol) against this plan, then mutation-tested and in
692several places re-tested here; every commit says which parts came from where.
693
694**What is NOT done, and why.** The two printer tests above, and the docs, need
695copy a person has approved — a printer line is a sentence somebody reads, and
696this project reviews user-facing language before it ships. Three pieces are
697waiting:
698
6991. The CLI reference for both `rotate-webhook-key` verbs (drafted, held out of
700 the commits, in `cli-copy-for-review.diff`).
7012. Two status lines: `queue: N item(s) waiting` and `queue: N apps with work`.
7023. **The tenant-facing half**, which the final review argues should land WITH
703 the merge rather than after it: once merged, the enqueue door accepts
704 `callback_path` from any Service credential and the console will POST signed
705 bodies to apps that have no documented way to verify them. That page must
706 carry `VerifyCallback`, and it must say that the delivered secret is HEX
707 while the MAC is keyed on the raw bytes — an author who feeds the
708 environment string straight into HMAC fails every verification, and the
709 next move after that is to turn verification off.
710
711**Known and recorded, not fixed.** A tag verdict answers without `at`, and
712`skipped` without a reason, because the `Scanner.Status` seam carries neither;
713widening it is its own task and is the same seam §5.2's example assumes.
714Metering counts a size-ceiling `skipped` as bytes, which is wrong and unpriced
715— fix before pricing. The long-poll checks consent once for up to 30s of
716waiting, answering from tags rather than bytes.
717
718## Remaining §13 coverage, assigned
719
720First-tick warning when wired with no record → task 4. `request`/`revoke`/
721`enforce off` still work while **disabled** → extend
722`TestScanRevokeAndEnforceOffWorkWithoutScanner` in task 5. Enqueue racing
723`PurgeApp` leaves no orphan beyond one tick → task 12.
724
725## Sequencing
726
7271, 2, 3 are independent. 4 needs 2 and 3. 5 needs 4. 7 and 8 are independent
728seams. **9 (the result object) is independent and comes before 10 (the queue
729core), which needs 7, 8 and 9** — draft 2 had these the other way round, which
730would have landed a `DrainApp` that drops unrecorded verdicts. 11 needs 1, 2,
73110. 12 needs 10. 13 needs 7, 9, 12. 14 is pure. 15 needs 14. 16 needs 12, 14,
73215. 17 last, so the docs describe what exists.
733
734**Docs hold until task 17.** The `carlos store scan` section drafted for the
735skills repo describes hourly-sweep behaviour and is in copy review; it is
736revised here rather than shipped twice.
737