Appearance
ADR 0010 — GrydFiles: object lifecycle, quarantine and a pluggable antivirus
- Status: Accepted
- Date: 2026-09-11
- Specification: GrydFiles specification v1.2 — the fields, routes, error codes and the invariants RN-GF-01 to RN-GF-12. This record keeps why; the specification keeps what.
Context
The framework could already talk to S3, Azure Blob Storage and Google Cloud Storage and issue presigned upload and download URLs (Gryd.Application.Abstractions.Storage). What it did not have anywhere was a record of the file: who uploaded it, what its hash is, whether an antivirus looked at it, who still points at it and when it may die. Each module improvised one — GrydReports kept a URI in a string, GrydNotifications kept the whole attachment as byte[] in a column — and neither could answer the question every purge depends on: does anybody still need these bytes?
GrydFiles is also the only module in which a third party writes bytes onto the platform's infrastructure and in which the content travels outside the API's process. The decisions below are the ones that are cheap to reverse under pressure and expensive to have reversed: the absence of a manual release button above all. Writing them down, with the alternatives that were refused and the price that was accepted, is what keeps the first operational incident from reopening them.
The specification went through three versions. v1.2 (2026-09-08) added sixteen detailing decisions (D1–D16) to the eight architectural ones; implementation (epic 1376, blocks 1–12) then settled a handful of points the text had wrong for this framework. All three layers are recorded here.
Decision
- Layer. The module knows the file and never the domain.
StoredFileis bytes and metadata; attachment types, visibility and propagation between documents belong to the consuming product. That ignorance is what makes it reusable. - Transit by presigned URL. The content never passes through the API: upload and download go straight to the bucket, and the API grants the permission and receives the confirmation. The confirmation is an explicit
POST /files/{id}/completerather than a bucket event, because bucket events differ per provider, arrive late or twice, and would make the local development mode a second code path. - Quarantine before reference. Nothing is referenceable before it has been scanned:
Pending → Scanning → Available, withInfectedandFailedas exits. There is deliberately no "released manually" state, no flag and no permission —admin:systemincluded — that reachesAvailablewithout a verdict. Infected content is moved under_quarantine/, never deleted, because deleting it destroys the evidence of the incident. sha256computed on the server is the identity. It is computed in the same read that feeds the antivirus and the type detector, and never accepted from the client. The original name is display metadata and never part of the object key.- Reference registry. Whoever points at a file registers (
FileReference:ownerScope+ownerKey, as text — the polymorphism the audit log already uses). Without it there is no answer to "does this blob still serve anything?", and without that answer there is no safe purge. - Retention declared by the consumer. Nobody uploads without saying how long the file stays. The module does not know that a fiscal document is kept for five years; it guarantees that the question was asked and that the answer only ever moves forward (RN-GF-09).
- Purge with a tombstone. The object dies, the record stays — hash, size, type, profile, scan result and the location it had. That reconciles LGPD erasure with
ISoftDeletableand with audit. - ClamAV, pluggable behind
IFileScanner. The engine runs as its own service, never embedded in the API, and the provider can be replaced without touching the module.
The sixteen detailing decisions (v1.2)
Each was chosen against an alternative that will be proposed again; the refusal is the part worth keeping.
| # | Decision | Alternative refused, and why |
|---|---|---|
| D1 | Permissions upload:files, read:files, retain:files, purge:files; the platform permission is the existing admin:system | The files:* spelling of v1.1 — PermissionCodeParser reads action:resource, resource in the plural, so it would never have parsed; and a new platform permission, which would be a second way of saying admin:system |
| D2 | sha256 nullable, and two partial unique indexes (tenant, and global for product files) | UNIQUE NULLS NOT DISTINCT (PostgreSQL 15+): it would treat every Pending record without a hash as equal and make concurrent uploads collide |
| D3 | A deduplicated record has objectKey, bucketName and storageProvider null; invariant canonicalFileId IS NULL ⟺ objectKey IS NOT NULL | Copying the canonical's key into the duplicate, or keeping the deleted object's own key — a lie on the record that only fails in production, the day somebody follows it |
| D4 | New field rejectionCode for the asynchronous type rejection | A Rejected state in the lifecycle — too expensive (every switch, every job, every screen) for a case the tombstone already covers |
| D5 | Extend the Core presigned request with ResponseContentDisposition / ResponseContentType; the defence is attachment + application/octet-stream | Requiring X-Content-Type-Options: nosniff on the presigned URL — impossible: S3 accepts a closed set of response-header overrides and nosniff is not in it. It is a bucket/CDN recommendation |
| D6 | New route GET /files/scanner-status, the only producer of FILE_SCAN_UNAVAILABLE (503) | Leaving the 503 code in the catalogue with no caller — no route depends on the scanner synchronously, so any other 503 would have been invented |
| D7 | Content-type sniffing is unconditional, in three classes (known signature; ZIP container read through [Content_Types].xml; text/* validated as text, no NUL). ScanRequired: false waives the antivirus and nothing else | Skipping the type check together with the antivirus — a report renderer that emits an executable under a .pdf name would go straight to Available |
| D8 | New field lastReferenceReleasedAt and the index (status, referenceCount, lastReferenceReleasedAt) | MAX(releasedAt) over FileReference on every run of the job — the cost the denormalised referenceCount exists to avoid |
| D9 | QuarantineRetentionDays: 365, global, never per profile | A quarantine window per profile — an infected file is not more or less evidence depending on the profile it was uploaded under |
| D10 | (superseded — see below) Legacy infected attachments would not block the GrydNotifications migration | Blocking the migration, which would keep the infected content inside the bytea column indefinitely |
| D11 | The GrydReports download breaks its contract (stream → URL), with no shim | A compatibility layer that proxied the bytes — it would violate RN-GF-06 and would never be removed |
| D12 | UseDefaultProfile removed; the framework declares only gryd.report and gryd.notification-attachment; product profiles come from the product | An implicit default profile — an upload without a profile would land on a silent limit nobody chose |
| D13 | clamd.conf: StreamMaxLength/MaxFileSize 64M, MaxScanSize 400M, MaxRecursion 16, MaxFiles 10000 | Keeping clamd's default of 25M — exactly the largest profile (26 214 400 bytes), with no margin for the INSTREAM framing |
| D14 | Retry backoff 1 / 5 / 15 min, scan timeout 120 s | "Increasing wait" without numbers — a decision postponed to the day of an incident |
| D15 | appsettings is the single source; clamd.conf is generated from GrydFiles:Scanner; an incoherence is fail-fast at start-up | Two files kept by hand with a warning in the log — they drift, and the engine then ignores part of the content while answering OK |
| D16 | purgedBy is null on an automated purge; the origin is in purgeReason (job:retencao, job:pending-expirado, job:quarentena-vencida) | A fixed "system user" GUID — the framework has no such concept, and it would put a phantom user in the audit trail |
Antivirus alternatives evaluated and refused
- GuardDuty Malware Protection for S3. It protects S3 buckets only, would tie the framework to AWS, and is billed per GB and per object. Its quotas were recorded: 100 GB per object, 100 000 extracted files, 100 levels of nesting and 25 buckets per account and region. It stays a candidate as an alternative
IFileScannerprovider. - A scanning API service. A cost per call and, decisively, the file leaves the platform's network.
- An ICAP appliance. Sensible where one already exists; that case is served by swapping the
IFileScannerimplementation, not by building on it. - Not scanning. Refused.
Where implementation departs from the v1.2 text
These supersede the specification. The specification carries an errata box that points here.
- D2 — the index predicate is
"Status" = 'Available', notstatus <> 'Purged'.MarkInfectedalso writes the hash, so the printed predicate put two quarantines of the same malware in one tenant into the same index and the second one failed with a unique violation nothing caught — a record stuck inScanningand the scan job failing forever.Availableis exactly the set the dedupe query reads; no other state is ever a canonical. - D4 —
rejectionCodeholds a code of the §16 catalogue (FILE_CONTENT_TYPE_NOT_ALLOWED_UNPROCESSABLE,FILE_SIZE_MISMATCH_UNPROCESSABLE). TheTYPE_MISMATCH/TYPE_NOT_ALLOWED/TYPE_UNDETECTABLEvocabulary some stories used does not exist, and a test keeps it out. The 422 status is answered only on the synchronousPOST /files; the asynchronous rejection puts the same code on the record. - D7 — the type-rejected tombstone keeps the
sha256. The type is only judged after the whole content was read, and that read produced the digest; it never meets the dedupe indexes. - D10 — dropped. GrydFiles had no consumer in production, so there was no data to migrate: the migrations of GrydFiles, GrydReports and GrydNotifications were regenerated as one initial migration each, and the batch migration, the infected-attachments report and the
migrados + infectados = origemcheck left the acceptance criteria (decision of 2026-09-10). - D14 — four passes: the first, then waits of 1, 5 and 15 minutes, one
FileScanAttemptrow per pass. It is the only reading in which "about twenty-one minutes" adds up. - D16 — two more reasons.
job:tipo-reprovado(the asynchronous type rejection, automated) andrejeicao:tamanho-divergente(the size mismatch found by an authenticated confirmation, so it names its author).purgedByis filled if and only if the reason does not start withjob:. - Jobs. The scan job runs on demand; five jobs are recurring: expired-upload sweep, retention purge, reference reconciliation, the weekly consistency sweep — which also purges expired quarantine (
job:quarentena-vencida) — and the scanner health monitor.[DisableConcurrentExecution]is inert under GrydJobs (Hangfire schedules an adapter, not the job), so exclusivity comes fromFOR UPDATE SKIP LOCKED. - One destruction path. Route, retention and quarantine purges all go through
FilePurger, and the bytes of a deduplicated set die only with the last member that is not purged, always through the owner's preserved location. - Limits that are not exceeded silently. Without
AlertExceedsMax, clamd answersstream: OKfor an archive that exceedsMaxRecursionorMaxFiles— it stops unpacking and releases the file.GrydFiles:Scanner:AlertOnExceededLimits(defaulttrue) renders that directive, and the result maps toError, never toInfectedand never toClean. - Product files.
admin:systemis required to reserve, change the retention of and purge a file with no tenant; reading it needs onlyread:files. - In-process consumers. GrydReports and GrydNotifications store and read content through
IServerFileStore(explicit tenant, no route, no download trail), and declare the reference they expect on the record; the scan job materialises it in the same write that releases the file.
Consequences
- Release dependency on the framework. The engine and the scanning policy ship with the platform's release cycle; a signature-database or clamd upgrade is a platform change.
- clamd memory. At least 3 GiB, preferably 4 GiB, and a concurrent database reload can double the resident size; the container limit is 4 GiB and liveness is
PING/PONG. - Operational cost of the daemon. A service of its own with 1..N replicas,
freshclamon a volume shared with the_baseimage, and two alert signals: the scan queue above its threshold or older than its maximum age, and a signature database older than 48 hours. - Unavailability never degrades into release. A stopped queue is the accepted price; files wait.
- Generated, not hand-written, configuration (D15).
clamd.confis rendered fromappsettings, and an incoherence betweenStreamMaxLengthand the largest profile ceiling fails the start-up. - A false positive has no button. The way out is a new upload after the signature is fixed, or the quarantine window.
- Ten error suffixes in the Core.
_UNAVAILABLE(503) is the one this module added — the only change to a shared contract, together with the two fields of the presigned request (D5). - A breaking change for GrydReports consumers (D11).
GET /reports/{id}/downloadanswers{url, expiresAt}; the next tag is a major version. - Documentation is part of the surface. As ADR 0009 requires, the module's public contract is documented in the same change that introduces it.