carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

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:

  1. storeScannerFor (main.go:755-758) builds the Scanner whenever CARLOS_CLAMD_ADDR, the retained CARLOS_CLAMD_SOCKET alias, or the deprecated CARLOS_STORE_SCANNER=clamav is 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 because NewClamAVScanner does not ping (clamav.go:56-70). main.go:772's fatal when no store backend is configured stays.
  2. StartScanPass (scanpass.go:37-39) loops whenever Scanner != nil and reads system/antivirus.json at the top of each tick. Disabled means the tick does nothing — not that there is no goroutine. That is what makes enable restartless. A per-tick GET of one small object is negligible against the enumeration the tick already does.
  3. scan grant (apiscan.go:252) and scan sweep (scanpass.go:198) check the record, and 503 naming carlos system antivirus enable.
  4. RunSweep's refusal (sweep.go:163) checks the record.
  5. apiscan.go:71-81 — the engine_error branch — reports disabled as its own state, distinct from unreachable.
  6. 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=clamav is 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_SOCKET is retained as an alias for CARLOS_CLAMD_ADDR; setting both to different values is fatal.
  • the record is seeded enabled, once, when the retired variable is set and system/antivirus.json is absent, stamped by: "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 failure main.go:751-753 exists to prevent.
  • where the seed happens: at the top of the first StartScanPass tick — the same record read §4.2 item 2 already performs — not in storeScannerFor, which runs at main.go:262 before openStore() at :269 and has no release store to write to.
  • the write is create-only (Put(…, ""), release/store.go:38,516 — the IfNoneMatch:* that TrashApp already relies on, release/trash.go:51-54), so two consoles or a restart loop converge; ErrCASConflict means "someone else seeded it" and is success.
  • seeding is therefore idempotent: it happens only when no record exists, so an operator who later runs disable is 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_SCANNER still in place (one tick seeds the record), then swap it for CARLOS_CLAMD_ADDR. Swapping in the same restart as the upgrade seeds nothing and stops scanning — warned every tick, but stopped. Running carlos system antivirus enable also settles it at any point.
  • main.go carries "the deprecated variable was set" to the first tick as a Config field (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 status reports "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-key

A 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 limit

Role 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 → else not_granted, the state apiscan.go:263-266 already names
  • the app is not in the trash — release.ReadAppTombstone, which the sweep checks at sweep.go:135-141 → else the drain deletes the items
  • rec.Scanning != nil && rec.Scanning.Requested != nil → else not_requested
  • !rec.Scanning.Requested.Encrypted → else encrypted
  • rec.Scanning.Enabled != nil → else not_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.1

The 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)
//                                                          ^ recorded

