carlos / platform Public

plan: the shortest honest path to a scanner on real infrastructure

Forty-eight commits above main, in two layers, neither ever proposed for
merge, both red in CI since df947ec. This sequences what is already
planned rather than planning anything new — and it changes the order.

SPLIT THE STACK, DEPLOY TWICE. The sweep half is a complete feature: all
fourteen tasks landed, inert until three separate things are true,
needing nothing from scan-on-demand. It has been finished for a week
while a second feature was built on top of it.

The weight of the argument is not tidiness. Three facts this design
rests on have been reasoned about and never observed, and all three are
INPUTS to tasks 11-17: whether RGW's HeadObject and ListObjectsV2 agree
on Last-Modified at second precision (ScanObject.Version() is built on
it, and the queue, the result object and the callback body all bind to
it); whether the app credential is really denied PutObjectTagging on a
Ceph gateway, which is where the anti-forgery property actually lives;
and what clamd costs on the box, which is currently three constants
calibrated against a discussion post.

Finding a timestamp disagreement now costs a spec draft. Finding it
after task 16 costs the queue, the result object, the status door and
the callback body. So the rollout gains a step 5a whose whole job is to
answer those three by observation, before any member is told scanning
exists — including an EICAR file through the real path, which no test on
this machine can stand in for.

The other two reasons: main has moved, and a 48-commit branch carrying
two features is not a diff anyone reads as one piece, which is the whole
stated purpose of the gate. And tito#6 — the clamd unit and the
console's tag permissions — is filed, detailed and unanswered; nothing
past rollout step 2 moves without it, so that clock should start now
rather than behind seven more tasks.

Phase 0 is about the red gate, and it is not really about one stale
assertion. The branch carried a note saying five root-package tests fail
environmentally. That note is true, and it is exactly the camouflage a
sixth failure needs: the package was known to be red, so the count was
never read. The note becomes a named list, and the end state is zero
known-failing tests, so that any red is news.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEkJh1ESqiA84quAHJV4K6
Paul Campbell pushed by paul@keymail.dev 161041b116a0b5bc547c8176854494d6398241fd parent 93e21c1
1 file changed, +249 −0
  • docs/superpowers/plans/2026-09-04-scanning-to-deployable.md +249 −0
