carlos / platform Public
Scan on demand: design
Status: draft 7 — final, cleared to plan from (closes reviews 1-6). Builds on: 2026-09-01-object-storage-virus-scanning-design.md — the sweep, the Scanner seam, verdict tags, enforcement. Landed and reviewed.
It contradicts that spec in one place, deliberately. Prior spec §4.1 records: "No TCP option in v1: an unauthenticated scanning service reachable over a network is a worse problem than the one being solved. (decision)". §9 reverses it, and argues the reversal rather than assuming it.
---
1. What is being asked
The shipped scanner answers "is this bucket clean?" hourly. It does not answer "is this object clean, now?" — the question an app has when someone has just uploaded a file it is about to serve. The current docs tell app authors to solve that themselves, which is the platform declining work it is better placed to do.
The delay is scheduling, not scanning. clamd answers on a typical upload in well under a second. So this is queueing and notification.
1.1 What can honestly be claimed
> Object <key> was scanned at <T> against <engine>/<signature-db> and > found clean.
plus the sentence that makes the first one worth anything:
> N objects in this store carry no verdict.
On-demand scanning shortens T. It does not make scanning synchronous with upload: CARLOS never sees the PUT.
---
2. Scope
In: svcrole.Service; carlos system antivirus; a priority scan door and a long-polling status door; a queue; an opt-in signed webhook; metering, recorded and unpriced; clamd over TCP on a services box.
Out (§12): synchronous scan on PUT, GuardDuty, Gotenberg as a capability, pricing.
---
3. The service role
3.1 Why not Instance
svcrole.go:34-36 calls Instance "the LEGACY app-token authority, defined by observation of today's call sites rather than by its name." A role defined by what happens to call it is not one to hang a new API class on, and a token minted to read logs must not silently gain the ability to spend deployment CPU.
Enforced, not merely intended: TestInstanceRoleMatchesTodaysAppTokenReach (svcroutes_test.go:262-291) pins Instance to exactly nine doors and fails with "widening the legacy role".
3.2 svcrole.Service
An app, at runtime, asking a platform service to do work on its behalf — work the platform performs on the app's own data that costs the deployment resources. Scanning is the first; PDF rendering is next.
Service Role = "service"Also update svcrole.go:41-44 ("the union of the three above") and the CLI's hardcoded role list in services.go:164 — "mintable with no further change" is true of the API, not of the CLI's usage string.
Allow unconditionally adds Admin (svcrole.go:109), so an Admin credential reaches the new doors by construction, and Admin can be account-scoped. That is within one account, not a tenant-boundary hole, but §11.5's property is scoped to role Service and must say so.
3.3 There is no migration
Draft 1 called this the document's biggest risk and proposed Allow(Service, Instance). Both were wrong.
| kind | minted by | roles | rotatable | |---|---|---|---| | person (kind="") | carlos auth login | none — never meets the role gate (authcli.go:425) | n/a | | legacy app token (kind="app") | a person, POST .../instance-token | Instance, hardcoded (svcprincipal.go:124) | no (svcprincipal.go:244-246) | | named service (kind="service") | account owner (apiservices.go:102-112,143) | any role svcrole.Parses (apiservices.go:180) | yes |
Adding Service to the catalogue makes it mintable: the mint door, the mint form (svcpage.go:176) and doorsFor all read svcrole.All().
Nothing migrates. A kind='app' token can never become Service, so an app that wants on-demand scanning is issued a new credential:
carlos services create -account <a> -app <app> -role service
carlos secrets set -app <app> CARLOS_SCAN_TOKEN=<...>the same delivery step it already performed for its instance token. Allow(Service, Instance) is deleted: it cannot terminate (legacy tokens do not rotate into anything) and it would fail the pinned test above — the exact widening §3.1 argues against.
3.4 Scope must be per-app, enforced in one place
NewServicePrincipal permits app == "" (svcprincipal.go:140-141) and apiAccountAppBy binds only when p.App != "" (apicli.go:70), so an account-scoped credential reaches every app in its account.
There are two mint doors — the API (apiservices.go:211) and the browser form (svcpage.go:299, which explicitly offers "an account-wide credential"). So the refusal goes in NewServicePrincipal (svcprincipal.go:170-172, beside the existing role parse), not in a handler, or one door forgets it. It needs a named sentinel — ErrAccountScopedService — because the API door today matches only ErrServiceNameTaken (apiservices.go:211-215) and turns anything else into a generic failure. Both doors then surface the same 400.
---
4. carlos system antivirus
4.1 Wiring versus policy
CARLOS_STORE_SCANNER conflates there is a clamd at this address with scanning is on. Wiring belongs in the unit file; policy does not. Turning a compliance control on should not mean editing a systemd unit and restarting the control plane, and today the only way to know whether scanning is healthy is to log into the box — which the no-infra-access rule forbids.
| what | where | |---|---| | CARLOS_CLAMD_ADDR — host:port, or a path when it begins / | env, boot | | CARLOS_CLAMD_MAX_BYTES | env, boot | | CARLOS_SCAN_WEBHOOK_KEY (+ _PREVIOUS, §6.3) | env, boot | | enabled / disabled, engine, at, by | record |
There is no clamd timeout variable today (clamd.Timeouts{} defaults, clamav.go:71); this spec does not add one.
4.2 What replaces the env var
Every place that keys off CARLOS_STORE_SCANNER or a nil Scanner:
storeScannerFor(main.go:755-758) builds the Scanner wheneverCARLOS_CLAMD_ADDR, the retainedCARLOS_CLAMD_SOCKETalias, or the deprecatedCARLOS_STORE_SCANNER=clamavis set — the last of those retaining today's socket default (/run/clamav/clamd.ctl,main.go:774). Without that third case a deployment that set only the old variable gets a nil Scanner, no goroutine (scanpass.go:37-39) and nothing to seed a record from: scanning stops silently, which is what §4.2.1 exists to prevent. Safe becauseNewClamAVScannerdoes not ping (clamav.go:56-70).main.go:772's fatal when no store backend is configured stays.StartScanPass(scanpass.go:37-39) loops wheneverScanner != niland readssystem/antivirus.jsonat the top of each tick. Disabled means the tick does nothing — not that there is no goroutine. That is what makesenablerestartless. A per-tick GET of one small object is negligible against the enumeration the tick already does.scan grant(apiscan.go:252) andscan sweep(scanpass.go:198) check the record, and 503 namingcarlos system antivirus enable.RunSweep's refusal (sweep.go:163) checks the record.apiscan.go:71-81— theengine_errorbranch — reports disabled as its own state, distinct from unreachable.apiscan.go:45's message names the command, not the variable.
Still correctly branching on wiring, and unchanged: appsettings.go:838-840 and walk.go:466 (Teardown must release an engine that exists regardless of policy), apiscan.go:351 (revoke's best-effort EnsureDisabled), apiscan.go:419 (enforce-on needs an engine to name a tag).
Comments and message strings naming the variable, which are reworded rather than rewired: objstore.go:86, scanner.go:89, server.go:522-523, and main.go:772's message.
Two documents change with this: docs/site/cli.md:474-488, which lists the variables, and docs/superpowers/plans/2026-09-01-carloku-scanning-rollout.md, whose step 4 sets CARLOS_STORE_SCANNER=clamav and whose rollback is "unset it and restart". Step 4 becomes CARLOS_CLAMD_ADDR plus carlos system antivirus enable; the rollback becomes carlos system antivirus disable, which needs no restart — the feature's own selling point.
enable refuses with 503 when no Scanner is wired, or the record can claim enabled with nothing behind it.
4.2.1 Migration — an upgrade must not silently stop scanning
Today CARLOS_STORE_SCANNER=clamav is the only gate. After this change the pass reads a record that exists on no current deployment, so every tick would do nothing, and an operator who did not touch the unit file would get a nil Scanner as well. That is precisely the failure main.go:751-753,766-768 refuses to allow: "a typo that silently disabled scanning would leave an operator believing a compliance control is on when nothing is running."
Draft 3 answered this with a boot log.Fatal on the retired variable. That was worse than the problem. Console env is unit-file managed, so an operator who ships the build before editing env gets a console that will not start and has to edit a unit file on the box — the exact access §4.1 says this design should remove the need for. (Draft 4 justified this partly by claiming the box's adopt pass does not restart the unit on rollback. That is wrong — WithStartUnit (adopt.go:204-215) exists to start the unit after a rolled-back failed restart, and the host agent passes it in production (host/agent.go:602,1155,2960), so a fataling console is rolled back and started on the old build. The decision stands on the other leg alone.) The other order — edit env first — requires restarting the old binary, which then sees no engine and stops scanning anyway. One order costs a scanning outage; the other costs a trip to the box.
So the migration preserves behaviour instead of demanding attention:
CARLOS_STORE_SCANNER=clamavis accepted as a deprecated alias for wiring, logged loudly on every boot. Any other value stays fatal, which is today's behaviour for an unknown engine.CARLOS_CLAMD_SOCKETis retained as an alias forCARLOS_CLAMD_ADDR; setting both to different values is fatal.- the record is seeded enabled, once, when the retired variable is set and
system/antivirus.jsonis absent, stampedby: "migration from CARLOS_STORE_SCANNER". Draft 3 refused to seed on the grounds that auto-enabling a control that reads tenant bytes is the wrong default. That reasoning does not apply to a migration: the operator already enabled it, explicitly, and per-app grants still gate every byte. Refusing to seed does not withhold consent — it silently withdraws consent already given, which is the failuremain.go:751-753exists to prevent. - where the seed happens: at the top of the first
StartScanPasstick — the same record read §4.2 item 2 already performs — not instoreScannerFor, which runs atmain.go:262beforeopenStore()at:269and has no release store to write to. - the write is create-only (
Put(…, ""),release/store.go:38,516— theIfNoneMatch:*thatTrashAppalready relies on,release/trash.go:51-54), so two consoles or a restart loop converge;ErrCASConflictmeans "someone else seeded it" and is success. - seeding is therefore idempotent: it happens only when no record exists, so an operator who later runs
disableis not overridden on the next restart. - a read error at boot neither seeds nor fatals — log and retry on the next tick. Seeding on a failed read would enable scanning on a deployment that may have disabled it.
- the seed fires only while the retired variable is still set, so the upgrade order matters and the rollout must say it: deploy the new build with
CARLOS_STORE_SCANNERstill in place (one tick seeds the record), then swap it forCARLOS_CLAMD_ADDR. Swapping in the same restart as the upgrade seeds nothing and stops scanning — warned every tick, but stopped. Runningcarlos system antivirus enablealso settles it at any point. main.gocarries "the deprecated variable was set" to the first tick as aConfigfield (ScanSeedFromDeprecatedEnv bool); the pass does not read the environment itself.- the first tick warns when a Scanner is wired and no record exists — not boot, which has no store — and
system antivirus statusreports "engine wired, no record" as its own state.
4.3 The record
system/antivirus.json, deployment-scoped — sibling in spirit to infraview.InventoryKey (internal/infraview/infraview.go:25), with the helper beside release.FeaturesKey (internal/release/features.go:53):
// package release
func SystemKey(name string) string { return "system/" + name + ".json" }
type AntivirusSettings struct {
Schema int `json:"schema"`
Enabled bool `json:"enabled"`
Engine string `json:"engine"`
At time.Time `json:"at"`
By string `json:"by"`
}CAS read/write, schema pinned, unknown schema refuses — the rules objstore.Record and ScanSidecar follow, and for the same reason: this one decides whether tenant bytes get read.
A typed record per feature, not a generic map. A map is features.json by another name: uninterpreted and unpinnable. system/pdf.json later is a new type and a new door, not a new magic string.
4.4 The doors
carlos system antivirus status|enable|disable|rotate-webhook-keyA deployment-wide door has no {account} segment, so apiPrincipalAccount (apicli.go:176-187) cannot resolve it. Precedent exists: GET /api/cli/apps (authcli.go:471) is already a no-{account} door. These need a person-only resolver, registered svcrole.Set{} so no service principal reaches them, and added to closedDoors (svcroutes_test.go:83) or TestEveryCLIDoorNamesItsRoles (svcroutes_test.go:140-172) fails.
Authority is storeOperator(addr) — the CARLOS_STORE_OPERATORS list that already governs store grant (apistore.go:520) and every scan operator verb. Draft 1's "not Admin alone" confused a service role with person authority: operators are addresses (server.go:558-571), a service actor is service:/app: (apicli.go:89-100), and the two can never match.
status answers "is scanning healthy" without SSH: engine version, whether it answers, signature age, queue depth.
4.5 Two layers
Deployment enable scans nothing by itself. Per-app store scan request (member) and store scan grant (operator) are unchanged.
Per-app store scan status must surface the deployment state, or a record saying enabled: clamav reads as healthy while nothing runs — a new way for the status output to lie. Note apiscan_test.go:254 asserts engine == "clamav" on a fixture with a Scanner and no record; that fixture must write an enabled record.
---
5. The pull path
5.1 Request
POST /api/cli/apps/{account}/{app}/store/scan/object
{"key": "uploads/x.pdf", "callback_path": "/hooks/scan"} // callback_path optional
→ 202 {"state":"pending"}
→ 409 {"state":"unavailable","reason":"not_granted"|"not_requested"|"encrypted"|"not_enabled"}
→ 503 {"state":"unavailable","reason":"disabled"|"no_engine"}
→ 429 on rate limitRole Service. It enqueues; it does not scan inline. Inline scanning would hold a console goroutine for the duration and hand one tenant a trivial way to occupy them all.
5.1.1 The consent chain applies here too
This was the hole in draft 2. The landed sweep refuses to read tenant bytes without the member's attestation (sweep.go:151-160), skips stores attested encrypted (:147-149), and scan grant refuses without a request (apiscan.go:268-285) on the stated ground that an operator must not be able to start that on their own. Draft 2's enqueue checked only the deployment switch — so an app's own credential could make the console fetch and scan objects in a store that was never requested, never granted, or attested client-encrypted. A new door straight through the property the whole feature exists to protect.
Both the enqueue and the drain refuse unless all of:
rec != nil && rec.Provisioned != nil→ elsenot_granted, the stateapiscan.go:263-266already names- the app is not in the trash —
release.ReadAppTombstone, which the sweep checks atsweep.go:135-141→ else the drain deletes the items rec.Scanning != nil && rec.Scanning.Requested != nil→ elsenot_requested!rec.Scanning.Requested.Encrypted→ elseencryptedrec.Scanning.Enabled != nil→ elsenot_enabled- the deployment record is enabled → else
disabled
Draft 3 omitted the first two. The trash one matters specifically for the drain: HasAppIn filters deleted_at = 0 (console/store.go:1208) so a trashed app 404s at the door, but items enqueued before TrashApp sit under the prefix for the 30-day cooloff with store.json intact and Enabled non-nil. Without the tombstone check the drain would read bytes from a store the sweep deliberately will not touch.
The drain re-checks all of it because the record can change between enqueue and drain, and the drain is where bytes are actually read. It also applies RunSweep's engine-drift refusal (sweep.go:175-178).
On a read error — not an absence — the drain keeps the items and the index entry, logs, and retries next tick. objstore.Get returns (nil, "", nil) for absent and an error for failure; §7.5's "delete when store.json is gone" must never be implemented as "delete on any error", or a transient bucket failure discards a tenant's queue.
5.2 Read
GET .../store/scan/object?key=uploads/x.pdf&wait=15s
→ {"state":"clean","at":"...","engine":"clamav","signature":"..."}
→ {"state":"infected","signature":"Eicar-Test"}
→ {"state":"skipped","reason":"too_large"}
→ {"state":"failed","detail":"..."} // the engine read it and could not judge it
→ {"state":"denied"} // the console may not read it
→ {"state":"pending"}
→ {"state":"gone"} // object does not exist
→ {"state":"unavailable","reason":"..."} // as §5.1The console does the version comparison, and the token never leaves it. The tag records etag@RFC3339 (sweep.go:49-51). An app must not reconstruct that: it is an internal format, and an app comparing it wrongly gets the laundering bug as a correctness bug — upload v2 over a key whose v1 was tagged clean, read the stale tag, serve the payload.
Draft 2 said "the seam widens" for HeadObject. That understates it: scanS3API (clamav.go:25-29) is the scanner's private seam, and the door reaches the scanner only through Scanner (scanner.go), which cannot ask about one key. So Scanner gains:
// Status answers what is known about the CURRENT version of one object, and
// returns the object as a listing page would have described it so a caller
// can go on to Scan it without a HeadObject of its own.
//
// Verdict is the recorded one when a tag matches the current version,
// VerdictUnknown when the tag is absent or stale. ErrObjectGone when the
// object is not there.
Status(ctx context.Context, bucket, key string) (Verdict, ScanObject, error)Status reads the tag as written; it does not apply Verdict's re-issue rule. The landed reader maps a stored denied or failed tag back to VerdictUnknown (clamav.go:160-163) so the sweep retries it — correct as sweep retry policy, and wrong as an answer to a question about an object. Left in place here, a failed scan reads as pending forever: the drain deletes the item as scanned, and nothing ever changes the answer until a sweep re-fails it. Verdict keeps the re-issue rule; Status reports what the tag says. Different questions, and the split is the point.
The ScanObject return is not decoration. Draft 3 had Status return a verdict alone, and §7.4 has the drain call Status before Scan — but Scan takes (bucket, key, version, size) (scanner.go:42) and the drain has no listing page. Without the object it would need its own HeadObject, which is exactly what keeping the version token inside the scanner forbids. Draft 2 had the same hole under a different name.
Status needs HeadObject, which scanS3API (clamav.go:25-29) and stubScanS3 (clamav_test.go:24) both gain.
When HeadObject itself is denied there is no version or size to hand Scan, so Status returns (VerdictDenied, ScanObject{}, nil) and the drain does not call Scan on a zero ScanObject. The SSE-KMS case the landed comment names behaves differently and correctly: HeadObject succeeds, GetObject is denied, so the drain gets a populated object, scans, and gets Denied from the fetch. memscanner.go and scanner_contract_test.go follow: MemScanner has no object model, so it gains a seeding call — PutObject(bucket, key, etag string, modified time.Time, size int64) — and the contract test asserts an absent object gives ErrObjectGone.
wait long-polls, capped at 30s, woken by in-process notification from the drain — a polling waiter costs one tag read per second per waiter. Cap concurrent waiters globally as well as per app.
skipped, gone and unavailable are not clean. Distinct states with reasons, because an app treating them as success reproduces the dishonesty the two-number rule exists to prevent.
5.2.1 When a verdict is reached but cannot be recorded
clamavScanner.tag returns success without writing in two landed cases: an object already carrying eight foreign tags (clamav.go:306-308, the ten-tag cap) and a pre-write tag read that is denied or gone (clamav.go:289-291). Both were deliberate — they stop one object stalling a whole sweep — and the sweep tolerates them because it re-lists every 24h.
A queue item does not. The drain would get a real verdict from Scan, send clean in the callback, delete the item — and Status would keep answering pending forever, because the tag is not there. The app's declared source of truth (§5.3) would never arrive, and the two answers it did get would disagree.
The Scanner interface changes, which draft 4 hid inside a sentence:
Scan(ctx, bucket, key, version string, size int64) (Verdict, bool, error)
// ^ recordedThe sweep's call site (sweep.go:235) takes _ for the flag and its counts are unaffected — Fresh and Reused count verdicts reached, which is what clamav.go:302-303 already says it is doing. MemScanner.Scan (memscanner.go:102) gains an Unrecordable(key) hook so the case is testable without a live S3 stub. The third return value touches roughly fifteen call sites in clamav_test.go and the contract test, not only the sweep. MemScanner.Verdict (memscanner.go:89-100) does not apply the re-issue rule today, so it gains it — otherwise the Verdict/Status split cannot be exercised against the fake — and scanner_contract_test.go pins both halves. The plan will extract a shared readTag helper: Verdict is called only from sweep.go:215 and no such helper exists yet.
Where the answer goes when the tag cannot hold it. Draft 4 said "keep the queue item, terminal" — but items are ULID-named and the door asks by key, so that would mean listing the whole queue prefix on every poll. Instead the drain writes a key-derived result object and deletes the queue item as normal:
apps/<acct>/<app>/scan-results/<sha256(key)>.json
{"schema":1, "key":"uploads/x.pdf", "verdict":"clean",
"version":"<etag>@<RFC3339>", "engine":"clamav", "signature":"...",
"at":"..."}It carries the version, and that is not optional. Draft 5 keyed it by sha256(key) with the verdict alone — which reintroduces, through the new mechanism, exactly the bug §5.2 exists to prevent: v1 scanned, tag not written because the cap fired, result object says clean; the tenant overwrites with v2; the tag is still absent, so the door falls through to the result object and answers clean for bytes nobody has read. The door compares the recorded version against the ScanObject that Status already returns and answers pending on a mismatch. The token stays inside the console throughout, which is what §5.2 requires.
- one GET for the door, on the
pendingpath only - durable across restarts, so §11.10 holds without soft state
- under the app prefix, so
PurgeAppand the claim gate cover it (§7.1's argument for keeping the queue there applies unchanged)
Written read-then-CAS. release.Store.Put has exactly two modes (release/store.go:37-45): create-only with ifMatch "", or replace with an etag. The second write to the same key is a replace — v1's result object is present, the tenant uploads v2, v2 is scanned and again unrecordable — so a create-only write there returns ErrCASConflict and silently drops the v2 verdict, leaving the door on pending until a sweep tags it. Get, then Put with the etag if present or create-only if absent, retrying once on conflict: the writeSidecar posture at sweep.go:370-395.
It carries the engine and signature alongside the verdict, because the recorded:false answer is still an audit answer and needs its provenance.
Written with the same guard as a sidecar. The drain re-reads store.json immediately before writing, the way writeSidecar does (sweep.go:358-367), because the identical race applies: read the record, scan for seconds, a teardown and PurgeApp land, and a create-only write then succeeds under a purged prefix. A stranded queue item is drained on the next tick because its index entry survives; a stranded result object is found by nothing, and it makes the app name permanently unclaimable (server.go:2921).
Collected, three ways — draft 5 said "by TTL" and named no collector:
- deleted by the drain as soon as a later scan persists a real verdict for that key
- deleted with the app's queue items whenever §7.5 deletes them for absence, tombstone, or a record that no longer permits scanning
- swept by the boot rebuild (§7.1), which lists
scan-results/as well asscan-queue/and collects anything older than the TTL byListModage — the name carries no timestamp, so age has to come from the store - and swept the same way by the drain for each app it visits, so a console that never restarts does not accumulate them. Note a sweep that later tags the object does not delete the result object; only the on-demand drain does.
Age-based collection needs release.ModLister, which is optional and unimplemented by the test fakes. Type-assert it and fall back to skipping age collection with a single logged line, exactly as storagePass does for SizeLister (economicspass.go:141-145).
TTL for both kinds is 24h, one sweep cycle: after that the ordinary sweep has had its chance to write a real verdict, and a stale on-demand answer is worth less than no answer.
infected with recorded:false means enforcement is NOT in effect. The Deny statement matches s3:ExistingObjectTag/carlos-scan=infected (aws.go:159-165), and a verdict that could not be tagged does not trigger it. The app can still read the object and must act on the verdict itself. This goes in the docs sentence too, not only here.
5.3 Pull is the source of truth
The tag on the object is the record; the status door reads it. Webhooks accelerate that and never replace it (§6.4).
---
6. The push path
6.1 Why
Long-poll covers the app being inside a request. Presigned direct-to-S3 uploads break that: the browser PUTs straight to the bucket and the app is never in a request. Common enough that designing without it is a mistake.
6.2 Where a callback may go
Draft 1 claimed taking a path rather than a URL meant the vulnerability "does not exist". Overstated. The host is still tenant-influenced: the outbound fetcher picks the first ready non-static route (instances.go:88-97), which can be a tenant's custom domain whose DNS they control. And Go follows redirects by default — a tenant server answering 302 → http://10.x/ turns a signed POST into a GET at an internal address. The codebase already knows this (sesevents.go:143-158).
So, all of:
- POST only to the platform-assigned host,
<app>.<sqid>.<AppsDomain>(apiinstances.go:566,1250) — never a custom domain. Correction (review 7): an app with only its own domain DOES get callbacks. The domains loop mints the platform host for any app with a ready route, andfirstReadyUpstream(domains/reconcile.go:268) accepts a custom-domain route as the source it copies an upstream from. The true condition is narrower and duller: an app with no ready route at all has no platform host yet, so it has nowhere to be called back and uses long-poll until it does. The docs say that, not the old sentence. - Why the host cannot be moved, with the evidence.
appis tenant-chosen but^[a-z][a-z0-9-]{0,31}$(release.go:242) — no dots, so no escaping the label;sqidandAppsDomainare the platform's. A tenant cannot attach a custom domain underAppsDomain(apidomains.go:96). DNS for the name is written by the fleet's domains loop to edge IPs, not by a tenant. DNS rebinding is closed twice, and the second reason is the one that matters: TLS name verification against a public CA means a poisoned resolver still cannot present a valid certificate for that name.InsecureSkipVerifyon the callback transport is therefore a tested absence, not a code-review item. - https only
CheckRedirectrefuses every redirect- bounded timeout,
LimitReaderon the response
Path validation as a rule. Refuse: absolute URLs; protocol-relative //evil/x and /\evil/x; anything not beginning with exactly one /; raw or percent-encoded CR/LF; fragments; .. segments; over 1KB. Then:
u, err := url.Parse(p) // must yield Scheme=="" Host=="" User==nil Opaque==""
target := url.URL{Scheme: "https", Host: appHost, Path: u.Path, RawQuery: u.RawQuery}Built structurally — never string concatenation.
6.3 Signing
An unsigned callback is a hole wearing a convenience's clothes: anyone who learns the path POSTs {"verdict":"clean"} and the app serves a payload believing the platform cleared it.
What can and cannot be reused from store credentials. Draft 2 said the key "cannot be delivered the same way". That was wrong about delivery and right about retention. Delivery is exactly the store path — appconfig.Seal against CARLOS_SECRETS_PUBKEY then one appconfig.Apply, which bumps the serial and reconverges the fleet (walk.go:602-640). What cannot be reused is keeping nothing: the console must be able to sign later, and a sealed value it cannot open is no use to it.
- one boot master,
CARLOS_SCAN_WEBHOOK_KEY - per-app key derived at sign time:
HMAC(master, "carlos-scan-webhook-v1" + 0x00 + sqid + "/" + app + "/" + gen). Nothing extra is stored and per-app isolation falls out — without it a single deployment secret lets app A forge callbacks to app B. The delimiter is injection-safe: a sqid is^[a-z]{3,16}$(qualified.go:56) and an app is^[a-z][a-z0-9-]{0,31}$(release.go:242), so neither can contain/, andgenis an integer. (Corrected: this bullet saidHMAC(master, sqid + "/" + app)while the bullets below it added the domain label and the generation, so the section specified two different schemes. The implementer read both, followed the later one, and said so — which is the only reason it was caught before the wire format froze.) - written by
store scan grant, gated bystoreSealKey(apistore.go:425) — one config write on an operator verb, never per enqueue - header
Carlos-Signature: t=<unix>,v1=<keyid>:<hex>[,v1=<keyid>:<hex>]overt + "." + body. Each signature carries its own key id; draft 3's singlek=could not label two of them, and an app does not need the id anyway — it holds one derived key and tries it against eachv1. - rotation carries two masters.
CARLOS_SCAN_WEBHOOK_KEY_PREVIOUSis accepted; the console signs under each master and the app accepts if either verifies. Without that window every app is broken between the console's rotation and its own re-seal.
What rotation actually does, stated because it is not free. appconfig.Apply bumps the generation, and the box's Sync restarts the app unit on a bundle change (sync.go:33,42,331). So:
rotate-webhook-keyrestarts every scanning-enabled app on the deployment. It paces the re-seal rather than doing them at once, and says so before it starts.store scan grantrestarts the tenant's app as a side effect, because that is where the key is written. The docs sentence forgrantsays so.- "done" means sealed, not converged. The console cannot see when a host has picked up the new bundle —
server.go:566-569makes exactly this point for store rotation. So dropping_PREVIOUSis a separate operator step after convergence, not somethingrotate-webhook-keydoes. Dropping it early degrades to one lost callback per stale host, which is survivable because pull is the source of truth — but it should be a choice, not a surprise. rotate-webhook-keyis idempotent and re-runnable after a partial failure, and is also the backfill for apps granted before this ships. A re-run with an unchanged master re-seals the same plaintext and therefore does not restart anything: Sync restarts only when the rendered bundle changes (sync.go:340+).- "Paced" needs a mechanism, and it is
scan sweep's. One budget per call: the door re-seals as many apps as it can insidescanSweepTimeout, answers with how many remain, and the CLI loops until none do. No background job and no pacing state — idempotence by key id is what makes a re-run safe, so a console that dies mid-rotation simply resumes. - Every console must be restarted with the new pair BEFORE the rotate runs. With more than one console, a console still holding only the old master will sign only under the old key, while apps another console has already re-sealed hold only the new derived key — and those callbacks fail verification. Order: restart all consoles with
KEY=new, PREVIOUS=old, then runrotate-webhook-key, then drop_PREVIOUSonce converged. store scan revokeunsets the app's callback secret. A key outliving the consent it was issued under is the same mistake as a result object outliving it.- constant-time compare; skew window 5 minutes
- the body carries
recorded, so a callback sayingcleanand a door sayingrecorded:falsecannot disagree — the disagreement §5.2.1 exists to prevent - the body carries account, app, key and the bare ETag — and not the opaque version. Draft 4 carried both, which contradicted §11.7 ("no door or app sees the token") in the same document; a review found the two sentences facing each other. §11.7 wins, because it is the property the version binding exists to protect: an app that holds the token is an app that can compare it wrongly, and that is the laundering bug arriving as a correctness bug. The app has the ETag from its own PUT response and matches on that.
- Replay, stated plainly because there is no nonce. A callback replayed inside the 5-minute skew window verifies and is accepted. That is harmless and it is the design: the body is idempotent on
(account, app, key, etag, verdict), which is the tuple an app uses as its idempotency key, and it is named here so two implementers do not choose differently. A nonce is deliberately NOT added — apps have no durable nonce store to check one against, so it would be assurance in name only. - Clock.
tis the console's clock. Apps run on platform boxes and do have a clock, so the skew check is expected of them; skipping it only widens the (harmless) replay window above. The verifier must tolerate twov1=entries and unknown key ids. - Key id:
HMAC("carlos-scan-webhook-key-id", master)[:16]— a different label fromscantag.go's tag-key id, so the two schemes cannot produce a colliding id from the same bytes.PREVIOUS == currentis refused, exactly asNewTagSignerrefuses it: a rotation that rotated to nothing. - Derivation carries a domain label:
HMAC(master, "carlos-scan-webhook-v1" + 0x00 + sqid + "/" + app). The label costs nothing and means no futureHMAC(master, x)use can collide with a derived per-app key. - Why a delimiter here when
scantag.gouses length prefixes.t + "." + bodyis injective becausetis decimal digits — it cannot contain a.— so the first.splits it unambiguously. The tag scheme needs length prefixes because an object key can contain any delimiter you pick. Stated because the two schemes now look inconsistent, and the next person to notice should find the reason rather than "fix" it: length-prefixing here would make every non-Go verifier harder for no gain. - Per-app rotation.
ScanBlockcarries a non-secretwebhook_gen, and the derivation is oversqid + "/" + app + "/" + gen. Without it a tenant that leaks its callback key has no remedy short of an operator rotating the deployment master and restarting every scanning app. The tag key needs no such thing because it never leaves the console; this one is handed to tenants, so it needs an owner-side rotation. webhook_genIS RANDOM, NOT A COUNTER FROM ZERO, and the reclaimed-name property depends entirely on that. Draft 5 said a generation stopped a reclaimed app name inheriting the deleted app's key. It did not. The implementer pointed out thatPurgeApplists the whole app prefix and deletes every key in it (trash.go:229-241),store.jsonincluded — so a counter starting at zero starts at zero again for the next app to claim that name, and derives the identical key. The claim was false as written. A fresh random value at grant makes it true, and makes it true because the record is destroyed rather than in spite of it: mint a random positive integer below 2^53 (JSON-number safe, since the console serves this to browsers) at the first grant, and mint a new one on every per-app rotation. Zero remains a legal derivation input, because records written before this ships have no field and their apps already hold a key derived with zero;rotate-webhook-keyis the backfill that moves them onto a random one.- With no master configured, the enqueue door refuses a
callback_pathwith 503callbacks_not_configuredrather than accepting it. A 202 would promise a delivery that cannot happen. Delivery refuses to build a request without a signer, which is a test (TestNeverPostsUnsigned) rather than a convention — §6.3 exists because an unsigned callback is the hole.
Considered and rejected: Gotenberg's replay-my-headers trick. Neat, avoids a signing scheme, but gives no body integrity, no replay protection, and puts a tenant secret in request logs.
6.4 Delivery is best effort
No dead-letter queue: the verdict is durably on the object and the status door always answers, so a dropped callback costs one poll rather than stranding a file. That is the dividend of building pull first.
The guarantee is AT-MOST-ONCE, not at-least-once. Draft 4 said the opposite and a review caught it: DrainApp deletes the queue items before delivery is attempted, so a console that dies between the delete and the POST loses that callback and nothing re-sends it — the sweep re-tags the object but never delivers. Duplicates are still possible (a retry after an ambiguous failure, or two consoles draining the same prefix), so apps must be idempotent and must not treat the callback as the only way an answer arrives. The docs sentence is: if no callback arrives within your own timeout, poll the status door.
The combination that actually hurts is infected + recorded:false + a lost callback: enforcement is not in effect (§5.2.1), the object is servable, and the app is never told unless it polls. That is the case the docs sentence is written for.
Delivery does not run in the drain's goroutine. Inline delivery would put a tenant's hanging endpoint directly in the path of every other tenant's verdict — 50 items times a bounded retry schedule, inside a tick that sweeps apps sequentially, which is the exact starvation §7.2 and §7.3 exist to prevent and a trivial tenant-triggered denial of service. Delivery is a bounded worker pool fed from done after the waiters are notified, with a bounded in-memory queue (overflow drops and logs — it is best effort by construction), a per-app in-flight cap, and retries scheduled by the pool rather than by the tick.
The policy, written down so it is not invented twice: success is 2xx only; 3xx is a refusal and is not retried, since redirects are refused by design; 4xx is not retried; 5xx, 429, timeouts and network errors are retried, three attempts at 1s/4s/16s; http.Client.Timeout around 10s so it covers connect, TLS, headers and body; the response body is drained through io.LimitReader(…, 4KiB) and discarded, because the console needs nothing from it; headers are Content-Type: application/json, a User-Agent and Carlos-Signature, and nothing else — no cookies, no credentials. Ordering is not guaranteed: retries reorder, and two verdicts for one key can arrive newest-first, which is why the body carries the ETag. One delivery per distinct callback path per key, which is already what Drained.CallbackPaths means.
---
7. The queue
7.1 Location and index
Items live in the app prefix:
apps/<acct>/<app>/scan-queue/<ulid>.jsonOne object per item, not one queue object: distinct keys never collide, so an enqueue needs no coordination. ULIDs sort by time, so a listing is the queue in order.
A top-level scan-queue/<acct>/<app>/ was rejected: PurgeApp lists only the app prefix (trash.go:229-238) and the name-claim gate (server.go:2875+) never looks outside it, so items would survive app deletion invisibly.
But S3 cannot list */scan-queue/*, so finding non-empty queues would be the full ListDirs("apps/") walk. Hence an index — and draft 2 left its removal rule unstated, which is a stranding race:
> drain lists (empty) → enqueue writes the item → enqueue marks the app → > drain removes the mark. The item is now in S3 with nothing pointing at it.
The rule, stated:
type queueIndex struct {
mu sync.Mutex
gen map[string]uint64 // "acct/app" → generation
}- enqueue: write the object first, then
gen[key]++under the mutex, and only then answer 202 — a 202 sent between the two invites a client to stop retrying an item nothing points at - drain: read
observed := gen[key], drain, then remove the entry only ifgen[key] == observedand the listing returned zero items. Draft 4 said "fully drained — listing not truncated";release.Store.Listnever truncates (release/store.go:555-569), so that clause was vacuous. Zero-items is the test that actually means "nothing is left", and it costs one empty listing per tick until the entry goes. - the drain needs a bounded, oldest-first listing, which does not exist today:
Listreturns every key under a prefix (release/store.go:42-55), so a flooded app's whole queue is enumerated every 30s regardless of §7.3's cap. This is an optional interface,release.PageLister, type-asserted at the call site exactly asrelease.SizeLister(release/store.go:181-185) is ateconomicspass.go:141— not a new method onrelease.Store, which has eleven implementations (DirStore,S3Store,prefixStore,fleetapi.storeand seven test fakes).DirStorecan serve it becauseListModis key-sorted (release/store.go:290), and S3 hasStartAfter+MaxKeys, already used atsweep.go:71-78. - a crash between the object write and the increment strands that item until the next console start
- boot rebuilds the index with one full enumeration, and in the same pass deletes items past their TTL — the only thing that collects a stranded item, so it is not optional
The removal condition is what draft 3 got wrong. §7.3 caps work per app per tick, so on a flooded app the drain deliberately leaves items behind; with generation-equality as the only condition it would then delete the entry pointing at them. The same happens when the drain aborts mid-app on an engine error — the RunSweep posture at sweep.go:222-240 that the drain inherits — or when Status errors on one item. Those items would sit until the next boot, and the callback and long-poll wake would be lost for every capped item, on precisely the app the cap exists for.
7.2 The drain is its own goroutine
Draft 1 put a queue phase inside the existing pass. That cannot deliver seconds of latency: scanPass sweeps apps sequentially, each for a full budget (scanpass.go:86-103), and the prior spec accepts ticks running back to back. A queue phase behind that starves for up to N × 60s.
Its own goroutine, its own ticker (30s). It never touches the sidecar; the only state shared with the sweep is object tags, idempotent per version.
7.3 Fairness
Round-robin across apps, 50 items per app per tick — at a 30s tick that is 100/minute, close to the door's own 120/minute limit, so a well-behaved app is never throttled by the drain. Not FIFO across the deployment. FIFO lets one tenant's ten thousand objects delay every other tenant's single urgent upload.
7.4 Amplification
A rate limit alone does not close this: at svcWriteBurst (120/min, authcli.go:140) one app can enqueue ~172k items a day, each up to the size ceiling.
- the drain calls
StatusbeforeScan, asRunSweepcallsVerdictfirst (sweep.go:215), so a key already judged clean, infected or skipped at its current version costs a tag read and nothing else - but the drain applies the re-issue rule itself: it scans when
StatusreturnsUnknown,FailedorDenied. Those two are transient by the landed code's own reasoning — "a permissions fix, a Glacier restore, an engine that was having a bad day" (clamav.go:151-163) — and the sweep re-issues them. Without this the on-demand path would be less able than the hourly one: an object the sweep last taggedfailedwould be answered from that tag forever and never rescanned on request. The cost is one failedGetObjecton an object that is still denied. - items deduplicated by key per tick
- per-app token bucket — the existing
svcWritelimiter, keyed on principal name (svcprincipal.go:375-380), not a new one. Exceeding it is a 429, never a silent drop: an app being throttled needs to know, because its upload flow depends on the answer
7.5 Deletion and the purge race
This is the single deletion list; §5.1.1's "refuse" means "does not read bytes", not "does not delete".
Delete the item when it has been scanned (including §5.2.1, where a result object is written first); when its TTL has passed; when store.json is absent; when the app is in the trash; and when the record no longer permits scanning — Provisioned == nil, Requested == nil, the attestation is encrypted, or Scanning.Enabled == nil. None of those can change back in a way that makes the item useful.
Keep the item — up to the TTL when the deployment record is disabled, on engine drift, on an unreachable engine, and on any read error. All of those are conditions an operator can reverse. A deployment disabled for longer than a day drops its queue, which is right: both doors answered disabled at the time, and the sweep re-covers those objects once it is re-enabled.
Deletion is driven by absence, never by error — and absence at the release store layer is os.ErrNotExist (trash.go:64), not a nil value, so queue items and the system record must test for it explicitly.
"Inherits the purge machinery" inherits the hazard too: sweep.go:346-355 — any object written under a purged prefix makes the app name permanently unclaimable, and an enqueue racing PurgeApp leaves one behind. The orphan persists for up to one drain tick, during which claimApp refuses the name. This spec accepts that, the way writeSidecar accepts its one-round-trip race; §13's test asserts "not longer than one tick", not "never".
---
8. Metering
usage.go:26-38 carries MeterBackhaulGB recorded and explicitly unpriced. Scanning follows that idiom — but not into an existing object. EdgeDay is per-box (edge.go:41-46) and StorageDay is written once daily by a sweep rather than incremented (storage.go:13-18); neither takes increments from a door.
- a new
usage/scan/<date>.json, CAS-merged the wayUpsertEdgeDaydoes (edge.go:85-116) - folded once per drain tick, not per object — each fold is a GET plus a conditional PUT of a deployment-wide object
- new meter names in
usage.go, frozen once merged (usage.go:4-5) - counts objects and bytes scanned on demand, per account per day
- unpriced
Sweep-driven scanning is not attributed to a tenant: the operator asked for it, not them.
---
9. Where clamd lives
9.1 Draft 1's argument was wrong
It said the console "shovels every tenant object through its own memory". It does not: clamd.go:76-79,173-187 streams 64KB chunks and never buffers. The MemoryMax in the rollout plan is on clamd's cgroup, protecting the console from clamd's ~1.5GB signature RSS — not mitigation for a byte-shovelling smell, because there is not one.
9.2 The argument that holds
Moving clamd to a services box relocates the resident cost — ~1.5GB of signatures and the scanning CPU leave a control-plane box, permanently, for a feature idle by default. That is the whole benefit. It costs doubled network traffic, since the console still fetches from S3 and streams onward (§9.4), and it does not remove tenant bytes from the console; they still transit it.
A resource-placement decision, worth doing, worth stating at that size.
9.3 Reversing the prior decision
Prior spec §4.1: "No TCP option in v1: an unauthenticated scanning service reachable over a network is a worse problem than the one being solved."
That objection is right and is not answered by wanting a services box. It is answered by the network:
- clamd has no authentication whatsoever
- private interface only (Tailscale or equivalent), never a public port, and the services box firewalls the port to the console's address
- a hard constraint on the infra ask, not a recommendation
internal/clamd has one "unix" literal (clamd.go:130); framing, deadlines and parsing are transport-independent. New gains a network parameter, and CARLOS_CLAMD_ADDR beginning with / means unix.
9.4 The console keeps fetching
Rather than giving the services box S3 read and tag permissions. Bytes move twice; tenant-data credentials stay on one box. Revisit with numbers.
---
10. Surface
carlos system antivirus status|enable|disable|rotate-webhook-key
carlos store scan request|status member, per app (shipped)
carlos store scan grant|revoke|sweep|enforce operator, per app (shipped)App-facing HTTP, role Service:
POST .../store/scan/object request a scan
GET .../store/scan/object?key=&wait= read the verdictstore scan status gains queue depth and the deployment state (§4.5); system antivirus status gains queue depth deployment-wide.
---
11. Security properties
Each is a test.
- No callback reaches a host the app was not assigned — absolute URLs,
//host,/\host,@-authority, traversal, CRLF, and encoded variants. - No redirect is followed.
- A tampered body fails; app A's signature does not verify for app B; a stale timestamp is refused; both masters verify during rotation. These are properties of the APP's verifier, which is tenant code — so the console ships
VerifyCallback(key, header, body, now)as the reference implementation, the docs reproduce it, and these tests run against it. Without that, the test tests nothing anybody runs. The signature is over the RAW BODY BYTES: an app that re-serialises the JSON before verifying will fail, and the docs say so. - The app policy still has no
s3:PutObjectTagging(aws.go:152-165). - A role-
Servicecredential cannot be minted account-scoped (§3.4) and cannot act on another app. (Admin reaches these doors by construction, §3.2 — this property is aboutService.) - A
Servicecredential cannot reachsystem antivirus,scan grant,revoke,sweeporenforce. - Version comparison is inside the scanner; no door or app sees the token.
- Enqueue and drain both enforce the consent chain (§5.1.1).
- Rate limits precede enqueue; a re-request of a key judged clean, infected or skipped costs a tag read and no fetch. (
failedanddeniedare deliberately rescanned — §7.4.) - A verdict that could not be persisted is reported as
recorded:false, never aspendingand never as a silent success (§5.2.1); and afailedordeniedverdict is reported as itself, never aspending(§5.2).
---
12. Out of scope
Synchronous scan on PUT (CARLOS does not see the PUT); Gotenberg as a capability (the role is designed to be reused, the rendering service is its own brainstorm); GuardDuty (gated on the tagging measurement); pricing.
---
13. Testing
- callback host and path validation as a table of hostile inputs; redirects refused
- signature: tamper, cross-app, replay, skew, two-master rotation
pendingfor a stale tag, never the stale verdict;gonefor a missing key- the consent chain: enqueue and drain both refuse
not_requested,encrypted,not_enabled,disabled, and engine drift - one app flooding the queue does not delay another's item beyond one tick
- a dropped callback leaves the verdict readable through the status door
system antivirus disablestops scanning without a restart and without touching per-app records — and per-appscan statusthen reports the deployment state rather than claiming healthrequest,revoke,enforce offstill work while disabled- boot accepts
CARLOS_STORE_SCANNER=clamavas a deprecated wiring alias, retaining the socket default; any other value stays fatal (§4.2.1) - the first pass tick — not boot — seeds the record and warns when a Scanner is wired with no record
- a
failedand adeniedverdict each answer as themselves, notpending - unknown
system/antivirus.jsonschema refuses - metering counts on-demand scans and not sweep scans
- the index: no item strands while the process stays up (enqueue racing a drain's removal); an app flooded past the per-tick cap is fully drained across successive ticks — the test that would have caught review 3's blocker; an item written before a crash is drained after restart; TTL'd items are collected by the boot rebuild
- an enqueue racing
PurgeAppleaves no orphan for longer than one drain tick - a verdict reached but not persisted answers
recorded:false, notpending(§5.2.1) - a result object written for v1 does not answer for v2 — the door compares versions and says
pending - a result object is not written under a prefix purged mid-scan, and is collected by the boot rebuild once past TTL
- the drain rescans an object whose tag says
failedordenied, rather than answering from the tag (§7.4) enable503s when no Scanner is wired- the seed happens once from the retired variable and does not override an existing record (§4.2.1)
- a second result object for the same key replaces the first (§5.2.1), and does not fail create-only
- the drain keeps items and the index entry on a record read error, and deletes only on absence
---
14. Risks
- The index is soft state, and losing an entry loses the callback. The fallback is the ordinary sweep, which re-sweeps a completed bucket only every
ScanInterval— 24h by default (scanpass.go:31,171), not hourly. The object gets a verdict eventually and a polling app sees it; the webhook is simply not sent, and the queue item is collected at the next boot. Test by killing the console mid-queue. - Boot rebuild cost. One full enumeration at start on a deployment with many apps. Measure it.
- Two new object kinds under the app prefix cost something.
storagePass(economicspass.go:163) folds queue and result bytes into the tenant'sBucketBytes, and the three fullList("apps/")passes (provision/reconcile.go:586,1050,domains/reconcile.go:87,materialize.go:292) grow with a flooded queue until the TTL collects it.provisionanddomainsskip unknown key shapes correctly, so for them this is cost, not breakage.materializedoes not, by design: it counts any key under an app prefix (materialize.go:333-345, "convergence fails toward KEEPING the row") and restores a trashed row to live when it finds one (:377-382). So a result object stranded under a purged prefix does not only block the claim gate — it resurrects the app row on the next boot. That is the safe direction for materialize, and one more reason §5.2.1's purge guard is not optional. - Index size is bounded by the number of apps that have enqueued since boot and not yet been seen empty — at most the number of provisioned stores, a few dozen bytes each. Trashed and purged apps clear on their next empty listing, since
PurgeAppdeletes the items (trash.go:229-238). - Long-poll occupancy. Bounded wait, global and per-app waiter caps.