The 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 pending path only
  • durable across restarts, so §11.10 holds without soft state
  • under the app prefix, so PurgeApp and 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 as scan-queue/ and collects anything older than the TTL by ListMod age — 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, and firstReadyUpstream (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. app is tenant-chosen but ^[a-z][a-z0-9-]{0,31}$ (release.go:242) — no dots, so no escaping the label; sqid and AppsDomain are the platform's. A tenant cannot attach a custom domain under AppsDomain (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. InsecureSkipVerify on the callback transport is therefore a tested absence, not a code-review item.
  • https only
  • CheckRedirect refuses every redirect
  • bounded timeout, LimitReader on 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 /, and gen is an integer. (Corrected: this bullet said HMAC(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 by storeSealKey (apistore.go:425) — one config write on an operator verb, never per enqueue
  • header Carlos-Signature: t=<unix>,v1=<keyid>:<hex>[,v1=<keyid>:<hex>] over t + "." + body. Each signature carries its own key id; draft 3's single k= could not label two of them, and an app does not need the id anyway — it holds one derived key and tries it against each v1.
  • rotation carries two masters. CARLOS_SCAN_WEBHOOK_KEY_PREVIOUS is 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-key restarts 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 grant restarts the tenant's app as a side effect, because that is where the key is written. The docs sentence for grant says so.
  • "done" means sealed, not converged. The console cannot see when a host has picked up the new bundle — server.go:566-569 makes exactly this point for store rotation. So dropping _PREVIOUS is a separate operator step after convergence, not something rotate-webhook-key does. 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-key is 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 inside scanSweepTimeout, 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 run rotate-webhook-key, then drop _PREVIOUS once converged.
  • store scan revoke unsets 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 saying clean and a door saying recorded:false cannot 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. t is 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 two v1= entries and unknown key ids.
  • Key id: HMAC("carlos-scan-webhook-key-id", master)[:16] — a different label from scantag.go's tag-key id, so the two schemes cannot produce a colliding id from the same bytes. PREVIOUS == current is refused, exactly as NewTagSigner refuses 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 future HMAC(master, x) use can collide with a derived per-app key.
  • Why a delimiter here when scantag.go uses length prefixes. t + "." + body is injective because t is 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. ScanBlock carries a non-secret webhook_gen, and the derivation is over sqid + "/" + 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_gen IS 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 that PurgeApp lists the whole app prefix and deletes every key in it (trash.go:229-241), store.json included — 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-key is the backfill that moves them onto a random one.
  • With no master configured, the enqueue door refuses a callback_path with 503 callbacks_not_configured rather 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>.json

One 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 if gen[key] == observed and the listing returned zero items. Draft 4 said "fully drained — listing not truncated"; release.Store.List never 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: List returns 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 as release.SizeLister (release/store.go:181-185) is at economicspass.go:141 — not a new method on release.Store, which has eleven implementations (DirStore, S3Store, prefixStore, fleetapi.store and seven test fakes). DirStore can serve it because ListMod is key-sorted (release/store.go:290), and S3 has StartAfter + MaxKeys, already used at sweep.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 Status before Scan, as RunSweep calls Verdict first (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 Status returns Unknown, Failed or Denied. 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 tagged failed would be answered from that tag forever and never rescanned on request. The cost is one failed GetObject on an object that is still denied.
  • items deduplicated by key per tick
  • per-app token bucket — the existing svcWrite limiter, 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 way UpsertEdgeDay does (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 verdict

store 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.

  1. No callback reaches a host the app was not assigned — absolute URLs, //host, /\host, @-authority, traversal, CRLF, and encoded variants.
  2. No redirect is followed.
  3. 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.
  4. The app policy still has no s3:PutObjectTagging (aws.go:152-165).
  5. A role-Service credential cannot be minted account-scoped (§3.4) and cannot act on another app. (Admin reaches these doors by construction, §3.2 — this property is about Service.)
  6. A Service credential cannot reach system antivirus, scan grant, revoke, sweep or enforce.
  7. Version comparison is inside the scanner; no door or app sees the token.
  8. Enqueue and drain both enforce the consent chain (§5.1.1).
  9. Rate limits precede enqueue; a re-request of a key judged clean, infected or skipped costs a tag read and no fetch. (failed and denied are deliberately rescanned — §7.4.)
  10. A verdict that could not be persisted is reported as recorded:false, never as pending and never as a silent success (§5.2.1); and a failed or denied verdict is reported as itself, never as pending (§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
  • pending for a stale tag, never the stale verdict; gone for 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 disable stops scanning without a restart and without touching per-app records — and per-app scan status then reports the deployment state rather than claiming health
  • request, revoke, enforce off still work while disabled
  • boot accepts CARLOS_STORE_SCANNER=clamav as 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 failed and a denied verdict each answer as themselves, not pending
  • unknown system/antivirus.json schema 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 PurgeApp leaves no orphan for longer than one drain tick
  • a verdict reached but not persisted answers recorded:false, not pending (§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 failed or denied, rather than answering from the tag (§7.4)
  • enable 503s 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's BucketBytes, and the three full List("apps/") passes (provision/reconcile.go:586,1050, domains/reconcile.go:87, materialize.go:292) grow with a flooded queue until the TTL collects it. provision and domains skip unknown key shapes correctly, so for them this is cost, not breakage. materialize does 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 PurgeApp deletes the items (trash.go:229-238).
  • Long-poll occupancy. Bounded wait, global and per-app waiter caps.