diff --git a/docs/superpowers/plans/2026-09-04-scanning-to-deployable.md b/docs/superpowers/plans/2026-09-04-scanning-to-deployable.md
new file mode 100644
index 000000000..bdb62894f
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-04-scanning-to-deployable.md
@@ -0,0 +1,249 @@
+# Getting scanning onto real infrastructure
+
+**Status:** draft 1 — sequencing, not tasks.
+**Covers:** the branch stack from where it stands today to a scanner running
+against real objects in a real bucket, twice.
+**Plans it sequences:** `2026-09-01-object-storage-virus-scanning.md` (the
+sweep, 14 tasks, all landed on the branch),
+`2026-09-01-carloku-scanning-rollout.md` (the operational sequence),
+`2026-09-02-scan-on-demand.md` (17 tasks, 10 landed).
+
+This is not a third implementation plan. It answers one question — *what is the
+shortest honest path to something we can deploy and test* — and the answer
+changes the order of the work already planned, which is why it is written down
+rather than just done.
+
+---
+
+## The state, stated plainly
+
+`worktree-store-scan-on-demand` carries **48 commits above `main`**, in two
+layers:
+
+| Layer | Commits | What it is | Complete? |
+|---|---|---|---|
+| `worktree-store-virus-scanning` | 24 | the reconciling sweep, its CLI, its doors, its docs | **yes** — all 14 tasks |
+| on top of it | 24 | scan-on-demand tasks 1–10, two review passes, this plan | tasks 11–17 outstanding |
+
+Neither branch has ever been proposed for merge. Both were **red in CI** until
+today, and the reason they were red is the first thing this plan has to say.
+
+### Why CI was red, and why nobody noticed
+
+`TestStoreVerbDispatchAndRefusals` asserts the `carlos store` usage block. The
+`scan` sub-verb landed at `df947ec` and the usage line grew; the assertion did
+not. It has been failing on every push since.
+
+It was not noticed because the branch carried a note — repeated into the
+scan-on-demand plan's global constraints — saying *five root-package tests fail
+on this machine for an environmental reason, not a regression*. That note is
+true. It is also the exact shape of camouflage a sixth failure needs: the
+package was known to be red, so the count was never read.
+
+The fix is one line. The lesson costs more than the fix: **a known-failing
+package cannot be a known-failing package.** The environmental failures are
+real and specific — the checkout sits under `/home/paulca`, so `pick()` finds a
+live `~/.carlos/config` — and from here they are named individually, by test,
+not as a count. Phase 0 closes this.
+
+### What is deployable today
+
+The sweep half, on its own, is a complete feature. It answers the audit
+question in the two sentences the spec asked for, it is inert until three
+separate things are true, and it needs nothing from scan-on-demand. It has been
+sitting finished for a week while a second feature was built on top of it.
+
+---
+
+## The decision: split the stack, deploy twice
+
+**Land and deploy the sweep half before finishing scan-on-demand.**
+
+Three reasons, in order of weight.
+
+### 1. The unverified facts are inputs, not footnotes
+
+Fable's review left one item unowned by any task, because no test on this
+machine can close it:
+
+> On a real RGW backend, does `HeadObject`'s `Last-Modified` agree with
+> `ListObjectsV2`'s stamp at second precision?
+
+`ScanObject.Version()` is `etag@RFC3339-lastmodified`. If those two stamps
+disagree — RGW rounding one and truncating the other, say — then the version a
+sweep writes and the version a door reads are different strings for the same
+object, and **every verdict looks stale to the reader that checks it.** The
+queue binds to that version. The result object binds to it. The callback body
+carries it. Tasks 13 and 16 are built on it.
+
+Two more of the same kind:
+
+- **Does object tagging behave on RGW the way the sweep assumes?** The
+ anti-forgery property rests on the app being unable to call
+ `PutObjectTagging` while the console can. That is an IAM assertion about a
+ Ceph gateway, not about AWS, and it has been reasoned about rather than
+ observed.
+- **What does clamd actually cost on the box?** `CARLOS_CLAMD_MAX_BYTES`, the
+ per-app scan budget and task 12's per-tick drain limit are all currently
+ guesses calibrated against a discussion post.
+
+Writing tasks 11–17 first means writing them against three assumptions, and the
+plan's own header names the bug class this produces: *a new durable fact about
+an object, introduced without carrying the version across the seam*. If the
+version is wrong at the bottom, the seam count that has to be revisited is
+every one of them.
+
+### 2. Forty-eight unmerged commits is the largest risk on the board
+
+`main` has moved (path-based routing, `#385`, `#386`). Every task added to the
+stack makes the eventual rebase worse and makes the first read of the diff
+harder. A 24-commit branch is already a big ask of a reviewer; 48 with two
+distinct features in it is not a diff anyone reads as a single piece, which is
+the entire stated purpose of the PR gate in AGENTS.md.
+
+Splitting costs one rebase now. Not splitting costs the same rebase later, on a
+bigger branch, with the sweep's real-world lessons still unlearned.
+
+### 3. Tito's half has a lead time we do not control
+
+`tito/carlos-infrastructure` discussion **#6** — the clamd unit, freshclam
+timer, cgroup limits and the console's S3 tag permissions — is filed, detailed
+and **unanswered**. Nothing on our side proceeds past step 2 of the rollout
+without it. Starting that clock now overlaps someone else's work with tasks
+11–17 instead of queuing behind them.
+
+---
+
+## Phase 0 — green, and honest about what red means
+
+**Today. Done except the confirmation.**
+
+- [x] Fix the `carlos store` usage assertion.
+- [ ] Push, and read `amadan ci status carlos/platform` until the gate passes.
+- [ ] Replace the "five tests fail environmentally" note in both plans with the
+ named list, and the reason (`pick()` finds a real `~/.carlos/config` because
+ the checkout is under `/home/paulca`). A count is not a note; a list is.
+- [ ] Add a `make test` wrapper or `CARLOS_CONFIG` override so the root package
+ passes locally too. **The right end state is zero known-failing tests**, so
+ that any red is news. If that turns out to be more than an hour, file it and
+ move on — but file it, because the camouflage is the actual defect here.
+
+**Done when:** `amadan ci status carlos/platform -branch worktree-store-scan-on-demand`
+exits 0.
+
+## Phase 1 — land the sweep half
+
+**Half a day.**
+
+- [ ] Cherry-pick the Phase 0 fix onto `worktree-store-virus-scanning`. It is
+ the branch that broke; the on-demand branch picks it up from `main`.
+- [ ] `amadan branch describe carlos/platform worktree-store-virus-scanning` —
+ the description is the PR body, and it carries what fourteen tasks and three
+ review rounds decided. Written for someone who has read none of it.
+- [ ] Confirm green on that branch specifically.
+- [ ] `amadan branch merge`, then fast-forward `main` on the mirror per
+ AGENTS.md.
+- [ ] Rebase `worktree-store-scan-on-demand` onto the new `main`. Twenty-four
+ commits over `#385`/`#386`; the overlap is `instances.go` and route
+ resolution, which task 16 will touch and nothing landed does.
+
+**Done when:** the sweep is on `main` and the on-demand branch is a clean 24
+commits above it.
+
+## Phase 2 — deploy the sweep to Tito, and *test it*
+
+**This is the deliverable the plan is named for.** It executes
+`2026-09-01-carloku-scanning-rollout.md` steps 1–7 against Tito only, with two
+amendments.
+
+**Amendment A:** step 4 is no longer "set `CARLOS_STORE_SCANNER`". Wiring is
+env (`CARLOS_CLAMD_ADDR`); policy is the record; the operator turns it on with
+`carlos system antivirus enable`. Rollback is `disable`, with no restart — a
+strictly better rollback than the one that plan was written against, and worth
+exercising deliberately at step 6 rather than discovering under pressure.
+
+**Amendment B — a new step 5a, the reason this phase is not just a rollout.**
+Before any member is told scanning exists, take one object in a granted store
+and answer the three open questions with observation:
+
+- [ ] `HeadObject` and `ListObjectsV2` on the same object, timestamps compared
+ byte for byte. **If they differ, stop and treat it as a design defect**, not
+ an operational surprise: the fix is a version function that reads one source
+ or normalises both, and it has to land before tasks 13 and 16 are written.
+- [ ] The app's own credential attempting `PutObjectTagging` on its own object
+ — must be denied. This is the anti-forgery property, tested against the real
+ gateway rather than the policy document.
+- [ ] `systemd-cgtop` across a `freshclam` reload, against the limits in
+ discussion #6. Record the real numbers; they set `CARLOS_CLAMD_MAX_BYTES` and
+ task 12's per-tick budget.
+- [ ] An EICAR file through a real scan, end to end: uploaded, swept, tagged
+ infected, and `carlos store scan status` saying so. **This is the first time
+ the whole path runs outside a fake.**
+
+**Blocked on:** discussion #6. Steps 1 (ship inert) and 3 (console IAM) can go
+first and sit; step 2 is Tito's.
+
+**Done when:** one Tito app owned by us has a real clean verdict from a real
+clamd against a real RGW object, an EICAR file caught, and three recorded
+numbers replacing three guesses.
+
+## Phase 3 — scan-on-demand, tasks 11–17
+
+**The existing plan, unchanged in content, re-ordered by what Phase 2 teaches.**
+
+Sequence stays 11 → 12 → 13 → (14 → 15) → 16 → 17. Two changes:
+
+- **Task 11 opens with the deferred refactor.** `objstore.queuePermitted` still
+ returns `(rec, permitted bool, err error)`. The door and the drain must not
+ be able to answer the consent chain differently, and today nothing stops
+ them; a typed refusal reason is the precondition for task 11, not a cleanup
+ after it. §5.1.1.
+- **Task 12 delivers `QueueIndex.Depth()`**, deferred out of task 10 because
+ the drain is where the listed depth is known.
+
+Phase 2's numbers land in task 12's budget and task 13's version handling. If
+5a turns up a timestamp disagreement, the fix precedes task 13 and the spec
+gets a draft 8.
+
+**Done when:** the seven tasks are committed, green, and Fable has reviewed the
+completed implementation.
+
+## Phase 4 — deploy on-demand
+
+- [ ] Land the on-demand branch the same way — describe, green, merge, mirror.
+- [ ] Rollout, on Tito, in this order: `enable` a store that is already
+ sweeping cleanly; mint a Service credential; enqueue one object; watch it
+ drain; receive one signed callback; verify the signature from the app side.
+- [ ] Exercise the failures that only a deployment shows: a callback to a dead
+ app, a redirect, a queue flooded past the per-tick limit, a `disable` with
+ items in flight.
+- [ ] Then Carloku, both features together, following rollout step 8.
+- [ ] Update the skills repo — held to task 17 deliberately, since the drafted
+ `carlos store scan` section describes hourly-sweep behaviour and would
+ otherwise ship twice. This is the copy-review branch already open at
+ `~/github.com/carlosframework/skills`.
+
+---
+
+## Not in this plan
+
+- **The services box and Gotenberg.** Agreed in principle, unspecced. It is a
+ fleet-architecture change, and putting it in front of a finished feature's
+ rollout would hold both. clamd goes on the Tito console box for now, under
+ the cgroup limits in discussion #6, exactly as the rollout plan says. When
+ the services box is specced, moving clamd is a change of `CARLOS_CLAMD_ADDR`
+ from a unix socket to a private TCP address — a change the client already
+ supports, deliberately (`f889f20`).
+- **Rastrillo's upload path.** Discussion #11 is filed and is theirs; scanning
+ does not wait on it.
+- **Anything on Carloku before Tito.** Unchanged from the rollout plan, for the
+ reason it gives.
+
+## The one thing that would change this plan
+
+If Phase 2's step 5a shows RGW's two timestamps disagreeing, the version
+function is wrong and **three landed commits** (`2485634`, `8774304`,
+`d456d03`) bind durable facts to it. That is not a reason to defer Phase 2 —
+it is the reason to do Phase 2 first. Finding it now costs a spec draft and a
+task; finding it after task 16 costs the queue, the result object, the status
+door and the callback body.