carlos / platform Public
Scanner host: getting object-storage scanning running on Carloku and Tito
Status: draft 6. Closes Astra review 1 (CODEX-FINDINGS-SCANNER-HOST.md, findings 1–5, all accepted), review 2 (CODEX-FINDINGS-SCANNER-HOST-R2.md, findings 6–8: 7 and 8 accepted, 6 declined with the reason in §4.3) and review 3 (CODEX-FINDINGS-SCANNER-HOST-R3.md, finding 9, accepted and tested against a real engine, §3.4) and review 4 (CODEX-FINDINGS-SCANNER-HOST-R4.md, findings 10 and 11, both accepted; 10 reshaped how the box listens, §3.2) and review 5 (CODEX-FINDINGS-SCANNER-HOST-R5.md, finding 12, accepted). Review 5 found the gating design sound at spec level once 12 is fixed, and left restart timing, connection cut-off and EICAR transport to the real-box acceptance checks in §3.3. Accepted findings are marked (codex N). Builds on: 2026-09-01-object-storage-virus-scanning-design.md (the sweep, verdict tags, enforcement) and 2026-09-02-scan-on-demand-design.md §9 (clamd off the console box, reachable over TCP). Supersedes: steps 2 and 4 of ../plans/2026-09-01-carloku-scanning-rollout.md (clamd on the console box, CARLOS_STORE_SCANNER as the switch). Its steps 5–8 and its watch list still stand.
---
1. Where things stand
The code is shipped and live. Both consoles run builds that carry the sweep, carlos store scan request|status|grant|revoke|sweep|enforce and carlos system antivirus status|enable|disable (Carloku 7edd4b33, Tito fe2088e3). CARLOS_CLAMD_ADDR already takes host:port as well as a socket path (2eaf2aae, in every release since v0.18.0).
What neither deployment has is a clamd to talk to, permission for the console to read and tag store objects, or a scan tag key. This spec supplies those three things, plus one small client change (§4.3) that stops a partly scanned archive being signed clean. (codex 4)
2. Decisions
| # | Decision | Why | |---|---|---| | D1 | clamd runs on its own box, not the console box | Its ~1.5 GB of resident signatures (about 3 GB during a reload) leave the control plane. Carloku's console box is a t4g.large that also serves IE apps. | | D2 | One box per deployment, in the home region only | Only the console talks to clamd, and every store bucket is in the one region CARLOS_STORE_REGION names (cmd/console/main.go:546-585). A box elsewhere would add cross-region transfer, not remove it. | | D3 | Console to clamd is plain TCP inside the VPC, closed by security group | clamd has no TLS and no authentication. Same-VPC, single-source security group is the same trust model as the console's own S3 traffic. TLS (stunnel or a dialler change) or Tailscale are later options, not v1. (Paul, 2026-10-04: "A is fine for now") | | D4 | The console keeps fetching from S3 and streams to clamd | Scan-on-demand §9.4: tenant-data credentials stay on one box. The scanner box gets no S3 or parameter grants. | | D5 | Both pieces are platform modules | Carloku consumes the platform the way a self-hoster does. Hand-written root resources would be a workaround and would duplicate clamd's setup per root. | | D6 | Carloku first, then Tito | Tito is a third party, and its console was only unpinned on 2026-10-03 with #201 phase 2 pending. Prove it on our own flagship first. | | D7 | Scope ends at "engine wired, switched on, no member granted" | Rollout plan steps 7–8: a member grant follows a member's request and the disclosure, never an operator's speculation. | | D8 | An object clamd only partly scanned, with no malware signature matched, is recorded unscanned, never clean, and never infected on the strength of the limit alone | A signed clean verdict over bytes nobody read is the failure the verdict design exists to prevent; flagging large legitimate archives infected would make enforce block them. Unscanned fits the existing audit arithmetic (unscanned = skipped + denied + failed). A real signature match is positive evidence and stays infected even when the scan was partial (§4.3). (Paul, 2026-10-04, on codex 4) |
Out of scope: the unmerged scan-on-demand API (worktree-store-scan-on-demand), TLS to clamd, alerting on signature age, GuardDuty, per-region scanning. If store buckets ever become per-region, scanning would carry tenant bytes out of their region; the answer then is a regional scanner that fetches for itself, which reopens D4. That is a new decision, not more boxes.
3. The scanner-host module (infra/modules/scanner-host)
3.1 Resources
aws_instance:ami_idfrom the caller (the caller passesmodule.<host>.ami_id, as Tito'ssvc-pdf.tfdoes),instance_typedefaultt4g.medium, 20 GiB encrypted gp3 root, IMDSv2 required (http_tokens = "required"),user_data_replace_on_change = false,lifecycle { ignore_changes = [ami, user_data] }, so a newer image or an edited script never silently replaces the box; changing either (includingstream_max_bytes) is an explicit-replace. Optionalsubnet_id; null means the default subnet, asregion-hostdoes, and a precondition then requires the client security group's VPC to be the default VPC.aws_security_group: ingress TCP 3310 fromclient_security_group_idonly. No other ingress, no SSH. Egress open, forfreshclamand apt. It lands in the client security group's VPC (vpc_idfrom a data source on that group), so the two can never end up in different VPCs.- IAM role and instance profile:
AmazonSSMManagedInstanceCoreattached, for Session Manager. That managed policy grantsssm:GetParameter*on*, which on its own would let the box read/carlos/scan-tag-keyand/carlos/release-key. So the role also carries an explicit Deny onssm:GetParameter,GetParameters,GetParameterHistoryandGetParametersByPathon*, the same shape asregion-host'shost_ssm_parameter_scope(main.tf:418-450) with no allowed parameter at all. Session Manager's channel actions are untouched. No bucket, no KMS. The box cannot fetch tenant data or the key even if clamd were compromised. (codex 1)
3.2 How the box listens: the invariant
TCP 3310 is open only while a self-test has passed against the clamd process that is running now. Not "the boot script checked once": a failed test, a clamd restart, or a ClamAV package upgrade closes the port until the test passes again. The console's own checks cannot stand in for this, because antivirus status and enable only ask clamd for VERSION (internal/objstore/clamav.go:108, internal/console/apisystem.go:85,133), and an engine that signs truncated scans clean answers VERSION perfectly well. (codex 10)
So clamd itself never listens on TCP. Three units:
clamav-daemon, Debian's own unit and socket activation, untouched except a drop-in forMemoryMax(variable, default3G),MemoryHighat about 85% of it, andRestart=on-failure. It listens on the unix socket/run/clamav/clamd.ctlonly;clamd.confhas noTCPSocketand noTCPAddr. That is why the earlier replacement unit is gone: socket activation handing clamd only the unix socket is now exactly what is wanted. (Finding 2's problem no longer arises.) A drop-inWants=scanner-tcp.socketmakes every start of clamd, including an automatic restart, ask for the gate below to be re-run.scanner-selftest.service,Type=oneshot,RemainAfterExit=yes,BindsTo=clamav-daemon.service,After=clamav-daemon.service. Runs/usr/local/sbin/scanner-selftestagainst the unix socket. Being bound to clamd, it goes inactive the moment clamd stops or restarts.scanner-tcp.socketandscanner-tcp.service: the socket listens on<private IPv4>:3310(from IMDSv2, written into the unit at first boot; never0.0.0.0) and hands connections tosystemd-socket-proxyd /run/clamav/clamd.ctl, running as a dedicated user in groupclamav. Both carryRequires=scanner-selftest.service,After=it andBindsTo=it. So the port cannot open before the test passes, and it closes, cutting connections in flight, when the test's unit goes inactive.
The socket unit sets DefaultDependencies=no. A socket unit is ordered Before=sockets.target by default, sockets.target comes before basic.target, and every service (the self-test included) comes after basic.target; a socket that must also come after the self-test closes that loop, and systemd breaks an ordering cycle by dropping a job, which could leave the port shut after a reboot. So the unit opts out, restores what it still needs by hand (After=sysinit.target, Conflicts=shutdown.target, Before=shutdown.target), and is started by WantedBy=multi-user.target and clamd's Wants=, never by sockets.target. (codex 12)
clamd.conf keys, each rewritten idempotently (clamd has no include directory):
- StreamMaxLength <stream_max_bytes / 1 MiB>M. Written in whole MiB, not bytes: Debian's debconf round trip strips an M and appends one, so a bare byte count would become a 25 TiB limit on the first configure (final review I1). clamav-daemon/debconf is also set false after install, so later upgrades leave clamd.conf alone. - AlertExceedsMax yes. With the default no, an archive that expands past MaxScanSize, MaxFileSize, MaxFiles or MaxRecursion is partly scanned and answered OK. With yes clamd answers Heuristics.Limits.Exceeded.<limit> FOUND, which §4.3 records as unscanned (D8). (codex 4) - MaxFileSize 100M and MaxScanSize 100M: the same value, and fixed in this release. AlertExceedsMax alone is not enough. ClamAV's zip extractor trims a member that expands past MaxFileSize and scans the trimmed copy without raising any limit (libclamav/unzip.c:222-226, 1.4.3). With the two limits equal, the trimmed copy is as large as the whole scan budget, so scanning it always trips MaxScanSize, which does alert. Tested, §3.4. (codex 9) It is a constant, not a variable, because the self-test's fixtures and expected answers are sized for 100 MiB; a configurable limit means deriving both from it, which is a follow-up (§11). (codex 11) - ConcurrentDatabaseReload from a variable, default yes.
3.3 user_data
A templated script (scanner-init.sh.tftpl), set -eu, each step logged to the serial console so aws ec2 get-console-output shows how far it got.
- SSM agent first. The AMI is the base Debian image
region-hostboots from (outputs.tf:26), not a configured host, and Debian ships no SSM agent. Install the arm64amazon-ssm-agent.debfrom the regional bucket and enable it, exactly ashost-init.sh.tftpldoes. With SSH closed this is the only way onto the box, so it goes before anything that can fail. Aregionvariable feeds the URL. Alsounattended-upgrades, as region hosts have. (codex 5) - Install without starting. Write a
policy-rc.dthat exits 101, thenapt-get install clamav-daemon clamav-freshclam clamdscan python3, then remove thepolicy-rc.d. Debian's postinst would otherwise startclamav-freshclam(clamav-freshclam.postinst.in:333) and the daemon before they are configured. (codex 3) - Write
clamd.conf's keys, the drop-in, the self-test script and the three units of §3.2.systemctl daemon-reload. - First signatures.
freshclamonce in the foreground, with noclamav-freshclamservice running (step 2 kept it stopped), so the two never contend for the lock or the update log. Refuse to continue unlessmain,dailyandbytecode(.cvdor.cld) exist. Then enable and startclamav-freshclam(hourly checks by default). (codex 3) - Enable and start
clamav-daemon,scanner-selftest.serviceandscanner-tcp.socket. The ordering in §3.2 does the rest: clamd loads, the self-test runs, and only on a pass does the port open. - Verify, and say so.
systemctl is-active scanner-selftest.service scanner-tcp.socketbothactive;zVERSIONanswers on<private ip>:3310;ss -ltnshows 3310 bound to the private IP only. Write/var/lib/scanner-host/readyand printSCANNER-HOST READYplus thezVERSIONline. Any failure printsSCANNER-HOST FAILED: <which check>and exits non-zero, with the port closed by construction.
The self-test (/usr/local/sbin/scanner-selftest) builds the §3.4 fixtures with python3 in a temp directory (members written from a temp file, so their zip headers are ordinary rather than zip64, which clamd would skip), streams each to the unix socket with clamdscan --stream, and requires the class of reply in the table's last column. An over-limit fixture answering OK is a failure. It re-proves the behaviour on whatever ClamAV version the image or a later upgrade installs, rather than trusting what 1.4.3 did.
Acceptance checks for the module, run on the first real box before any console is wired (§6.2), and recorded in the README:
- kill clamd: 3310 closes at once, and reopens only after the self-test has re-run and passed;
- force the self-test to fail (an environment override in a drop-in, used only for this check): clamd comes up, 3310 stays closed, the unit shows failed; remove the override and restart: it opens;
- an INSTREAM scan of EICAR through
<private ip>:3310from the console box answersEicar-Test-Signature FOUND, so the proxy carries the protocol; - reboot the box: it comes back with the self-test passed and 3310 open,
systemd-analyze verifyon the three units is clean, andjournalctl -bhas noordering cycleline. First boot cannot show this, becausebasic.targetandsockets.targetare already reached by the timeuser_dataruns. (codex 12)
3.4 What the limits do, measured
Run 2026-10-05 against Debian trixie's clamd (ClamAV 1.4.3), StreamMaxLength 25M, AlertExceedsMax yes, every fixture streamed with clamdscan --stream. Members are a repeating 16 KiB random block, distinct per member, so the clean cache cannot short-circuit them. "Limit" means a Heuristics.Limits.Exceeded.* reply.
| Fixture | MaxFileSize 25M, MaxScanSize 100M | Both 100M (this spec) | Self-test requires | |---|---|---|---| | small clean file | OK | OK | OK | | EICAR | infected | infected | infected | | zip, one member expanding to 500 MiB | OK (trimmed, silent) | Limit | Limit | | zip, a zip inside holding that member | OK (silent) | Limit | Limit | | zip, 30 distinct 20 MiB members | Limit | Limit | Limit | | gzip of 500 MiB | Limit | Limit | Limit | | tar.gz, 5 × 40 MiB | Limit | Limit | Limit | | bzip2, xz, tar.xz of the same | Limit | Limit | not in self-test | | zip, 500 MiB member then EICAR | infected | Limit | Limit | | zip, 60 MiB member then EICAR | infected | infected | infected | | zip, one 60 MiB member | OK (trimmed, silent) | OK (fully scanned) | OK |
The first column is finding 9, observed: three fixtures signed clean over content nobody read. The second is what this spec configures.
Known and not fixed: zip64 local headers. A zip member written with zip64 local headers is not extracted at all by 1.4.3 (local header - stream out of file), and EICAR inside one reads OK under every config. That is parser coverage, not a limit, and no clamd setting changes it. Encrypted archives are the same class. So a clean verdict means "clamd found nothing in what it could open", never "this object is safe", and the disclosure a member reads before opting in (rollout plan step 7) should say so in those words. Recorded in §11 as a follow-up; it does not block this rollout, because it is true of the engine wherever it runs.
3.5 Variables and outputs
| Variable | Default | Notes | |---|---|---| | name | — | Prefixes every resource | | ami_id | — | Debian arm64, from region-host | | client_security_group_id | — | The console box's group | | instance_type | t4g.medium | t4g.small only with concurrent_database_reload = false | | stream_max_bytes | 26214400 | Must equal the console's CARLOS_CLAMD_MAX_BYTES. Validation: a whole number of MiB, between 8 MiB and 100 MiB, so the self-test's fixtures (about 5 MB compressed) always fit the stream and a whole stream always fits the scan budget (codex 11) | | memory_max | "3G" | clamd's cgroup ceiling | | concurrent_database_reload | true | | | subnet_id | null | | | region | — | For the SSM agent download URL |
Validation: instance_type beginning t4g.small/t4g.micro/t4g.nano with concurrent_database_reload = true is refused with a message naming the reload spike.
Outputs: clamd_addr ("<private_ip>:3310"), instance_id, security_group_id, stream_max_bytes.
3.6 Replacement
A replaced box gets a new private IP, so CARLOS_CLAMD_ADDR must be updated by hand. Accepted: replacements are explicit (-replace) and the failure is loud (sweeps fail per tick, scan status and antivirus status say so, the console stays up). The README says this in its first section.
4. The console's read-and-tag grant
4.1 region-host (Carloku)
- New variable
store_scanning(bool, defaultfalse). A precondition refusestruewith an emptystore_bucket_prefix. - When
true, a separateaws_iam_role_policy,${iam_name}-store-scan:s3:GetObject,s3:GetObjectTagging,s3:PutObjectTaggingonarn:aws:s3:::${store_bucket_prefix}*/*. These are exactly the calls the scanner makes (HeadObjectis authorised byGetObject;ListObjectsV2is already granted byhost_store). Store buckets use SSE-S3, so no KMS grant. - Separate, so an opted-out deployment plans zero changes, and so the existing
host_storecomment ("No GetObject: the console provisions stores, it does not read tenant data") stays true. That comment gains one line pointing atstore_scanning. - The store-user boundary is untouched. Apps still cannot tag their own objects.
4.2 Tito (object-store.tf)
A new statement StoreScan in host_ie_object_store with the same three actions on arn:aws:s3:::carlos-store-tito-*/* and arn:aws:s3:::titogo-uploads-665106592142/*. Tito keeps its own store policy (#201 settled this), so store_scanning is not used there.
4.3 The client change: limits exceeded is unscanned (D8)
internal/clamd/clamd.go parseReply (:273) maps any FOUND to StatusInfected. With AlertExceedsMax yes, a limit hit arrives as stream: Heuristics.Limits.Exceeded.MaxScanSize FOUND (likewise .MaxFileSize, .MaxFiles, .MaxRecursion, .MaxScanTime). Add two cases before the FOUND case, both keeping the raw reply:
- signature exactly
Heuristics.Limits.Exceeded.MaxScanTimebecomesStatusError. A timeout depends on load, not on the bytes, so it must be retried.verdictFormapsStatusErrortoVerdictFailed, whichclamav.go:249-251re-issues every sweep rather than carrying forward. (codex 7) - any other signature beginning
Heuristics.Limits.ExceededbecomesStatusSizeExceeded. These limits are deterministic for given bytes and config.clamav.go:374-383maps it toVerdictSkipped, so it lands inskippedandunscanned, nevercleanorinfected, and like an oversize object it is carried forward until the bytes change.
Declined: codex 6 (a malware match hiding an earlier limit hit). When clamd reports a real match, the client records infected, whatever limit was also hit. That is correct under D8: the object is known to contain malware, whatever else went unread. Setting HeuristicScanPrecedence yes to surface the limit instead would turn a known infection into unscanned, which is worse. When nothing matches, clamd answers with the limit heuristic, so the case D8 exists for is covered. Which of the two an archive gets depends on what clamd reaches before the budget runs out (§3.4: a 500 MiB member ahead of EICAR answers the limit, a 60 MiB member ahead of it answers EICAR). Both are honest and neither is clean. §7 step 2 tests both.
Tests in clamd_test.go: MaxScanTime maps to error; each of the other four limit names maps to size-exceeded; Eicar-Signature FOUND still maps to infected; a signature that merely contains the phrase elsewhere (Win.Heuristics.Limits.Exceeded.Fake) still maps to infected, so the match is a prefix on the signature, not a substring of the reply. The docs for carlos store scan status that explain skipped gain one sentence naming over-limit archives.
Because this is console code, both consoles roll to a build containing it before they are wired (§6.2). A console without it, wired to a clamd with AlertExceedsMax yes, would record over-limit archives as infected.
5. Release and consumption
- The module change,
scanner-hostand the §4.3 client change land on amadan main through a branch andamadan branch merge -squash, then the GitHub mirror is pushed forward (fast-forward checked first), then v0.24.1 is cut per the README's "Releasing the platform" recipe and pushed to the mirror. - Carloku (
carloku-infrastructure): move onlyhost_ie's?ref=to v0.24.1, setstore_scanning = true, addmodule "scanner"withclient_security_group_id = module.host_ie.security_group_id. The other three regions stay on v0.21.0. Every apply is-targeted (module.host_ie.aws_iam_role_policy.host_store_scan,module.scanner), and the plan is read for anyuser_dataline before it runs, because auser_datachange stops and starts a box. Any otherhost_iediff the v0.21.0 to v0.24.1 bump shows is listed in the PR and not applied. - Tito (
teamtito/carlos-infrastructure): a branch from thestore-boundary-201line (main cannot plan while the Canada box is only onregion-ca),module "scanner"at?ref=v0.24.1, theStoreScanstatement. Targeted applies. Tito pinsregion-hostto a SHA; that pin does not move.
6. Wiring the console
6.1 The scan tag key
- 32 random bytes, hex. Generated once per deployment, never in git, tofu state or a transcript: generated and written in one pipeline.
- Durable copy: SSM SecureString
/carlos/scan-tag-keyin the deployment's account (inside the/carlos/*path the box can already read). Losing it makes every stored verdict unverifiable. CARLOS_SCAN_TAG_KEY_PREVIOUSstays unset; rotation exists and is not needed.
6.2 The console's environment
Two preconditions, both checked before any variable is written.
The scanner box is ready. Over SSM on the scanner instance: cloud-init status --wait reports done with no error; /var/lib/scanner-host/ready exists; systemctl is-active scanner-selftest.service scanner-tcp.socket says active twice; and the module's acceptance checks (§3.3) have been run on this box. Nothing the console can ask tells a tested engine from an untested one, so this is checked on the box, on each deployment, Tito included. (codex 10)
The console process runs a build containing §4.3. Promoting only moves the channel pointer (internal/release/release.go:718); adoption is separate and can defer (adopt.go:956); and x-carlos-version comes from the edge's route state (internal/edge/edge.go:2192), not from the running process. So neither a promote nor the header proves it. The check is the one roll-console.sh already makes (scripts/roll-console.sh:93-97): over SSM, readlink /proc/$(systemctl show -p MainPID --value console)/exe must resolve to versions/<sha>/console for the exact SHA built. Roll with roll-console.sh (it ships, promotes, adopts, restarts and runs that check); if the check fails, stop: nothing is wired. Boxes are untouched. On Tito, the same from Tito's bucket. (codex 8)
First confirm over SSM, read-only, which file console.service loads. Then, after a dated backup of that file:
CARLOS_CLAMD_ADDR=<module.scanner.clamd_addr>
CARLOS_CLAMD_MAX_BYTES=<module.scanner.stream_max_bytes>
CARLOS_SCAN_TAG_KEY=<from /carlos/scan-tag-key>systemctl restart console, then repeat the /proc/<pid>/exe check (same SHA, new PID), and carlos system antivirus status answers with the engine wired. If it fails to start: restore the backup, systemctl reset-failed console && systemctl restart console (a start-limited unit ignores try-restart; seen 2026-09-11).
CARLOS_STORE_SCANNER stays unset. It is deprecated, and the switch is the record (system/antivirus.json), so the restart wires the engine without turning it on.
6.3 Switching on
carlos system antivirus status: engine wired, clamd answering, signature date today or yesterday. Then carlos system antivirus enable. With no app granted the hourly pass runs and correctly does nothing. Sit in that state until §7 passes.
7. Proving it on Carloku
On a throwaway app we own, with a plaintext store:
- Upload one clean object and the EICAR test file.
scan request --plaintext,grant,sweep. Pass:scan statusshows a completed sweep, one clean, one infected; both objects' tags verify under this deployment's key and carry the object's current ETag. - Upload three archives from the §3.4 fixture set and
sweep: - the zip with one member expanding to 500 MiB. Pass:skippedandunscanned, nevercleanorinfected; its tag carries no clean verdict. (codex 4, codex 9) - the zip with that member followed by EICAR. Pass:skippedandunscanned(clamd stops at the limit before reaching EICAR). - the zip with a 60 MiB member followed by EICAR. Pass:infected(D8: a real match stands).
If any of the three reads clean, stop.
- Overwrite the clean object,
sweep. Pass: it is re-scanned, not carried forward. If it is carried forward, stop the rollout: a verdict that outlives its bytes is worse than none. enforce on. RemoveCARLOS_CLAMD_ADDR, restart. Pass:enforce offstill works with no engine built. Restore the address, restart.- Destroy the app. Pass: no
scan.jsonremains under its prefix and the name is claimable at once.
Then stop. Scanning stays enabled; no member store is granted.
8. Tito
After §7 passes and after #201 phase 2 has landed (so two changes do not edit Tito's console environment on the same day): §5's Tito apply, the Tito console roll and the scanner-box readiness check (§6.2's two preconditions), §6 in Tito's account, antivirus status, enable. Tito's proof stops at status and enable. §7 exercises platform code identical to Carloku's, and a throwaway app in a customer's deployment needs the customer's say-so.
9. Docs
infra/modules/scanner-host/README.md: what it is, the three outputs the console needs, replacement (§3.6), the invariant (§3.2), the limits and why they are equal (§3.4), the acceptance checks (§3.3), and the sizing rule.infra/modules/region-host/README.md:store_scanning.- A dated note at the top of
../plans/2026-09-01-carloku-scanning-rollout.md: steps 2 and 4 are superseded by this spec,CARLOS_STORE_SCANNERis deprecated. - An amadan discussion on
carlos/platformtracking the rollout.
10. Cost
One t4g.medium plus 20 GiB gp3 per deployment: about $25 a month each, $50 for the pair.
11. What would change this design
t4g.mediumturning out too small for the reload spike in practice: raise the instance type, or setconcurrent_database_reload = false.- A member asking for scanning on a client-encrypted store: no change here, the existing
--client-encryptedpath handles it. - Store buckets becoming per-region: see §2, out of scope.
- zip64 and encrypted archive members are not opened by clamd (§3.4), so a clean verdict never covers them. Follow-ups, each needing its own decision: say so in the
scan requestdisclosure (user-facing copy, so copy review); considerAlertEncrypted yeswith a mapping to unscanned, the way §4.3 maps limits. - Making the scan limit configurable: the self-test's fixture sizes and expected answers must then be derived from it (codex 11). Fixed at 100 MiB until someone needs otherwise.
- Raising clamd's limits later: objects already recorded
skippedunder the old limits are carried forward until their bytes change, as oversize objects are today. A forced re-sweep of skipped objects is a follow-up if anyone raises a limit, not part of this rollout.