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: design
2
3**Status:** draft 7 — final, cleared to plan from (closes reviews 1-6).
4**Builds on:** `2026-09-01-object-storage-virus-scanning-design.md` — the sweep,
5the `Scanner` seam, verdict tags, enforcement. Landed and reviewed.
6
7**It contradicts that spec in one place, deliberately.** Prior spec §4.1
8records: *"No TCP option in v1: an unauthenticated scanning service reachable
9over a network is a worse problem than the one being solved. **(decision)**"*.
10§9 reverses it, and argues the reversal rather than assuming it.
11
12---
13
14## 1. What is being asked
15
16The shipped scanner answers "is this bucket clean?" hourly. It does not answer
17"is *this object* clean, now?" — the question an app has when someone has just
18uploaded a file it is about to serve. The current docs tell app authors to
19solve that themselves, which is the platform declining work it is better placed
20to do.
21
22**The delay is scheduling, not scanning.** `clamd` answers on a typical upload
23in well under a second. So this is queueing and notification.
24
25### 1.1 What can honestly be claimed
26
27> Object `<key>` was scanned at `<T>` against `<engine>/<signature-db>` and
28> found clean.
29
30plus the sentence that makes the first one worth anything:
31
32> `N` objects in this store carry no verdict.
33
34On-demand scanning shortens T. It does not make scanning synchronous with
35upload: **CARLOS never sees the PUT**.
36
37---
38
39## 2. Scope
40
41In: `svcrole.Service`; `carlos system antivirus`; a priority scan door and a
42long-polling status door; a queue; an opt-in signed webhook; metering, recorded
43and unpriced; clamd over TCP on a services box.
44
45Out (§12): synchronous scan on PUT, GuardDuty, Gotenberg as a capability,
46pricing.
47
48---
49
50## 3. The service role
51
52### 3.1 Why not `Instance`
53
54`svcrole.go:34-36` calls `Instance` *"the LEGACY app-token authority, defined by
55observation of today's call sites rather than by its name."* A role defined by
56what happens to call it is not one to hang a new API class on, and a token
57minted to read logs must not silently gain the ability to spend deployment CPU.
58
59Enforced, not merely intended:
60`TestInstanceRoleMatchesTodaysAppTokenReach` (`svcroutes_test.go:262-291`) pins
61`Instance` to exactly nine doors and fails with "widening the legacy role".
62
63### 3.2 `svcrole.Service`
64
65**An app, at runtime, asking a platform service to do work on its behalf** —
66work the platform performs on the app's own data that costs the deployment
67resources. Scanning is the first; PDF rendering is next.
68
69```go
70Service Role = "service"
71```
72
73Also update `svcrole.go:41-44` ("the union of the three above") and the CLI's
74hardcoded role list in `services.go:164` — "mintable with no further change" is
75true of the API, not of the CLI's usage string.
76
77**`Allow` unconditionally adds `Admin`** (`svcrole.go:109`), so an Admin
78credential reaches the new doors by construction, and Admin *can* be
79account-scoped. That is within one account, not a tenant-boundary hole, but
80§11.5's property is scoped to role `Service` and must say so.
81
82### 3.3 There is no migration
83
84Draft 1 called this the document's biggest risk and proposed
85`Allow(Service, Instance)`. Both were wrong.
86
87| kind | minted by | roles | rotatable |
88|---|---|---|---|
89| person (`kind=""`) | `carlos auth login` | none — never meets the role gate (`authcli.go:425`) | n/a |
90| legacy app token (`kind="app"`) | a person, `POST .../instance-token` | `Instance`, hardcoded (`svcprincipal.go:124`) | **no** (`svcprincipal.go:244-246`) |
91| named service (`kind="service"`) | account **owner** (`apiservices.go:102-112,143`) | any role `svcrole.Parse`s (`apiservices.go:180`) | yes |
92
93Adding `Service` to the catalogue makes it mintable: the mint door, the mint
94form (`svcpage.go:176`) and `doorsFor` all read `svcrole.All()`.
95
96Nothing migrates. A `kind='app'` token can never become `Service`, so an app
97that wants on-demand scanning is issued a *new* credential:
98
99```
100carlos services create -account <a> -app <app> -role service
101carlos secrets set -app <app> CARLOS_SCAN_TOKEN=<...>
102```
103
104the same delivery step it already performed for its instance token.
105**`Allow(Service, Instance)` is deleted**: it cannot terminate (legacy tokens
106do not rotate into anything) and it would fail the pinned test above — the
107exact widening §3.1 argues against.
108
109### 3.4 Scope must be per-app, enforced in one place
110
111`NewServicePrincipal` permits `app == ""` (`svcprincipal.go:140-141`) and
112`apiAccountAppBy` binds only when `p.App != ""` (`apicli.go:70`), so an
113account-scoped credential reaches every app in its account.
114
115There are **two** mint doors — the API (`apiservices.go:211`) and the browser
116form (`svcpage.go:299`, which explicitly offers "an account-wide credential").
117So the refusal goes in **`NewServicePrincipal`** (`svcprincipal.go:170-172`,
118beside the existing role parse), not in a handler, or one door forgets it. It
119needs a named sentinel — `ErrAccountScopedService` — because the API door
120today matches only `ErrServiceNameTaken` (`apiservices.go:211-215`) and turns
121anything else into a generic failure. Both doors then surface the same 400.
122
123---
124
125## 4. `carlos system antivirus`
126
127### 4.1 Wiring versus policy
128
129`CARLOS_STORE_SCANNER` conflates *there is a clamd at this address* with
130*scanning is on*. Wiring belongs in the unit file; policy does not. Turning a
131compliance control on should not mean editing a systemd unit and restarting the
132control plane, and today the only way to know whether scanning is healthy is to
133log into the box — which the no-infra-access rule forbids.
134
135| what | where |
136|---|---|
137| `CARLOS_CLAMD_ADDR` — `host:port`, or a path when it begins `/` | env, boot |
138| `CARLOS_CLAMD_MAX_BYTES` | env, boot |
139| `CARLOS_SCAN_WEBHOOK_KEY` (+ `_PREVIOUS`, §6.3) | env, boot |
140| enabled / disabled, engine, at, by | **record** |
141
142There is no clamd timeout variable today (`clamd.Timeouts{}` defaults,
143`clamav.go:71`); this spec does not add one.
144
145### 4.2 What replaces the env var
146
147**Every place that keys off `CARLOS_STORE_SCANNER` or a nil Scanner:**
148
1491. `storeScannerFor` (`main.go:755-758`) builds the Scanner whenever
150 `CARLOS_CLAMD_ADDR`, the retained `CARLOS_CLAMD_SOCKET` alias, **or the
151 deprecated `CARLOS_STORE_SCANNER=clamav`** is set — the last of those
152 retaining today's socket default (`/run/clamav/clamd.ctl`, `main.go:774`).
153 Without that third case a deployment that set only the old variable gets a
154 nil Scanner, no goroutine (`scanpass.go:37-39`) and nothing to seed a record
155 from: scanning stops silently, which is what §4.2.1 exists to prevent.
156 Safe because `NewClamAVScanner` does not ping (`clamav.go:56-70`).
157 `main.go:772`'s fatal when no store backend is configured stays.
1582. `StartScanPass` (`scanpass.go:37-39`) loops whenever `Scanner != nil` and
159 **reads `system/antivirus.json` at the top of each tick**. Disabled means the
160 tick does nothing — not that there is no goroutine. That is what makes
161 `enable` restartless. A per-tick GET of one small object is negligible
162 against the enumeration the tick already does.
1633. `scan grant` (`apiscan.go:252`) and `scan sweep` (`scanpass.go:198`) check
164 the **record**, and 503 naming `carlos system antivirus enable`.
1654. `RunSweep`'s refusal (`sweep.go:163`) checks the record.
1665. `apiscan.go:71-81` — the `engine_error` branch — reports *disabled* as its
167 own state, distinct from *unreachable*.
1686. `apiscan.go:45`'s message names the command, not the variable.
169
170**Still correctly branching on wiring, and unchanged:** `appsettings.go:838-840`
171and `walk.go:466` (Teardown must release an engine that exists regardless of
172policy), `apiscan.go:351` (revoke's best-effort `EnsureDisabled`),
173`apiscan.go:419` (enforce-on needs an engine to name a tag).
174
175**Comments and message strings naming the variable, which are reworded rather
176than rewired:** `objstore.go:86`, `scanner.go:89`, `server.go:522-523`, and
177`main.go:772`'s message.
178
179Two documents change with this: `docs/site/cli.md:474-488`, which lists the
180variables, and **`docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md`**,
181whose step 4 sets `CARLOS_STORE_SCANNER=clamav` and whose rollback is "unset it
182and restart". Step 4 becomes `CARLOS_CLAMD_ADDR` plus `carlos system antivirus
183enable`; the rollback becomes `carlos system antivirus disable`, which needs no
184restart — the feature's own selling point.
185
186**`enable` refuses with 503 when no Scanner is wired**, or the record can claim
187enabled with nothing behind it.
188
189### 4.2.1 Migration — an upgrade must not silently stop scanning
190
191Today `CARLOS_STORE_SCANNER=clamav` is the only gate. After this change the
192pass reads a record that **exists on no current deployment**, so every tick
193would do nothing, and an operator who did not touch the unit file would get a
194nil Scanner as well. That is precisely the failure `main.go:751-753,766-768`
195refuses to allow: *"a typo that silently disabled scanning would leave an
196operator believing a compliance control is on when nothing is running."*
197
198**Draft 3 answered this with a boot `log.Fatal` on the retired variable. That
199was worse than the problem.** Console env is unit-file managed, so an operator
200who ships the build before editing env gets a console that will not start and
201has to edit a unit file on the box — the exact access §4.1 says this design
202should remove the need for. (Draft 4 justified this partly by claiming the
203box's adopt pass does not restart the unit on rollback. **That is wrong** —
204`WithStartUnit` (`adopt.go:204-215`) exists to start the unit after a
205rolled-back failed restart, and the host agent passes it in production
206(`host/agent.go:602,1155,2960`), so a fataling console is rolled back *and*
207started on the old build. The decision stands on the other leg alone.) The
208other order — edit env first — requires restarting the *old* binary, which then
209sees no engine and stops scanning anyway. One order costs a scanning outage;
210the other costs a trip to the box.
211
212So the migration preserves behaviour instead of demanding attention:
213
214- `CARLOS_STORE_SCANNER=clamav` is accepted as a **deprecated alias for
215 wiring**, logged loudly on every boot. Any other value stays fatal, which is
216 today's behaviour for an unknown engine.
217- `CARLOS_CLAMD_SOCKET` is retained as an alias for `CARLOS_CLAMD_ADDR`;
218 setting both to different values is fatal.
219- **the record is seeded enabled, once, when the retired variable is set and
220 `system/antivirus.json` is absent**, stamped `by: "migration from
221 CARLOS_STORE_SCANNER"`. Draft 3 refused to seed on the grounds that
222 auto-enabling a control that reads tenant bytes is the wrong default. That
223 reasoning does not apply to a *migration*: the operator already enabled it,
224 explicitly, and per-app grants still gate every byte. Refusing to seed does
225 not withhold consent — it silently withdraws consent already given, which is
226 the failure `main.go:751-753` exists to prevent.
227- **where the seed happens**: at the top of the first `StartScanPass` tick —
228 the same record read §4.2 item 2 already performs — not in
229 `storeScannerFor`, which runs at `main.go:262` before `openStore()` at
230 `:269` and has no release store to write to.
231- the write is **create-only** (`Put(…, "")`, `release/store.go:38,516` — the
232 `IfNoneMatch:*` that `TrashApp` already relies on,
233 `release/trash.go:51-54`), so two
234 consoles or a restart loop converge; `ErrCASConflict` means "someone else
235 seeded it" and is success.
236- seeding is therefore idempotent: it happens only when no record exists, so an
237 operator who later runs `disable` is not overridden on the next restart.
238- **a read error at boot neither seeds nor fatals** — log and retry on the next
239 tick. Seeding on a failed read would enable scanning on a deployment that may
240 have disabled it.
241- **the seed fires only while the retired variable is still set**, so the
242 upgrade order matters and the rollout must say it: deploy the new build with
243 `CARLOS_STORE_SCANNER` still in place (one tick seeds the record), *then*
244 swap it for `CARLOS_CLAMD_ADDR`. Swapping in the same restart as the upgrade
245 seeds nothing and stops scanning — warned every tick, but stopped. Running
246 `carlos system antivirus enable` also settles it at any point.
247- `main.go` carries "the deprecated variable was set" to the first tick as a
248 `Config` field (`ScanSeedFromDeprecatedEnv bool`); the pass does not read the
249 environment itself.
250- the first tick warns when a Scanner is wired and no record exists — not boot,
251 which has no store — and `system antivirus status` reports "engine wired, no
252 record" as its own state.
253
254### 4.3 The record
255
256`system/antivirus.json`, deployment-scoped — sibling in spirit to
257`infraview.InventoryKey` (`internal/infraview/infraview.go:25`), with the
258helper beside `release.FeaturesKey` (`internal/release/features.go:53`):
259
260```go
261// package release
262func SystemKey(name string) string { return "system/" + name + ".json" }
263
264type AntivirusSettings struct {
265 Schema int `json:"schema"`
266 Enabled bool `json:"enabled"`
267 Engine string `json:"engine"`
268 At time.Time `json:"at"`
269 By string `json:"by"`
270}
271```
272
273CAS read/write, schema pinned, unknown schema refuses — the rules
274`objstore.Record` and `ScanSidecar` follow, and for the same reason: this one
275decides whether tenant bytes get read.
276
277**A typed record per feature, not a generic map.** A map is `features.json` by
278another name: uninterpreted and unpinnable. `system/pdf.json` later is a new
279type and a new door, not a new magic string.
280
281### 4.4 The doors
282
283```
284carlos system antivirus status|enable|disable|rotate-webhook-key
285```
286
287A deployment-wide door has **no `{account}` segment**, so `apiPrincipalAccount`
288(`apicli.go:176-187`) cannot resolve it. Precedent exists: `GET /api/cli/apps`
289(`authcli.go:471`) is already a no-`{account}` door. These need a person-only
290resolver, registered `svcrole.Set{}` so no service principal reaches them, and
291added to `closedDoors` (`svcroutes_test.go:83`) or
292`TestEveryCLIDoorNamesItsRoles` (`svcroutes_test.go:140-172`) fails.
293
294Authority is `storeOperator(addr)` — the `CARLOS_STORE_OPERATORS` list that
295already governs `store grant` (`apistore.go:520`) and every scan operator verb.
296Draft 1's "not `Admin` alone" confused a service role with person authority:
297operators are addresses (`server.go:558-571`), a service actor is
298`service:`/`app:` (`apicli.go:89-100`), and the two can never match.
299
300`status` answers "is scanning healthy" without SSH: engine version, whether it
301answers, signature age, queue depth.
302
303### 4.5 Two layers
304
305Deployment enable scans nothing by itself. Per-app `store scan request`
306(member) and `store scan grant` (operator) are unchanged.
307
308**Per-app `store scan status` must surface the deployment state**, or a record
309saying `enabled: clamav` reads as healthy while nothing runs — a new way for
310the status output to lie. Note `apiscan_test.go:254` asserts `engine ==
311"clamav"` on a fixture with a Scanner and no record; that fixture must write an
312enabled record.
313
314---
315
316## 5. The pull path
317
318### 5.1 Request
319
320```
321POST /api/cli/apps/{account}/{app}/store/scan/object
322{"key": "uploads/x.pdf", "callback_path": "/hooks/scan"} // callback_path optional
323→ 202 {"state":"pending"}
324→ 409 {"state":"unavailable","reason":"not_granted"|"not_requested"|"encrypted"|"not_enabled"}
325→ 503 {"state":"unavailable","reason":"disabled"|"no_engine"}
326→ 429 on rate limit
327```
328
329Role `Service`. It enqueues; it does not scan inline. Inline scanning would
330hold a console goroutine for the duration and hand one tenant a trivial way to
331occupy them all.
332
333### 5.1.1 The consent chain applies here too
334
335**This was the hole in draft 2.** The landed sweep refuses to read tenant bytes
336without the member's attestation (`sweep.go:151-160`), skips stores attested
337encrypted (`:147-149`), and `scan grant` refuses without a request
338(`apiscan.go:268-285`) on the stated ground that *an operator must not be able
339to start that on their own*. Draft 2's enqueue checked only the deployment
340switch — so an app's own credential could make the console fetch and scan
341objects in a store that was never requested, never granted, or attested
342client-encrypted. A new door straight through the property the whole feature
343exists to protect.
344
345Both the enqueue **and the drain** refuse unless all of:
346
347- `rec != nil && rec.Provisioned != nil` → else `not_granted`, the state
348 `apiscan.go:263-266` already names
349- the app is not in the trash — `release.ReadAppTombstone`, which the sweep
350 checks at `sweep.go:135-141` → else the drain deletes the items
351- `rec.Scanning != nil && rec.Scanning.Requested != nil` → else `not_requested`
352- `!rec.Scanning.Requested.Encrypted` → else `encrypted`
353- `rec.Scanning.Enabled != nil` → else `not_enabled`
354- the deployment record is enabled → else `disabled`
355
356Draft 3 omitted the first two. The trash one matters specifically for the
357drain: `HasAppIn` filters `deleted_at = 0` (`console/store.go:1208`) so a trashed app
358404s at the door, but items enqueued *before* `TrashApp` sit under the prefix
359for the 30-day cooloff with `store.json` intact and `Enabled` non-nil. Without
360the tombstone check the drain would read bytes from a store the sweep
361deliberately will not touch.
362
363The drain re-checks all of it because the record can change between enqueue and
364drain, and the drain is where bytes are actually read. It also applies
365`RunSweep`'s engine-drift refusal (`sweep.go:175-178`).
366
367**On a read error — not an absence — the drain keeps the items and the index
368entry, logs, and retries next tick.** `objstore.Get` returns `(nil, "", nil)`
369for absent and an error for failure; §7.5's "delete when `store.json` is gone"
370must never be implemented as "delete on any error", or a transient bucket
371failure discards a tenant's queue.
372
373### 5.2 Read
374
375```
376GET .../store/scan/object?key=uploads/x.pdf&wait=15s
377→ {"state":"clean","at":"...","engine":"clamav","signature":"..."}
378→ {"state":"infected","signature":"Eicar-Test"}
379→ {"state":"skipped","reason":"too_large"}
380→ {"state":"failed","detail":"..."} // the engine read it and could not judge it
381→ {"state":"denied"} // the console may not read it
382→ {"state":"pending"}
383→ {"state":"gone"} // object does not exist
384→ {"state":"unavailable","reason":"..."} // as §5.1
385```
386
387**The console does the version comparison, and the token never leaves it.** The
388tag records `etag@RFC3339` (`sweep.go:49-51`). An app must not reconstruct
389that: it is an internal format, and an app comparing it wrongly gets the
390laundering bug *as a correctness bug* — upload v2 over a key whose v1 was
391tagged clean, read the stale tag, serve the payload.
392
393Draft 2 said "the seam widens" for `HeadObject`. That understates it:
394`scanS3API` (`clamav.go:25-29`) is the **scanner's private** seam, and the door
395reaches the scanner only through `Scanner` (`scanner.go`), which cannot ask
396about one key. So `Scanner` gains:
397
398```go
399// Status answers what is known about the CURRENT version of one object, and
400// returns the object as a listing page would have described it so a caller
401// can go on to Scan it without a HeadObject of its own.
402//
403// Verdict is the recorded one when a tag matches the current version,
404// VerdictUnknown when the tag is absent or stale. ErrObjectGone when the
405// object is not there.
406Status(ctx context.Context, bucket, key string) (Verdict, ScanObject, error)
407```
408
409**`Status` reads the tag as written; it does not apply `Verdict`'s re-issue
410rule.** The landed reader maps a stored `denied` or `failed` tag back to
411`VerdictUnknown` (`clamav.go:160-163`) so the sweep retries it — correct as
412*sweep retry policy*, and wrong as *an answer to a question about an object*.
413Left in place here, a failed scan reads as `pending` forever: the drain deletes
414the item as scanned, and nothing ever changes the answer until a sweep re-fails
415it. `Verdict` keeps the re-issue rule; `Status` reports what the tag says.
416Different questions, and the split is the point.
417
418**The `ScanObject` return is not decoration.** Draft 3 had `Status` return a
419verdict alone, and §7.4 has the drain call `Status` before `Scan` — but `Scan`
420takes `(bucket, key, version, size)` (`scanner.go:42`) and the drain has no
421listing page. Without the object it would need its own `HeadObject`, which is
422exactly what keeping the version token inside the scanner forbids. Draft 2 had
423the same hole under a different name.
424
425`Status` needs `HeadObject`, which `scanS3API` (`clamav.go:25-29`) and
426`stubScanS3` (`clamav_test.go:24`) both gain.
427
428**When `HeadObject` itself is denied** there is no version or size to hand
429`Scan`, so `Status` returns `(VerdictDenied, ScanObject{}, nil)` and the drain
430does not call `Scan` on a zero `ScanObject`. The SSE-KMS case the landed
431comment names behaves differently and correctly: `HeadObject` succeeds,
432`GetObject` is denied, so the drain gets a populated object, scans, and gets
433`Denied` from the fetch. `memscanner.go` and
434`scanner_contract_test.go` follow: `MemScanner` has no object model, so it
435gains a seeding call — `PutObject(bucket, key, etag string, modified
436time.Time, size int64)` — and the contract test asserts an absent object gives
437`ErrObjectGone`.
438
439**`wait` long-polls**, capped at 30s, woken by **in-process notification from
440the drain** — a polling waiter costs one tag read per second per waiter. Cap
441concurrent waiters **globally** as well as per app.
442
443**`skipped`, `gone` and `unavailable` are not `clean`.** Distinct states with
444reasons, because an app treating them as success reproduces the dishonesty the
445two-number rule exists to prevent.
446
447### 5.2.1 When a verdict is reached but cannot be recorded
448
449`clamavScanner.tag` returns success **without writing** in two landed cases:
450an object already carrying eight foreign tags (`clamav.go:306-308`, the ten-tag
451cap) and a pre-write tag read that is denied or gone (`clamav.go:289-291`).
452Both were deliberate — they stop one object stalling a whole sweep — and the
453sweep tolerates them because it re-lists every 24h.
454
455**A queue item does not.** The drain would get a real verdict from `Scan`, send
456`clean` in the callback, delete the item — and `Status` would keep answering
457`pending` forever, because the tag is not there. The app's declared source of
458truth (§5.3) would never arrive, and the two answers it did get would disagree.
459
460**The `Scanner` interface changes**, which draft 4 hid inside a sentence:
461
462```go
463Scan(ctx, bucket, key, version string, size int64) (Verdict, bool, error)
464// ^ recorded
465```
466
467The sweep's call site (`sweep.go:235`) takes `_` for the flag and its counts
468are unaffected — Fresh and Reused count verdicts *reached*, which is what
469`clamav.go:302-303` already says it is doing. `MemScanner.Scan`
470(`memscanner.go:102`) gains an `Unrecordable(key)` hook so the case is testable
471without a live S3 stub. The third return value touches roughly fifteen call
472sites in `clamav_test.go` and the contract test, not only the sweep.
473`MemScanner.Verdict` (`memscanner.go:89-100`) does **not** apply the re-issue
474rule today, so it gains it — otherwise the `Verdict`/`Status` split cannot be
475exercised against the fake — and `scanner_contract_test.go` pins both halves.
476The plan will extract a shared `readTag` helper: `Verdict` is called only from
477`sweep.go:215` and no such helper exists yet.
478
479**Where the answer goes when the tag cannot hold it.** Draft 4 said "keep the
480queue item, terminal" — but items are ULID-named and the door asks by key, so
481that would mean listing the whole queue prefix on every poll. Instead the drain
482writes a **key-derived result object** and deletes the queue item as normal:
483
484```
485apps/<acct>/<app>/scan-results/<sha256(key)>.json
486{"schema":1, "key":"uploads/x.pdf", "verdict":"clean",
487 "version":"<etag>@<RFC3339>", "engine":"clamav", "signature":"...",
488 "at":"..."}
489```
490
491**It carries the version, and that is not optional.** Draft 5 keyed it by
492`sha256(key)` with the verdict alone — which reintroduces, through the new
493mechanism, exactly the bug §5.2 exists to prevent: v1 scanned, tag not written
494because the cap fired, result object says clean; the tenant overwrites with v2;
495the tag is still absent, so the door falls through to the result object and
496answers `clean` for bytes nobody has read. The door compares the recorded
497version against the `ScanObject` that `Status` already returns and answers
498`pending` on a mismatch. The token stays inside the console throughout, which
499is what §5.2 requires.
500
501- one GET for the door, on the `pending` path only
502- durable across restarts, so §11.10 holds without soft state
503- under the app prefix, so `PurgeApp` and the claim gate cover it (§7.1's
504 argument for keeping the queue there applies unchanged)
505
506**Written read-then-CAS.** `release.Store.Put` has exactly two modes
507(`release/store.go:37-45`): create-only with `ifMatch ""`, or replace with an
508etag. The second write to the same key is a *replace* — v1's result object is
509present, the tenant uploads v2, v2 is scanned and again unrecordable — so a
510create-only write there returns `ErrCASConflict` and silently drops the v2
511verdict, leaving the door on `pending` until a sweep tags it. Get, then Put
512with the etag if present or create-only if absent, retrying once on conflict:
513the `writeSidecar` posture at `sweep.go:370-395`.
514
515It carries the engine and signature alongside the verdict, because the
516`recorded:false` answer is still an audit answer and needs its provenance.
517
518**Written with the same guard as a sidecar.** The drain re-reads `store.json`
519immediately before writing, the way `writeSidecar` does (`sweep.go:358-367`),
520because the identical race applies: read the record, scan for seconds, a
521teardown and `PurgeApp` land, and a create-only write then *succeeds* under a
522purged prefix. A stranded queue item is drained on the next tick because its
523index entry survives; **a stranded result object is found by nothing**, and it
524makes the app name permanently unclaimable (`server.go:2921`).
525
526**Collected**, three ways — draft 5 said "by TTL" and named no collector:
527
528- deleted by the drain as soon as a later scan persists a real verdict for that
529 key
530- deleted with the app's queue items whenever §7.5 deletes them for absence,
531 tombstone, or a record that no longer permits scanning
532- swept by the boot rebuild (§7.1), which lists `scan-results/` as well as
533 `scan-queue/` and collects anything older than the TTL by `ListMod` age —
534 the name carries no timestamp, so age has to come from the store
535- and swept the same way by the drain for each app it visits, so a console that
536 never restarts does not accumulate them. Note a *sweep* that later tags the
537 object does not delete the result object; only the on-demand drain does.
538
539Age-based collection needs `release.ModLister`, which is optional and
540unimplemented by the test fakes. Type-assert it and fall back to skipping age
541collection with a single logged line, exactly as `storagePass` does for
542`SizeLister` (`economicspass.go:141-145`).
543
544**TTL for both kinds is 24h**, one sweep cycle: after that the ordinary sweep
545has had its chance to write a real verdict, and a stale on-demand answer is
546worth less than no answer.
547
548**`infected` with `recorded:false` means enforcement is NOT in effect.** The
549Deny statement matches `s3:ExistingObjectTag/carlos-scan=infected`
550(`aws.go:159-165`), and a verdict that could not be tagged does not trigger it.
551The app can still read the object and must act on the verdict itself. This goes
552in the docs sentence too, not only here.
553
554### 5.3 Pull is the source of truth
555
556The tag on the object is the record; the status door reads it. Webhooks
557accelerate that and never replace it (§6.4).
558
559---
560
561## 6. The push path
562
563### 6.1 Why
564
565Long-poll covers the app being inside a request. **Presigned direct-to-S3
566uploads break that**: the browser PUTs straight to the bucket and the app is
567never in a request. Common enough that designing without it is a mistake.
568
569### 6.2 Where a callback may go
570
571Draft 1 claimed taking a path rather than a URL meant the vulnerability "does
572not exist". **Overstated.** The host is still tenant-influenced: the outbound
573fetcher picks the first ready non-static route (`instances.go:88-97`), which can
574be a tenant's custom domain whose DNS they control. And Go follows redirects by
575default — a tenant server answering `302 → http://10.x/` turns a signed POST
576into a GET at an internal address. The codebase already knows this
577(`sesevents.go:143-158`).
578
579So, all of:
580
581- POST only to the **platform-assigned host**, `<app>.<sqid>.<AppsDomain>`
582 (`apiinstances.go:566,1250`) — never a custom domain.
583 **Correction (review 7): an app with only its own domain DOES get
584 callbacks.** The domains loop mints the platform host for any app with a
585 ready route, and `firstReadyUpstream` (`domains/reconcile.go:268`) accepts a
586 custom-domain route as the source it copies an upstream from. The true
587 condition is narrower and duller: an app with **no ready route at all** has
588 no platform host yet, so it has nowhere to be called back and uses long-poll
589 until it does. The docs say that, not the old sentence.
590- **Why the host cannot be moved, with the evidence.** `app` is tenant-chosen
591 but `^[a-z][a-z0-9-]{0,31}$` (`release.go:242`) — no dots, so no escaping the
592 label; `sqid` and `AppsDomain` are the platform's. A tenant cannot attach a
593 custom domain under `AppsDomain` (`apidomains.go:96`). DNS for the name is
594 written by the fleet's domains loop to edge IPs, not by a tenant. **DNS
595 rebinding is closed twice**, and the second reason is the one that matters:
596 TLS name verification against a public CA means a poisoned resolver still
597 cannot present a valid certificate for that name. `InsecureSkipVerify` on the
598 callback transport is therefore a **tested absence**, not a code-review item.
599- **https only**
600- `CheckRedirect` **refuses every redirect**
601- bounded timeout, `LimitReader` on the response
602
603**Path validation as a rule.** Refuse: absolute URLs; protocol-relative
604`//evil/x` and `/\evil/x`; anything not beginning with exactly one `/`; raw or
605percent-encoded CR/LF; fragments; `..` segments; over 1KB. Then:
606
607```go
608u, err := url.Parse(p) // must yield Scheme=="" Host=="" User==nil Opaque==""
609target := url.URL{Scheme: "https", Host: appHost, Path: u.Path, RawQuery: u.RawQuery}
610```
611
612Built structurally — **never string concatenation**.
613
614### 6.3 Signing
615
616An unsigned callback is a hole wearing a convenience's clothes: anyone who
617learns the path POSTs `{"verdict":"clean"}` and the app serves a payload
618believing the platform cleared it.
619
620**What can and cannot be reused from store credentials.** Draft 2 said the key
621"cannot be delivered the same way". That was wrong about delivery and right
622about retention. Delivery is exactly the store path — `appconfig.Seal` against
623`CARLOS_SECRETS_PUBKEY` then one `appconfig.Apply`, which bumps the serial and
624reconverges the fleet (`walk.go:602-640`). What cannot be reused is *keeping
625nothing*: the console must be able to sign later, and a sealed value it cannot
626open is no use to it.
627
628- one boot master, `CARLOS_SCAN_WEBHOOK_KEY`
629- **per-app key derived at sign time**:
630 `HMAC(master, "carlos-scan-webhook-v1" + 0x00 + sqid + "/" + app + "/" + gen)`.
631 Nothing extra is stored and per-app isolation falls out — without it a single
632 deployment secret lets app A forge callbacks to app B. The delimiter is
633 injection-safe: a sqid is `^[a-z]{3,16}$` (`qualified.go:56`) and an app is
634 `^[a-z][a-z0-9-]{0,31}$` (`release.go:242`), so neither can contain `/`, and
635 `gen` is an integer.
636 *(Corrected: this bullet said `HMAC(master, sqid + "/" + app)` while the
637 bullets below it added the domain label and the generation, so the section
638 specified two different schemes. The implementer read both, followed the
639 later one, and said so — which is the only reason it was caught before the
640 wire format froze.)*
641- **written by `store scan grant`**, gated by `storeSealKey`
642 (`apistore.go:425`) — one config write on an operator verb, never per enqueue
643- header `Carlos-Signature: t=<unix>,v1=<keyid>:<hex>[,v1=<keyid>:<hex>]` over
644 `t + "." + body`. Each signature carries its own key id; draft 3's single
645 `k=` could not label two of them, and an app does not need the id anyway — it
646 holds one derived key and tries it against each `v1`.
647- **rotation carries two masters.** `CARLOS_SCAN_WEBHOOK_KEY_PREVIOUS` is
648 accepted; the console signs under **each** master and the app accepts if
649 either verifies. Without that window every app is broken between the
650 console's rotation and its own re-seal.
651
652**What rotation actually does, stated because it is not free.**
653`appconfig.Apply` bumps the generation, and the box's Sync **restarts the app
654unit** on a bundle change (`sync.go:33,42,331`). So:
655
656- `rotate-webhook-key` restarts every scanning-enabled app on the deployment.
657 It paces the re-seal rather than doing them at once, and says so before it
658 starts.
659- `store scan grant` restarts the tenant's app as a side effect, because that
660 is where the key is written. The docs sentence for `grant` says so.
661- **"done" means sealed, not converged.** The console cannot see when a host
662 has picked up the new bundle — `server.go:566-569` makes exactly this point
663 for store rotation. So dropping `_PREVIOUS` is a separate operator step after
664 convergence, not something `rotate-webhook-key` does. Dropping it early
665 degrades to one lost callback per stale host, which is survivable because
666 pull is the source of truth — but it should be a choice, not a surprise.
667- `rotate-webhook-key` is idempotent and re-runnable after a partial failure,
668 and is also the backfill for apps granted before this ships. A re-run with an
669 unchanged master re-seals the same plaintext and therefore does **not**
670 restart anything: Sync restarts only when the rendered bundle changes
671 (`sync.go:340+`).
672- **"Paced" needs a mechanism, and it is `scan sweep`'s.** One budget per
673 call: the door re-seals as many apps as it can inside `scanSweepTimeout`,
674 answers with how many remain, and the CLI loops until none do. No background
675 job and no pacing state — idempotence by key id is what makes a re-run safe,
676 so a console that dies mid-rotation simply resumes.
677- **Every console must be restarted with the new pair BEFORE the rotate runs.**
678 With more than one console, a console still holding only the old master will
679 sign only under the old key, while apps another console has already re-sealed
680 hold only the new derived key — and those callbacks fail verification.
681 Order: restart all consoles with `KEY=new, PREVIOUS=old`, then run
682 `rotate-webhook-key`, then drop `_PREVIOUS` once converged.
683- `store scan revoke` **unsets** the app's callback secret. A key outliving the
684 consent it was issued under is the same mistake as a result object outliving
685 it.
686- constant-time compare; skew window 5 minutes
687- the body carries `recorded`, so a callback saying `clean` and a door saying
688 `recorded:false` cannot disagree — the disagreement §5.2.1 exists to prevent
689- the body carries account, app, key and the **bare ETag** — and **not** the
690 opaque version. Draft 4 carried both, which contradicted §11.7 ("no door or
691 app sees the token") in the same document; a review found the two sentences
692 facing each other. §11.7 wins, because it is the property the version binding
693 exists to protect: an app that holds the token is an app that can compare it
694 wrongly, and that is the laundering bug arriving as a correctness bug. The
695 app has the ETag from its own PUT response and matches on that.
696- **Replay, stated plainly because there is no nonce.** A callback replayed
697 inside the 5-minute skew window verifies and is accepted. That is harmless
698 and it is the design: the body is idempotent on `(account, app, key, etag,
699 verdict)`, which is the tuple an app uses as its idempotency key, and it is
700 named here so two implementers do not choose differently. A nonce is
701 deliberately NOT added — apps have no durable nonce store to check one
702 against, so it would be assurance in name only.
703- **Clock.** `t` is the console's clock. Apps run on platform boxes and do have
704 a clock, so the skew check is expected of them; skipping it only widens the
705 (harmless) replay window above. The verifier must tolerate two `v1=` entries
706 and unknown key ids.
707- **Key id**: `HMAC("carlos-scan-webhook-key-id", master)[:16]` — a *different*
708 label from `scantag.go`'s tag-key id, so the two schemes cannot produce a
709 colliding id from the same bytes. `PREVIOUS == current` is refused, exactly
710 as `NewTagSigner` refuses it: a rotation that rotated to nothing.
711- **Derivation carries a domain label**: `HMAC(master, "carlos-scan-webhook-v1"
712 + 0x00 + sqid + "/" + app)`. The label costs nothing and means no future
713 `HMAC(master, x)` use can collide with a derived per-app key.
714- **Why a delimiter here when `scantag.go` uses length prefixes.** `t + "." +
715 body` is injective because `t` is decimal digits — it cannot contain a `.` —
716 so the first `.` splits it unambiguously. The tag scheme needs length
717 prefixes because an object key can contain any delimiter you pick. Stated
718 because the two schemes now look inconsistent, and the next person to notice
719 should find the reason rather than "fix" it: length-prefixing here would make
720 every non-Go verifier harder for no gain.
721- **Per-app rotation.** `ScanBlock` carries a non-secret `webhook_gen`, and the
722 derivation is over `sqid + "/" + app + "/" + gen`. Without it a tenant that
723 leaks its callback key has no remedy short of an operator rotating the
724 deployment master and restarting every scanning app. The tag key needs no
725 such thing because it never leaves the console; this one is handed to
726 tenants, so it needs an owner-side rotation.
727- **`webhook_gen` IS RANDOM, NOT A COUNTER FROM ZERO**, and the reclaimed-name
728 property depends entirely on that. Draft 5 said a generation stopped a
729 reclaimed app name inheriting the deleted app's key. **It did not.** The
730 implementer pointed out that `PurgeApp` lists the whole app prefix and
731 deletes every key in it (`trash.go:229-241`), `store.json` included — so a
732 counter starting at zero starts at zero again for the next app to claim that
733 name, and derives the identical key. The claim was false as written.
734 A fresh random value at grant makes it true, and makes it true *because* the
735 record is destroyed rather than in spite of it: mint a random positive
736 integer below 2^53 (JSON-number safe, since the console serves this to
737 browsers) at the first grant, and mint a new one on every per-app rotation.
738 Zero remains a legal derivation input, because records written before this
739 ships have no field and their apps already hold a key derived with zero;
740 `rotate-webhook-key` is the backfill that moves them onto a random one.
741- **With no master configured**, the enqueue door refuses a `callback_path`
742 with 503 `callbacks_not_configured` rather than accepting it. A 202 would
743 promise a delivery that cannot happen. Delivery refuses to build a request
744 without a signer, which is a test (`TestNeverPostsUnsigned`) rather than a
745 convention — §6.3 exists because an unsigned callback is the hole.
746
747Considered and rejected: Gotenberg's replay-my-headers trick. Neat, avoids a
748signing scheme, but gives no body integrity, no replay protection, and puts a
749tenant secret in request logs.
750
751### 6.4 Delivery is best effort
752
753**No dead-letter queue**: the verdict is durably on the object and the status
754door always answers, so a dropped callback costs one poll rather than stranding
755a file. That is the dividend of building pull first.
756
757**The guarantee is AT-MOST-ONCE, not at-least-once.** Draft 4 said the
758opposite and a review caught it: `DrainApp` deletes the queue items before
759delivery is attempted, so a console that dies between the delete and the POST
760loses that callback and nothing re-sends it — the sweep re-tags the object but
761never delivers. Duplicates are still possible (a retry after an ambiguous
762failure, or two consoles draining the same prefix), so apps must be idempotent
763**and** must not treat the callback as the only way an answer arrives. The
764docs sentence is: *if no callback arrives within your own timeout, poll the
765status door.*
766
767The combination that actually hurts is `infected` + `recorded:false` + a lost
768callback: enforcement is not in effect (§5.2.1), the object is servable, and
769the app is never told unless it polls. That is the case the docs sentence is
770written for.
771
772**Delivery does not run in the drain's goroutine.** Inline delivery would put a
773tenant's hanging endpoint directly in the path of every other tenant's verdict
774— 50 items times a bounded retry schedule, inside a tick that sweeps apps
775sequentially, which is the exact starvation §7.2 and §7.3 exist to prevent and
776a trivial tenant-triggered denial of service. Delivery is a bounded worker pool
777fed from `done` after the waiters are notified, with a bounded in-memory queue
778(overflow drops and logs — it is best effort by construction), a per-app
779in-flight cap, and retries scheduled by the pool rather than by the tick.
780
781**The policy, written down so it is not invented twice:** success is 2xx only;
7823xx is a refusal and is not retried, since redirects are refused by design;
7834xx is not retried; 5xx, 429, timeouts and network errors are retried, three
784attempts at 1s/4s/16s; `http.Client.Timeout` around 10s so it covers connect,
785TLS, headers *and* body; the response body is drained through
786`io.LimitReader(…, 4KiB)` and discarded, because the console needs nothing from
787it; headers are `Content-Type: application/json`, a User-Agent and
788`Carlos-Signature`, and nothing else — no cookies, no credentials. Ordering is
789not guaranteed: retries reorder, and two verdicts for one key can arrive
790newest-first, which is why the body carries the ETag. One delivery per distinct
791callback path per key, which is already what `Drained.CallbackPaths` means.
792
793---
794
795## 7. The queue
796
797### 7.1 Location and index
798
799Items live in the app prefix:
800
801```
802apps/<acct>/<app>/scan-queue/<ulid>.json
803```
804
805One object per item, not one queue object: distinct keys never collide, so an
806enqueue needs no coordination. ULIDs sort by time, so a listing is the queue in
807order.
808
809A top-level `scan-queue/<acct>/<app>/` was rejected: `PurgeApp` lists only the
810app prefix (`trash.go:229-238`) and the name-claim gate (`server.go:2875+`)
811never looks outside it, so items would survive app deletion invisibly.
812
813But S3 cannot list `*/scan-queue/*`, so finding non-empty queues would be the
814full `ListDirs("apps/")` walk. Hence an index — and draft 2 left its removal
815rule unstated, which is a stranding race:
816
817> drain lists (empty) → enqueue writes the item → enqueue marks the app →
818> drain removes the mark. The item is now in S3 with nothing pointing at it.
819
820**The rule, stated:**
821
822```go
823type queueIndex struct {
824 mu sync.Mutex
825 gen map[string]uint64 // "acct/app" → generation
826}
827```
828
829- enqueue: **write the object first**, then `gen[key]++` under the mutex, and
830 only then answer 202 — a 202 sent between the two invites a client to stop
831 retrying an item nothing points at
832- drain: read `observed := gen[key]`, drain, then remove the entry **only if
833 `gen[key] == observed` and the listing returned zero items**. Draft 4 said
834 "fully drained — listing not truncated"; `release.Store.List` never truncates
835 (`release/store.go:555-569`), so that clause was vacuous. Zero-items is the test that
836 actually means "nothing is left", and it costs one empty listing per tick
837 until the entry goes.
838- the drain needs a **bounded, oldest-first listing**, which does not exist
839 today: `List` returns every key under a prefix (`release/store.go:42-55`), so
840 a flooded app's whole queue is enumerated every 30s regardless of §7.3's cap.
841 This is an **optional interface**, `release.PageLister`, type-asserted at the
842 call site exactly as `release.SizeLister` (`release/store.go:181-185`) is at
843 `economicspass.go:141` — *not* a new method on `release.Store`, which has
844 eleven implementations (`DirStore`, `S3Store`, `prefixStore`,
845 `fleetapi.store` and seven test fakes). `DirStore` can serve it because
846 `ListMod` is key-sorted (`release/store.go:290`), and S3 has `StartAfter` +
847 `MaxKeys`, already used at `sweep.go:71-78`.
848- a crash between the object write and the increment strands that item until
849 the next console start
850- **boot rebuilds the index with one full enumeration**, and in the same pass
851 deletes items past their TTL — the only thing that collects a stranded item,
852 so it is not optional
853
854**The removal condition is what draft 3 got wrong.** §7.3 caps work per app per
855tick, so on a flooded app the drain deliberately leaves items behind; with
856generation-equality as the only condition it would then delete the entry
857pointing at them. The same happens when the drain aborts mid-app on an engine
858error — the `RunSweep` posture at `sweep.go:222-240` that the drain inherits —
859or when `Status` errors on one item. Those items would sit until the next boot,
860and the callback and long-poll wake would be lost for every capped item, on
861precisely the app the cap exists for.
862
863### 7.2 The drain is its own goroutine
864
865Draft 1 put a queue phase inside the existing pass. That cannot deliver seconds
866of latency: `scanPass` sweeps apps sequentially, each for a full budget
867(`scanpass.go:86-103`), and the prior spec accepts ticks running back to back.
868A queue phase behind that starves for up to N × 60s.
869
870Its own goroutine, its own ticker (30s). It never touches the sidecar; the only
871state shared with the sweep is object tags, idempotent per version.
872
873### 7.3 Fairness
874
875Round-robin across apps, **50 items per app per tick** — at a 30s tick that is
876100/minute, close to the door's own 120/minute limit, so a well-behaved app is
877never throttled by the drain. Not FIFO across the deployment. FIFO lets one tenant's ten thousand objects delay every other
878tenant's single urgent upload.
879
880### 7.4 Amplification
881
882A rate limit alone does not close this: at `svcWriteBurst` (120/min,
883`authcli.go:140`) one app can enqueue ~172k items a day, each up to the size
884ceiling.
885
886- the drain calls `Status` **before** `Scan`, as `RunSweep` calls `Verdict`
887 first (`sweep.go:215`), so a key already judged clean, infected or skipped at
888 its current version costs a tag read and nothing else
889- but the drain **applies the re-issue rule itself**: it scans when `Status`
890 returns `Unknown`, `Failed` or `Denied`. Those two are transient by the
891 landed code's own reasoning — "a permissions fix, a Glacier restore, an
892 engine that was having a bad day" (`clamav.go:151-163`) — and the sweep
893 re-issues them. Without this the on-demand path would be *less* able than the
894 hourly one: an object the sweep last tagged `failed` would be answered from
895 that tag forever and never rescanned on request. The cost is one failed
896 `GetObject` on an object that is still denied.
897- items **deduplicated by key per tick**
898- per-app token bucket — the existing `svcWrite` limiter, keyed on principal
899 name (`svcprincipal.go:375-380`), not a new one. Exceeding it is a 429, never
900 a silent drop: an app being throttled needs to know, because its upload flow
901 depends on the answer
902
903### 7.5 Deletion and the purge race
904
905This is the single deletion list; §5.1.1's "refuse" means "does not read
906bytes", not "does not delete".
907
908**Delete the item** when it has been scanned (including §5.2.1, where a result
909object is written first); when its TTL has passed; when `store.json` is absent;
910when the app is in the trash; and when the record no longer permits scanning —
911`Provisioned == nil`, `Requested == nil`, the attestation is encrypted, or
912`Scanning.Enabled == nil`. None of those can change back in a way that makes
913the item useful.
914
915**Keep the item — up to the TTL** when the deployment record is `disabled`, on
916engine drift, on an unreachable engine, and on any read error. All of those are
917conditions an operator can reverse. A deployment disabled for longer than a day
918drops its queue, which is right: both doors answered `disabled` at the time,
919and the sweep re-covers those objects once it is re-enabled.
920
921Deletion is driven by **absence**, never by error — and absence at the release
922store layer is `os.ErrNotExist` (`trash.go:64`), not a nil value, so queue
923items and the system record must test for it explicitly.
924
925"Inherits the purge machinery" inherits the hazard too: `sweep.go:346-355` —
926any object written under a purged prefix makes the app name permanently
927unclaimable, and an enqueue racing `PurgeApp` leaves one behind. The orphan
928persists for **up to one drain tick**, during which `claimApp` refuses the
929name. This spec accepts that, the way `writeSidecar` accepts its
930one-round-trip race; §13's test asserts "not longer than one tick", not "never".
931
932---
933
934## 8. Metering
935
936`usage.go:26-38` carries `MeterBackhaulGB` recorded and explicitly unpriced.
937Scanning follows that idiom — but not into an existing object. `EdgeDay` is
938per-box (`edge.go:41-46`) and `StorageDay` is written once daily by a sweep
939rather than incremented (`storage.go:13-18`); neither takes increments from a
940door.
941
942- a new `usage/scan/<date>.json`, CAS-merged the way `UpsertEdgeDay` does
943 (`edge.go:85-116`)
944- **folded once per drain tick, not per object** — each fold is a GET plus a
945 conditional PUT of a deployment-wide object
946- new meter names in `usage.go`, **frozen once merged** (`usage.go:4-5`)
947- counts objects and bytes scanned **on demand**, per account per day
948- unpriced
949
950Sweep-driven scanning is **not** attributed to a tenant: the operator asked for
951it, not them.
952
953---
954
955## 9. Where clamd lives
956
957### 9.1 Draft 1's argument was wrong
958
959It said the console "shovels every tenant object through its own memory". It
960does not: `clamd.go:76-79,173-187` streams 64KB chunks and never buffers. The
961`MemoryMax` in the rollout plan is on **clamd's** cgroup, protecting the
962console from clamd's ~1.5GB signature RSS — not mitigation for a
963byte-shovelling smell, because there is not one.
964
965### 9.2 The argument that holds
966
967Moving clamd to a services box **relocates the resident cost** — ~1.5GB of
968signatures and the scanning CPU leave a control-plane box, permanently, for a
969feature idle by default. That is the whole benefit. It **costs doubled network
970traffic**, since the console still fetches from S3 and streams onward (§9.4),
971and it **does not remove tenant bytes from the console**; they still transit
972it.
973
974A resource-placement decision, worth doing, worth stating at that size.
975
976### 9.3 Reversing the prior decision
977
978Prior spec §4.1: *"No TCP option in v1: an unauthenticated scanning service
979reachable over a network is a worse problem than the one being solved."*
980
981That objection is right and is not answered by wanting a services box. It is
982answered by the network:
983
984- **clamd has no authentication whatsoever**
985- private interface only (Tailscale or equivalent), **never** a public port,
986 and the services box firewalls the port to the console's address
987- a hard constraint on the infra ask, not a recommendation
988
989`internal/clamd` has one `"unix"` literal (`clamd.go:130`); framing, deadlines
990and parsing are transport-independent. `New` gains a network parameter, and
991`CARLOS_CLAMD_ADDR` beginning with `/` means unix.
992
993### 9.4 The console keeps fetching
994
995Rather than giving the services box S3 read and tag permissions. Bytes move
996twice; tenant-data credentials stay on one box. Revisit with numbers.
997
998---
999
1000## 10. Surface
1001
1002```
1003carlos system antivirus status|enable|disable|rotate-webhook-key
1004carlos store scan request|status member, per app (shipped)
1005carlos store scan grant|revoke|sweep|enforce operator, per app (shipped)
1006```
1007
1008App-facing HTTP, role `Service`:
1009
1010```
1011POST .../store/scan/object request a scan
1012GET .../store/scan/object?key=&wait= read the verdict
1013```
1014
1015`store scan status` gains queue depth and the deployment state (§4.5);
1016`system antivirus status` gains queue depth deployment-wide.
1017
1018---
1019
1020## 11. Security properties
1021
1022Each is a test.
1023
10241. No callback reaches a host the app was not assigned — absolute URLs,
1025 `//host`, `/\host`, `@`-authority, traversal, CRLF, and encoded variants.
10262. No redirect is followed.
10273. A tampered body fails; app A's signature does not verify for app B; a stale
1028 timestamp is refused; both masters verify during rotation. **These are
1029 properties of the APP's verifier**, which is tenant code — so the console
1030 ships `VerifyCallback(key, header, body, now)` as the reference
1031 implementation, the docs reproduce it, and these tests run against it.
1032 Without that, the test tests nothing anybody runs. The signature is over the
1033 RAW BODY BYTES: an app that re-serialises the JSON before verifying will
1034 fail, and the docs say so.
10354. The app policy still has no `s3:PutObjectTagging` (`aws.go:152-165`).
10365. A role-`Service` credential cannot be minted account-scoped (§3.4) and
1037 cannot act on another app. (Admin reaches these doors by construction,
1038 §3.2 — this property is about `Service`.)
10396. A `Service` credential cannot reach `system antivirus`, `scan grant`,
1040 `revoke`, `sweep` or `enforce`.
10417. Version comparison is inside the scanner; no door or app sees the token.
10428. Enqueue and drain both enforce the consent chain (§5.1.1).
10439. Rate limits precede enqueue; a re-request of a key judged **clean, infected
1044 or skipped** costs a tag read and no fetch. (`failed` and `denied` are
1045 deliberately rescanned — §7.4.)
104610. A verdict that could not be persisted is reported as `recorded:false`,
1047 never as `pending` and never as a silent success (§5.2.1); and a `failed`
1048 or `denied` verdict is reported as itself, never as `pending` (§5.2).
1049
1050---
1051
1052## 12. Out of scope
1053
1054Synchronous scan on PUT (CARLOS does not see the PUT); Gotenberg as a
1055capability (the role is designed to be reused, the rendering service is its own
1056brainstorm); GuardDuty (gated on the tagging measurement); pricing.
1057
1058---
1059
1060## 13. Testing
1061
1062- callback host and path validation as a table of hostile inputs; redirects
1063 refused
1064- signature: tamper, cross-app, replay, skew, two-master rotation
1065- `pending` for a stale tag, never the stale verdict; `gone` for a missing key
1066- **the consent chain**: enqueue and drain both refuse `not_requested`,
1067 `encrypted`, `not_enabled`, `disabled`, and engine drift
1068- one app flooding the queue does not delay another's item beyond one tick
1069- a dropped callback leaves the verdict readable through the status door
1070- `system antivirus disable` stops scanning without a restart and without
1071 touching per-app records — and per-app `scan status` then reports the
1072 deployment state rather than claiming health
1073- `request`, `revoke`, `enforce off` still work while disabled
1074- boot accepts `CARLOS_STORE_SCANNER=clamav` as a deprecated wiring alias,
1075 retaining the socket default; any other value stays fatal (§4.2.1)
1076- the **first pass tick** — not boot — seeds the record and warns when a
1077 Scanner is wired with no record
1078- a `failed` and a `denied` verdict each answer as themselves, not `pending`
1079- unknown `system/antivirus.json` schema refuses
1080- metering counts on-demand scans and not sweep scans
1081- **the index**: no item strands while the process stays up (enqueue racing a
1082 drain's removal); **an app flooded past the per-tick cap is fully drained
1083 across successive ticks** — the test that would have caught review 3's
1084 blocker; an item written before a crash is drained after restart; TTL'd items
1085 are collected by the boot rebuild
1086- an enqueue racing `PurgeApp` leaves no orphan for longer than one drain tick
1087- a verdict reached but not persisted answers `recorded:false`, not `pending`
1088 (§5.2.1)
1089- **a result object written for v1 does not answer for v2** — the door
1090 compares versions and says `pending`
1091- a result object is not written under a prefix purged mid-scan, and is
1092 collected by the boot rebuild once past TTL
1093- the drain rescans an object whose tag says `failed` or `denied`, rather than
1094 answering from the tag (§7.4)
1095- `enable` 503s when no Scanner is wired
1096- the seed happens once from the retired variable and does **not** override an
1097 existing record (§4.2.1)
1098- a second result object for the same key **replaces** the first (§5.2.1), and
1099 does not fail create-only
1100- the drain keeps items and the index entry on a record **read error**, and
1101 deletes only on absence
1102
1103---
1104
1105## 14. Risks
1106
1107- **The index is soft state, and losing an entry loses the callback.** The
1108 fallback is the ordinary sweep, which re-sweeps a completed bucket only every
1109 `ScanInterval` — **24h by default** (`scanpass.go:31,171`), not hourly. The
1110 object gets a verdict eventually and a polling app sees it; the webhook is
1111 simply not sent, and the queue item is collected at the next boot. Test by
1112 killing the console mid-queue.
1113- **Boot rebuild cost.** One full enumeration at start on a deployment with
1114 many apps. Measure it.
1115- **Two new object kinds under the app prefix cost something.** `storagePass`
1116 (`economicspass.go:163`) folds queue and result bytes into the tenant's
1117 `BucketBytes`, and the three full `List("apps/")` passes
1118 (`provision/reconcile.go:586,1050`, `domains/reconcile.go:87`,
1119 `materialize.go:292`) grow with a flooded queue until the TTL collects it.
1120 `provision` and `domains` skip unknown key shapes correctly, so for them
1121 this is cost, not breakage. **`materialize` does not, by design**: it counts
1122 *any* key under an app prefix (`materialize.go:333-345`, "convergence fails
1123 toward KEEPING the row") and restores a trashed row to live when it finds one
1124 (`:377-382`). So a result object stranded under a purged prefix does not only
1125 block the claim gate — it **resurrects the app row** on the next boot. That
1126 is the safe direction for materialize, and one more reason §5.2.1's purge
1127 guard is not optional.
1128- **Index size is bounded** by the number of apps that have enqueued since boot
1129 and not yet been seen empty — at most the number of provisioned stores, a few
1130 dozen bytes each. Trashed and purged apps clear on their next empty listing,
1131 since `PurgeApp` deletes the items (`trash.go:229-238`).
1132- **Long-poll occupancy.** Bounded wait, global and per-app waiter caps.
1133