plan: scan on demand, fourteen tasks
Fourteen commits, each TDD, from a spec that took six review rounds to clear. The plan carries the review history where it changes what someone would write, rather than as trivia. Three tasks exist mainly because of a bug found in review: task 6's Status/Verdict split (a failed scan read as pending forever), task 9's consent chain (an enqueue door straight through the attestation the whole feature rests on), and task 10's version binding. It also names the mistake this design kept making, at the top, because it recurred three times in three different mechanisms: a new durable fact about an object, introduced without carrying the version across the seam. Every task that writes or reads a fact about an object says so. Two tests are named for the blockers they would have caught rather than for what they assert: TestIndexEntrySurvivesPartialDrain, because the per-tick cap and the index removal rule disagreed and the cap is exactly the case the entry is needed for; and TestResultObjectIsVersionBound. Docs hold until task 14. The store scan section drafted for the skills repo describes hourly-sweep behaviour and would need rewriting the moment this lands, so Tony gets one revision rather than two.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+413
−0
docs/superpowers/plans/2026-09-02-scan-on-demand.md+413 −0
diff --git a/docs/superpowers/plans/2026-09-02-scan-on-demand.md b/docs/superpowers/plans/2026-09-02-scan-on-demand.md| new file mode 100644 |
| index 000000000..60576e301 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-09-02-scan-on-demand.md |
| @@ -0,0 +1,413 @@ |
| +# Scan on demand: implementation plan |
| + |
| +**Spec:** `docs/superpowers/specs/2026-09-02-scan-on-demand-design.md` (draft 7, |
| +cleared to plan from after six review rounds). |
| +**Branch:** `worktree-store-scan-on-demand`, stacked on the landed scanner work. |
| + |
| +Fourteen tasks, each its own commit, each TDD: write the failing tests, watch |
| +them fail for the right reason, implement, watch them pass. |
| + |
| +--- |
| + |
| +## Global constraints |
| + |
| +- `go build ./...`, `go vet ./...`, `gofmt -l .` clean before every commit. |
| +- **No new third-party dependencies.** Signing is `crypto/hmac` + `crypto/sha256`. |
| +- Prefix Go commands with `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache`. |
| +- Five root-package tests (`TestAccountsList*`, `TestResolveMode*`) fail on this |
| + machine for an environmental reason confirmed at `8103270`: the checkout sits |
| + under `/home/paulca`, whose real `~/.carlos/config` `pick()` finds by walking |
| + ancestors. Not a regression; do not chase them. |
| +- **`s3:PutObjectTagging` must never enter the app's policy.** Unchanged. |
| +- `grant` and `provisioned` in `store.json` stay frozen — `release.PurgeApp` |
| + parses them with its own struct. |
| + |
| +### The mistake this design keeps making |
| + |
| +Three separate review rounds caught the same class of bug: **a new durable fact |
| +about an object, introduced without carrying the object version across the |
| +seam.** The ETag binding, then `Status` versus `Verdict`, then the result |
| +object. Every task below that writes or reads a fact about an object must be |
| +checked for it explicitly, and the tests named in tasks 6, 10 and 11 exist |
| +because of it. |
| + |
| +--- |
| + |
| +## Task 1: `svcrole.Service`, and per-app scope |
| + |
| +**Files:** `internal/svcrole/svcrole.go`, `internal/console/svcprincipal.go`, |
| +`internal/console/apiservices.go`, `svcpage.go`, `services.go` (repo root). |
| +**Tests:** `internal/svcrole/svcrole_test.go`, |
| +`internal/console/apiservices_test.go`. |
| + |
| +**Produces:** `svcrole.Service`; `ErrAccountScopedService`. |
| + |
| +- [ ] **Failing tests** |
| + - `TestServiceRoleIsMintable` — `svcrole.Parse("service")` round-trips and |
| + `All()` includes it, so the mint door and form pick it up with no further |
| + change. |
| + - `TestServiceCredentialCannotBeAccountScoped` — `NewServicePrincipal` with |
| + `app == ""` and role `Service` returns `ErrAccountScopedService`; the same |
| + call with any other role still succeeds. **In `NewServicePrincipal`, not a |
| + handler:** there are two mint doors (`apiservices.go:211`, `svcpage.go:299`) |
| + and a handler-level check leaves one of them open. |
| + - `TestServiceRoleDoesNotWidenInstance` — `Instance` still reaches exactly |
| + its nine pinned doors. `TestInstanceRoleMatchesTodaysAppTokenReach` |
| + (`svcroutes_test.go:262-291`) already asserts this; confirm it still passes |
| + rather than writing a second one. |
| +- [ ] **Run:** `go test ./internal/svcrole/ ./internal/console/ -run 'Service|Instance' -count=1` — FAIL, undefined. |
| +- [ ] **Implement.** Add the constant and the catalogue entry; update |
| + `svcrole.go:41-44`'s "union of the three above" comment; add the sentinel and |
| + the check; add `service` to `services.go:164`'s hardcoded usage list. |
| + `apiservices.go:211-215` matches only `ErrServiceNameTaken` today, so the new |
| + sentinel needs its own 400 arm in both doors. |
| +- [ ] **Commit:** `svcrole: a Service role for apps calling platform services` |
| + |
| +--- |
| + |
| +## Task 2: the deployment settings record |
| + |
| +**Files:** `internal/release/system.go` (new), `system_test.go`. |
| + |
| +**Produces:** `release.SystemKey(name)`, `AntivirusSettings`, |
| +`GetAntivirus`/`PutAntivirus` with CAS. |
| + |
| +- [ ] **Failing tests** |
| + - `TestSystemKeyShape` — `system/antivirus.json`. |
| + - `TestAntivirusSchemaRefusesUnknown` — schema 2 is an error, not a guess. |
| + Same rule as `objstore.Record` and `ScanSidecar`, and for the same reason: |
| + this record decides whether tenant bytes get read. |
| + - `TestPutAntivirusCreateOnlyThenReplace` — `""` creates, an etag replaces, a |
| + stale etag is `ErrCASConflict`. |
| +- [ ] **Run:** FAIL, package does not exist. |
| +- [ ] **Implement.** A typed record, not a generic settings map — a map is |
| + `features.json` by another name, uninterpreted and unpinnable. |
| +- [ ] **Commit:** `release: system/<name>.json, a typed deployment settings record` |
| + |
| +--- |
| + |
| +## Task 3: wiring, migration and the seed |
| + |
| +**Files:** `cmd/console/main.go`, `internal/console/server.go`, |
| +`internal/console/scanpass.go`. |
| +**Tests:** `internal/console/scanpass_test.go`. |
| + |
| +**Produces:** `Config.ScanSeedFromDeprecatedEnv`; the Scanner built from |
| +wiring; the record read per tick. |
| + |
| +- [ ] **Failing tests** |
| + - `TestScanPassSeedsRecordOnFirstTick` — no record + the flag set → one |
| + create-only write, enabled, `by: "migration from CARLOS_STORE_SCANNER"`. |
| + - `TestScanPassSeedDoesNotOverrideExistingRecord` — a record saying |
| + `enabled:false` survives; an operator's `disable` is not undone by a |
| + restart. |
| + - `TestScanPassSeedIsSilentOnReadError` — a bucket read error neither seeds |
| + nor fatals. Seeding on a failed read would enable scanning on a deployment |
| + that had disabled it. |
| + - `TestScanPassDoesNothingWhenDisabled` — Scanner non-nil, record disabled → |
| + the lister is never consulted, and the goroutine keeps ticking. |
| + |
| + **Gotchas.** The seed goes at the top of the first tick, not in |
| + `storeScannerFor`, which runs at `main.go:262` before `openStore()` at |
| + `:269`. `StartScanPass` runs `scanPass` *before* waiting on the ticker |
| + (`scanpass.go:43-49`), so the seed happens at boot in practice. |
| + `ErrCASConflict` means another console seeded it — success, not failure. |
| +- [ ] **Run:** FAIL. |
| +- [ ] **Implement.** `storeScannerFor` builds whenever `CARLOS_CLAMD_ADDR`, |
| + `CARLOS_CLAMD_SOCKET`, **or `CARLOS_STORE_SCANNER=clamav`** is set, the last |
| + retaining the socket default at `main.go:775`. Any other value of the retired |
| + variable stays fatal. Setting `_ADDR` and `_SOCKET` to different values is |
| + fatal. `main.go` sets the Config flag; the pass never reads the environment. |
| +- [ ] **Commit:** `console: antivirus policy moves to a record, wiring stays in env` |
| + |
| +--- |
| + |
| +## Task 4: the `system antivirus` doors and CLI |
| + |
| +**Files:** `internal/console/apisystem.go` (new), `authcli.go`, |
| +`system.go` (repo root), `apimode.go`, `store.go`. |
| +**Tests:** `internal/console/apisystem_test.go`, `system_test.go`. |
| + |
| +- [ ] **Failing tests** |
| + - `TestSystemDoorsRefuseServicePrincipals` — registered `svcrole.Set{}`, so |
| + no service credential reaches them, and the door appears in `closedDoors` |
| + (`svcroutes_test.go:83`) or `TestEveryCLIDoorNamesItsRoles` fails. |
| + - `TestSystemAntivirusNeedsOperator` — `storeOperator(addr)`. Operators are |
| + addresses (`server.go:558-571`); a service actor is `service:`/`app:` |
| + (`apicli.go:89-100`) and can never match one, which is why the role gate |
| + alone is not the authority here. |
| + - `TestSystemAntivirusEnableRefusesWithoutScanner` — 503; a record must not |
| + claim enabled with nothing behind it. |
| + - `TestSystemAntivirusStatusReportsWiredWithNoRecord` — its own state. |
| + - `TestPrintSystemAntivirusStatus` — rendering, against a literal reply. |
| + |
| + **Gotcha.** A deployment-wide door has no `{account}` segment, so |
| + `apiPrincipalAccount` (`apicli.go:176-187`) cannot resolve it. `GET |
| + /api/cli/apps` (`authcli.go:471`) is the precedent for a person-only |
| + resolver. |
| +- [ ] **Implement.** `status|enable|disable` now; `rotate-webhook-key` in task 12. |
| +- [ ] **Commit:** `console: carlos system antivirus` |
| + |
| +--- |
| + |
| +## Task 5: clamd over TCP |
| + |
| +**Files:** `internal/clamd/clamd.go`, `clamd_test.go`, `internal/objstore/clamav.go`. |
| + |
| +- [ ] **Failing tests** |
| + - `TestNewDialsTCPWhenAddrIsHostPort` and |
| + `TestNewDialsUnixWhenAddrBeginsWithSlash` — one rule, stated once. |
| + - The existing INSTREAM tests must keep passing over TCP unchanged: the |
| + framing, deadlines and reply parsing are transport-independent |
| + (`clamd.go:76-79,173-187`), and `"unix"` appears exactly once |
| + (`clamd.go:130`). |
| +- [ ] **Implement.** `New(addr string, timeouts Timeouts)` picks the network. |
| +- [ ] **Commit:** `clamd: dial tcp or unix, decided by the address` |
| + |
| +--- |
| + |
| +## Task 6: `Scanner.Status`, and `Scan` reports whether it recorded |
| + |
| +**Files:** `internal/objstore/scanner.go`, `clamav.go`, `memscanner.go`, |
| +`sweep.go`. |
| +**Tests:** `scanner_contract_test.go`, `clamav_test.go`, `sweep_test.go`. |
| + |
| +**Produces:** `Status(ctx, bucket, key) (Verdict, ScanObject, error)`; |
| +`Scan(...) (Verdict, bool, error)`. |
| + |
| +- [ ] **Failing tests** |
| + - `TestStatusReturnsObjectSoCallerCanScan` — the `ScanObject` is populated on |
| + the no-tag path. **This is the whole point of the second return value:** |
| + `Scan` needs version and size, and the drain has no listing page, so a |
| + `Status` returning a verdict alone forces a `HeadObject` outside the |
| + scanner — exactly what keeping the token inside it forbids. |
| + - `TestStatusDoesNotApplyTheReIssueRule` — a stored `failed` or `denied` tag |
| + comes back **as itself**. `Verdict` keeps mapping them to `Unknown` |
| + (`clamav.go:151-163`) so the sweep retries; that is retry policy, not a |
| + fact about the object, and leaving it in `Status` makes a failed scan read |
| + as `pending` forever. |
| + - `TestStatusOnDeniedHead` — `(VerdictDenied, ScanObject{}, nil)`. |
| + - `TestStatusOnMissingObject` — `ErrObjectGone`. |
| + - `TestScanReportsUnrecorded` — the ten-tag cap (`clamav.go:306-308`) returns |
| + `recorded == false` with a real verdict. |
| + - `TestSweepCountsUnchangedByRecordedFlag` — Fresh/Reused count verdicts |
| + *reached*, so the sweep takes `_` and its numbers do not move. |
| + |
| + **Gotchas.** `tag()` currently returns `nil` silently at `clamav.go:291,309`; |
| + it must report whether it wrote, or `recorded` is a lie. `Scan`'s denied path |
| + (`:203`) discards `tag`'s result — `recorded` there is `false`. The third |
| + return value touches ~14 call sites in `clamav_test.go` plus the contract |
| + test. `MemScanner.Verdict` (`memscanner.go:89-100`) does **not** apply the |
| + re-issue rule today, so it gains it, or the split cannot be exercised against |
| + the fake; `MemScanner` also gains `PutObject` and `Unrecordable(key)`. |
| + `scanS3API` and `stubScanS3` gain `HeadObject`. |
| +- [ ] **Commit:** `objstore: Status answers about an object, Verdict decides retries` |
| + |
| +--- |
| + |
| +## Task 7: `release.PageLister` |
| + |
| +**Files:** `internal/release/store.go`, `store_test.go`. |
| + |
| +- [ ] **Failing tests** |
| + - `TestDirStorePageListerOrdersAndBounds` — key-sorted, `startAfter` |
| + exclusive, `limit` respected. `DirStore.ListMod` is already key-sorted |
| + (`release/store.go:290`). |
| + - `TestS3StorePageLister` — `StartAfter` + `MaxKeys`, the shape `sweep.go:71-78` |
| + already uses. |
| +- [ ] **Implement.** An **optional interface**, type-asserted at the call site |
| + exactly as `SizeLister` (`release/store.go:181-185`) is at |
| + `economicspass.go:141`. **Not** a method on `release.Store`, which has eleven |
| + implementations — three real, `fleetapi.store`, and seven test fakes. |
| +- [ ] **Commit:** `release: PageLister, an optional bounded listing` |
| + |
| +--- |
| + |
| +## Task 8: the queue and its index |
| + |
| +**Files:** `internal/objstore/queue.go` (new), `queue_test.go`. |
| + |
| +**Produces:** `QueueKey(acct, app, id)`, `QueueItem`, `Enqueue`, `queueIndex`. |
| + |
| +- [ ] **Failing tests** |
| + - `TestEnqueueWritesObjectBeforeMarkingIndex` — order matters: a 202 sent |
| + between the two invites a client to stop retrying an item nothing points |
| + at. |
| + - `TestIndexEntrySurvivesEnqueueDuringDrain` — the generation counter. Drain |
| + reads `observed`, an enqueue increments, the drain must **not** remove. |
| + - `TestIndexEntrySurvivesPartialDrain` — **the test that would have caught |
| + review 3's blocker.** More items than the per-tick cap: the entry stays, |
| + and successive ticks drain the rest. |
| + - `TestIndexEntryRemovedOnlyOnEmptyListing` — "listing not truncated" was |
| + vacuous, because `release.Store.List` never truncates |
| + (`release/store.go:555-569`). Zero items is the test. |
| +- [ ] **Implement.** `apps/<acct>/<app>/scan-queue/<ulid>.json`, one object per |
| + item — distinct keys never collide, so an enqueue needs no coordination, and |
| + ULIDs sort by time. A top-level queue was rejected: `PurgeApp` lists only the |
| + app prefix (`trash.go:229-238`) and the claim gate never looks outside it. |
| +- [ ] **Commit:** `objstore: the scan queue, one object per item` |
| + |
| +--- |
| + |
| +## Task 9: the enqueue door and the consent chain |
| + |
| +**Files:** `internal/console/apiscan.go`, `authcli.go`. |
| +**Tests:** `internal/console/apiscan_test.go`. |
| + |
| +- [ ] **Failing tests** — one per refusal, because each is a separate promise: |
| + - `not_granted` (`Provisioned == nil`), `not_requested`, `encrypted`, |
| + `not_enabled`, `disabled`, and 429 past the limit. |
| + - `TestEnqueueRefusesWithoutAttestation` — **the consent hole review 3 |
| + found.** The landed sweep refuses to read tenant bytes without the member's |
| + attestation (`sweep.go:151-160`) and `scan grant` refuses without a request |
| + (`apiscan.go:268-285`), on the stated ground that an operator must not start |
| + that alone. An enqueue door that checked only the deployment switch put a |
| + door straight through it. |
| + - `TestEnqueueRequiresServiceRole` — and `Admin` reaches it by construction |
| + (`svcrole.go:109`), which the test states rather than fights. |
| +- [ ] **Implement.** Role `Service`, `svcWrite` limiter keyed on principal name |
| + (`svcprincipal.go:375-380`) — not a new bucket. |
| +- [ ] **Commit:** `console: the scan enqueue door, behind the consent chain` |
| + |
| +--- |
| + |
| +## Task 10: the result object |
| + |
| +**Files:** `internal/objstore/scanresult.go` (new), `scanresult_test.go`. |
| + |
| +- [ ] **Failing tests** |
| + - `TestResultObjectIsVersionBound` — **the review 5 blocker, and the third |
| + time this class of bug appeared.** A result object written for v1 must not |
| + answer for v2: the door compares `result.version` against |
| + `ScanObject.Version()` and says `pending` on mismatch. |
| + - `TestResultObjectReplacesOnSecondWrite` — read-then-CAS. `Put` has only |
| + create-only and replace (`release/store.go:37-45`); a create-only second |
| + write returns `ErrCASConflict` and silently drops the v2 verdict. |
| + - `TestResultObjectNotWrittenUnderPurgedPrefix` — the `store.json` re-read |
| + guard (`sweep.go:358-367`). A stranded queue item is drained next tick; a |
| + stranded **result** object is found by nothing, blocks the app-name claim |
| + gate (`server.go:2921`), and — because `materialize.go:333-345,377-382` |
| + counts any key and restores a trashed row — **resurrects the app** on the |
| + next boot. |
| + - `TestResultObjectCollectedByAge` — with a `ModLister` fallback that skips |
| + collection and logs once, as `storagePass` does (`economicspass.go:141-145`). |
| +- [ ] **Commit:** `objstore: the scan result object, version-bound and guarded` |
| + |
| +--- |
| + |
| +## Task 11: the drain |
| + |
| +**Files:** `internal/console/scandrain.go` (new), `scandrain_test.go`. |
| + |
| +- [ ] **Failing tests** |
| + - `TestDrainIsItsOwnGoroutine` — it cannot share the sweep's: `scanPass` |
| + sweeps apps sequentially for a full budget each (`scanpass.go:86-103`), so |
| + a queue phase behind it starves for up to N × 60s. |
| + - `TestDrainReChecksConsentChain` — the record can change between enqueue and |
| + drain, and the drain is where bytes are read. |
| + - `TestDrainSkipsTrashedApp` — items enqueued before `TrashApp` survive the |
| + 30-day cooloff with an intact record; without the tombstone check |
| + (`sweep.go:135-141`) the drain reads bytes the sweep deliberately will not. |
| + - `TestDrainReIssuesFailedAndDenied` — scans when `Status` is Unknown, |
| + Failed or Denied. Without it, on-demand is *less* able than the hourly |
| + sweep it exists to improve on. |
| + - `TestDrainKeepsItemsOnReadError` — deletion is driven by absence |
| + (`os.ErrNotExist`, `trash.go:64`), never by error, or a transient bucket |
| + failure discards a tenant's queue. |
| + - `TestDrainRoundRobinFairness` — one app's flood does not delay another's |
| + item beyond one tick. |
| + - `TestDrainDedupesByKeyPerTick`. |
| +- [ ] **Implement.** 30s ticker, 50 items per app per tick, `Status` before |
| + `Scan`, the §7.5 delete/keep lists, result objects on `recorded == false`, |
| + in-process notify for waiters. |
| +- [ ] **Commit:** `console: the scan drain, its own goroutine and its own clock` |
| + |
| +--- |
| + |
| +## Task 12: the status door and long-poll |
| + |
| +**Files:** `internal/console/apiscan.go`. |
| +**Tests:** `apiscan_test.go`. |
| + |
| +- [ ] **Failing tests** |
| + - `TestStatusDoorReadsTagThenResultObject` — tag first; on `Unknown`, the |
| + result object; absent, `pending`. |
| + - `TestStatusDoorRefusesStaleResultObject` — version mismatch → `pending`. |
| + - `TestStatusDoorReportsFailedAndDenied` — as themselves, never `pending`. |
| + - `TestStatusDoorGoneForMissingObject`. |
| + - `TestLongPollWakesOnDrain` and `TestLongPollCapsWaiters` — global *and* |
| + per-app, or it is a cheap way to occupy the console. |
| +- [ ] **Commit:** `console: the scan status door, long-polling and honest` |
| + |
| +--- |
| + |
| +## Task 13: the signed webhook |
| + |
| +**Files:** `internal/console/scanhook.go` (new), `scanhook_test.go`, |
| +`internal/objstore/walk.go` (key delivery on grant). |
| + |
| +- [ ] **Failing tests** — a table of hostile `callback_path` inputs: |
| + absolute URLs, `//evil/x`, `/\evil/x`, `@`-authority, traversal, raw and |
| + percent-encoded CRLF, fragments, >1KB. Each must be refused. |
| + - `TestCallbackGoesToPlatformHostOnly` — never a custom domain. The outbound |
| + fetcher picks the first ready non-static route (`instances.go:88-97`), which |
| + a tenant can point where they like. |
| + - `TestCallbackRefusesRedirects` — Go follows them by default, and a tenant |
| + 302 turns a signed POST into a GET at an internal address |
| + (`sesevents.go:143-158` is the existing precedent). |
| + - `TestSignatureRejectsTamperedBody`, `TestSignatureIsPerApp` (app A's does |
| + not verify for app B), `TestSignatureRejectsStaleTimestamp`, |
| + `TestTwoMasterRotationVerifies`. |
| + - `TestCallbackBodyCarriesRecorded` — or a callback saying `clean` and a door |
| + saying `recorded:false` disagree. |
| +- [ ] **Implement.** Per-app key `HMAC(master, sqid + "/" + app)` derived at |
| + sign time — nothing extra stored, and without it one deployment secret lets |
| + app A forge callbacks to app B. Delivered sealed on `store scan grant` |
| + (`apistore.go:425` gates the seal key). URL built structurally, never by |
| + concatenation. Add `rotate-webhook-key`; dropping `_PREVIOUS` is a separate |
| + operator step, because "done" means sealed, not converged |
| + (`server.go:566-569` makes this point for store rotation). |
| +- [ ] **Commit:** `console: signed scan callbacks, to the app's own host` |
| + |
| +--- |
| + |
| +## Task 14: metering, surfaces and docs |
| + |
| +**Files:** `internal/usage/scan.go` (new), `internal/console/apiscan.go`, |
| +`scan.go` and `store.go` (repo root), `docs/site/cli.md`, |
| +`docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md`. |
| + |
| +- [ ] **Failing tests** |
| + - `TestScanUsageCountsOnDemandOnly` — sweep scans are **not** attributed to a |
| + tenant who did not ask for them. |
| + - `TestScanUsageFoldsPerTick` — not per object; each fold is a GET plus a |
| + conditional PUT of a deployment-wide object. |
| + - `TestStoreScanStatusReportsDeploymentState` — a per-app record saying |
| + `enabled: clamav` while the deployment switch is off is a new way for the |
| + status output to lie. Note `apiscan_test.go:254` asserts `engine == |
| + "clamav"` on a fixture with no record; that fixture must write one. |
| +- [ ] **Implement.** `usage/scan/<date>.json`, CAS-merged the way |
| + `UpsertEdgeDay` does (`edge.go:85-116`); meter names are **frozen once |
| + merged** (`usage.go:4-5`). Update the rollout plan: step 4 becomes |
| + `CARLOS_CLAMD_ADDR` + `enable`, and rollback becomes `disable`, which needs |
| + no restart. |
| +- [ ] **Commit:** `console+cli: scan metering, surfaces and docs` |
| + |
| +--- |
| + |
| +## Sequencing |
| + |
| +1-2-3 unlock everything and are independent of each other. 5-6-7 are the seams; |
| +8-9-10-11 are the queue; 12-13 the surfaces; 14 last so the docs describe what |
| +exists. |
| + |
| +**Docs hold until the feature is whole.** The `carlos store scan` section for |
| +the skills repo is drafted and in copy review, and describes hourly-sweep |
| +behaviour. It gets revised as part of task 14 rather than shipped twice. |
| + |
| +## Self-review |
| + |
| +Spec §3 → tasks 1, 9. §4 → tasks 2, 3, 4. §5 → tasks 6, 9, 12. §5.2.1 → tasks |
| +6, 10, 12. §6 → task 13. §7 → tasks 7, 8, 11. §8 → task 14. §9 → task 5. |
| +§11 → the security tests in 9, 10, 12, 13. §13's list is distributed across |
| +every task's step 1. |