M3-04: Scan pass with clamd¶
Status: ready | Depends on: M3-03 | Milestone: M3
Goal¶
After this ticket every completed upload runs through one worker job files.scan that reads the stored bytes exactly once and, from that single stream, counts the size and computes the SHA-256 against the declaration, detects the type from the bytes, scans with clamd over INSTREAM and, with the file system driver, writes the assembled object. The file then ends complete with scan_status clean (deliverable), infected or error (blocked: objects deleted at once, malware audited), skipped (scan switched off by the operator) or with upload_state = 'failed' (wrong size, wrong checksum or a denied detected type). While clamd is unreachable the file stays pending, is never deliverable, and the job keeps retrying without an attempt limit (fail closed, ADR 0033). clamd is reachable only through a Unix socket that the Compose stack shares over a volume instead of tcp://clamav:3310, configured with the mandatory options of 12 5.4, and the server refuses to start when file.max_upload_bytes is larger than file.scan.clamd_stream_max_bytes while the scan is required. This is the guarantee of 12 "Goals and limits" item 1 (no unchecked byte reaches a person or an agent); downloads (M3-05), images (M3-06) and the release of held agent invocations (M3-07) build on the states and the in-transaction event this ticket provides.
Read first¶
docs/spec/12-files.md: 1 "Goals and limits" item 1; 2 "States of a file" (table, definitions "servable" and "final", the rule that every change writesfile.updated); 3.2 (file system parts underuploads/<file_id>/<n>); 3.4 "Completion and verification pass" (the single read pass; the spec still calls the jobfile_verify); 3.5 (sequence); 3.6 "Error cases" (last row); 4.1 "Port" (ObjectStore), 4.2 "File system driver (default)" (temporary file,fsync,rename), 4.4 "Key schema" (objects are never overwritten); 5 "Malware scan" with 5.1 "Flow", 5.2 "Result mapping", 5.3 "When ClamAV is missing or not ready", 5.4 "Required settings for clamd"; 6.1 "Type"; 10 "Attaching and agent invocation" (files.FileFinalized); 12 "Retention, deletion, deduplication" (row "Failed or blocked"); 14 "Observability"; 15 "Tests" (rows EICAR, clamd stopped, Archive aboveMaxRecursion, Startup check, Total checksum wrong); 16 R-FI-04, R-FI-05, R-FI-06; 18 "Configuration values of this chapter" (file.scan.*,file.sniff_bytes,file.denied_mime_types); 19 Q7 to Q10, Q14.docs/adr/0033-malware-scan-of-uploads-as-a-separate-service.md(Decision Outcome, Consequences, Confirmation: the five checks this ticket proves),docs/adr/0010-object-storage-behind-an-s3-interface-local-driver-as-default.md, ADR 0008 and ADR 0050 (module register, vendor groups), ADR 0047 (only for the boundary to M3-06), ADR 0052 and ADR 0053 (envelope, no JSONnull).docs/spec/19-security.md: 2 "Transport" SEC-TLS-5 (virus scanner only over a Unix socket); 7 "Upload hardening" SEC-UP-2, SEC-UP-5, SEC-UP-6, SEC-UP-7; 9 "Logs without secrets and without content"; 12 SEC-AUD-5 (audit_details.v1, code format); 14 "Tests" SEC-T08 and SEC-T10.docs/spec/03-data-model.md(core contract, read only): 4.7files(upload_state,scan_status,scan_engine,scanned_at,mime_type, the partial index onscan_status = 'pending'),file_upload_sessions(declared_size_bytes,declared_sha256,failure_reason"code without contents",storage_upload_id),file_upload_parts; 4.10event_log(actor_kindNULL for a job without principal); 4.12audit_events; 6.3 "Lock order".docs/spec/05-actions.md(core contract, read only): rowfile.upload(Audit "yes on malware detection"); 6 "Durable event types and receive action" rowfile.updated(resourcefile, receive actionfile.read).docs/spec/04-sync-protocol.md(core contract, read only): 2.2 "Envelope" (actorabsent for jobs without principal and without system action,resource.versionabsent where the object has no version); 15.3 "Invocation and run start" step 1.docs/spec/07-permissions.md: 4.1 "Decisions and reasons" rowmalware_detected.docs/spec/02-architecture.md: 3.3 "Commands of the binary" (rowbothy files rescan, see Out of scope); 4.3 "Ports, events and sources per module" (rowfiles: emitsFileFinalized); 5 "Layers within a module" (rules 2, 4 and 5: metrics port andadapters/otelmetrics); 8.3 "In-Tx events"; 8.5 "What never happens in a transaction"; 12.1 to 12.4 "Background jobs (River)" (job catalogue rowfiles.scan, queuefiles, rules 1, 2, 5, 6); 13 "Configuration loading" item 4 (cross-checks) and item 5 (a key enters the schema with its first user); 14.4 "Failure of a dependency" (row clamd).docs/spec/06-api.md10 "Files" item 2;docs/api/openapi.yaml: operationcompleteUpload(its description),getFile, schemasFile,FilePayload,FileUpdatedEvent, webhookfile.updated(onFileUpdatedEvent), problem codesfile_not_readyandfile_blocked(used by M3-05).docs/spec/16-operations.md: 2 "Building blocks of an instance" (worker,clamav), 3 rule 2 (memory of the Docker VM), 12 "Status page" (health_sourcefiles.scan=PINGto clamd).docs/observability/signals.md: 2 "Cardinality"; 4.4 rowsbothy.file.scan,bothy.file.scan.duration,bothy.file.scan.queue; 4.7 "Histogram boundaries" (long durations); 5.2 "Spans" rowfile.scan; 5.3bothy.file.id,bothy.outcome.docs/observability/alerting.md3.2BothyMalwareScanFailingand its runbook anchor;infra/observability/prometheus/rules/bothy-alerts.yaml(the rule readsbothy_file_scan_total).docs/operations/configuration.md: section 4 rowsfile.scan.mode,file.scan.clamd_address,file.scan.chunk_bytes,file.scan.timeout,file.scan.max_concurrent,file.scan.retry_backoff_cap,file.scan.health_interval,file.scan.clamd_stream_max_bytes,file.sniff_bytes,file.denied_mime_types,file.max_upload_bytes,jobs.queue.files.max_workers; section 9 rowsBOTHY_CLAMD_ADDRESS,BOTHY_CLAMAV_VERSION,BOTHY_CLAMAV_MEMORY_LIMIT.infra/compose/compose.yaml(x-server-env, servicesworkerandclamav, volumes),infra/compose/.env.example,infra/compose/README.md("Security rules in this file");docs/architecture/seq-7-file-upload.mmd.docs/security/threat-model.md5.8 "Files, object storage, virus scanner" and T-UP-01;docs/RISKS.mdRK-43 and RK-52.docs/DECISIONS.md: D-FI-04, D-FI-05, D-FI-11 (12 Files), D-K-G5, D-K-G6 (6.3), D-K-X3 (namesfile.scan.*), D-W-100, section 10 (format ofD-M3-04-<n>).docs/PLAN.md: 3 "Way of working per ticket"; 5 "Test strategy" (row "Test doubles": clamd container); 9 "M3 Platform services" (Goal, Scope, Out of scope, Exit criteria 1 and 2, Test strategy, Red team, Risks) and the rows M3-01, M3-03 (before), M3-05, M3-06, M3-07, M3-08, M3-15 (after); 13 row M7-09.- Briefs:
docs/briefs/M3-01-object-storage-fs.mdanddocs/briefs/M3-03-upload-sessions.md(written in the same batch; the merged code of both wins over any name in this brief); M1 briefs for the interfaces used: M1-07 (merged:internal/platform/config,internal/wire/configkeys_*.go,configchecks.go,configchapters.go), M1-10 (internal/platform/otel), M1-11 (lifecycle.Component), M1-12 (pgtest, testcontainers pattern), M1-13 (txn.UnitOfWork,txn.Publish, golden file of registrations), M1-14 (internal/platform/jobs:Args,Register,HandlerOptions,Inserter.InsertTx,InsertOptions.ScheduledAt, the argument type check), M1-16 (eventlog.Appender), M1-24 (auditing.Recorder.Record,auditing.Code,auditing.Int,ReasonMalwareDetected), M1-68 (instrument registryinternal/platform/otel/metrics, see Design note 12). - Implied dependencies not in the header: M3-01 (object store port and file system driver) and M1-55 through M3-03, and with them M1-07, M1-10 to M1-14, M1-16, M1-18, M1-20, M1-24 and M1-26.
- Code:
services/server/internal/files(port,domain,app,adapters/fs,adapters/postgres,adapters/httpapifrom M3-01 and M3-03),internal/platform/jobs,internal/platform/otel,internal/kernel/txn,internal/kernel/eventlog,internal/kernel/auditing,internal/wire,cmd/bothy,architecture/modules.yaml,tools/archgen.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Product requirements implemented here:
- One read pass: the job reads the uploaded bytes exactly once and distributes the stream to SHA-256 and byte counter, type detection over the first
file.sniff_bytes, clamdINSTREAMand, with the file system driver, the assembly into the target object (temporary file,fsync,renamein the driver); afterwards the parts are deleted (12 3.4, R-FI-04, D-FI-04). - Size or SHA-256 different from the declaration gives
upload_state = 'failed'with reasonsize_mismatchorchecksum_mismatchin the session, the objects are deleted andfile.updatedis written (12 3.4, 3.6). - The detected type is stored as
mime_type;declared_mime_typestays diagnosis only; a detected type listed infile.denied_mime_typesgivesupload_state = 'failed'(12 6.1, SEC-UP-2). The server never unpacks archives (SEC-UP-7); detection reads only the buffered head. - Result mapping exactly as 12 5.2:
stream: OKgivesclean;<signature> FOUNDwith a signature starting withHeuristics.Limits.Exceededgiveserrorwith reasonscan_limits_exceeded; any otherFOUNDgivesinfected;INSTREAM size limit exceededor anotherERRORgiveserror; a timeout or connection error leavespendingand retries. infected: objects deleted at once, the row stays, and an audit entry withaction = 'file.upload',decision = 'deny',deny_reason = 'malware_detected'and indetailsthe signature, the SHA-256 and the size, never content (12 5.2, 05file.upload, 07 4.1, SEC-AUD-5, D-K-G5). Blocked and failed files lose their objects at once (12 12).- Fail closed with
file.scan.mode = required(default): clamd unreachable orPINGnot answered withPONGleaves the filepending; the job retries with backoff up tofile.scan.retry_backoff_capwithout an upper limit of attempts while the file is not deleted; nothing unchecked is delivered.file.scan.mode = offgivesscan_status = 'skipped'(12 5.3, R-FI-05, SEC-UP-6, 02 14.4). - The server does not start when
file.scan.mode = requiredandfile.max_upload_bytesis larger thanfile.scan.clamd_stream_max_bytes(12 5.4, 12 15 "Startup check", ADR 0033 Confirmation). - clamd only over a Unix socket (19 SEC-TLS-5):
file.scan.clamd_addressaccepts onlyunix://with an absolute path; the Compose stack shares the socket through a volume instead oftcp://clamav:3310and sets the mandatory clamd options of 12 5.4 (AlertExceedsMax yes,StreamMaxLengthandMaxFileSizeat leastfile.max_upload_bytes, defaults forMaxScanSize,MaxRecursion,MaxFiles,AlertEncrypted no). - Protocol (12 5.1): command
zINSTREAM\0; data in chunks of at mostfile.scan.chunk_bytes, each preceded by its length as 4 bytes in network byte order, ended by a chunk of length 0; the reply is read until clamd closes the connection;file.scan.timeoutper file; at mostfile.scan.max_concurrentscans per worker process; an own small client, nogo-clamd, no cgo scanner library (ADR 0033). - Every change of
upload_stateorscan_statuswrites the durable eventfile.updated(resourcefile, receive actionfile.read, no content) in the same transaction (12 2, 05 6); when a file becomes final ("final", 12 2) the same transaction publishes the in-transaction eventfiles.FileFinalized(02 4.3, 12 10, D-K-G6). - The worker pings clamd every
file.scan.health_interval; the result is kept for the status component withhealth_sourcefiles.scan(12 5.3, 16 12). - Signals only from the catalogue:
bothy.file.scan(outcomeclean,infected,error,skipped),bothy.file.scan.duration,bothy.file.scan.queue, spanfile.scan; no file names, no contents and no signature names in metrics, spans or logs; signature names only in the audit log (12 14, signals.md 2). - Job
files.scanin queuefiles, inserted withInsertTxin the transaction that completes the upload, arguments only IDs (02 12.1 to 12.4); no clamd or object store call inside a database transaction (02 8.5).
Scope¶
All paths under services/server/ unless noted. Where M3-01 or M3-03 already created a file named below, extend it.
Create:
internal/files/domain/scan.go(standard library andkernel-coreonly):type VerdictKindwithVerdictClean,VerdictInfected,VerdictError;type Verdict struct { Kind VerdictKind; Signature string; Reason string }.func ParseClamdReply(reply []byte) (Verdict, error): the mapping of requirement 14; any other form returnsErrClamdProtocol.func DecidePass(in PassFacts) PassOutcome: the outcome table of Design note 5 (pure).func NextScanDelay(round int64, first, ceiling time.Duration) time.Duration: Design note 7.func AuditSignature(sig string) string: Design note 15.func Deliverable(...) boolandfunc Final(...) booloverupload_state,scan_statusanddeleted_atexactly as 12 2 defines them (keep M3-03's if it already has them).
internal/files/app/scan.go: the use caseScanPass.Run(ctx, fileID ids.ID, round int64) (PassResult, error); outbound portsScanner { Scan(ctx context.Context, r io.Reader) (domain.Verdict, error); Ping(ctx context.Context) error }(12 5: portScanner),TypeDetector { Detect(head []byte) string },ScanQueue { EnqueueScan(ctx context.Context, fileID ids.ID, round int64, at time.Time) error }; the repository methods of Design notes 2 and 6.internal/files/app/scanhealth.go: the health monitor of Design note 11.internal/files/app/metrics.go: outbound metrics port with a no-op implementation for unit tests (02 5 rule 5), or extend M3-03's.internal/files/adapters/clamd/:client.go(theScannerimplementation),config.go(ConfigwithAddress,ChunkBytes,Timeout,MaxConcurrent),doc.go, tests;clamdtest/(throwaway clamd container and a fake clamd server, Design note 13).internal/files/adapters/sniff/: theTypeDetectorwithgithub.com/gabriel-vasile/mimetype(12 6.1, Q14).internal/files/adapters/jobs/scan.go: argsScan struct { FileID ids.ID; Round int64 }withKind() = "files.scan", the handler, theScanQueueimplementation overjobs.Inserter.internal/files/adapters/otelmetrics/metrics.go: the three instruments (Design note 12).internal/files/adapters/postgres/queries/scan.sql: the queries of Design notes 2, 6, 7 and 11.infra/compose/clamav/clamd.conf(repository root path): the clamd configuration of Design note 10.tools/docs/tests/test_compose_clamd.py(repository root path): the static Compose checks of the test list.
Change:
internal/files(port): in-transaction eventFileFinalized struct { FileID ids.ID };type ScannerHealth interface { ScannerState() ScannerState }withtype ScannerState struct { Status string; CheckedAt time.Time }, status one ofok,unreachable,off,unknown.- The completion use case of M3-03 (operation
completeUpload): callScanQueue.EnqueueScan(ctx, fileID, 0, time.Time{})inside its unit of work. Keep it if M3-03 already inserts a job of this kind. internal/platform/jobs: ifHandlerOptionshas no per-kind timeout, addTimeout time.Duration(zero: River default; negative: no River timeout) mapped to River's per-worker timeout, with a test and a line in its README (Design note 7).internal/wire:configkeys_files.go(or the area file M3-01/M3-03 created) with the keys of Design note 9 and the chapter constantchapter12 = "12-files.md"inconfigchapters.goif missing; the cross-checksscan_stream_limitandclamd_address_unixinconfigChecks; the expected keys inTestConfigSchemaBuilds; job registration offiles.scan(queuefiles); construction of scanner, detector, metrics and the health monitor as alifecycle.Componentin rolesworkerandall; regeneratetestdata/txn_registrations.goldenonly if a registration changes (publishingFileFinalizedwithout a subscriber changes none).architecture/modules.yaml: vendor groupmimetype: [github.com/gabriel-vasile/mimetype, github.com/gabriel-vasile/mimetype/**];files.adapter_vendorsgainsmimetypeand the vendor groupsclamdtestandotelmetricsneed (the testcontainers group of M1-12, the OpenTelemetry group the metrics pattern uses); regenerate.go-arch-lint.ymlwithcd services/server && go generate ./tools/archgen.go.mod,go.sum:github.com/gabriel-vasile/mimetypeat its newest release, pinned; MIT is on the allowed list (make licenses).internal/files/README.md: status, the job, the portsScanner,TypeDetector,ScanQueue,ScannerHealth, the eventFileFinalized, the adaptersclamd,sniff,jobs,otelmetrics, the retry rule, the residual crash window of Design note 6 and the test requirements (Docker runtime, pinned clamd image).services/server/CHANGELOG.mdunder "Unreleased".infra/compose/compose.yaml,infra/compose/.env.example,infra/compose/README.md(Design note 10).docs/spec/12-files.md(not core contract): 3.4 and 3.5 name the jobfiles.scaninstead offile_verify; 5.1 item 1 says "only a Unix socket (19 SEC-TLS-5)" and drops TCP; 18 changes the default offile.scan.clamd_addresstounix:///run/clamav/clamd.sockand its meaning.docs/spec/02-architecture.md13 item 4: add the two cross-checks to "Cross conditions today".docs/spec/06-api.md10 item 2 and the description ofcompleteUploadindocs/api/openapi.yaml: completion queues the passfiles.scan, which checks size and SHA-256, detects the type and scans; nothing else in the API changes.docs/operations/configuration.md: section 4 regenerated (make config-reference); section 9 rowBOTHY_CLAMD_ADDRESSwith the new template value.docs/architecture/seq-7-file-upload.mmdand its.svg: completion only sets the sessioncompletedand insertsfiles.scan; the job checks size and SHA-256, detects the type, scans and then setsupload_stateandscan_status(12 3.4); render withuv run --project tools --locked --quiet python tools/docs/render_diagrams.py --write.docs/DECISIONS.mdsection 10: rowsD-M3-04-<n>for the rulings of Design note 14.
This ticket enables no operation in api/operations.yaml, adds no migration, no action and no Makefile target.
Out of scope¶
- Upload sessions, parts, completion and the early checks against declared type and extension (M3-03); the object store port and the file system driver (M3-01); the S3 driver and the presigned mode (M3-02, M3-03).
- Download, thumbnails over HTTP, transcripts, fetch tickets and the answers
409 file_not_readywithRetry-Afterand409 file_blocked(M3-05); this ticket provides the states anddomain.Deliverable. - Metadata removal and thumbnails, and inserting
files.thumbnailafterclean(M3-06, see Design note 13). - Attachments, the subscriber of
files.FileFinalized,ReleaseInvocationandruns.invocation_hold_timeout(M3-07). files.cleanup_pending,files.purge_objects,file.failed_row_ttland the catch-up of missed object deletions (M3-08).- The transcript condition of
FileFinalizedfor voice messages in modeservice(M3-09); text extraction afterclean(M3-15). - The status component "Virus scan",
degradedafterfile.scan.pending_alert_after, its alert and the keyfile.scan.pending_alert_afterin the schema (M7-09); "Virus scan off" in console and status page (M6, M7-21). - Choosing the profile
scanandfile.scan.modeduring installation (M7-29); Compose runs with the profilescanon real hosts (M7-14). IngestFileand the queuefiles_bulkof 12 11 (import, M8-03).bothy files rescan --skipped(02 3.3, 12 5.3): no ticket of the plan owns it yet; reported as a plan gap, not built here.- Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M3-04-clamd-scan; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(files): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. No network calls except package downloads for pinned tools and Go modules.
- Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 or M2 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>. - The same applies to M3-01 and M3-03: follow their merged names (object store port, repository, completion use case, event builder for
file.updated) and list each difference. - clamd runs in tests only as a throwaway container of the image pinned in
infra/compose/.env.example(BOTHY_CLAMAV_VERSION), pulled once like any pinned tool; it never downloads signatures (freshclam off) and binds only127.0.0.1with a random port. Stop only containers you started, by id. - Never commit the EICAR test string or any real malware sample: tests assemble the EICAR string at run time from two parts. No real third-party service in tests.
- The production code accepts only
unix://clamd addresses; the loopback TCP seam of Design note 13 exists only in test files.
Design notes¶
- Start of the pass. The completion use case of M3-03 calls
ScanQueue.EnqueueScan(ctx, fileID, 0, time.Time{})in its unit of work, so the job exists exactly when the session iscompleted(02 8.5, 12 3.4 step 3). AnIdempotency-Keyreplay ofcompleteUploadinserts nothing (M1-20 returns the stored answer). The jobfiles.scanruns in queuefiles(02 12.2, 12.3); its args areFileIDandRound(int64, the retry round), which the type check of M1-14 accepts. - Awaiting the scan. A file awaits the pass when
upload_state = 'pending',scan_status = 'pending',deleted_at IS NULLand its sessionstate = 'completed'. The handler first reads this without a transaction; any other state ends the job withniland no effect (02 12.4 rule 1). Order in the handler: a. read the target (file row, session row: declared size and SHA-256,part_count,storage_upload_id); b. withfile.scan.mode = required:Scanner.Ping; an error goes to the retry of note 7 without reading any byte; c. run the pass of note 4 under a context with the deadlinefile.scan.timeout; d. decide the outcome (note 5) and write it (note 6), or reschedule (note 7). The handler inadapters/jobsstarts the spanfile.scan(kind CLIENT) withbothy.file.idaround steps b to d and setsbothy.outcomefrom the result. - Never in a transaction (02 8.5):
Ping, the pass,Head,Get,PutandDeleterun outside every unit of work; the unit of work of note 6 or note 7 only reads and writes PostgreSQL and inserts jobs. - The single pass (12 3.4, R-FI-04).
a. Source.
ObjectStore.Head(object_key)first. If the object exists, the pass reads it withGet(object_key, nil): always with S3 (M3-03 completed the multipart upload), and with the file system after an earlier attempt already assembled it (objects are never overwritten, 12 4.4, D-FI-11). Otherwise, with the file system layout, the pass reads the partsuploads/<file_id>/<n>fornfrom 1 topart_countin this order, opening each part only when the previous one is exhausted, and writes the same stream withObjectStore.Put(object_key, reader, declared size)through a pipe; the driver writes a temporary file,fsyncandrename(12 4.2). If the object is missing and the session has astorage_upload_id(S3), that is an internal error: Error log, the file stayspending, retry (note 7). If merged M3-03 already assembles file system parts at completion, the pass simply readsobject_key; name this second read of the bytes in the report as a deviation from D-FI-04. b. The source is wrapped in a reader limited to the declared size plus one byte, so an oversized object is detected without reading unbounded data. c. A tee distributes every byte once to: SHA-256 and the byte counter; the head buffer of at mostfile.sniff_bytesbytes for the type; thePutpipe (file system assembly only). The scanner consumes the tee:Scanner.Scan(ctx, tee)withfile.scan.mode = required, otherwise the pass copies the tee toio.Discard. d. If the scanner returns before the end of the stream (a verdict such as the stream limit, or a transport error), the pass drains the rest of the tee, so size, SHA-256 and the assembled object are complete and the size or checksum decision of note 5 never depends on clamd. e. ThePutpipe is closed with an error whenever the pass fails or the byte count differs from the declared size, so no half object stays; an object that does remain belongs to a failed or blocked file and is deleted with it (note 6). f. Type detection runs once on the head buffer after the stream ends. Store the media type without parameters, in lower case. A detected type is denied when it equals an entry offile.denied_mime_typescase-insensitively without parameters; reuse M3-03's matcher for the early check if it exists, so both checks use one rule (12 6.1). -
Outcome (first matching row wins; Ruling for docs/DECISIONS.md for the order, the codes marked "ruling" and the audit in the first three rows):
Condition upload_statescan_statussession failure_reasonmime_typeobjects audit metric outcome byte count differs from declared_size_bytesfailedpendingsize_mismatchunchanged delete malware entry if the scan reported a FOUNDthat maps toinfectednone SHA-256 differs from declared_sha256failedpendingchecksum_mismatchunchanged delete as above none detected type denied failedpendingfile_type_denied(ruling, the reason of the early415)detected delete as above none file.scan.mode = offcompleteskippednone detected keep none skippedstream: OKcompletecleannone detected keep none cleanFOUNDwithHeuristics.Limits.Exceededcompleteerrorscan_limits_exceededdetected delete none errorother FOUNDcompleteinfectedmalware_detected(ruling)detected delete malware entry infectedINSTREAM size limit exceededcompleteerrorscan_stream_limit_exceeded(ruling)detected delete none errorany other ERRORreplycompleteerrorscan_engine_error(ruling)detected delete none errorThe three
failedrows decide the outcome even when the scan failed transiently, because size, SHA-256 and type are known from the complete stream (note 4d) and the bytes are deleted anyway; otherwise a transient scanner error or a reply thatParseClamdReplyrejects leaves the filepending(note 7).scanned_at = now()andscan_engine = 'clamd'(ruling) are set whenever clamd returned a verdict; both stay NULL forskippedand for failed files. The reason codes are machine codes without content (03failure_reason); the free text of a clamdERRORline is never stored or logged. The malware entry for a failed file closes the gap that a client could hide a detection behind a wrong checksum. -
Result unit of work (one
txn.UnitOfWork.Do): a. Lock thefilesrowFOR UPDATE, then thefile_upload_sessionsrow (03 6.3: domain rows; keep the order M3-03 uses for the same pair if it differs, and name it in the README). Check note 2 again; if the file is no longer awaiting (final, ordeleted_atset) change nothing. b. Updatefiles(upload_state,scan_status,mime_type,scanned_at,scan_engine) and the session (failure_reason). c. Appendfile.updatedwitheventlog.Appender: no actor (job without principal and without system action, 04 2.2, 03event_log.actor_kind); resource{kind: file, id}withoutversion(fileshas no version column); scope{space_id, channel_id}ofscope_channel_idforpurpose = 'attachment'and empty for the other purposes; receive actionfile.read; payloadFilePayload {file: File}with the row after the change, without JSONnull(ADR 0053). Reuse M3-03's builder for this payload and scope if it exists. d. Malware entry withauditing.Recorder.Record(the transaction commits, so notRecordDenial):Action "file.upload",Decision deny,Reason ReasonMalwareDetected,ResourceKind "file",ResourceIDthe file,Attrsexactlysignature(Code(AuditSignature(sig))),sha256(Codeof the lowercase hex of the computed digest) andsize_bytes(Intof the counted bytes). The actor is NULL because the job has none (03audit_events.actor_principal_id). If the merged code bound ofauditing.Codeis shorter than a hex SHA-256, raise it in the same change with its test and record the ruling. e.txn.Publish(ctx, files.FileFinalized{FileID})whenever the new state is final (12 2: deliverable,failed,infectedorerror). f. After the commit, and only if this run made the transition: deleteobject_key(forfailed,infected,error) and, with the file system layout, every partuploads/<file_id>/<n>(all outcomes); a missing object is no error (12 4.1). A failed delete is logged and left to the sweep of M3-08; the crash window between commit and delete is a residual named in the module README. If step a found the file deleted, the run deletes the objects it wrote itself. g. Record the metrics of note 12 after the commit. - Fail closed and retry (Ruling for docs/DECISIONS.md). River's attempt limit must never end the retries (12 5.3: no upper limit while the file is not deleted), so a transient failure (ping failed, connection error, timeout, protocol error, object store error) does not return an error to River: in a short unit of work the handler checks note 2 again and, if the file still awaits, inserts the next
files.scanwithRound + 1andScheduledAt = now + NextScanDelay(Round, file.scan.health_interval, file.scan.retry_backoff_cap), then returnsnil.NextScanDelaydoubles fromfile.scan.health_intervalper round, never exceedsfile.scan.retry_backoff_capand never overflows for large rounds. No state change, no event, no audit; a Warn log withbothy.file.idand a reason class (unreachable,timeout,protocol,storage). Only when this unit of work itself fails (database) does the handler return the error to River. A cancelled job context (shutdown) returnsctx.Err()(M1-14). The pass enforcesfile.scan.timeoutitself; registerfiles.scanwith no River job timeout (HandlerOptions.Timeoutnegative), because River's default job timeout from theplatform/jobsREADME can be shorter thanfile.scan.timeoutand would cut the follow-up unit of work; the database work stays bounded bydatabase.statement_timeoutanddatabase.lock_timeout(02 12.4 rules 5 and 6). - clamd client (
adapters/clamd, standard library only, ADR 0033). a.New(cfg Config) (*Client, error)accepts onlyunix://followed by an absolute path; anything else is an error naming the key, never the value. b.Ping: connect, sendzPING\0, read the NUL-terminated reply; anything butPONGis an error. c.Scan: acquire the process-wide semaphore of sizefile.scan.max_concurrent(respectingctx), connect, sendzINSTREAM\0, then read from the reader into a buffer offile.scan.chunk_bytesand send each non-empty read as one chunk with its 4-byte big-endian length (a read of zero bytes sends nothing), end with a zero-length chunk, read the reply until the connection closes, strip the trailing NUL, and returndomain.ParseClamdReply. Connection deadlines followctx. d. A write error does not end the conversation at once: clamd closes the stream on its size limit and replies first, so the client tries to read a reply and returns the verdict if there is one, otherwise a transport error. e. The reply is bounded byfile.scan.chunk_bytes(one chunk is the largest unit the client exchanges with clamd); a longer reply is a protocol error. f. No signature, file name or reply text in logs or span attributes of the adapter. - Configuration and start. Add to the schema, with the defaults and kinds of the chapter tables (02 13 item 5):
file.scan.mode(enumrequired,off),file.scan.clamd_address(string),file.scan.chunk_bytes,file.scan.timeout,file.scan.max_concurrent,file.scan.retry_backoff_cap,file.scan.health_interval,file.scan.clamd_stream_max_bytes,file.sniff_bytes, and, if M3-03 has not added them,file.max_upload_bytes(chapter 03) andfile.denied_mime_types. Two cross-checks inconfigChecks, whose messages name keys and never values:scan_stream_limit(keysfile.scan.mode,file.max_upload_bytes,file.scan.clamd_stream_max_bytes: fails when the mode isrequiredand the upload limit is larger than the mirror) andclamd_address_unix(keyfile.scan.clamd_address: fails unlessunix://plus an absolute path).bothy config checkandbothy servetherefore fail before start with exit code2(02 3.3). Withfile.scan.mode = offthe worker logs one Warn at start that the virus scan is off. - Compose (Ruling for docs/DECISIONS.md: socket path and permissions).
- A named volume
clamav-socket, mounted at/run/clamavinclamavand read-only inworker(connecting to a socket does not write to the file system).apiandmigratedo not mount it; they never scan. x-server-env:BOTHY_FILE__SCAN__CLAMD_ADDRESS: ${BOTHY_CLAMD_ADDRESS:-unix:///run/clamav/clamd.sock};.env.example:BOTHY_CLAMD_ADDRESS=unix:///run/clamav/clamd.sockwith a comment that the socket lives in the volumeclamav-socket.infra/compose/clamav/clamd.conf, mounted read-only where the pinned image reads its clamd configuration: start from the image's own configuration of the pinned version (read it from the image, do not guess) and setLocalSocket /run/clamav/clamd.sock, a socket mode the worker's user can connect to (the volume is shared only withworker), noTCPSocket,AlertExceedsMax yes,StreamMaxLengthequal to the default offile.scan.clamd_stream_max_bytesandMaxFileSizenot below the default offile.max_upload_bytes(a comment names both keys),MaxScanSize,MaxRecursion,MaxFilesandAlertEncryptedleft at their defaults; verify every option name against Q10.clamavpublishes noportsand noexpose; it keeps the networkdefaultbecause freshclam needs egress for signatures (install-compose "Network"). Its health check must work without a TCP listener reachable from other containers: prefer a Composehealthcheckthat pings over the socket; if the pinned image cannot do that, setTCPAddrto loopback inside the container for the image's own check and record which variant you took.workerkeepsdepends_on: clamav: service_healthy, required: false.infra/compose/README.md: a rule under "Security rules in this file" that clamd is reached only over the socket volume (SEC-TLS-5) and the profile table entry forscannames the volume.
- A named volume
- Health monitor (12 5.3). A loop in the worker process (roles
worker,all) wired as alifecycle.Component: everyfile.scan.health_intervalit callsPingwith that interval as deadline (moderequiredonly), storesScannerState{Status, CheckedAt}(ok,unreachable;offwith modeoff;unknownbefore the first ping), logs each change of status once (Warn when clamd becomes unreachable, Info when it is back), and recordsbothy.file.scan.queueas the number of files awaiting the pass (note 2, a count over the partial index onscan_status = 'pending'). It implements the portfiles.ScannerHealth, which M7-09 reads forhealth_sourcefiles.scan(Ruling for docs/DECISIONS.md: per process, kept in memory). It never changes readiness: a missing clamd keeps files pending but the worker stays ready for its other queues. - Observability (12 14, signals.md 4.4 and 5.2).
bothy.file.scancounts one per final scan outcome (clean,infected,error,skipped; failed files and retries are not counted, soBothyMalwareScanFailingkeeps its meaning);bothy.file.scan.durationmeasures the whole pass per outcome (ruling: the pass and the scan are one stream, so one histogram covers both, with the long-duration bounds of signals.md 4.7);bothy.file.scan.queuefrom note 11. Recording goes through the metrics port ofappandadapters/otelmetrics(02 5 rule 5). Declare the three instruments in the instrument registryinternal/platform/otel/metricsof M1-68 with exactly the catalogue names, units, buckets and closed attribute sets. Logs use constant messages and onlybothy.file.id,bothy.outcomeand the reason class; never file names, signatures or reply text. - Interfaces for later tickets and tests.
domain.Deliverableanddomain.Finalare the only definitions of 12 2; M3-05 maps them to409 file_not_readyand409 file_blocked.- Keep the outcome write of note 6 in one function with a clear place where M3-06 inserts
files.thumbnailand M3-15 insertsfiles.extract_textfor a deliverable file in the same unit of work (12 3.4 last paragraph). This ticket inserts neither. clamdtest(non-test package used only by tests): starts the pinned image through testcontainers-go as M1-12 does forpgtest(Ryuk off, labelorg.bothy.test=clamdtest, loopback binding,AutoRemove), with the image tag read fromBOTHY_CLAMAV_VERSIONininfra/compose/.env.exampleand pinned by digest with a renovate comment likepgtest.Image. Ruling for docs/DECISIONS.md: the test container runsclamdin the foreground directly (not the image's init script, which may download signatures) with a testclamd.conffrommktemp -d(AlertExceedsMax yes, small explicitStreamMaxLength,MaxRecursionandMaxScanSize,LocalSocketand a TCP listener for the test seam) and a database directory holding only one hash signature for the EICAR string, computed at run time (MD5, size and the nameBothy.Test.EICAR). Reason: the full signature database needs minutes and gigabytes of memory to load (RK-52) and must not be downloaded in tests. ClamAV may append.UNOFFICIALto local signature names; assert with a prefix.- Test seam: VM-based Docker runtimes on macOS do not pass Unix sockets of bind mounts through to the host, so the real-clamd tests dial the container's loopback TCP port through an unexported dialer option set from
export_test.go; the dialer resolves the mapped port at every dial, because a restarted container gets a new port. The production constructor has no such option. - A fake clamd server in
clamdtestlistens on a Unix socket in amktemp -ddirectory and scripts replies, delays and early closes.
- Rulings for
docs/DECISIONS.mdsection 10 (one row each,D-M3-04-<n>, with reason): the retry by re-insertion without attempt limit and its delay rule (note 7); no River job timeout forfiles.scan(note 7); the outcome precedence, the reason codesfile_type_denied,malware_detected,scan_stream_limit_exceeded,scan_engine_errorand the malware entry on failed files (note 5); a rejected clamd reply stayspending(note 5);scan_engine = 'clamd', NULL forskipped(note 5); media type without parameters and the denial match (note 4f); the source rule withHead(note 4a); the audit signature normalisation (note 15); the socket path, volume and socket permissions (note 10); the health state per process (note 11); one histogram for the pass (note 12); the test clamd with a local signature and the TCP test seam (note 13). The job namefiles.scan, the unix-only address and the mandatory clamd options come from 02 12.3, 19 SEC-TLS-5 and 12 5.4 and need no ruling. - Audit signature (Ruling for docs/DECISIONS.md). Codes in
audit_details.v1allow only lower-case letters, digits and_.:-(SEC-AUD-5), while ClamAV names contain upper-case letters.AuditSignaturelower-cases the name, replaces every other character by_and cuts it to the code bound ofauditing.Code; the original name is never stored elsewhere.
Tests (write them first)¶
Unit:
internal/files/domain:TestParseClamdReply(table:stream: OK;stream: <name> FOUND;stream: Heuristics.Limits.Exceeded.MaxRecursion FOUNDgiveserrorwithscan_limits_exceeded;INSTREAM size limit exceeded. ERRORgiveserrorwithscan_stream_limit_exceeded; another... ERRORgivesscan_engine_error; a trailing NUL is accepted; empty,stream: ok,stream: FOUND,PONGand binary noise giveErrClamdProtocol);FuzzParseClamdReply(never panics; returnsVerdictCleanonly for exactlystream: OKwith an optional trailing NUL);TestDecidePass(one row per line of the table in Design note 5, plus a checksum mismatch together with aFOUND, a size mismatch and a denied type each together with a transient scan failure);TestNextScanDelay(starts atfile.scan.health_interval, doubles, stops atfile.scan.retry_backoff_cap, no overflow for the largestint64round);TestDeliverableAndFinal(every row of 12 2 and a deleted clean file, which is not deliverable);TestAuditSignature(upper case, spaces and slashes, an over-long name).internal/files/adapters/clamdagainst the fake server:TestNewRejectsNonUnixAddress(tcp://clamav:3310, anhttpURL, a relativeunixpath; the error namesfile.scan.clamd_addressand not the value);TestInstreamFraming(commandzINSTREAM\0, every chunk at mostfile.scan.chunk_byteswith a correct big-endian length, no empty chunk before the terminator, exactly one zero-length terminator);TestPing(PONGok, anything else an error);TestScanReadsReplyAfterWriteError(the fake replies with the stream limit and closes mid-stream: the verdict iserror, not a transport error);TestScanTimeout(a stalled fake: transport error by the context deadline);TestScanMaxConcurrent(withfile.scan.max_concurrentset low in the test config, one more parallel scan waits; the fake never sees more connections at once);TestReplyBound;TestClientLogsNoSignature(captured logs and spans contain neither the signature nor the reply text).internal/files/adapters/sniff:TestDetect(PNG bytes declared astext/plaingiveimage/png; an OOXML document whose markers lie inside the head is detected as such; parameters are stripped; an empty head givesapplication/octet-stream).internal/files/appwith fakes (counting object store, scripted scanner, fake clock):TestScanPassReadsEachByteOnce(file system layout: every part byte read once andPutreceives identical bytes; existing object: read once, noPut);TestScanPassModeOff(skipped, scanner never called);TestScanPassTransientReschedules(ping failure: no byte read, no state change, no event, no audit, one follow-up job withRound + 1at the expected time);TestScanPassNotAwaiting(final or deleted file: no effect, no delete);TestScanPassDrainsAfterEarlyVerdict.
Integration (PostgreSQL 18 via pgtest, the file system driver of M3-01 in a mktemp -d root, real modules through internal/wire, uploads through the real HTTP chain with createUpload, uploadPart and completeUpload as Alex in a channel of a test space, people from the identity test helpers; tests marked "real clamd" use clamdtest):
TestScanEICARInfected(real clamd; 12 15 row EICAR, ADR 0033):scan_status = 'infected',upload_state = 'complete',domain.Deliverablefalse andgetFileshowsinfected;object_keyand all parts are gone (Headreports missing), the row stays; exactly one audit row withaction = 'file.upload',decision = 'deny',deny_reason = 'malware_detected', NULL actor, resource the file,detailswith exactlysignature(prefix of the normalised test name),sha256andsize_bytes; exactly onefile.updated;FileFinalizedpublished once (test subscriber registered only in the test).TestScanCleanFile(real clamd):clean,complete, detectedmime_type,scanned_atset,scan_engine = 'clamd', the object exists and its SHA-256 equals the declaration, parts deleted; thefile.updatedrow validates againstFileUpdatedEventand contains no JSONnull;FileFinalizedonce.TestScanClamdStoppedStaysPending(real clamd; 12 15 row "clamd stopped", ADR 0033): with the container stopped the job leaves the filepending, not deliverable, writes no event, and inserts the nextfiles.scandelayed byfile.scan.health_interval; repeated failures keep inserting rounds with growing delay up tofile.scan.retry_backoff_capand never stop; after the container starts again the due job setsclean. (409 file_not_readyon download is asserted by M3-05.)TestScanArchiveOverMaxRecursion(real clamd; 12 15, ADR 0033): ZIP archives nested one level deeper than the test configuration'sMaxRecursiongiveerrorwithfailure_reason = 'scan_limits_exceeded', neverclean; objects deleted; no malware audit entry.TestScanArchiveOverMaxScanSize(real clamd; plan Test strategy "archive limits"): a small archive that expands beyond the testMaxScanSizegiveserrorwithscan_limits_exceeded.TestScanStreamLimitExceeded(real clamd): with the test clamd'sStreamMaxLengthbelow the file size while the server's mirror is consistent, the replyINSTREAM size limit exceededgiveserrorwithscan_stream_limit_exceeded.TestScanChecksumMismatch(12 15 row "Total checksum wrong"): one stored part is changed on disk with the same length after its digest was accepted:upload_state = 'failed',failure_reason = 'checksum_mismatch',scan_status = 'pending', objects deleted, onefile.updated,FileFinalizedonce. With an EICAR payload the malware audit entry is written as well.TestScanSizeMismatchwith a fake object store in the assembled layout whose object is one byte longer:failedwithsize_mismatch; the pass reads at most the declared size plus one byte.TestScanDetectedTypeDeniedandTestScanDeclaredTypeIgnored(SEC-UP-2, part of SEC-T08): a PNG declared astext/plainis stored asimage/png; withimage/pnginfile.denied_mime_typesthe same upload endsfailedwithfile_type_denied.TestCompleteUploadInsertsScanJob:completeUploadinserts exactly onefiles.scanjob withRound0 in its transaction; a replay with the sameIdempotency-Keyinserts none; a rolled-back completion inserts none.TestScanThroughRiver: with the jobs runtime in roleall, the job is picked from queuefilesand the file endsclean(real clamd).TestScanConcurrentRuns: two passes for the same file in parallel produce one transition, one event, one audit entry at most and no deletion by the losing run.TestScanFileDeletedDuringPass:deleted_atset while the pass runs: no state change, no event, and the assembled object is removed by the job.TestScanNoNamesOrSignaturesOutsideAudit(SEC-T10 for this path): the upload's file name is a canary and the file is EICAR; captured logs, spans and metric attributes of the pass contain neither the canary nor the signature; the signature appears only in the audit row.TestScanHealthMonitor(real clamd and fake clock): stateunknown, thenok;unreachableafter the container stops,okafter it starts again; each change logged once;bothy.file.scan.queueequals the number of files awaiting the pass; with modeoffthe state isoffand no ping happens.TestScanMetrics(manual metric reader):bothy.file.scanper outcome,bothy.file.scan.durationwith the catalogue buckets, no attribute outsidebothy.outcome; failed files and retries are not counted.
Configuration and start (internal/wire, cmd/bothy):
TestConfigScanStreamLimit(12 15 row "Startup check", ADR 0033): moderequiredwithfile.max_upload_bytesabovefile.scan.clamd_stream_max_bytesmakesbothy config checkexit2with a message that names both keys and contains no value; equal values pass; modeoffpasses.TestServeRefusesInconsistentScanLimits:bothy serve --role=workerwith that configuration ends before any listener opens, with exit code2.TestConfigClamdAddressUnixOnly(SEC-TLS-5):tcp://clamav:3310, anhttpURL andunix://relative/pathare rejected;unix:///run/clamav/clamd.sockpasses.TestConfigSchemaBuildslists the new keys with the chapter defaults.
Static and platform:
tools/docs/tests/test_compose_clamd.py(pytest, PyYAML, no network): the default ofBOTHY_FILE__SCAN__CLAMD_ADDRESSinx-server-env,BOTHY_CLAMD_ADDRESSin.env.exampleand the default offile.scan.clamd_addressin the table of 12 18 are the sameunix://address; neithercompose.yamlnor.env.examplecontains atcp://clamd address;clamavhas noportsand noexpose; the volumeclamav-socketis mounted inclamavand read-only inworkerat the directory of the socket, and in no other service;clamd.confis mounted read-only intoclamav, setsAlertExceedsMax yes, itsLocalSocketlies in that directory, it has noTCPSocketunlessTCPAddris a loopback address,StreamMaxLengthequals the default offile.scan.clamd_stream_max_bytesandMaxFileSizeis not below the default offile.max_upload_bytes(both defaults read from the chapter tables, sizes parsed with clamd's suffixes).docker compose --env-file infra/compose/.env.example -f infra/compose/compose.yaml config --quietis valid with the template profiles.- Architecture (ADR 0033 Confirmation):
make archis green withmimetypeonly ininternal/files/adapters, the S3 rule of M3-02 untouched, and no scanner library in the register (any unregistered third-party import fails go-arch-lint).
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/files/... ./internal/platform/jobs/... ./internal/wire/... ./cmd/bothy/...- a short fuzz run of
FuzzParseClamdReply(cd services/server && go test -run '^$' -fuzz FuzzParseClamdReply ./internal/files/domain/stopped after a short-fuzztime); its seed corpus runs in everygo test uv run --project tools --locked pytest tools/docs/tests/test_compose_clamd.pydocker compose --env-file infra/compose/.env.example -f infra/compose/compose.yaml config --quietmake arch,make codegen-check,make register-check,make config-reference(no diff afterwards),make config-doc-check,make api-lint,make api-checks,make licensesmake diagrams,make docs,make linkcheckmake check
Acceptance criteria¶
- EICAR against a real clamd container becomes
infected, is never deliverable, its objects are deleted, the row stays, and one audit entryfile.upload,deny,malware_detectedcarries signature, SHA-256 and size and nothing else. - With clamd stopped the file stays
pendingand undeliverable, the job retries with backoff up tofile.scan.retry_backoff_capwithout an attempt limit, and after clamd starts the file becomesclean. - An archive over
MaxRecursion(and overMaxScanSize) becomeserrorwithscan_limits_exceeded, neverclean. -
bothy config checkandbothy serverefuse to start whenfile.scan.mode = requiredandfile.max_upload_bytesis larger thanfile.scan.clamd_stream_max_bytes. - The pass reads every byte once and decides size, SHA-256, detected type and scan from that one stream; size or checksum mismatches and denied detected types end
failedwith their reason and without objects. - clamd is reached only over a Unix socket: the configuration rejects every other address, and
infra/compose/compose.yamlshares the socket through the volumeclamav-socketinstead oftcp://clamav:3310, with the mandatory clamd options of 12 5.4 ininfra/compose/clamav/clamd.conf. - Every state change writes one
file.updatedthat validates againstFileUpdatedEvent; a final state publishesfiles.FileFinalizedonce. - Metrics, span and logs follow signals.md; no file name, content or signature name outside the audit log.
- Chapter 12 (job name, unix-only address, default), 02 13 item 4, 06 10 item 2, the
completeUploaddescription,configuration.md, the Compose README,seq-7-file-upload, thefilesREADME, the CHANGELOG and the rowsD-M3-04-<n>are updated. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- README of
filesupdated (ports, job, adapters, retry rule, residual crash window of note 6f, test requirements of note 13); README ofplatform/jobsifHandlerOptionschanged;infra/compose/README.mdupdated. - Rulings of note 14 recorded in
docs/DECISIONS.md(one row each with reason). services/server/CHANGELOG.mdentry under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven.
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.