Skip to content

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 ​

  1. Layer. The module knows the file and never the domain. StoredFile is bytes and metadata; attachment types, visibility and propagation between documents belong to the consuming product. That ignorance is what makes it reusable.
  2. 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}/complete rather 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.
  3. Quarantine before reference. Nothing is referenceable before it has been scanned: Pending → Scanning → Available, with Infected and Failed as exits. There is deliberately no "released manually" state, no flag and no permission — admin:system included — that reaches Available without a verdict. Infected content is moved under _quarantine/, never deleted, because deleting it destroys the evidence of the incident.
  4. sha256 computed 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.
  5. 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.
  6. 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).
  7. 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 ISoftDeletable and with audit.
  8. 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.

#DecisionAlternative refused, and why
D1Permissions upload:files, read:files, retain:files, purge:files; the platform permission is the existing admin:systemThe 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
D2sha256 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
D3A deduplicated record has objectKey, bucketName and storageProvider null; invariant canonicalFileId IS NULL ⟺ objectKey IS NOT NULLCopying 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
D4New field rejectionCode for the asynchronous type rejectionA Rejected state in the lifecycle — too expensive (every switch, every job, every screen) for a case the tombstone already covers
D5Extend the Core presigned request with ResponseContentDisposition / ResponseContentType; the defence is attachment + application/octet-streamRequiring 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
D6New 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
D7Content-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 elseSkipping the type check together with the antivirus — a report renderer that emits an executable under a .pdf name would go straight to Available
D8New 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
D9QuarantineRetentionDays: 365, global, never per profileA 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 migrationBlocking the migration, which would keep the infected content inside the bytea column indefinitely
D11The GrydReports download breaks its contract (stream → URL), with no shimA compatibility layer that proxied the bytes — it would violate RN-GF-06 and would never be removed
D12UseDefaultProfile removed; the framework declares only gryd.report and gryd.notification-attachment; product profiles come from the productAn implicit default profile — an upload without a profile would land on a silent limit nobody chose
D13clamd.conf: StreamMaxLength/MaxFileSize 64M, MaxScanSize 400M, MaxRecursion 16, MaxFiles 10000Keeping clamd's default of 25M — exactly the largest profile (26 214 400 bytes), with no margin for the INSTREAM framing
D14Retry backoff 1 / 5 / 15 min, scan timeout 120 s"Increasing wait" without numbers — a decision postponed to the day of an incident
D15appsettings is the single source; clamd.conf is generated from GrydFiles:Scanner; an incoherence is fail-fast at start-upTwo files kept by hand with a warning in the log — they drift, and the engine then ignores part of the content while answering OK
D16purgedBy 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 IFileScanner provider.
  • 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 IFileScanner implementation, 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', not status <> 'Purged'. MarkInfected also 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 in Scanning and the scan job failing forever. Available is exactly the set the dedupe query reads; no other state is ever a canonical.
  • D4 — rejectionCode holds a code of the §16 catalogue (FILE_CONTENT_TYPE_NOT_ALLOWED_UNPROCESSABLE, FILE_SIZE_MISMATCH_UNPROCESSABLE). The TYPE_MISMATCH / TYPE_NOT_ALLOWED / TYPE_UNDETECTABLE vocabulary some stories used does not exist, and a test keeps it out. The 422 status is answered only on the synchronous POST /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 = origem check left the acceptance criteria (decision of 2026-09-10).
  • D14 — four passes: the first, then waits of 1, 5 and 15 minutes, one FileScanAttempt row 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) and rejeicao:tamanho-divergente (the size mismatch found by an authenticated confirmation, so it names its author). purgedBy is filled if and only if the reason does not start with job:.
  • 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 from FOR 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 answers stream: OK for an archive that exceeds MaxRecursion or MaxFiles — it stops unpacking and releases the file. GrydFiles:Scanner:AlertOnExceededLimits (default true) renders that directive, and the result maps to Error, never to Infected and never to Clean.
  • Product files. admin:system is required to reserve, change the retention of and purge a file with no tenant; reading it needs only read: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, freshclam on a volume shared with the _base image, 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.conf is rendered from appsettings, and an incoherence between StreamMaxLength and 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}/download answers {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.

Updated at:

Released under the MIT License.