Especificação · Plataforma Gryd.IO

GrydFiles

Este documento não é do Nexio. Especifica um módulo novo do Gryd.IO — por isso a cor muda. O Nexio é o primeiro consumidor, não o dono. O Gryd.Infrastructure já sabe falar com S3, Azure Blob e Google Cloud Storage, e já emite URL pré-assinada de upload e de download; o que não existe em lugar nenhum do framework é registro do arquivo: quem subiu, que hash tem, se passou pelo antivírus, quem ainda aponta para ele e quando ele pode morrer. Sem ele, cada módulo guardaria arquivo do seu jeito — URI em string, byte[] numa coluna. O GrydFiles fecha essa lacuna uma vez, e o GrydReports e o GrydNotifications já o consomem.

depende de Gryd.Application.Abstractions.Storage · GrydJobs · GrydAudit consumidores GrydReports · GrydNotifications · Nexio.Attachment 4 entidades · 1 serviço · 7 jobs · 1 provedor de scan v1.16 · 02/11/2026 · 16 decisões de detalhamento · conformidade atualizada para o Gryd.IO 11.1.0 · status dos subitens

Ponto de partida

Nove decisões

Decisão 1 · Camada

O módulo conhece o arquivo e nunca o domínio. StoredFile é binário e metadado; anexo, tipo de anexo, visibilidade e propagação entre documentos ficam no produto que consome. O GrydFiles não sabe o que é uma requisição, e é essa ignorância que o torna reusável. A mesma linha separa, do lado do Nexio, o StoredFile do Attachment.

Decisão 2 · Trânsito

O binário nunca passa pela API. Subida e descida por URL pré-assinada, direto no bucket. A API concede a permissão e recebe a confirmação. Passar 100 MB pelo processo é pagar memória, egress duas vezes e o timeout do request — e nenhuma das três compra nada.

Decisão 3 · Quarentena

Nada é referenciável antes de ser escaneado. O estado vive no registro, não no bucket: Pending → Scanning → Available, com Infected e Failed como saídas. Arquivo infectado não é apagado — vai para um prefixo de quarentena com acesso restrito, porque apagar destrói a evidência do incidente.

Decisão 4 · Identidade

A identidade do conteúdo é o sha256, calculado no servidor. Nunca aceito do cliente. O nome original é metadado de exibição e não entra na chave do objeto — nome de arquivo carrega acentuação, colisão, path traversal e, com frequência, dado pessoal.

Decisão 5 · Referência

Quem aponta para o arquivo se registra. FileReference guarda ownerScope + ownerKey em texto — o mesmo polimorfismo que o AuditLog do framework já pratica. Sem esse registro não existe resposta para "este blob ainda serve para alguma coisa?", e sem essa resposta não existe expurgo seguro.

Decisão 6 · Retenção declarada

Ninguém sobe arquivo sem dizer por quanto tempo ele fica. A retenção vem no upload, do consumidor. O módulo não sabe que documento fiscal se guarda por cinco anos — o Nexio sabe. O que o módulo garante é que a pergunta foi feita e a resposta está gravada.

Decisão 7 · Expurgo com lápide

O objeto morre; o registro fica. Expurgar apaga o blob e preserva hash, tamanho, tipo, datas e quem pediu. Atende ao apagamento da LGPD sem quebrar o ISoftDeletable do framework e sem perder a prova de que o arquivo existiu — que é exatamente o que uma auditoria pergunta depois.

Decisão 8 · Antivírus

ClamAV, em serviço próprio, com o provedor plugável. É o único caminho que funciona igual nos três provedores de storage, não cobra por GB nem por objeto, e mantém o arquivo dentro da rede. Quem quiser trocar por GuardDuty ou por serviço pago troca a implementação de IFileScanner, registrada por UseScanner<T>() — a decisão está isolada atrás de uma interface.

Decisão 9 · Liberação

A referência nasce na liberação, não na confirmação. Quem consome declara, no processo e só sobre o registro que o mesmo chamador reservou (RN-GF-22), a quem o arquivo vai pertencer — a referência esperada expectedOwnerScope · expectedOwnerKey —, e é a saída para Available que cria a FileReference, na mesma transação da transição. Depois do commit, o veredito chega ao consumidor pelo contrato de veredito. Veredito negativo não cria referência nenhuma: o consumidor recebe que o arquivo não serve e descarta o vínculo que já tinha mostrado na tela. Sem esta decisão, o consumidor precisava criar a referência sobre Scanning — e a RN-GF-01, que é a invariante central do módulo, seria falsa no passo normal do único caminho que existe.

Por que isso não nasce dentro do Nexio. Guardar arquivo com hash, antivírus e retenção não tem nada de específico de compras. Nascendo no Nexio, o próximo produto do Gryd reescreve tudo — e cada módulo da plataforma volta a guardar arquivo do seu jeito, URI em string ou byte[] no banco. O custo de subir para a plataforma é real e tem nome: o Nexio passa a depender de um release do framework, e a Requisição não começa antes da v1 deste módulo existir.

Escopo

A fronteira

A pergunta que decide cada campo é sempre a mesma: isso continuaria verdade num produto que não é o Nexio? Tamanho, tipo, hash, vírus e retenção continuam. Anexo de requisição, visibilidade para o fornecedor e propagação do pedido para o recebimento não continuam.

O que fica de cada lado
AssuntoGrydFilesConsumidor (ex.: Nexio)
Bytes, bucket, chaveSim — é a razão de existirNunca vê objectKey nem nome de bucket
Hash, tamanho, tipoCalcula e validaDeclara o esperado e recebe o verificado
Antivírus e quarentenaSim, com o resultado no registroSó lê status
Quanto tempo guardarExecuta e cobra a declaraçãoDecide — é regra de negócio e de lei
Quem pode subir e baixarPermissão genérica do móduloPermissão de domínio, antes de chamar
O que o arquivo éNão sabe e não quer saberAttachment, ItemDocument, relatório, planilha de importação
Visibilidade para terceiroFora — o módulo só emite URL curta para quem já foi autorizadovisibility = Internal | Supplier vive no anexo
Vínculo com o donoFileReference em texto opacoSabe traduzir ownerKey de volta em documento

Reaproveitamento

O que já existe no Core

Metade deste módulo é composição, não construção. Gryd.Application.Abstractions.Storage e Gryd.Infrastructure.Storage já entregam o acesso ao objeto; o GrydFiles é a camada de registro por cima.

PeçaOndeO que o GrydFiles faz com ela
IObjectStorageServiceGryd.ApplicationUploadAsync, OpenReadAsync, DeleteAsync, ExistsAsync e GeneratePresignedUrlAsync — usado sem envelope novo. Na 10.2.0, OpenReadAsync faz streaming nos três provedores: o stream é só para frente, segura a conexão até o descarte e a queda na leitura vira IOException
ObjectStorageSignedUrlOperationGryd.ApplicationUpload e Download. É o que torna a decisão 2 possível sem escrever nada de provedor
ObjectStoragePresignedUrlRequestGryd.ApplicationJá expõe a sobreposição de cabeçalho de resposta — ResponseContentDisposition e ResponseContentType, implementados nos três provedores —, que é o que faz o download carregar o nome original. Ver § Download
ProvedoresGryd.InfrastructureAmazonS3 · AzureBlobStorage · GoogleCloudStorage, resolvidos por IObjectStorageProviderResolver. O ServiceUrl + ForcePathStyle do S3 cobre MinIO e R2
ObjectStorageOptionsseção GrydStorageBucket, região, credencial e DefaultPrefix. O GrydFiles acrescenta a seção GrydFiles com os perfis, e não duplica nada de provedor
ObjectStorageObjectDescriptorGryd.ApplicationDevolve SizeBytes e ContentType reais — é com ele que a confirmação do upload confere o que o cliente declarou
Padrão do GrydReportsModules/ReportsConfigurableReportFileStore (local × nuvem) e ReportFilePathGenerator (tenant/aaaa/MM/dd/id.ext) são o molde da chave e do modo local de desenvolvimento
GrydJobsModules/JobsSete jobs sobre o Hangfire já configurado (IRecurringJob + RecurringJobRegistrationService): scan; varredura de Pending vencido, que também devolve à fila o arquivo parado (RN-GF-19); expurgo por retenção; varredura de consistência com expurgo de quarentena; reconciliação de referenceCount; monitor do scanner, que alerta por fila acima do limite e por base de assinaturas velha; e re-scan de acervo depois de atualização da base. Exclusividade pela trava de linha que pula as já travadas na seleção de candidatos (em Postgres, FOR UPDATE SKIP LOCKED; em outro banco, o equivalente) — [DisableConcurrentExecution] não tem efeito sob o GrydJobs, que agenda um adaptador e não o job (ADR 0010 do Gryd.IO, l.117; escrito em 19c2e8b2 e presente no HEAD pelo squash d9781a63)
IAuditStoreGryd.ObservabilitySaveAsync, chamado na entrega da trilha do framework para o que não é mudança de linha: emissão de URL de download, expurgo, quarentena e ligar e retirar guarda, com o tipo de autor. A linha é preparada na transação do ato por IAuditTrail e IPlatformAuditTrail e entregue depois do commit. O padrão registrado é NullAuditStore, e o host que compõe o módulo com ele não sobe; o armazenamento durável é o do GrydAudit ou um IAuditStore próprio

O que o framework não tinha antes deste módulo — e é a lista de entregas deste documento: entidade de arquivo, sha256, antivírus, validação por assinatura de conteúdo, retenção com expurgo e contagem de referência. Boa parte já está em código; o que está e o que falta, item a item, está na seção seguinte.

Conformidade

Estado da implementação

O registro, o antivírus, a porta de servidor, a referência esperada, a leitura que falha fechada sem tenant, o contrato de veredito, a posse da referência (também na leitura em processo), a guarda por detentor com autor declarado marcado, o escopo do consumidor registrado, a dupla custódia na eliminação de Permanent, a integridade do objeto servido, o re-scan e as regras de fila, retenção e expurgo estão em código. O módulo vive em src/Modules/Files do Gryd.IO. A conferência abaixo foi refeita em 25/10/2026 na branch de integração feature/grydfiles-conformidade (commit be907d37, depois das GF-01 a GF-22) e atualizada em 02/11/2026 para o Gryd.IO 11.1.0, e substitui a de 23/10 sobre o develop d6ecf107, que apontava 21 linhas não conformes. Cada linha cita o código e os testes nomeados pela RN. Não há linha "Divergente" nem "A implementar": o que não é plenamente conforme está marcado como Conforme, com limitação, com a decisão (DEC-…) ou a pendência que o justifica; as decisões vivem na seção 10 do backlog/backlog-grydfiles-conformidade.md do Gryd.IO. Os números de linha da versão anterior foram retirados porque o código andou; o nome do arquivo e do teste bastam para achar o ponto.

Atualizando para a v11.0.0. A tag v10.2.0 não existe: o que esta página chama de 10.2.0 saiu na v11.0.0.

  • O host sem store durável não sobe, em qualquer ambiente. Quem compõe AddGrydFiles precisa de um IAuditStore durável (AddGrydAudit ou um próprio); com o NullAuditStore a subida falha, e o download sem trilha durável é recusado (FILE_AUDIT_TRAIL_UNAVAILABLE).
  • Sobrecargas com o tenant nomeado. IFileStore<TScope> ganha GetAsync, AddReferenceAsync e UpdateRetentionAsync com Guid? tenantId (RN-GF-08), que funcionam sem request. As assinaturas existentes não mudam.
  • RescanAsync exige autor. Sem usuário no contexto responde FILE_CALLER_UNAUTHORIZED; o re-scan automático não passa por esse caminho.
  • Catálogo contribuído (GrydAuth). Um produto soma permissões e papéis-semente por IAuthorizationCatalogContributor; o host chama ReconcileAuthorizationCatalogAsync depois das migrations. POST /permissions e a edição recusam nome exclusivo de plataforma.
  • CNPJ. Gryd.Core.Documents.Cnpj normaliza, valida, formata e mascara o CNPJ numérico e o alfanumérico; SensitiveDataMasker.MaskCnpj delega a ele.
Código × spec, item a item
ItemNo códigoSituaçãoBloqueia
EntidadesStoredFile : BaseEntity, IOptionalTenant com ExpectedOwnerScope/ExpectedOwnerKey (a chave é token de concorrência); FileReference; FileScanAttempt; FileLegalHold (uma linha por detentor, tabela file_legal_holds, o armazenamento que a seção Campos deixa à implementação); GrydFilesDbContext no esquema files e oito migrations (inicial, índices únicos de soft delete, Files_GF_02_RemoveDedupe, Files_GF_08_ActorAndOutbox, Files_GF_15_LegalHoldHolders, Files_PlatformOutboxScope, Files_ExplicitOwnerRouting, OutboxOperations). Testes: SchemaTests, MigrationTests, StoredFileTests, FileOutcomeDeliveryTests (token de concorrência)Conforme—
PermissõesSeis de tenant — upload:files, read:files, retain:files, purge:files, release-hold:files, purge-permanent:files — e quatro de plataforma sobre arquivo de produto, com read:product-files (FilePermissions.cs; AuthorizationCatalogDefinitions.cs); nenhuma em template de role. RN-GF-23 (c): a opção GrydAuth:Roles:TenantAssignable:Deny recusa as permissões negadas ao compor papel (POST /roles, PUT e POST de permissão de papel), ao atribuir papel, na concessão direta e na vinculação de usuário, e derruba a subida se um template do seed as concede (DEC-GF-07-1/2/3). Testes: TenantAssignableDenyTests.Rn_gf_23_c_*, TenantAssignabilityGuardTests, TenantAssignableOptionsValidatorTests, PermissionCatalogTestsConforme, com limitação: a opção é de adesão (vazia por padrão — o produto que autoriza pelo dono a declara no appsettings) e age ao conceder, sem limpar papel composto antes de ligá-laO produto que autoriza pelo dono (Nexio) liga a opção antes de abrir /files/*
Permissão em processoA conferência de ISessionGrants saiu dos casos de uso: só GrydFiles.API a lê (HttpFileAuthorization, FilePermissionAuthorizationHandler); IFileStore<TScope> e IServerFileStore<TScope> chamam os casos de uso sem conferir permissão (D-01, RN-GF-28 (c)). Testes: ScopedPortConsumersTests.D_01_*, HttpFileAuthorizationTests, Rn_gf_28_c_*Conforme—
AntivírusO host não sobe sem motor (FileScannerRequirement); UseScanner<T>() e AddGrydFilesClamAv registram o motor sob VerifiedFileScanner, que submete a amostra EICAR (gerada em memória) antes de aceitar a palavra do motor; o ScannerHealthMonitorJob força a verificação a cada 5 minutos, alerta e deixa a saúde NotDetectingTestSample; motor que não detecta não libera arquivo (Failed e nova tentativa). Testes: VerifiedFileScannerTests.Rn_gf_05_b_*, ScannerHealthMonitorJobTests, AddGrydFilesTests ((a) na subida), EngineVerificationTests (clamd real)Conforme: o módulo consome só IVerifiedFileScanner, o motor fica sob chave e um IFileScanner registrado direto no contêiner derruba a subida—
URL de downloadResponseContentDisposition (attachment; filename*=UTF-8''…) e ResponseContentType (application/octet-stream) nos três provedores; a linha da trilha é preparada na transação e a emissão exige usuário (FILE_CALLER_UNAUTHORIZED). Testes: IssueDownloadUrlHandlerTests, FileDownloadHeadersTests, PresignedResponseHeaderOverrideTestsConforme—
Porta de servidorIServerFileStore<TScope> (StoreAsync, OpenReadAsync(fileId, tenantId, ownerKey), ReleaseAsync); consumidores declarados em DeclaredServerPortConsumers: seis — GrydReports.Application/.Infrastructure, GrydNotifications.Application/.Infrastructure, GrydAudit.Export e GrydAudit.Integrity (DEC-GF-06-1). Testes: ServerPortConsumersTests.Rn_gf_06_b_*, ServerContentStaysOffTheSurfaceTests (a)Conforme (a spec cita quatro assemblies: corrigir)—
Mapeador de errosO GrydBaseController testa primeiro os códigos explícitos e depois os sufixos (_UNAVAILABLE → 503; hoje são onze, com _PRECONDITION_FAILED → 412 do SSO, que nenhum código de arquivo usa). Teste: FileErrorStatusRoutingTestsConforme—
Referência na liberaçãoStoredFile.MarkClean cria a FileReference da referência esperada e a zera na mesma transação da liberação; CompleteUploadRequest segue sem campos (o dono viaja pela porta: CompleteUploadAsync(fileId, ownerKey)). Testes: StoredFileExpectedReferenceTests, CompleteUploadHandlerTests, RecordVerdictStepTestsConforme—
Posse da referênciaCreatedBy gravado na reserva; ExpectReference só sobre Pending do mesmo chamador; MayBeReferencedBy na referência direta (reservou, ou propagação nomeando referência ativa do mesmo escopo); segundo dono → FILE_REFERENCE_NOT_ALLOWED_CONFLICT, nunca 500; o GrydNotifications recusa anexo de fileId que o remetente não subiu (DEC-GF-14-1/3/5/6/7). Testes: Rn_gf_22_a…f em CompleteUploadHandlerTests, ReferenceHandlersTests, ServerFileStoreTests, ReferenceOwnershipTests, NotificationAttachmentsTestsConforme—
Leitura em processoOpenReadAsync(fileId, tenantId, ownerKey) só devolve bytes ao par com referência ativa; o tenant nomeado é o tenant em vigor (diferente → FILE_NOT_FOUND, nada lido) e, sem tenant em vigor, o escopo do nomeado é aberto; EfStoredFileStore.InTenant não usa IgnoreQueryFilters, e a exceção da asserção 9 saiu (DEC-GF-05-1/2, GF-14). Testes: ServerFileStoreTests.Rn_gf_08_*/Rn_gf_22_e_*, NamedTenantTests, NamedTenantStoreTestsConforme—
Rotas HTTP de referênciaPOST/DELETE /files/{id}/references só agem sobre escopo que o consumidor declarou com ManagedThroughHttpSurface() — nenhum por padrão, e nenhum consumidor do repositório o declara (DEC-GF-16-2/3); as demais recebem FILE_OWNER_SCOPE_FORBIDDEN (403), na criação e na liberação, e a criação obedece à posse. Testes: ReferenceRouteScopeTests (RN-GF-23 a e b), ReferenceSurfacePolicyTestsConforme—
Usuário na IFileServiceFileActorPolicy: reservar, confirmar, emitir URL, expurgar e mexer em guarda exigem usuário ou autor declarado, senão FILE_CALLER_UNAUTHORIZED (401) antes de qualquer efeito; GetAsync não exige usuário; nenhum InvalidOperationException de usuário ausente; ligar e retirar guarda gravam na transação. Testes: Rn_gf_26_* (um por operação), FileActorPolicyTestsConforme—
Guarda por detentor e autor declaradoTabela file_legal_holds; legalHold derivado das linhas; detentor platform (rota) ou o escopo do consumidor (porta); PUT /files/{id}/retention com reason; retirar da plataforma exige release-hold:files e trilha durável, e guarda só de consumidor → 403; autor declarado marcado, com escopo e executor; trilha na transação; guarda só em Available e Infected (DEC-GF-15-x, DEC-GF-18-1). Testes: LegalHoldHandlersTests (RN-GF-27 a, b, e, f, h, i), UpdateRetentionHandlerTests (c, g), LegalHoldHoldersTests, FileAuditOutboxTests (j); o (d) é o Rn_gf_25_g da eliminaçãoConforme. Arquivo de produto (sem tenant) grava a linha na transação com a rota de plataforma; sem a adesão, ligar guarda grava sem linha de trilha e retirar responde 503 (DEC-GF-15-5)O host adere com AddGrydFilesPlatformOutbox e AddGrydFilesPlatformAudit
Escopo do consumidorAddGrydFilesConsumer<TScope>, IFileConsumerRegistry, IFileStore<TScope>/IServerFileStore<TScope> sem parâmetro de escopo; escopo sem registro → FILE_OWNER_SCOPE_FORBIDDEN; o portão FileScopeGate confere, a cada chamada, que o chamador referencia o assembly do tipo-marca (DEC-GF-09-1); dois marcadores no mesmo escopo, e o escopo platform, derrubam a subida. Testes: FileConsumerPortsTests, FileScopeGateTests, ScopedPortConsumersTests, AddGrydFilesTestsConforme (a RN-GF-28 (e) da spec diz "falha na resolução": o código falha na primeira chamada, DEC-GF-09-1 — corrigir o texto)—
Leitura sem tenantO filtro do Core falha fechado; o FileScanJob abre o escopo de sistema só em SelectScanCandidateStep e trata cada registro no tenant dele (OpenInTenantStep), com o filtro ligado; e as varreduras recorrentes (expurgo por retenção, consistência e expiração da quarentena, reconciliação, Pending vencido, fila parada e re-scan de acervo) fazem o mesmo, por um colaborador só (CandidateScopes): a seleção é a única leitura com o filtro desligado — fechada antes de qualquer efeito, uma linha na trilha de escopo por página — e cada registro é travado, lido, gravado e entregue ao storage ou à fila no tenant dele, com o filtro ligado; o arquivo de produto (sem tenant) no escopo global; o candidato que a leitura filtrada não enxerga no tenant dele não é tratado e é registrado (log e gryd_files.sweep.candidate_unreadable), sem travar a varredura. A leitura sob a trava (EfStoredFileLock) deixou de usar IgnoreQueryFilters, e a lista de exceções (build/ignore-query-filters-exceptions.txt) encolheu em seis entradas. Testes: OpenInTenantStepTests, NamedTenantTests, SweepsInTheTenantOfTheRecordTests (RN-GF-08, um por varredura, com dois tenants e um arquivo de produto), ArchiveRescanJobTests.Rn_gf_08_*, CandidateScopesTestsConforme (DEC-GF-21-1)—
Contrato de vereditoIFileOutcomeHandler obrigatório no registro; o registro do veredito (tenant, dono, desfecho) é gravado na transação de toda transição de veredito, pela outbox do módulo, e entregue depois do commit, pelo menos uma vez, com o tenant restaurado e sem usuário; FileOutcomeFlow concentra a sequência dos handlers. Testes: FileOutcomeDeliveryTests (RN-GF-13 a a g), RecordVerdictStepTests, os quatro …OutcomeHandlerTests, ReportsConsumerTestsConforme. Arquivo de produto (sem tenant) usa o contrato v2 (IFileOutcomeV2Handler), com destino persistido do dono, por adesão (AddGrydFilesPlatformOutcomes e .ProductFileOutcomes<T>()); sem adesão, e em dono legado Unknown, o veredito é aplicado e o dono não é avisado (DEC-GF-13-1; contador gryd_files.scan.verdict_undeliverable). O host precisa compor AddGrydFilesOutbox — sem ele não sobe, com a mensagem dizendo o que compor (FilesOutboxRequirement; teste AddGrydFilesTests.Rn_gf_13_a_module_staged_into_an_outbox_nobody_delivers_stops_startup; o AdminDevHost o compõe) — e AddOutboxWorker no processo que drena: a falta dele o módulo não pode recusar (API só produtora é legítima), então o processo avisa no log na subida e publica o gauge gryd_files.outbox.drain_in_process (1 onde o worker roda, 0 onde não)O host adere à rota de plataforma; o produto reconcilia os donos Unknown
RotasOnze rotas em FilesController: POST /files, POST …/complete, GET /files/scanner-status, GET …/{id}, GET …/download-url, GET …/scan-attempts, POST e DELETE …/references, PUT …/retention, POST …/rescan, POST …/purge. Testes: RouteAuthorizationTests, FilesControllerRouteTests, RoutePermissionTests. O OpenApiDocumentTests fixa o 503 só no status do scanner (D6): o 503 do expurgo e da retirada de guarda está documentado em api-reference, não no OpenAPIConforme—
JobsSeis recorrentes (fila files): varredura de Pending vencido, que também devolve à fila o arquivo parado (a cada 5 min); monitor do scanner (5 min); re-scan de acervo (15 min); reconciliação de referenceCount (03:00); expurgo por retenção (04:00); varredura de consistência com quarentena (domingo 05:00) — mais o scan sob demanda (pipeline de etapas). Testes: AddGrydFilesTests (registro), um …JobTests por job, FileScanPipelineTestsConforme (sete jobs, como a spec)—
Integridade do objeto servidoURL de upload assinada só para a chave de trânsito _transit/…, com o tamanho onde o provedor assina; o scan promove o objeto para a chave final com CopyAsync condicionado à versão descrita; leitura limitada a sizeBytes + 1; objeto alterado → Purged com FILE_OBJECT_CHANGED_UNPROCESSABLE e job:objeto-alterado; no re-scan de Available, Infected, com evidência copiada na mesma condição e retainUntil intacto; leitura cortada por E/S → tentativa com erro. Testes: ObjectServedIsObjectScannedTests (a a d), RescanTests (e, f), PromoteFromTransitStepTests, ReadIntegrityStepTests, ScanPipelineStreamTests, SignedUploadAndConditionalCopyContractTests (S3, GCS e Azure)Conforme, com limitação de prova: os critérios (a) a (f) rodam ponta a ponta no MinIO/S3; Azure e GCS são provados pelos testes de contrato dos adaptadoresA regra de ciclo de vida de _transit/ (expirar em um dia) é do bucket do host
RetençãoPurgeEligibility é a única regra do expurgo automático (uma expressão: o banco a traduz para selecionar e a forma compilada reavalia sob a trava); carência também em RetainUntil, contada da última liberação; retainUntil é piso. Testes: PurgeEligibilityTests (RN-GF-14 a, b, c), RetentionPurgeJobTests, PurgeEligibilityAgreementTests (SQL × memória)Conforme—
Quarentena e guardaA varredura de consistência expurga Infected só depois do mais tarde entre QuarantineRetentionDays e retainUntil, nunca com guarda nem em Permanent; Available → Infected libera as referências sem tocar em retenção nem guarda. Testes: PurgeEligibilityTests, ConsistencySweepJobTests, StoredFileRescanTests, ReleasedFileRescanTests (RN-GF-24 a, b, c)Conforme—
Expurgo manual antes da dataPurgeFileRequest.Erasure; o expurgo ordinário respeita o piso (FILE_RETENTION_NOT_EXPIRED_CONFLICT, DEC-GF-19-9); a eliminação alcança também Infected e Failed, apaga o nome, tem dupla custódia em Permanent e em arquivo de produto em processo, autores conferidos por IFileActorDirectory (adaptador no host), lista de pares liberada na transação, consulta de pré-condição em dois modos (CheckPurgeAsync) e tentativa recusada na trilha. Testes: ErasureTests (Application: RN-GF-25 a a s, com Rn_gf_25_i_the_query_answers_what_the_purge_answers; integração)Conforme, com limitações justificadas: a tentativa recusada só vai à trilha quando a recusa é sobre o que prende o arquivo ou é a mesma pessoa nos dois papéis da dupla custódia (DEC-GF-19-4, DEC-GF-19-11 — a (f) é exceção da (q)); a guarda é conferida antes da referência (DEC-GF-19-7); o expurgo de arquivo de produto pela rota responde 503 só sem a rota durável de plataforma (DEC-GF-08-1); sem adaptador de IFileActorDirectory no host, toda eliminação em processo responde 422O host registra o adaptador de autores (existe só no AdminDevHost)
DedupeRetirado: nenhuma referência a Canonical* em src/ e tests/ fora do histórico das migrations; índices e coluna removidos pela Files_GF_02_RemoveDedupe (D-06)Conforme—
Referência, guarda × expurgoIStoredFileLock (FOR UPDATE) é a única porta que trava registro por id; usada pelo expurgo manual, pelo job, pela criação e liberação de referência, e por ligar e retirar guarda; o predicado e as guardas são reavaliados sob a trava. Testes: RecordLockRaceTests (duas conexões reais), ReleasedFileRescanRaceTests, OneRecordLockTests, LockedFilePurgeHoldTestsConforme—
Nome no expurgo automáticoOs cinco caminhos de job (job:retencao, job:pending-expirado, job:quarentena-vencida, job:tipo-reprovado, job:objeto-alterado) e a rejeição de tamanho gravam a lápide sem originalFileName; o manual só com redactFileName, e a eliminação sempre (FilePurgeReasons.RedactsFileName). Testes: StoredFileTests.Rn_gf_21_*, NegativePathsOverHttpTestsConforme—
sha256 na lápide de tipo reprovadoRejectContentType grava o sha256 do conteúdo lido até o fim; a lápide de objeto alterado sai sem sha256 (DEC-GF-10-3). Teste: StoredFileTests.RejectContentType_keeps_the_digest_of_the_content_it_read_to_the_endConforme—
legalHold com motivoPUT /files/{id}/retention exige reason (≤ 200) para ligar e para retirar, e o 400 não tem código; a retirada exige release-hold:files. Testes: UpdateRetentionHandlerTests, LegalHoldHandlersTests, LegalHoldHoldersTestsConforme—
Trilha do móduloIFileAuditTrail prepara a linha na transação do ato (IFileAuditOutbox, sobre IAuditTrail, o ponto único do framework desde a 11.1.0) e ela é entregue ao IAuditStore depois do commit, com o id da mensagem; as rotas files.audit-trail e files.platform-audit-trail e seus consumidores ficam só para mensagens de versões anteriores e saem na próxima major; NullAuditStore não é durável: o host não sobe com ele (FileAuditTrailRequirement, em todo ambiente), e download, eliminação e retirada de guarda de arquivo de tenant respondem FILE_AUDIT_TRAIL_UNAVAILABLE como defesa em profundidade; sete ações (stored_file.download_url_issued, .purged, .quarantined, .integrity_incident, .legal_hold_placed, .legal_hold_released, .erasure_refused), mais .rescan_condemned (o Available → Infected do re-scan: Updated, sem motivo de expurgo, Cause = signature ou object-changed); Meter GrydFiles.Trail. Testes: FileAuditOutboxTests (RN-GF-27 (j)), FileAuditTrailTests (inclusive Rn_gf_17_*: a linha do re-scan não é Deleted e não leva motivo de expurgo), FileAuditTrailConsumerTests, RescanTestsConforme, com limitações: (1) sem a adesão à rota de plataforma, arquivo de produto não recebe linha de quarentena, incidente nem guarda ligada (DEC-GF-08-1/3, 15-5; contador gryd_files.audit_trail.unreachable); (2) a gravação no Audit e o receipt do Files são commits distintos, e a repetição é segura porque o EfCoreAuditStore preserva o id da mensagem e ignora a duplicata (DEC-GF-08-2); (3) fechada na 10.2.0: o download exige trilha durável e o host sem store durável não sobe. O Available → Infected do re-scan tem linha própria (DEC-GF-21-2)(1) o host adere com AddGrydFilesPlatformOutbox; (3) o host registra AddGrydAudit; decidido em 30/10/2026 que a recusa valerá em todos os ambientes
Contrato de errosVinte e três códigos em FileErrorCodes, cada um com o status do sufixo e nenhum no catálogo de códigos explícitos da plataforma. Testes: FileErrorCodesTests.The_catalogue_is_exactly_the_twenty_three_codes…, FileErrorStatusRoutingTestsConforme—
Documentação do frameworkdocs/modules/files/spec.md na v1.13; grydfiles-spec.html sem a API antiga de auditoria; api-reference.md com re-scan, paginação, restrições de escopo, o expurgo e os 23 códigos; páginas de consumidor registrado, contrato de veredito, guarda por detentor, eliminação e trilha/outbox; guia de migração para a v10.1 e a v10.2.0; dedupe-and-canonical-resolution.md removida (GF-02); check-doc-contracts.sh e build-docs.sh verdesConforme—
Histórico de tentativasGET /files/{id}/scan-attempts paginado (20 por página, attemptNumber decrescente, PagedResult); page < 1 → 400 sem código. Testes: GetFileAndScanAttemptsHandlerTestsConforme, com limitação: a rota pagina no banco (IFileScanAttemptReader), mas as demais leituras e escritas do agregado ainda carregam o histórico inteiro, que cresce a cada re-scan de acervoItem de desempenho quando o acervo crescer
Re-scan e fila paradaPOST /files/{id}/rescan (Failed → Scanning; Available segue Available; Pending, Infected e Purged → FILE_RESCAN_NOT_APPLICABLE_CONFLICT); pedido gravado como tentativa aberta de motor requested (DEC-GF-17-8); ArchiveRescanJob com RescanLookbackDays e RescanBatchSize; StalledScanRequeue devolve Scanning e Failed com tentativa restante após ScanRequeueAfterMinutes, validado contra a soma das esperas (DEC-GF-12-2). Testes: RescanTests, ReleasedFileRescanTests, ArchiveRescanJobTests, StalledScanRequeueTests (RN-GF-19), GrydFilesOptionsValidatorTestsConforme—

O que a implementação decidiu diferente do texto

Onde o código decidiu diferente do texto desta página, vale a decisão registrada abaixo, e a página do framework (docs/modules/files) já usa estes nomes. O texto das seções seguintes foi ajustado a elas.

  • Permissões (admin:system). Onde o texto ainda diz admin:system, leia: retirar a guarda da plataforma → release-hold:files (RN-GF-27 e a tabela de rotas); eliminar arquivo Permanent pela rota → purge-permanent:files (RN-GF-25); arquivo de produto → a permissão de plataforma *:product-files da operação (DEC-GF-15-10, ADR 0011).
  • Linhas "Parcial" da conferência final (GF-21). A RN-GF-08 nas varreduras recorrentes, a linha própria do Available → Infected do re-scan e a guarda de subida da outbox passaram a Conforme (DEC-GF-21-1 a 21-3). As demandas ao Core (mensagem sem tenant na outbox) e ao GrydAudit (id da mensagem) foram atendidas; ficam a adesão do host e a reconciliação de donos Unknown.
  • RN-GF-06 (b). Os consumidores declarados da porta de servidor são seis, não quatro: GrydReports.Application, GrydReports.Infrastructure, GrydNotifications.Application, GrydNotifications.Infrastructure, GrydAudit.Export e GrydAudit.Integrity (DEC-GF-06-1).
  • RN-GF-28 (e). O pedido, em execução, de uma porta de escopo por Type montado em tempo de execução falha na primeira chamada, não na resolução: o contêiner devolve o mesmo Type nos dois casos, e a porta confere, a cada chamada, que o assembly chamador referencia o do tipo-marca (DEC-GF-09-1).
  • RN-GF-25 (f) é exceção da (q). A tentativa recusada só vai à trilha quando a recusa é sobre o que prende o arquivo (vínculo do produto, guarda, referência) ou é a mesma pessoa nos dois papéis da dupla custódia, sempre depois de os autores serem conferidos; recusa de pedido (autor, motivo, contrato, segundo autor faltante), de estado, de marcação ou de trilha não grava linha (DEC-GF-19-4, DEC-GF-19-11). A asserção 9 da Arquitetura perde a exceção do GrydFilesEmailAttachmentReader (GF-05).
  • Guarda só onde toda purga a honra. Ligar guarda é aceito em Available e Infected; em Pending, Scanning e Failed é FILE_NOT_AVAILABLE_CONFLICT (409) (DEC-GF-18-1).
  • Dupla custódia em arquivo de produto. Em processo vale sempre, não só em Permanent (DEC-GF-19-3); pela rota, um autor só — o usuário — basta, também em Permanent.
  • Expurgo ordinário respeita o piso. POST /files/{id}/purge e IFileStore.PurgeAsync sem a marcação de eliminação recusam retainUntil futuro e Permanent com FILE_RETENTION_NOT_EXPIRED_CONFLICT (DEC-GF-19-9). A rota documenta 503 e 422 do expurgo (DEC-GF-19-10).
  • Arquivo de produto (sem tenant) e a trilha. O Core tem escopo explícito de plataforma, e o módulo o adota por adesão (AddGrydFilesPlatformOutbox, AddGrydFilesPlatformAudit, AddGrydFilesPlatformOutcomes): download, guardas, expurgo, retenção, quarentena, incidente, re-scan e disclosure gravam a linha na transação. Sem a adesão, o expurgo e a retirada de guarda de arquivo de produto pela rota respondem FILE_AUDIT_TRAIL_UNAVAILABLE, o job pula o expurgo, e ligar guarda, a quarentena e o aviso de veredito acontecem sem linha de trilha nem aviso, contados em métrica (DEC-GF-08-1/3, DEC-GF-13-1, DEC-GF-15-5). A eliminação em processo tem trilha no tenant de quem pede (DEC-GF-19-5).
  • Arquivo de produto só se referencia por propagação. Um tenant o referencia nomeando uma referência ativa do mesmo escopo; a plataforma a cria antes (DEC-GF-14-3).
  • Pedido de re-scan. É gravado, sob a trava, como tentativa aberta com motor requested — exceção à frase "passagens pelo antivírus": o histórico mostra o pedido (DEC-GF-17-8).
  • Nenhum escopo é gerido pela rota. O repositório não tem consumidor que declare ManagedThroughHttpSurface(); as duas rotas de referência recusam todo escopo não declarado (DEC-GF-16-3).
  • Contagem de rotas, de jobs e de entidades. Onze rotas; sete jobs (seis recorrentes e o scan sob demanda); quatro entidades — a guarda por detentor é a tabela file_legal_holds, uma linha por detentor, o armazenamento que a seção Campos deixa à implementação (DEC-GF-15-1).

Modelo

Mapa de entidades

StoredFile aggregate root · GrydFiles O registro do arquivo: perfil, nome original, tipo, tamanho, sha256, provedor, bucket, chave, estado, resultado do scan, retenção e lápide de expurgo.
FileReference N por arquivo · GrydFiles Quem aponta para o arquivo, em texto opaco: ownerScope + ownerKey. Nasce com o vínculo e é liberada quando ele cai. É o que autoriza o expurgo.
FileScanAttempt N por arquivo · append-only Cada passagem pelo antivírus: motor, versão da base de assinaturas, início, fim, resultado e mensagem. Nunca sobrescrito — é a resposta de "com que base este arquivo foi liberado".
IObjectStorageService Gryd.Application · já existe A porta para S3, Azure Blob e GCS, incluindo a URL pré-assinada. O GrydFiles é cliente dela e não conhece nenhum SDK de nuvem.
Attachment Nexio · fora deste módulo O vínculo de negócio: dono polimórfico, tipo de anexo, visibilidade, propagação e revogação com motivo. Cria uma FileReference ao nascer.
clamd serviço externo · 1..N réplicas O daemon do ClamAV, com a base de assinaturas em memória. Falado por socket, comando INSTREAM. Não é biblioteca embarcada.

Quatro entidades (StoredFile, FileReference, FileScanAttempt e FileLegalHold, uma linha por detentor de guarda), uma tabela de configuração em appsettings e nenhum SDK de nuvem novo. O módulo tem DbContext e migrations próprios, como todos os outros do framework.

Máquina de estados

Ciclo de vida do arquivo

O estado é do registro, não do objeto no bucket. Um blob existir no storage não significa que ele possa ser usado — significa apenas que alguém escreveu bytes lá. A única transição que libera o arquivo para consumo é a saída limpa do antivírus.

Pending aguardando o PUT Scanning hash + antivírus Available pode ser referenciado Purged blob apagado, lápide Failed erro de infra Infected em quarentena confirmação stream: OK sem referência · retenção vencida erro FOUND retry ×3 quarentena vencida re-scan: FOUND
Seis estados e uma única porta de saída limpa. Infected não volta: arquivo com assinatura encontrada nunca é liberado, nem por permissão elevada. E Available não é definitivo: o re-scan de acervo pode levá-lo a Infected.
As transições, e quem as dispara
DeParaQuem disparaRegra
—PendingPOST /filesValida perfil, tipo e tamanho declarados antes de emitir a URL. Grava uploadExpiresAt. sha256 nasce nulo — só o servidor o calcula, e só na leitura do scan.
PendingScanningPOST /files/{id}/completeConfere que o objeto existe e que o tamanho real bate com o declarado. Divergência é 422, o objeto é apagado e o registro vai a Purged com rejectionCode = FILE_SIZE_MISMATCH_UNPROCESSABLE.
PendingPurgedJob de varredurauploadExpiresAt vencido sem confirmação. O objeto órfão, se existir, é apagado. É o caminho do usuário que desistiu no meio. purgedBy nulo (autor é job).
ScanningAvailableJob de scanResposta stream: OK, tipo verificado dentro da lista do perfil e objeto íntegro (RN-GF-18). Numa só transação: grava sha256, motor e versão da base, cria a FileReference da referência esperada e registra o veredito a entregar. Depois do commit, o consumidor recebe o veredito (RN-GF-13). Só aqui o arquivo passa a existir para quem consome.
ScanningPurgedJob de scanReprovação por tipo. A assinatura de conteúdo não bate com o declarado, ou o tipo verificado está fora do perfil. O objeto é apagado e o registro grava rejectionCode = FILE_CONTENT_TYPE_NOT_ALLOWED_UNPROCESSABLE — o veredito é assíncrono e não tem request para responder 422. Mesmo destino quando o objeto mudou depois da confirmação — leitura que passa de sizeBytes ou termina antes dele, ou versão diferente da lida —, com rejectionCode = FILE_OBJECT_CHANGED_UNPROCESSABLE (RN-GF-18). O consumidor recebe o veredito negativo. Ver § Contrato de erros.
ScanningInfectedJob de scanResposta FOUND. Move para o prefixo de quarentena a versão do objeto que o scan leu (RN-GF-18), grava sha256 e o nome da assinatura, e registra a quarentena na trilha do módulo (IFileAuditTrail). Na mesma transação, zera a referência esperada e grava o dono dela no registro do veredito; depois do commit, o consumidor recebe o veredito (RN-GF-13). Terminal.
ScanningFailedJob de scanErro de infraestrutura — clamd fora do ar, timeout, leitura do bucket. Não é veredito sobre o arquivo, e sha256 não é gravado: a leitura pode ter sido interrompida. Esgotadas as tentativas, o consumidor recebe Failed — para mostrar a falha e oferecer nova varredura, nunca para liberar.
FailedScanningJob de scan · POST /files/{id}/rescanAté três tentativas automáticas, com espera de 1 min, 5 min e 15 min. Esgotadas, o arquivo fica em Failed e alerta o operador — e o operador tem como recolocá-lo na fila pela rota de re-scan, que é o que impede Failed de ser terminal de fato. Nunca libera sem veredito.
ScanningScanningVarredura de Pending vencidoArquivo parado. Sem varredura em andamento nem agendada há mais de ScanRequeueAfterMinutes, o registro volta à fila (RN-GF-19). Vale também para Failed com tentativa automática restante e nenhuma agendada.
AvailableAvailableJob de re-scan de acervo · POST /files/{id}/rescanRe-scan limpo, com erro, com timeout ou com a leitura interrompida por erro de E/S. Grava o FileScanAttempt e não muda o estado: o arquivo segue referenciável e baixável durante e depois da leitura (RN-GF-17).
AvailableInfectedJob de re-scan de acervo · POST /files/{id}/rescanRe-scan com resposta FOUND — ou com divergência de tamanho confirmada, que é incidente de integridade e nunca vai a Purged (RN-GF-18). Numa só transação: move o objeto para a quarentena, grava a assinatura (no incidente, o rejectionCode), libera todas as referências ativas sem tocar em retainUntil nem nas guardas (RN-GF-24) e grava cada dono liberado no registro do veredito. Depois do commit, cada ownerScope que tinha referência ativa recebe o veredito — o arquivo era limpo e deixou de ser.
AvailablePurgedJob de retenção · POST /files/{id}/purgePredicado completo em § Retenção (RN-GF-15): nenhuma referência ativa, carência vencida desde a última liberação, retainUntil vencido quando o modo é RetainUntil, nenhuma guarda ativa e modo diferente de Permanent. O expurgo manual respeita o resto — referência ativa que o pedido não alcança e guarda de qualquer detentor — mas não espera a carência, que é do expurgo automático (RN-GF-15): o pedido do titular não fica em fila por causa de uma liberação recente. Só passa antes da data, ou em Permanent, como ato de eliminação (RN-GF-25), que alcança também Infected e Failed. Apaga o objeto e grava a lápide com o tipo de autor (RN-GF-10). O nome original sai em toda lápide de job e sempre no ato de eliminação (RN-GF-21, RN-GF-25); no expurgo manual sem a marcação, sai quando o pedido traz redactFileName.
InfectedPurgedJob de varredura de consistênciaQuarentena vencida: QuarantineRetentionDays, padrão 365 dias, contados do veredito positivo — e nunca antes de retainUntil, com legalHold ligado nem em Permanent (RN-GF-24). A lápide guarda para sempre o nome da assinatura — ou, no incidente de integridade, o rejectionCode — e perde o nome original (RN-GF-21).
Infected · FailedPurgedIFileService em processo · POST /files/{id}/purgeSó o ato de eliminação a pedido do titular (RN-GF-25), antes do fim da quarentena e em qualquer modo: marcação e motivo, dois autores diferentes em Permanent, nenhuma referência de outro dono, nenhuma guarda de qualquer detentor. A lápide guarda a assinatura ou o rejectionCode e perde o nome original.

Não existe estado "liberado manualmente". Foi uma tentação e foi descartada: a permissão de sobrepor o antivírus é exatamente a que um atacante quer, e o caso legítimo — falso positivo do ClamAV numa planilha com macro — se resolve subindo de novo depois que a base de assinaturas for corrigida, ou trocando o arquivo. Liberar por botão transforma o scan em teatro.

Modelo

Campos · StoredFile

Herda de BaseEntity e implementa IOptionalTenant, com TenantId anulável — nulo significa arquivo de produto, o mesmo padrão que o NotificationTemplate já usa para o modelo global. Id, CreatedAt, CreatedBy, UpdatedAt, UpdatedBy e o bloco de soft delete vêm do BaseEntity e não estão repetidos aqui.

CampoTipoRegra
idGuidgeradoAtribuído pelo agregado, não pelo banco — o BaseEntity já faz isso no construtor. É o que permite emitir a URL de upload antes de qualquer commit.
tenantIdGuid?geradoDo token, nunca aceito do cliente. Nulo é arquivo de produto: pela rota exige a permissão de plataforma *:product-files da operação, de pessoa no contexto; em processo vale o consumidor registrado para o escopo e, na eliminação, a dupla custódia declarada com par do próprio escopo (RN-GF-25, RN-GF-28).
profilestring(40)obrigO perfil declarado pelo consumidor — gryd.report, gryd.notification-attachment, nexio.attachment, nexio.import. É onde moram tipo permitido, tamanho máximo, retenção padrão e obrigatoriedade de scan. Sempre explícito: não existe perfil padrão (ver § Perfis). Perfil desconhecido é 400.
originalFileNamestring(255)?obrigSó exibição e nome sugerido no download. Nunca compõe a chave do objeto. Normalizado: sem separador de caminho, sem caractere de controle, truncado. Obrigatório na criação; nulo só na lápide — sai em todo expurgo automático, na rejeição de tamanho, no manual com redactFileName e no ato de eliminação (RN-GF-21, RN-GF-25).
contentTypestring(120)obrigDeclarado na criação e reconferido no scan contra a assinatura de conteúdo, inclusive quando ScanRequired: false. Divergência leva a Purged com rejectionCode.
declaredSizeBytesbigintobrigO tamanho que o cliente promete. Serve para recusar antes de gastar rede, e para comparar depois. Zero é recusado.
sizeBytesbigint?geradoO tamanho real, lido do ObjectStorageObjectDescriptor na confirmação. Divergiu do declarado, o arquivo não entra.
sha256char(64)?geradoCalculado no servidor, na mesma leitura do antivírus. Nunca aceito do cliente. Anulável por construção: nulo em Pending e Scanning, gravado na saída para Available, para Infected e na lápide de reprovação por tipo — nos três a leitura chegou ao fim e já produziu o digest, e as lápides o preservam. Não é gravado em Failed, onde a leitura pode ter sido interrompida.
storageProviderenum?geradoAmazonS3 · AzureBlobStorage · GoogleCloudStorage. Guardado no registro porque a configuração padrão muda com o tempo e o objeto antigo continua onde está.
bucketNamestring(120)?geradoIdem: o bucket efetivo no momento da gravação, não o configurado hoje.
objectKeystring(400)?geradoVer § Chave do objeto. Nunca exposto fora do módulo. Cada registro é dono do seu próprio objeto — não há reaproveitamento de objeto entre registros (§ Hash e referência). No registro expurgado a localização é preservada — a lápide responde onde o arquivo esteve.
statusenumgeradoPending · Scanning · Available · Infected · Failed · Purged. Só o serviço muda; não existe rota que aceite estado do cliente.
expectedOwnerScope / expectedOwnerKeystring(60)? / string(80)?opcA referência esperada: a quem o arquivo vai pertencer se for liberado — o mesmo par opaco da FileReference. Declarada por quem consome, no processo — ao confirmar pela IFileService, só sobre registro em Pending que o mesmo chamador reservou (RN-GF-22), ou ao gravar pela IServerFileStore —, nunca pela rota HTTP. Zerada quando a referência é criada, quando o veredito é negativo — o dono segue no registro do veredito a entregar (RN-GF-13) —, no expurgo ou quando o consumidor a cancela antes do veredito (o dono foi descartado). A chave é token de concorrência: a liberação que cruza com um cancelamento falha e se repete, em vez de entregar referência a dono que já desistiu. Existe porque a referência nasce na liberação (decisão 9): sem ela, a saída para Available não teria a quem avisar. Foi preferida a uma entidade de intenção porque há uma referência esperada por registro, nunca duas — quem precisa de um segundo vínculo cria uma FileReference normal, depois da liberação.
rejectionCodestring(80)?geradoO motivo estruturado da reprovação. Guarda o código do catálogo de erros que reprovou o arquivo — FILE_SIZE_MISMATCH_UNPROCESSABLE, FILE_CONTENT_TYPE_NOT_ALLOWED_UNPROCESSABLE ou FILE_OBJECT_CHANGED_UNPROCESSABLE. Também em Infected por incidente de integridade no re-scan (RN-GF-18). Nulo quando não houve reprovação. Existe porque a conferência de tipo é assíncrona: sem ele, quem subiu um .exe renomeado receberia o mesmo FILE_PURGED_CONFLICT de um arquivo apagado por retenção há dois anos.
uploadExpiresAtDateTimegeradoFim da validade da URL de upload. Padrão de 15 minutos, configurável por perfil. Vencido sem confirmação, o registro vira Purged pela varredura.
scannedAtDateTime?geradoQuando o veredito saiu. Junto com scanEngine e scanDatabaseVersion responde "com que base este arquivo foi liberado".
scanEnginestring(60)?geradoEx.: ClamAV 1.4.x. Existe porque o provedor é plugável e a resposta de auditoria precisa dizer qual motor deu o parecer.
scanDatabaseVersionstring(40)?geradoA versão da base de assinaturas no momento do scan, obtida do comando VERSION do clamd.
scanSignaturestring(160)?geradoO nome da assinatura quando houve detecção — a parte antes do FOUND na resposta. Nulo quando limpo.
retentionModeenumobrigUntilReleased (morre quando a última referência cai) · RetainUntil (data explícita) · Permanent (só expurgo manual). Sem padrão implícito: quem sobe escolhe, herdando do perfil se não disser.
retainUntilDateTime?condObrigatório em RetainUntil, recusado nos outros modos. Quando o modo é herdado do perfil, a data é calculada pelo módulo a partir de DefaultRetentionDays e o consumidor não precisa informá-la — só o retainUntil explícito exige modo explícito. Precisa ser futuro, e só pode ser empurrado para frente.
legalHoldboolgeradoDerivado: ligado enquanto houver ao menos um detentor com guarda ativa (RN-GF-27). Trava o expurgo, inclusive o manual e o pedido de apagamento por titular. É ato por caso, nunca por tipo: o detentor liga quando há litígio, fiscalização ou ordem de preservação, com motivo obrigatório; perfil e tipo de arquivo não ligam guarda — guarda de rotina é retainUntil. Ligar e retirar são atos de controle, os dois com autor, tipo de autor e motivo na trilha; cada detentor retira só a sua.
guardas por detentorcoleçãogeradoNo máximo uma guarda ativa por detentor: o detentor (o ownerScope do consumidor registrado que ligou em processo, ou a plataforma, pela rota), o autor e o tipo de autor (Authenticated ou Declared, com o escopo e o executor de serviço no declarado), o motivo, quando ligou e quando retirou. Decide quem retira e mantém o arquivo preso enquanto outro detentor precisa dele (RN-GF-27) — com um bit só, a segunda guarda não fica registrada e a primeira retirada solta o arquivo de todos. O armazenamento é da implementação; uma linha por detentor, no mesmo desenho da FileReference, é o exemplo.
referenceCountintgeradoDenormalizado a partir de FileReference ativas, para o job de expurgo não varrer a tabela filha. A contagem é conferência, não verdade: a verdade é a tabela.
lastReferenceReleasedAtDateTime?geradoQuando a última referência ativa caiu — é desta data que a carência do expurgo conta. Denormalizado junto do referenceCount, no mesmo método do agregado e na mesma transação, e recalculado pela mesma rotina de reconciliação. Sem ele o job de retenção teria de agregar FileReference para cada candidato, que é exatamente o que o referenceCount existe para evitar — e a carga cresceria para sempre, porque liberação é append.
purgedAt / purgedBy / purgeReasonDateTime? / Guid? / string(200)?geradoA lápide. purgedAt e purgeReason são obrigatórios e gravados no mesmo método. purgedBy é nulo quando e somente quando purgeAuthorKind é Job (RN-GF-10): no autor declarado ele traz quem executou, ao lado de erasureRequestedBy, purgeDeclaredScope e purgeServiceExecutor — repetição de ato de pessoa depois do commit roda sem usuário e não é autor job. No autor job, o purgeReason identifica a origem: job:retencao, job:pending-expirado, job:quarentena-vencida, job:tipo-reprovado, job:objeto-alterado. A rejeição de tamanho na confirmação grava rejeicao:tamanho-divergente, com purgedBy de quem confirmou.
purgeAuthorKind / purgeDeclaredScope / purgeServiceExecutor / erasureRequestedByenum? / string(60)? / string(80)? / Guid?geradoQue tipo de autor gravou a lápide (RN-GF-10): Job · Authenticated · Declared, gravado com purgedAt. No Declared — autor declarado pelo consumidor em processo, sem usuário no contexto (RN-GF-27) —, purgeDeclaredScope e purgeServiceExecutor dizem quem declarou e qual job ou serviço chamou; nos outros tipos são nulos. erasureRequestedBy é o segundo autor da eliminação com dupla custódia (RN-GF-25) — quem registrou o pedido —, e nulo fora dela. Existe porque, sem o tipo, a lápide gravada pela repetição de um job passa a mostrar uma pessoa como se ela tivesse agido autenticada.

Modelo

FileReference e FileScanAttempt

FileReference — quem ainda precisa deste arquivo

CampoTipoRegra
storedFileIdGuidobrigO arquivo. Referência só pode ser criada sobre arquivo Available — é isso que impede um anexo apontar para algo que ainda está na fila do antivírus. A primeira referência não é criada pelo consumidor: ela nasce sozinha na saída para Available, a partir da referência esperada declarada por quem consome.
ownerScopestring(60)obrigQuem aponta, em texto opaco: nexio.attachment, nexio.item-document, gryd.report-execution. O módulo não interpreta — só agrupa e reporta.
ownerKeystring(80)obrigA chave do dono no mundo dele. Normalmente um Guid em texto. Mesmo polimorfismo que o AuditLog do framework já usa em EntityType + EntityId.
releasedAt / releasedByDateTime? / Guid?geradoLiberação é append, não exclusão: a linha continua, com data. O histórico de quem já apontou para o arquivo faz parte da prova.

Único por (storedFileId, ownerScope, ownerKey) entre as não liberadas. Registrar duas vezes é operação idempotente, não erro — o consumidor pode reprocessar sem medo. Criar e liberar referência ajusta, no mesmo método do agregado e na mesma transação, o referenceCount e o lastReferenceReleasedAt do StoredFile; a chamada idempotente não incrementa e a liberação repetida não decrementa duas vezes nem reescreve a data.

FileScanAttempt — o histórico do veredito

CampoTipoRegra
storedFileId · attemptNumberGuid · intobrigChave composta natural. Começa em 1.
engine · databaseVersionstring(60) · string(40)geradoMotor e base no momento da tentativa.
startedAt · finishedAtDateTime · DateTime?geradoTambém servem de métrica: tempo de scan por MB é o número que dimensiona as réplicas do clamd.
outcomeenumgeradoClean · Infected · Error · Timeout · SizeLimitExceeded. O último é a resposta INSTREAM size limit exceeded do clamd e é erro de configuração, não do arquivo.
messagestring(400)?geradoA resposta literal do motor. Guardada como veio, sem interpretação.

Append-only. Nunca sobrescrito, nunca apagado antes do arquivo — mesma tese do SupplierVerification do Nexio: evidência que se reescreve não é evidência.

Fluxo

Upload em duas fases

Três chamadas, e o binário não toca no processo da API em nenhuma delas.

1 · Reservar

POST /files com perfil, nome, tipo, tamanho declarado e retenção. O serviço valida antes de gastar rede: perfil existe, tipo está na lista do perfil, tamanho cabe no teto. Cria o StoredFile em Pending, monta a objectKey e devolve fileId + uploadUrl pré-assinada com validade curta.

2 · Enviar

O cliente faz PUT direto no bucket, com a URL recebida. A URL é assinada para uma operação, um objeto, um Content-Type e, onde o provedor assina, o tamanho declarado; não serve para listar, não serve para outro caminho e vence sozinha. Enquanto não vence, ela escreve de novo — por isso o objeto que o download entrega nunca é um objeto que essa URL alcança (RN-GF-18). Se o cliente sumir no meio, sobra um registro Pending e talvez um objeto órfão — os dois morrem na varredura.

Exemplo de implementação, com equivalente. A URL escreve numa chave de trânsito; o objeto escaneado e servido vive numa chave final que nenhuma URL de escrita alcança. A cópia para a chave final pode acontecer na confirmação, antes do scan — e então o scan lê a chave final —, ou depois do veredito, condicionada à versão que o scan leu (ETag no S3 e no Azure, generation no GCS); nos dois casos a chave de trânsito é apagada. Equivalentes: servir por versão fixada, com versionamento do bucket, ou escrita condicional que recusa sobrescrever, onde o provedor a oferece. Tamanho assinado: Content-Length na assinatura do S3, x-goog-content-length-range no GCS; a SAS do Azure não restringe tamanho, e ali valem a conferência da confirmação e o teto de leitura do scan — sizeBytes + 1 bytes, que vale nos três. A cópia para a quarentena segue a mesma regra: parte da chave que nenhuma URL alcança, ou é condicionada à versão que o scan leu (RN-GF-18). A escolha é da implementação, conferida provedor a provedor; os critérios de aceite da RN-GF-18 são os mesmos nos três.

3 · Confirmar

POST /files/{id}/complete. O serviço chama ExistsAsync, lê o descritor, compara SizeBytes com o declarado, passa o registro para Scanning e enfileira o job. A resposta é imediata: o cliente não espera o antivírus, ele recebe o fileId e o estado, e a tela mostra "verificando".

É aqui que o módulo consumidor diz a quem o arquivo vai pertencer. Quando confirma no processo, pela IFileService, ele declara a referência esperada (ownerScope, ownerKey) — intenção, não referência. Só sobre o registro que o mesmo chamador reservou, no mesmo tenant, ainda em Pending (RN-GF-22): o fileId vem do cliente, e sem essa amarra serviria para prender ao documento de uma pessoa o arquivo que outra subiu. A rota HTTP não aceita dono: o corpo de POST /files/{id}/complete é vazio, porque um cliente HTTP escolheria o dono do arquivo que ele mesmo subiu. Nada fica referenciável, baixável ou propagável por causa da declaração.

Depois das três chamadas: o veredito volta ao consumidor, depois do commit. A transição de veredito é uma transação do GrydFiles: estado, sha256, a FileReference da referência esperada e o registro do veredito a entregar — com o tenant e o dono (ownerScope, ownerKey) — são gravados juntos. O dono vai no registro porque o veredito negativo zera a referência esperada na mesma transação e o re-scan positivo libera as referências: sem ele, ninguém saberia a quem avisar. O consumidor fica sabendo por mensagem publicada depois do commit, entregue ao contrato de veredito registrado para o ownerScope — IFileOutcomeHandler, uma implementação por escopo, registrada na inicialização. São entregues os quatro desfechos: Available, Infected, reprovação (Purged com rejectionCode) e Failed. ownerScope sem contrato registrado derruba a inicialização; referência esperada é opcional, e sem ela a liberação apenas não avisa ninguém.

Por que não na mesma transação. O GrydFiles tem DbContext, esquema e conexão próprios, e o consumidor tem os dele: não há unidade de trabalho que atravesse os dois sem transação distribuída. A regra é a mesma de qualquer outro ato: O efeito que precisa ser atômico com o ato acontece na mesma transação — pelo próprio caso de uso, por chamada síncrona declarada entre contextos (.Contracts) ou pelo contrato de desfecho do motor; o evento de domínio é publicado depois do commit e serve para notificar, medir e integrar — nunca para produzir efeito de que o ato dependa. O que precisa ser atômico com o veredito é a referência, e ela está na transação; o que o consumidor faz com o veredito depende do veredito, e não o contrário.

O que o contrato exige de quem consome (RN-GF-13). Idempotência: a entrega é pelo menos uma vez. Decisão pelo estado atual: o handler relê o arquivo pela IFileService.GetAsync — fora de request, sem usuário, com o tenant do registro do veredito — e age sobre o estado de agora, não sobre o que a mensagem diz — por isso duas entregas fora de ordem (liberado e, depois de um re-scan, infectado) terminam no mesmo lugar. Tenant restaurado, sem identidade de pessoa: o handler roda no escopo do tenant gravado no registro do veredito, não executa comando em nome de usuário (Ata B3) e, sem tenant, falha fechado. Dono que não existe mais — ou que não aponta para este arquivo — é desfecho normal: o consumidor libera a referência e confirma a entrega. A garantia de publicação — gravada com o commit, entregue pelo menos uma vez, re-tentativa que não perde mensagem, mensagem que não pode sair visível como "aguardando" — é requisito da página de Mensageria; o mecanismo é do estudo da plataforma.

Por que confirmação explícita em vez de evento do bucket. O S3 avisa por event notification, o Azure por Event Grid, o GCS por Pub/Sub — três integrações diferentes, três formatos, três modos de falhar, e nenhuma delas existe quando o provedor é o disco local do desenvolvedor. Uma chamada de confirmação funciona igual nos quatro casos e ainda dá ao serviço o momento exato de comparar o declarado com o real. O custo é depender do cliente chamar; a varredura de Pending vencido cobre quem não chama.

Segurança

Antivírus · ClamAV

Anexo é por onde bytes de terceiro entram na nossa infraestrutura — e não só pelo portal do fornecedor: a proposta que chega por e-mail e o comprador anexa na requisição é arquivo de fora desde o primeiro dia. Não escanear é aceitar que o produto vire meio de distribuição de malware entre os clientes — o arquivo que o comprador baixa foi enviado por alguém de fora.

Por que ClamAV

CritérioComo pesa
Funciona nos três provedoresO módulo é multi-provedor por construção — S3, Azure Blob e GCS. O GuardDuty Malware Protection for S3 só protege bucket S3, e amarraria o framework inteiro à AWS.
CustoSem licença e sem preço por GB nem por objeto. Volume de anexo em compras cresce com o uso do cliente; scan cobrado por objeto cria um custo que sobe junto com a adoção, o que é o pior formato possível para um módulo de plataforma.
O dado não sai da redeProposta comercial, contrato e planilha de custo passariam por um terceiro se o scan fosse serviço externo. É decisão de compliance que nem todo cliente aceita, e que teríamos que negociar cliente a cliente.
Roda em qualquer lugarContêiner, on-premise, nuvem privada. O Gryd.IO não pode assumir nuvem pública em todo cliente.
TrocávelFica atrás de IFileScanner, registrado por UseScanner<T>() ou AddGrydFilesScanEngine<T>() para passar pela verificação, no mesmo padrão de GrydNotifications.Infrastructure.SendGrid. Quem já tem GuardDuty ou appliance ICAP troca o projeto de provedor sem tocar no módulo.

O que ele é — e o que ele não é

ClamAV é um motor de assinatura. Ele reconhece o que já está catalogado: macro maliciosa em documento do Office, PDF com JavaScript conhecido, executável embutido, conteúdo dentro de arquivo compactado — ele descompacta e olha dentro. Ele não reconhece ameaça nova, não faz análise comportamental e não diz nada sobre o conteúdo ser apropriado. Por isso ele é uma camada: a lista de tipos permitidos, o teto de tamanho, a URL de vida curta e a permissão de domínio continuam valendo, e nenhuma delas é substituída pelo scan.

Como funciona na prática

Daemon, não linha de comando

O clamscan de linha de comando recarrega a base de assinaturas inteira a cada execução — em servidor isso é inviável. O que se usa é o clamd, um daemon que mantém as assinaturas carregadas em memória e atende por socket. Ele roda como serviço próprio, com 1..N réplicas, nunca embarcado em cada instância da API: a base em memória é cara demais para replicar por processo.

INSTREAM — escanear sem gravar em disco

O comando INSTREAM manda o conteúdo pelo próprio socket, em pedaços: cada um prefixado por 4 bytes big-endian de tamanho, e o fim marcado por um pedaço de tamanho zero (00 00 00 00). É o que permite ao job ler o objeto do bucket em stream e empurrar direto para o motor, sem materializar o arquivo em disco no nó de scan.

Uma leitura, dois consumidores

O stream devolvido por OpenReadAsync alimenta ao mesmo tempo o SHA256 e o socket do clamd. Ler o objeto duas vezes — uma para o hash, outra para o scan — dobraria o tráfego de saída do bucket e o tempo do job sem nenhum ganho.

A resposta é texto, e é curta

stream: OK quando limpo. stream: <NomeDaAssinatura> FOUND quando detecta — o nome vai para scanSignature. ... ERROR em falha, e INSTREAM size limit exceeded quando o conteúdo passa de StreamMaxLength. Os quatro casos são distintos no modelo: só o segundo é veredito sobre o arquivo.

O clamd.conf é gerado, não escrito à mão

Não existe comando do protocolo que permita ao serviço perguntar a configuração ao daemon — o VERSION devolve motor e base, nada mais. Uma validação que compare MaxSizeBytes contra um StreamMaxLength espelhado no appsettings atesta um valor que pode não ser o do daemon. Por isso a seção GrydFiles é a fonte única: o clamd.conf é renderizado a partir dela na subida do serviço, e a checagem de coerência passa a comparar contra o valor que realmente vale. Incoerência derruba a aplicação na inicialização — é configuração de plataforma, e o módulo é fail-closed.

freshclam mantém a base

A base de assinaturas é atualizada pelo freshclam, por padrão diariamente. A base fica em volume compartilhado e a imagem usada é a variante _base, sem assinaturas embutidas — a documentação do próprio projeto pede parcimônia com a banda gratuita do CDN, e reconstruir o contêiner baixando tudo de novo a cada deploy é o caminho mais rápido para ser bloqueado.

Memória é o recurso crítico

A documentação oficial pede mínimo de 3 GiB e preferencialmente 4 GiB por instância, porque a base fica toda em memória e o recarregamento concorrente chega a dobrar o consumo enquanto troca. Faltando memória, o sistema operacional mata o clamd e o contêiner fica de pé sem responder — que é a falha mais traiçoeira possível. O limit do contêiner nasce em 4 GiB e o liveness probe é o comando PING, que responde PONG.

Configuração que precisa de decisão explícita

ParâmetroComo definir
StreamMaxLength64M. Precisa ser maior que o maior maxSizeBytes de todos os perfis — hoje 26.214.400 bytes, que é exatamente 25 MiB, o mesmo valor do padrão do clamd. Igual não basta: o INSTREAM acrescenta 4 bytes de enquadramento por pedaço, e o arquivo no tamanho máximo do perfil voltaria SizeLimitExceeded de forma intermitente — a armadilha mais comum do ClamAV se manifestando já na configuração padrão. 64M dá ~2,5× de folga e admite um perfil futuro maior sem mexer no daemon.
MaxFileSize64M, igualado ao StreamMaxLength. Teto do maior membro individual extraído de um compactado. É a única linha deste arquivo em que errar para menos libera arquivo não examinado: abaixo do necessário o motor não recusa — ele ignora o membro e responde OK.
MaxScanSize400M. Total examinado somando todos os membros de um compactado. Permite um zip de 25 MiB expandir cerca de 16× antes de estourar. Mesma falha silenciosa do anterior se ficar curto.
MaxRecursion · MaxFiles16 e 10000. Proteção contra zip bomb: profundidade de aninhamento e quantidade de arquivos extraídos. Estouro é tratado como Error, e arquivo que estoura esses limites não é liberado.
ConcurrentDatabaseReloadLigado, para o scan não parar durante a atualização — é ele que exige a folga de memória de 4 GiB.
IDSESSIONReaproveita a conexão para vários arquivos na mesma sessão. Desligado no caminho padrão (um arquivo por vez, conexão simples); ligado apenas onde houver processamento em lote.
Timeout do scanner120s, do lado da aplicação, não do clamd.conf. Cabe folgadamente num compactado de 25 MiB no pior caso e libera o worker do Hangfire antes de virar acúmulo. Estouro produz o desfecho Timeout, distinto de Error, e nunca liberação.

Falha, degradação e teste

Fora do ar não libera nada

clamd indisponível deixa os arquivos em Scanning e a fila cresce. O job re-tenta até três vezes, com espera de 1 minuto, 5 minutos e 15 minutos — cerca de vinte minutos no total, que absorvem reinício de contêiner e recarregamento de base sem prender o arquivo a tarde inteira. Esgotadas, o registro fica em Failed. Não existe modo degradado que libere sem veredito. O alerta dispara por dois sinais: fila acima do limite, ou base de assinaturas com mais de 48 horas — a segunda é a que passa despercebida, porque tudo parece funcionar enquanto o motor envelhece.

EICAR no teste de integração

O arquivo de teste EICAR é a cadeia padrão que todo antivírus reconhece por convenção, e existe exatamente para provar que a esteira funciona sem usar malware real. O teste sobe o EICAR e exige que o arquivo termine em Infected, na quarentena, com scanSignature preenchido. Detalhe prático: a cadeia é gerada em tempo de teste, nunca commitada como arquivo no repositório — o antivírus da máquina do desenvolvedor ou do agente de CI apaga o arquivo e quebra a compilação por um motivo que ninguém entende de primeira.

Falso positivo tem caminho, e não é botão

Acontece com planilha com macro e com instalador. O caminho é reportar a assinatura ao ClamAV, aguardar a correção da base e subir de novo — ou converter o arquivo. Não existe liberação manual (ver § Ciclo de vida), porque a permissão de sobrepor o antivírus é a que um atacante mais quer.

Alternativas avaliadas

OpçãoPor que não é a padrão
GuardDuty Malware Protection for S3Só S3 — o módulo perderia Azure e GCS. Cobra por GB e por objeto. Tem cotas próprias: objeto até 100 GB, 100.000 arquivos extraídos, 100 níveis de aninhamento e 25 buckets protegidos por conta e por região, o que é um teto real para uma plataforma multi-cliente. Continua sendo o provedor alternativo natural para quem já vive na AWS.
Serviço de scan por APICusto por chamada e, sobretudo, o arquivo sai da nossa rede. Vira cláusula de contrato com cada cliente.
Appliance ICAPFaz sentido onde já existe um, em cliente grande on-premise. Como padrão de plataforma, custo e operação não se justificam.
Não escanearRecusada. Desde a primeira requisição com anexo, o arquivo que o comprador abre pode ter vindo de fora da empresa — por e-mail hoje, pelo portal depois.

O antivírus está no caminho crítico da v1. A v1 do módulo é o registro e o ClamAvFileScanner com a sua infraestrutura — contêiner do clamd, freshclam, os 4 GiB, liveness e o teste de EICAR contra motor real. Nenhuma Requisição com anexo entra em produção sem ele: arquivo de terceiro chega por e-mail e é anexado por usuário interno desde o primeiro dia, e os perfis nexio.attachment e nexio.import exigem scan.

Não há fake nem modo degradado. O módulo não traz implementação falsa de IFileScanner nem configuração que a habilite: dublês existem só nos testes, e a RN-GF-05 continua valendo inteira. A ordem de entrega dentro da v1 está em § Detalhamento: a infraestrutura do clamd vem antes da história do provedor.

Defesa

Validação de tipo e tamanho

Assinatura de conteúdo, não extensão

O tipo é conferido pelos primeiros bytes do arquivo contra a lista permitida do perfil. Extensão e Content-Type declarado são informação do cliente — um .exe renomeado para .pdf passa nos dois. A conferência acontece no job de scan, na mesma leitura do hash, e divergência entre o declarado e o real reprova o arquivo.

A conferência é incondicional, inclusive com ScanRequired: false. A leitura única acontece de todo jeito, porque o sha256 é calculado no servidor; só o socket do clamd deixa de ser alimentado. Desligar o antivírus não desliga a RN-GF-03.

Três classes de tipo, três regras — sem elas, uma leitura literal de "assinatura de conteúdo decide" reprovaria todo CSV legítimo e todo xlsx legítimo:

  • Assinatura conhecida e inequívoca (PDF, PNG, JPEG, WebP): os primeiros bytes decidem. Divergir do declarado, ou cair fora da lista do perfil, reprova.
  • Container ZIP: .docx, .xlsx, .pptx e .zip começam todos com PK\x03\x04 — os quatro primeiros bytes não os distinguem. O detector abre o container e lê o [Content_Types].xml para resolver o tipo OOXML concreto, caindo para application/zip quando não resolver. Sem isso, um perfil que aceita planilha do Office mas não compactado ou reprova toda planilha, ou aceita um zip disfarçado.
  • Sem assinatura (text/csv, text/plain): texto não tem magic bytes. O conteúdo é validado como texto — decodificação válida e ausência de byte NUL — e o tipo declarado prevalece se estiver na lista do perfil. É o que barra o executável renomeado para .csv, porque binário carrega NUL, sem barrar a planilha de carga do produto.

Lista de permitidos, nunca de proibidos

Cada perfil declara o que aceita. Lista de proibidos é um jogo que se perde sempre: basta um formato novo. O perfil de anexo do Nexio nasce com PDF, imagem, documento e planilha do Office, texto e compactado.

HTML e SVG são código

SVG carrega script. Servido no mesmo domínio da aplicação, vira XSS armazenado — e o vetor entra pelo portal do fornecedor. Três defesas juntas: text/html e image/svg+xml fora da lista de permitidos nos perfis de anexo; todo download sai com Content-Disposition: attachment e Content-Type: application/octet-stream, que juntos impedem qualquer renderização no navegador; e o domínio é o do bucket, não o da aplicação, o que a URL pré-assinada já garante.

O nosniff não vem na URL. X-Content-Type-Options: nosniff não pode ser emitido por URL pré-assinada: o conjunto de cabeçalhos que uma URL pré-assinada pode sobrepor é fechado e igual nos três provedores — content-type, content-language, expires, cache-control, content-disposition e content-encoding. Metadado customizado volta como x-amz-meta-*, não como o cabeçalho. O nosniff só entra por política de bucket ou CDN, e passa a ser recomendação de operação, não garantia da URL. A defesa real não depende dele: attachment mais octet-stream já impedem a renderização.

Tamanho é por perfil, nunca global

Anexo de requisição não precisa do mesmo teto de um contrato digitalizado. O Ariba é o exemplo de para onde isso vai: 100 MB de limite geral e 2 GB só para contrato. O teto do perfil é validado duas vezes — no declarado, antes de emitir a URL, e no real, na confirmação.

Storage

Chave do objeto

{tenantId | _global}/{profile}/{aaaa}/{MM}/{dd}/{fileId}.{ext}
_quarantine/{tenantId | _global}/{fileId}.{ext}

É o padrão que o ReportFilePathGenerator já usa no GrydReports, com o perfil a mais. Cada parte tem motivo:

PartePor quê
tenantId na raizIsolamento visível na chave. Permite política de bucket, ciclo de vida e relatório de custo por cliente sem consultar o banco — e torna evidente, olhando o caminho, se algo vazou de tenant.
PerfilAnexo, relatório e planilha de importação têm retenção e volume muito diferentes. Separados no caminho, aceitam regra de ciclo de vida diferente no próprio storage.
DataEvita diretório com milhões de objetos e dá um caminho barato para diagnóstico e para expurgo em massa.
fileId, não o nomeNome original traz acentuação, espaço, colisão, ../ e, com frequência, dado pessoal — "rescisão_joão_silva.pdf" não deveria estar no caminho de um objeto. O nome viaja no Content-Disposition do download, onde ele serve.
Extensão pelo tipoDerivada do contentType verificado, não do nome enviado. Só ajuda ferramenta de diagnóstico; nada no sistema decide por ela.
Prefixo de quarentenaSeparado, com política de acesso mais restrita e nenhuma URL pré-assinada emitida jamais. Move-se para lá, não se apaga.

Integridade

Hash e referência

O sha256 responde "este é exatamente o conteúdo que foi escaneado e que está sendo servido"; a FileReference responde "este objeto ainda serve para alguma coisa". São as duas perguntas que o expurgo e a auditoria fazem, e nenhuma delas depende de reaproveitar objeto entre envios.

Um registro por envio, um objeto por registro

Cada upload gera o seu StoredFile e o seu objeto. O mesmo PDF enviado duas vezes são dois registros e dois objetos: quem subiu, quando e com que perfil é informação que não se funde, e a retenção de um nunca segura nem apaga o outro. O que se reaproveita é o registro: o mesmo documento anexado numa requisição e copiado para o pedido são duas FileReference sobre um único StoredFile — é assim que o consumidor propaga arquivo entre documentos sem subir de novo.

Para que serve o sha256

Três usos, todos de integridade: provar que o objeto servido é o objeto escaneado (RN-GF-18); guardar, na quarentena, a identidade do conteúdo infectado para a análise do incidente; e responder, na lápide, "este era o conteúdo" sem guardar o conteúdo.

Por que ele fica na lápide depois do expurgo. Um hash não devolve o conteúdo nem o nome: ele só confirma a posse a quem já tem os mesmos bytes. Essa confirmação é a prova que a auditoria pede ("o arquivo que me mostraram é o que existiu aqui?"), é lida só dentro do tenant e com read:files, e arquivo de outro tenant responde FILE_NOT_FOUND igual a arquivo inexistente (RN-GF-08). O nome original, que é o dado pessoal da lápide, sai em todo expurgo automático (RN-GF-21).

Nada atravessa tenant

Nenhum objeto, registro ou referência é compartilhado entre clientes, mesmo com hash idêntico. Compartilhar objeto economizaria armazenamento e criaria um canal de inferência — dá para descobrir que outro cliente tem o mesmo arquivo pelo tempo de resposta. A chave do objeto começa pelo tenantId (§ Chave do objeto).

Referência é do consumidor, contagem é do módulo

Quem cria vínculo chama AddReferenceAsync; quem desfaz chama ReleaseReferenceAsync. O módulo mantém referenceCount e nada mais sabe — com uma exceção, e é a decisão 9: a primeira referência é criada pelo próprio módulo, na liberação, a partir da referência esperada. Quem consome declara a intenção e depois só desfaz. No Nexio, o Attachment libera a sua quando o vínculo de negócio acaba, pelas regras da spec de Anexo — o registro do anexo continua vivo; o que caiu foi o direito do objeto de continuar existindo.

v2 · Dedupe de conteúdo. Reaproveitar o objeto quando o mesmo sha256 já existe no tenant não faz parte da v1. O ganho é o armazenamento de duplicatas exatas, e o caso comum — o mesmo arquivo em vários documentos — já reaproveita o StoredFile por referência. O custo era alto: um campo de posse (canonicalFileId), localização nula no registro que reaproveita, predicado de expurgo sobre o conjunto, unicidade só entre os liberados — e dois problemas ainda abertos: o re-scan que encontra vírus no canônico precisa alcançar todo o conjunto, e a liberação que reaproveita o canônico disputa com o expurgo que o apaga.

O que a análise já fechou, para quando voltar. (1) Nunca entre tenants. (2) A ordem é hash → veredito → dedupe: registro Infected nunca vira nem reaproveita canônico. (3) Unicidade: no máximo um registro dono de objeto em Available por sha256 dentro do tenant e, à parte, entre os arquivos de produto. (4) O objeto só morre quando nenhum registro do conjunto o segura. (5) A liberação que reaproveita o canônico trava o canônico contra o expurgo, na mesma transação. (6) Re-scan positivo no canônico leva o conjunto inteiro a Infected. Exemplo de implementação da unicidade, com equivalente:

CREATE UNIQUE INDEX ux_storedfile_tenant_sha256 ON stored_files (tenant_id, sha256)
  WHERE canonical_file_id IS NULL AND sha256 IS NOT NULL AND status = 'Available';

CREATE UNIQUE INDEX ux_storedfile_global_sha256 ON stored_files (sha256)
  WHERE tenant_id IS NULL AND canonical_file_id IS NULL AND sha256 IS NOT NULL AND status = 'Available';

Portabilidade. Índice parcial existe em Postgres, SQLite e SQL Server (onde, por NULL colidir com NULL, o segundo índice vira redundante e inofensivo) e se emula no Oracle por índice baseado em função. MySQL e MariaDB não têm índice parcial — se um produto sobre o Gryd.IO precisar deles, a saída portável é uma coluna persistida dedupeKey, valendo tenantId || sha256 quando o registro é dono de blob não expurgado e NULL caso contrário, com índice único simples: MySQL admite múltiplos NULL em índice único e a semântica cai certa nos cinco bancos. É troca de uma migration, não de arquitetura.

Ciclo

Retenção, expurgo e LGPD

O framework não apaga nada fisicamente: quem herda BaseEntity é ISoftDeletable por construção, e o filtro global esconde o que foi marcado. Guardar arquivo por prazo indeterminado, porém, é obrigação legal invertida — a LGPD manda eliminar o dado quando a finalidade acaba. A conciliação é a lápide: o registro segue as regras do framework, e o que é apagado de verdade é o objeto no bucket, que não é entidade.

Os três modos de retenção
ModoQuando usarComo morre
UntilReleasedPadrão. Relatório gerado, planilha de importação, rascunho — o arquivo existe enquanto alguém aponta para ele.Última referência liberada e carência vencida — ou, se nunca houve referência, carência contada do veredito.
RetainUntilPrazo legal ou contratual: o consumidor calcula a data e a informa. No Nexio, os tipos de anexo semeados, com os cinco anos contados do fechamento do documento dono — menos SIGNED_CONTRACT, que é Permanent.Quando as duas coisas valem: retainUntil vencido e nenhuma referência ativa, com a carência contada da última liberação. A data é piso, não data de morte — documento ainda em uso não some porque o prazo passou.
PermanentExceção com nome. Nunca entra no job. No Nexio, o contrato assinado (SIGNED_CONTRACT).Só como ato de eliminação (RN-GF-25), com marcação erasure, motivo e trilha durável: pela rota, com purge:files e, em Permanent, purge-permanent:files (arquivo de produto: purge:product-files), de pessoa autenticada no contexto; em processo, pelo consumidor registrado, com dois autores declarados diferentes.

A quarentena tem prazo próprio: 365 dias

QuarantineRetentionDays, global na seção GrydFiles e nunca por perfil — perfil a perfil permitiria encurtar a guarda de evidência escolhendo o perfil certo, que é o tipo de porta que este módulo fecha. Um ano cobre um ciclo completo de auditoria de segurança e a janela de reanálise forense, sem transformar o bucket em depósito permanente de malware. O que expira é o binário: o scanSignature, o sha256, o tamanho, o tipo, as datas e todo o histórico de FileScanAttempt ficam na lápide para sempre. legalHold ligado trava também este expurgo, e o prazo é piso ao lado da guarda: vence o mais tarde entre os 365 dias e retainUntil, e em Permanent a quarentena não vence (RN-GF-24).

Retenção é piso

Em RetainUntil, o objeto só pode morrer quando as duas condições valem: retainUntil vencido e nenhuma FileReference ativa, com a carência contada da última liberação. A data garante que nada some antes do prazo; a referência garante que nada some enquanto está em uso. A FISPQ de um item ativo ou a certidão de um fornecedor vinculado continuam lá no sexto ano, porque ainda têm quem aponte para elas.

Quem encerra a guarda é o consumidor, e o módulo dá os dois gestos. Guarda contada de um fato do dono — "cinco anos a partir do fechamento do documento" — se escreve empurrando retainUntil para a data certa quando o fato acontece (a data só anda para frente, RN-GF-09) e liberando a referência quando o vínculo acaba. Referência que nunca é liberada segura o objeto para sempre, e isso é decisão do consumidor, não do módulo; no Nexio, quando o anexo de documento fechado libera a sua está na spec de Anexo.

O que não muda. UntilReleased morre pela última referência liberada mais a carência; Permanent fica fora do job; guarda de qualquer detentor trava tudo. E o pedido de eliminação por titular (LGPD) não espera a data: o consumidor libera a referência e pede o expurgo manual marcado como eliminação — o único que passa antes de retainUntil, o único que alcança Permanent e o único que alcança a quarentena (RN-GF-25) —, que recusa referência ativa e legalHold, apaga o nome original e é ato de controle, com autor, motivo e marcação na trilha. Expurgo manual sem a marcação respeita a data.

Carência antes de apagar

Referência liberada não expurga na mesma hora: há uma carência configurável em PurgeGracePeriodDays, padrão de 30 dias, em UntilReleased e em RetainUntil; arquivo que nunca teve referência conta a carência a partir do veredito. É o intervalo em que se descobre que a exclusão foi engano — e depois dele não há mais volta. A contagem parte de lastReferenceReleasedAt, denormalizado no próprio StoredFile, e não de uma agregação sobre FileReference: liberação é append, o histórico nunca encolhe, e varrer a tabela filha por candidato é exatamente o que o referenceCount existe para evitar. O índice (status, referenceCount, lastReferenceReleasedAt) resolve a seleção do job em index scan.

Assinatura nova alcança acervo liberado

Um arquivo liberado na segunda que o freshclam de quarta passa a detectar não pode continuar Available para sempre: o veredito é sobre uma base que muda todo dia. Por isso existe o job de re-scan de acervo: depois de uma atualização da base de assinaturas, ele reprocessa os Available em lotes, do mais recente para o mais antigo, com teto de trabalho por janela para não competir com a fila de upload.

O re-scan não tira o arquivo de Available. Enquanto a nova leitura acontece, o arquivo continua referenciável e baixável — ele já tinha veredito, e voltar a Scanning o sumiria da tela de quem o usa por causa de uma rotina. Resultado limpo, erro ou timeout gravam só o FileScanAttempt. Resultado positivo leva Available → Infected: o objeto vai para a quarentena, todas as referências ativas são liberadas na mesma transação — sem tocar em retainUntil nem legalHold (RN-GF-24) —, e o veredito é entregue a cada ownerScope que tinha referência ativa, dizendo que o arquivo era limpo e deixou de ser.

O acervo inteiro em toda atualização é caro e não é o objetivo: a política é configurável em RescanLookbackDays e RescanBatchSize, com o padrão cobrindo a janela em que um falso negativo ainda muda alguma decisão de negócio. Fora dessa janela, o caminho é o re-scan manual por registro.

A data só anda para frente

retainUntil pode ser estendido por quem tem a permissão, nunca encurtado por API. Encurtar prazo de guarda é como se apaga prova sem querer; quando é legítimo, o caminho é o ato de eliminação, que fica na trilha como ato de controle, com autor e motivo (RN-GF-25).

Apagamento por titular esbarra em obrigação legal

A LGPD prevê a eliminação a pedido, e prevê também a guarda para cumprimento de obrigação legal. Quem sabe qual das duas prevalece é o consumidor, não a plataforma — por isso o legalHold é ligado por ele. O módulo garante o mecanismo e a trilha; a decisão jurídica não é dele.

O que sobra na lápide

sha256, tamanho, tipo, perfil, datas, resultado do scan, a localização onde o objeto esteve e quem pediu o expurgo. É o suficiente para responder "existiu um arquivo aqui, este era o conteúdo, foi apagado nesta data por este motivo" — sem guardar o conteúdo. O nome original sai em toda lápide que nenhuma pessoa pediu — os cinco caminhos de job e a rejeição de tamanho (RN-GF-21): nome de arquivo carrega dado pessoal com frequência, e depois do expurgo não responde a nenhuma pergunta que o sha256 e as datas não respondam. No expurgo manual, sai quando o pedido traz redactFileName, e sempre no ato de eliminação por titular (RN-GF-25). E a lápide diz que tipo de autor a gravou — job, usuário autenticado ou autor declarado pelo consumidor, com o escopo e o executor de serviço (RN-GF-10). Por que o sha256 fica está em § Hash e referência.

Quem pediu, quando o autor é um job. Sete caminhos terminam em lápide: cinco feitos por job — retenção, Pending vencido, quarentena vencida, reprovação por tipo e objeto alterado depois da confirmação —, a rejeição de tamanho na confirmação, cujo autor é quem confirmou, e o expurgo manual. Job não tem usuário: o framework não tem conceito de usuário de sistema, e o ICurrentUserService.UserId devolve nulo fora de request autenticado. Então purgedAt e purgeReason são obrigatórios e gravados juntos, e purgedBy é nulo quando e somente quando purgeAuthorKind é Job — com a origem nomeada no motivo (job:retencao, job:pending-expirado, job:quarentena-vencida, job:tipo-reprovado, job:objeto-alterado) e a execução registrada na trilha do módulo (IFileAuditTrail). A repetição de um ato de pessoa por job do consumidor não é autor job: grava o autor declarado, marcado como Declared (RN-GF-27). Um GUID de sistema reservado foi avaliado e recusado: seria um identificador sem linha correspondente em Users, sem chave estrangeira possível, que alguma tela tentaria resolver em nome e receberia vazio.

Fluxo

Download

Uma chamada devolve uma URL pré-assinada de leitura, com validade em minutos. O serviço nunca faz proxy do conteúdo.

RegraDetalhe
Só AvailablePending, Scanning, Failed, Infected e Purged devolvem 409 com o código próprio de cada um — a tela precisa distinguir "ainda verificando" de "infectado". A resposta de Purged traz purgedAt, o motivo e o rejectionCode, quando houve reprovação.
Validade curtaPadrão de 5 minutos, configurável por perfil em DownloadUrlTtlMinutes. A URL é a credencial: quanto mais longa, mais tempo ela vale se for colada num chat.
Autorização é de quem chamaO módulo confere a permissão genérica read:files. Quem decide se este usuário pode ver este documento é o consumidor, antes de pedir a URL. O GrydFiles não conhece o Nexio e não tem como avaliar visibilidade de anexo — por isso o Nexio não mapeia /files/* e pede a URL pela IFileService, depois do CanRead do dono (RN-GF-23).
Nome no cabeçalhoContent-Disposition: attachment; filename*=UTF-8''… com o nome original do registro pedido, e Content-Type: application/octet-stream — nunca abertura no navegador. O Core já oferece a sobreposição (ver a linha abaixo).
Sobreposição de cabeçalho no CoreO ObjectStoragePresignedUrlRequest tem ResponseContentDisposition e ResponseContentType, implementados nos três provedores: S3 por ResponseHeaderOverrides, Azure por BlobSasBuilder (rscd/rsct) e GCS por response-content-disposition na URL v4. Sem ela o usuário baixaria a3f9….pdf em vez do nome que enviou.
Nunca sobre quarentenaNenhuma URL pré-assinada é emitida para objeto sob o prefixo _quarantine/, por nenhuma rota e com nenhuma permissão — purge:files e purge-permanent:files inclusive.
Cada emissão vai para a trilhaIFileAuditTrail.DownloadUrlIssuedAsync, gravado por IAuditStore.SaveAsync, com arquivo, usuário, IP e finalidade (RN-GF-12). Emissão de URL não é mudança de linha e não seria capturada pelo interceptor de SaveChanges — precisa ser explícita. Sem usuário identificado, a URL não é emitida (RN-GF-26).

Operação

Perfis e configuração

Perfil é configuração, não tabela. Nasce assim porque muda com deploy, não com uso: é o operador que decide o teto de tamanho, não o usuário. Personalização por tenant é gancho declarado — quando aparecer o primeiro caso real, vira tabela com a configuração como padrão.

Não existe perfil padrão. Não há opção de perfil padrão: a RN-GF-11 diz que não existe upload sem perfil e o profile é campo obrigatório, então o padrão nunca teria quando agir — é API sem chamador, que a premissa de "nada de código sem uso" classifica como defeito e não como preparação. Mantê-lo só criaria a categoria de bug "subiu no perfil errado por omissão", com os limites errados aplicados em silêncio.

O framework declara apenas perfis gryd.*. Os perfis nexio.* são configuração do produto e vivem no appsettings dele — pela decisão 1, o módulo não conhece o domínio, e o template da plataforma não deveria carregar a lista de tipos de anexo de um produto de compras. Aparecem abaixo como exemplo de consumidor.

builder.Services
    .AddGrydObjectStorage(builder.Configuration)  // já existe em Gryd.Infrastructure
    .AddGrydFiles(builder.Configuration)          // seção GrydFiles e connection string
    .AddGrydFilesClamAv(builder.Configuration);   // GrydFiles.Infrastructure.ClamAv — sem motor, o host não sobe
// Outro motor: .AddGrydFiles(builder.Configuration, f => f.UseScanner<CustomScanner>())
"GrydFiles": {
  "PurgeGracePeriodDays": 30,
  "QuarantineRetentionDays": 365,
  "ScanRequeueAfterMinutes": 45,
  "Scanner": {
    "TimeoutSeconds": 120,
    "RetryDelays": [ "00:01:00", "00:05:00", "00:15:00" ],
    "StreamMaxLengthBytes": 67108864,
    "MaxFileSizeBytes": 67108864,
    "MaxScanSizeBytes": 419430400,
    "MaxRecursion": 16,
    "MaxFiles": 10000,
    "ConcurrentDatabaseReload": true,
    "UseIdSession": false
  },
  "Profiles": {
    "gryd.report": {
      "MaxSizeBytes": 52428800,
      "AllowedContentTypes": ["application/pdf", "text/csv",
        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
      "ScanRequired": false,
      "DefaultRetentionMode": "UntilReleased"
    },
    "gryd.notification-attachment": {
      "MaxSizeBytes": 10485760,
      "AllowedContentTypes": ["application/pdf", "image/png", "image/jpeg", "text/csv"],
      "ScanRequired": true,
      "DefaultRetentionMode": "RetainUntil",
      "DefaultRetentionDays": 30
    }
  }
}

A seção Scanner é a fonte única do clamd.conf, que é renderizado a partir dela na subida do serviço — ver § Antivírus. StreamMaxLengthBytes menor que o maior MaxSizeBytes de qualquer perfil derruba a aplicação na inicialização.

Exemplo de consumidor · os perfis do Nexio

Vivem no appsettings do produto, não no template do framework.

"GrydFiles": {
  "Profiles": {
    "nexio.attachment": {
      "MaxSizeBytes": 26214400,
      "AllowedContentTypes": ["application/pdf", "image/png", "image/jpeg", "…"],
      "ScanRequired": true,
      "UploadUrlTtlMinutes": 15,
      "DownloadUrlTtlMinutes": 5,
      "DefaultRetentionMode": "UntilReleased"
    },
    "nexio.import": {
      "MaxSizeBytes": 20971520,
      "AllowedContentTypes": ["text/csv", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
      "ScanRequired": true,
      "DefaultRetentionMode": "RetainUntil",
      "DefaultRetentionDays": 90
    },
    "nexio.item-photo": {
      "MaxSizeBytes": 5242880,
      "AllowedContentTypes": ["image/png", "image/jpeg", "image/webp"],
      "ScanRequired": true,
      "DefaultRetentionMode": "UntilReleased"
    },
    "nexio.item-document": {
      "MaxSizeBytes": 26214400,
      "AllowedContentTypes": ["application/pdf", "image/png", "image/jpeg"],
      "ScanRequired": true,
      "DefaultRetentionMode": "RetainUntil",
      "DefaultRetentionDays": 1825
    },
    "nexio.supplier-document": {
      "MaxSizeBytes": 26214400,
      "AllowedContentTypes": ["application/pdf", "image/png", "image/jpeg"],
      "ScanRequired": true,
      "DefaultRetentionMode": "RetainUntil",
      "DefaultRetentionDays": 1825
    }
  }
}

gryd.notification-attachment e gryd.report são os dois perfis de plataforma; o primeiro atende a spec de Notificação. O GrydNotifications guarda o anexo de e-mail como storedFileId, gravado e lido pela IServerFileStore — a mesma troca por storedFileId que Item e Supplier fazem. Trinta dias de retenção, porque anexo de e-mail entregue não é prova de nada depois que o e-mail chegou.

Os cinco perfis do Nexio. nexio.attachment é o anexo solto; nexio.import, a planilha de carga, com vida curta. Os outros três existem porque arquivo com metadado próprio não é anexo: nexio.item-photo só aceita imagem e morre com a foto; nexio.item-document e nexio.supplier-document guardam FISPQ, laudo e certidão por cinco anos — o prazo de decadência do CTN, o mesmo número que o tipo fiscal do anexo usa. Nenhum deles dispensa o scan: nexio.supplier-document é justamente o que o fornecedor sobe pelo portal.

ScanRequired: false existe para o arquivo que o próprio sistema gera — o PDF do GrydReports, que saiu do nosso processo e não passou por ninguém de fora. Para qualquer arquivo recebido, ligar essa opção é decisão de quem assina o risco, e ela vai para a trilha na inicialização. Ela dispensa o antivírus e nada mais: o sha256 continua sendo calculado no servidor e a conferência de tipo por assinatura de conteúdo continua valendo (§ Validação).

API

Endpoints

A superfície HTTP existe para a SPA de produto cuja autorização sobre arquivo é a permissão do próprio módulo — como o cliente do GrydNotifications, que sobe o anexo por POST /files. Produto que autoriza arquivo pelo dono — o Nexio — não mapeia /files/* e não deixa papel de tenant receber nenhuma das quatro permissões do módulo: a pessoa sobe e baixa pelas rotas do produto, que chamam a IFileService depois do CanRead do dono (RN-GF-23). Módulo que roda no mesmo processo não passa por HTTP, e tem duas portas, conforme quem tem os bytes. Onze rotas. O status do scanner existe para dar chamador real ao FILE_SCAN_UNAVAILABLE, e o re-scan por registro, para tirar Failed da condição de terminal de fato.

As duas portas em processo
PortaQuem tem os bytesComo funcionaNo Nexio
IFileServiceO cliente, do outro lado da redeO mesmo serviço de aplicação das rotas: reservar, PUT na URL pré-assinada, confirmar. O tenant é nomeado pelo chamador, e igual ao que está em vigor quando há um (RN-GF-08); reservar, confirmar, emitir URL, expurgar e mexer em legalHold exigem usuário autenticado — guarda e eliminação aceitam, no lugar dele, o autor declarado do ato que o consumidor já gravou, marcado como declarado (RN-GF-27) —, e ler metadado não exige — é assim que o handler do veredito relê fora de request (RN-GF-26). Em processo não confere permissão de módulo: vale o consumidor registrado para o escopo, o ato e o autor marcado (RN-GF-28). É por ela que o módulo consumidor declara a referência esperada na confirmação, sobre o registro que o mesmo chamador reservou (RN-GF-22), e é a única que entrega arquivo a uma pessoa — pela URL de download, que vai para a trilhaTudo que a pessoa sobe pela tela — anexo, foto e documento de item, documento do fornecedor, planilha de importação — e todo download
IServerFileStoreO servidor, dentro do processoStoreAsync grava conteúdo que o processo gerou ou recebeu, OpenReadAsync devolve o conteúdo de arquivo Available para uso no servidor, só ao dono que tem referência ativa nele (RN-GF-22), ReleaseAsync libera a referência. O tenant é nomeado pelo chamador a partir do registro do próprio consumidor — funciona em job, sem request — e conferido contra o que está em vigor (RN-GF-08), e a referência esperada é obrigatória. O arquivo percorre o caminho normal: perfil e teto, registro Pending antes de mover byte, scan, sha256 e tipo conferidos no servidor. Sem autorização HTTP: o consumidor que a chama já respondeu pelo seu chamador. OpenReadAsync não é download — não emite URL nem grava na trilha de download; o que se faz com os bytes é trilha do consumidor. Em arquivo de produto, o chamador de plataforma usa StoreForPlatformOwnerAsync. Hoje a usam o GrydReports (PDF gerado) e o GrydNotifications (anexo de e-mail)Arquivo que o próprio Nexio gera ou recebe no servidor, sem pessoa do outro lado; e a leitura, no servidor, de arquivo já liberado — a planilha de importação processada por job
RotaPermissãoDevolveErros
POST /filesupload:filesfileId, uploadUrl pré-assinada, uploadExpiresAt e os cabeçalhos que o PUT precisa repetir400 · 403 · 422
POST /files/{id}/completeupload:filesStoredFileDto em Scanning. Idempotente: chamar duas vezes devolve o mesmo estado404 · 409 · 422
GET /files/{id}read:filesMetadado, estado, resultado do scan e rejectionCode. Nunca objectKey nem bucketName404
GET /files/{id}/download-urlread:filesURL assinada de leitura com validade curta. Exige purpose (400 sem código se faltar). Registra a emissão na trilha. Em arquivo de produto exige read:product-files e a rota durável de plataforma400 · 403 · 404 · 409 · 503
POST /files/{id}/referencesupload:filesA referência criada. Idempotente por (escopo, chave). Só para ownerScope gerido pela superfície HTTP (RN-GF-23), com a posse da RN-GF-22403 · 404 · 409
DELETE /files/{id}/references/{referenceId}upload:files204. Libera, não apaga — a linha fica com releasedAt. Referência de escopo que não é gerido pela superfície HTTP é recusada (RN-GF-23)403 · 404
GET /files/{id}/scan-attemptsread:filesO histórico de tentativas, ordenado por attemptNumber decrescente e paginado (20 por página): o re-scan de acervo acrescenta uma tentativa a cada passagem, e o histórico de um arquivo antigo não tem teto. page menor que 1 → 400 sem código. A página é cortada no banco400 · 404
PUT /files/{id}/retentionretain:filesEstende retainUntil, ou liga e retira a guarda da plataforma, que é um detentor ao lado dos consumidores (RN-GF-27). Só para frente. Ligar ou retirar exige motivo; retirar exige release-hold:files e nunca alcança a guarda de consumidor. Só aceita ligar em Available e Infected; em Pending, Scanning e Failed responde FILE_NOT_AVAILABLE_CONFLICT403 · 404 · 409 · 422 · 503
POST /files/{id}/purgepurge:filesExpurgo manual com motivo obrigatório e redactFileName opcional. Antes de retainUntil, e em Permanent, só como ato de eliminação, com a marcação erasure; em Permanent, também com purge-permanent:files (RN-GF-25); arquivo de produto exige purge:product-files; sem a marcação, só Available; como eliminação, também Infected e Failed; recusa com referência ativa ou guarda de qualquer detentor. O 422 do autor declarado inválido só existe em processo; pela rota o autor é o usuário autenticado403 · 404 · 409 · 503
GET /files/scanner-statusread:filesMotor, versão e idade da base de assinaturas e profundidade da fila de scan, a partir do PING e do VERSION. É o chamador do FILE_SCAN_UNAVAILABLE, e é o que permite à SPA dizer "verificação indisponível, seu envio ficará na fila" em vez de deixar o usuário no escuro503
POST /files/{id}/rescanupload:filesPede nova varredura do registro. Em Failed, é o caminho de saída: o registro volta a Scanning. Em Available, é o re-scan manual de acervo: o registro continua Available durante a leitura, e só detecção ou divergência de tamanho confirmada o movem, para Infected (RN-GF-17, RN-GF-18) — leitura interrompida por E/S é tentativa com erro, e o registro segue Available. Idempotente enquanto já há varredura pedida. Pending, Infected e Purged recusam — o primeiro não tem objeto confirmado, e os outros dois são terminais404 · 409

Não existe DELETE /files/{id}. Apagar arquivo é expurgo, tem motivo obrigatório e permissão própria — dar a ele o verbo mais fácil da API seria convidar ao acidente.

Contrato

Contrato de erros

Mesma convenção do resto da plataforma: o status HTTP vem do código, nunca da mensagem. O GrydBaseController testa primeiro uma lista de códigos canônicos explícitos e depois os onze sufixos do ErrorCodeSuffixes (o décimo primeiro, _PRECONDITION_FAILED → 412, é do SSO; nenhum código do módulo o usa): _NOT_FOUND → 404 · _ALREADY_EXISTS, _ALREADY_ASSIGNED e _CONFLICT → 409 · _FORBIDDEN, _ACCESS_DENIED e _BLOCKED → 403 · _UNAUTHORIZED → 401 · _UNPROCESSABLE → 422 · _UNAVAILABLE → 503 · sem sufixo → 400.

Código exato vence sufixo. Na lista de códigos explícitos, USER_EMAIL_UNAVAILABLE responde 409, por decisão anti-disclosure: dizer "serviço indisponível" para um e-mail já cadastrado entregaria a existência da conta. É essa precedência que deixa o _UNAVAILABLE → 503 conviver com ele, e é ela que qualquer mudança no mapeador precisa preservar.

O caminho assíncrono não tem request para responder. A conferência de tipo acontece no job de scan, depois de o cliente já ter recebido 200 da confirmação. Nesse caminho o código não vira status HTTP: ele é gravado em rejectionCode no registro e devolvido no corpo do FILE_PURGED_CONFLICT e no GET /files/{id}. Sem isso, quem subiu um .exe renomeado receberia a mesma resposta de um arquivo apagado por retenção há dois anos.

CódigoHTTPQuando
FILE_NOT_FOUND404Não existe neste tenant. Arquivo de outro tenant responde igual, de propósito
FILE_CALLER_UNAUTHORIZED401Operação da IFileService que a trilha nomeia — reservar, confirmar, emitir URL, expurgar, legalHold — chamada sem usuário autenticado e sem autor declarado, ou com autor declarado diferente do usuário do contexto (RN-GF-26, RN-GF-27). Pela rota não acontece, porque a autenticação vem antes: é o erro do chamador em processo
FILE_OWNER_SCOPE_FORBIDDEN403Rota HTTP de referência sobre ownerScope que não é gerido pela superfície HTTP (RN-GF-23). Vale para criar e para liberar — e para retirar pela rota guarda que é só de consumidor (RN-GF-27), e para ownerScope sem consumidor registrado chamado em processo (RN-GF-28)
FILE_PROFILE_UNKNOWN400Perfil não está na configuração. Erro de programação do consumidor, não do usuário
FILE_CONTENT_TYPE_NOT_ALLOWED_UNPROCESSABLE422Tipo fora da lista do perfil — no declarado, ou no verificado durante o scan
FILE_TOO_LARGE_UNPROCESSABLE422Acima do teto do perfil. A mensagem carrega o teto, para a tela poder dizer qual é
FILE_SIZE_MISMATCH_UNPROCESSABLE422O tamanho real não bate com o declarado. O objeto é apagado e o registro vai para Purged
FILE_UPLOAD_EXPIRED_CONFLICT409Confirmação depois de uploadExpiresAt. O caminho é reservar de novo
FILE_NOT_UPLOADED_CONFLICT409Confirmação sem objeto no bucket — o PUT não aconteceu ou falhou
FILE_NOT_AVAILABLE_CONFLICT409Uso de arquivo que ainda está em Pending, Scanning ou Failed. É o estado normal de "espere um pouco"
FILE_INFECTED_CONFLICT409Código separado do anterior de propósito: a tela precisa dizer o que houve, e o suporte precisa distinguir fila de incidente
FILE_PURGED_CONFLICT409O registro existe, o conteúdo não. A resposta traz purgedAt, o motivo e o rejectionCode, quando o arquivo morreu por reprovação e não por retenção
FILE_STILL_REFERENCED_CONFLICT409Expurgo com referência ativa. A resposta lista os ownerScope que ainda apontam — sem isso ninguém descobre quem está segurando
FILE_LEGAL_HOLD_CONFLICT409Expurgo, inclusive a eliminação, com guarda ativa de qualquer detentor. A resposta lista os detentores, porque cada um retira só a sua (RN-GF-27). Ligar guarda nunca recebe este código: acrescenta a guarda de quem liga
FILE_ERASURE_DUAL_CUSTODY_CONFLICT409Eliminação de arquivo Permanent em processo sem dois autores declarados, ou com quem registrou o pedido igual a quem o executou (RN-GF-25). Nada é apagado. A consulta de pré-condição responde o mesmo código no modo execução; no modo registro a falta do segundo autor não é recusa
FILE_ERASURE_AUTHOR_INVALID_UNPROCESSABLE422Autor declarado da eliminação que não é usuário existente e ativo no tenant conferido — o do arquivo e, em arquivo de produto (tenantId nulo), o do consumidor que pede (RN-GF-25). Id vazio, id inexistente, usuário inativo ou usuário de outro tenant. Vale para os dois autores da dupla custódia e para o autor único do caminho RetainUntil; nada é apagado, e a consulta responde o mesmo nos dois modos
FILE_REFERENCE_NOT_ALLOWED_CONFLICT409Referência esperada ou direta recusada: registro fora de Pending, reservado por outro chamador, já esperado por outro dono, ou Available sem posse nem referência de origem — e leitura em processo por dono sem referência ativa no arquivo (RN-GF-22). Arquivo de outro tenant continua FILE_NOT_FOUND
FILE_RETENTION_NOT_EXPIRED_CONFLICT409Expurgo manual com retainUntil futuro, ou de arquivo Permanent, sem a marcação de eliminação (RN-GF-25). A resposta traz a data, para a tela dizer até quando
FILE_OBJECT_CHANGED_UNPROCESSABLE422Só em rejectionCode, nunca em resposta de request: o objeto mudou depois da confirmação — leitura do scan que passa de sizeBytes ou termina antes dele, ou versão diferente da lida (RN-GF-18). O arquivo não é liberado — no re-scan de Available, vai a Infected, nunca a Purged —, e a ocorrência vai para a trilha como incidente — objeto confirmado só é reescrito por URL vazada ou cliente malicioso
FILE_RETENTION_SHORTENED_UNPROCESSABLE422Tentativa de puxar retainUntil para trás
FILE_SCAN_UNAVAILABLE503Dependência externa fora do ar não é erro do cliente: pelo sufixo _UNAVAILABLE, responde 503. Chamador único: GET /files/scanner-status. Nenhuma outra rota fala com o clamd de forma síncrona; arquivo parado em Scanning continua respondendo FILE_NOT_AVAILABLE_CONFLICT
FILE_AUDIT_TRAIL_UNAVAILABLE503Download, eliminação ou retirada de guarda de arquivo de tenant com a trilha padrão (NullAuditStore) registrada, sem armazenamento durável: o ato não teria registro (RN-GF-12, RN-GF-25, RN-GF-27); o host nem sobe nessa composição, e a recusa fica como defesa em profundidade. Em arquivo de produto, também download e expurgo sem a rota durável de plataforma. Nada é apagado nem desligado. Ligar guarda nunca recebe este código
FILE_RESCAN_NOT_APPLICABLE_CONFLICT409Re-scan pedido sobre Pending, Infected ou Purged. A resposta traz o estado atual, porque a tela precisa dizer por que não dá — "ainda não subiu", "é terminal" e "não existe mais conteúdo" são três conversas diferentes

Segurança

Permissões

PermissãoO que libera
upload:filesReservar, confirmar e gerir referência sobre arquivo do tenant. Pela rota, referência só de escopo gerido pela superfície HTTP (RN-GF-23)
read:filesLer metadado, pedir URL de download, listar tentativas de scan e consultar o status do scanner. Não é autorização de negócio — quem decide se este usuário pode ver este documento é o consumidor. Por isso produto que autoriza pelo dono — o Nexio — não deixa papel de tenant receber nenhuma permissão do módulo (RN-GF-23)
retain:filesEstender retenção e ligar legal hold. Separada porque prende custo de armazenamento por anos
release-hold:filesRetirar a guarda da plataforma, além de retain:files. Fora de todo modelo de papel: o administrador do tenant a dá a alguém de propósito, ou ninguém a tem. Nunca alcança a guarda de consumidor, que só o consumidor retira (RN-GF-27)
purge:filesExpurgo manual pela rota. É a que destrói conteúdo
purge-permanent:filesAto de eliminação de arquivo Permanent pela rota, além de purge:files e da marcação de eliminação (RN-GF-25). Fora de todo modelo de papel
upload:product-files · read:product-files · retain:product-files · purge:product-filesPermissões de plataforma (platform_permission) sobre arquivo de produto (tenantId nulo), de qualquer sessão. Nenhuma permissão de tenant alcança arquivo de produto, e nenhuma destas alcança arquivo de tenant; retain:product-files cobre ligar e retirar a guarda da plataforma, e purge:product-files cobre o Permanent de produto

Seis permissões de tenant e quatro de plataforma (ADR 0011, D10, no código desde 5ff4c74a; read:product-files na 10.2.0), e nenhuma que libere arquivo sem scan. A ausência é a decisão. Sobre as menções a admin:system: o texto das seções já usa as permissões específicas; onde o histórico de revisões ou as decisões de detalhamento ainda dizem admin:system, leiam-se assim — arquivo de produto: a permissão de plataforma *:product-files da operação; retirar a guarda da plataforma: release-hold:files; eliminação de Permanent pela rota: purge-permanent:files. A regra continua a mesma: só de pessoa autenticada no contexto, nunca de autor declarado, nunca de serviço.

Em processo. Pela RN-GF-28, as permissões do módulo são policies da superfície HTTP e não são conferidas em processo. O código atual confere as concessões da sessão dentro da IFileService (§ Estado da implementação, "Permissão em processo"); a reconciliação é a decisão D-01 do backlog de conformidade.

Forma dos códigos. {ação}:{recurso}, com o recurso no plural — upload:files, read:files, retain:files, purge:files —, que é a forma do código (FilePermissions) e a do catálogo semeado da plataforma (read:users, update:tenants). O PermissionCodeParser do GrydAuth corta no primeiro : e lê o que vem antes como ação, e a tela de papéis exibe "Upload Files"; upload, retain e purge estão no ActionOrderMap do PermissionCatalogMetadata para manter a ordenação. A linha de permissão é por tenant, e as de tenant são semeadas com backfill idempotente para os tenants existentes; as de plataforma existem só em papel de plataforma.

Invariantes

Regras de negócio

RN-GF-01Nenhum arquivo é referenciável antes do veredito

FileReference só pode ser criada sobre Available. É a invariante central: tudo o mais no módulo existe para sustentá-la. A primeira referência é criada pelo próprio módulo, na saída para Available: o consumidor declara a intenção na confirmação e nunca a referência, e por isso não existe caminho em que ele precise contrariar esta regra para funcionar.

RN-GF-02O hash é sempre calculado pelo servidor

Na mesma leitura do antivírus, a partir do objeto gravado. Hash informado pelo cliente é ignorado, mesmo quando enviado.

RN-GF-03Tipo declarado que não bate com o conteúdo reprova o arquivo

A conferência é por assinatura de conteúdo, sempre — inclusive com ScanRequired: false, porque a leitura única já acontece para o hash. O objeto é apagado e o registro termina em Purged. No caminho síncrono (tipo declarado, antes da URL) a resposta é 422; no assíncrono (tipo verificado, no job) não há request para responder, e o motivo fica em rejectionCode.

RN-GF-04Infected é terminal

Não existe transição de volta, nem por permissão elevada. O objeto vai para a quarentena e o motivo fica gravado para sempre: o nome da assinatura, na detecção pelo motor, ou o rejectionCode, no incidente de integridade do re-scan (RN-GF-18), que não tem assinatura.

RN-GF-05Falha de infraestrutura nunca libera arquivo

clamd indisponível produz Failed e nova tentativa. Não há modo degradado, timeout que libere, nem flag de emergência. Sem motor, não há módulo: o host não sobe sem um motor de antivírus registrado. E motor registrado precisa ser motor de verdade: implementação que responde "limpo" sem ler o conteúdo libera tudo, e o registro de motor alternativo aceita qualquer implementação — por isso o monitor do scanner submete a amostra EICAR ao motor registrado, e motor que não a detecta é tratado como indisponível. Critérios de aceite: (a) host com o GrydFiles e sem motor registrado → falha na inicialização, com a mensagem que nomeia o registro que falta; (b) motor registrado que não detecta EICAR → o monitor o marca indisponível, o scan não libera nenhum arquivo (Failed e nova tentativa) e o operador é alertado.

RN-GF-06O binário não passa pela API

Subida e descida pela superfície HTTP sempre por URL pré-assinada: nenhuma rota aceita ou devolve o conteúdo do arquivo. Exceção declarada: a IServerFileStore, em processo, para bytes que o servidor já tem. StoreAsync e OpenReadAsync movem bytes e só servem a consumidor declarado; ReleaseAsync não move byte e pode ser alcançado por rota do próprio dono — DELETE /reports/{id} apaga o relatório e libera a referência dele. O que o teste de arquitetura prova, e onde: (a) no GrydFiles.API, nenhum tipo depende do espaço de nomes da porta e nenhuma action recebe ou devolve conteúdo (IFormFile, Stream, byte[], FileResult); (b) no repositório do Gryd.IO, os assemblies que dependem da porta são exatamente os consumidores declarados — hoje seis: GrydReports.Application, GrydReports.Infrastructure, GrydNotifications.Application, GrydNotifications.Infrastructure, GrydAudit.Export e GrydAudit.Integrity (DEC-GF-06-1) —, e consumidor novo quebra o teste até ser declarado; produto que usa a porta repete a asserção (b) no próprio repositório. Que nenhuma rota do consumidor devolva os bytes que ele leu não se prova daqui: cada consumidor declarado responde por isso no próprio host. Critérios de aceite: assembly fora da lista que chama OpenReadAsync → o teste falha; action do GrydFiles.API que devolve Stream → o teste falha.

RN-GF-07objectKey e bucketName nunca saem do módulo

Não aparecem em DTO, resposta de API nem log de aplicação. Quem consome recebe fileId e, quando precisa, uma URL que vence. Dentro do módulo, a localização é preservada na lápide.

RN-GF-08Nenhum arquivo atravessa tenant

Objeto, registro e referência pertencem a um único tenant — ou ao produto, com tenantId nulo. Arquivo de outro tenant responde FILE_NOT_FOUND, igual a arquivo inexistente, e nenhum objeto é reaproveitado entre clientes, mesmo com sha256 idêntico: seria canal de inferência. Leitura sem tenant falha fechada: consulta sem tenant no contexto não devolve registro de tenant nenhum — só arquivo de produto —, e job que atravessa tenants (scan, varreduras) desliga o filtro só para selecionar candidatos, trata cada registro no tenant dele e nunca entrega veredito nem chama consumidor com o filtro desligado. É o requisito de tenant restaurado e leitura que falha fechada da página de Mensageria. Tenant nomeado é o tenant em vigor. As duas portas em processo recebem o tenant nomeado pelo chamador, a partir do registro do próprio consumidor — é o único contrato, porque job e repetição depois do commit rodam sem request e não têm contexto para ler. Nomear não é escolher: havendo tenant em vigor no contexto, o tenant nomeado precisa ser igual a ele, e diferente responde FILE_NOT_FOUND, nunca a leitura atendida; não havendo tenant em vigor, o escopo do tenant nomeado é aberto antes da leitura e a leitura acontece dentro dele. Nenhuma porta desliga o filtro de tenant para atender o tenant do parâmetro — desligar o filtro continua valendo só para o job que seleciona candidatos, que trata cada registro no tenant dele. Critério de aceite: IFileService.GetAsync sem tenant no contexto, sobre arquivo de um tenant → FILE_NOT_FOUND; IServerFileStore.OpenReadAsync com tenant nomeado diferente do tenant em vigor → FILE_NOT_FOUND e nada lido; a mesma chamada sem tenant em vigor → o escopo do tenant nomeado é aberto e a leitura alcança só arquivo dele.

RN-GF-09Retenção só se estende

retainUntil nunca é puxado para trás por API, e Permanent jamais entra no job de expurgo.

RN-GF-10Expurgo apaga o objeto e preserva a lápide, e a lápide diz que tipo de autor a gravou

Nunca o contrário. purgedAt, purgeReason e purgeAuthorKind são gravados juntos, no mesmo método. purgeAuthorKind é Job, Authenticated — o usuário autenticado no contexto — ou Declared — o autor que o consumidor declarou na chamada em processo (RN-GF-27). purgedBy é nulo quando e somente quando o tipo é Job; com Declared, purgeDeclaredScope (o escopo que declarou) e purgeServiceExecutor (o executor de serviço que chamou) são obrigatórios, e nos outros dois tipos são nulos. Na eliminação com dupla custódia (RN-GF-25), purgedBy é quem executou e erasureRequestedBy, quem registrou o pedido. Nenhuma leitura da lápide ou da trilha apresenta autor declarado como usuário autenticado: o tipo acompanha o autor. Registro fora de Pending, sem purgedAt e cujo objeto não existe é defeito, e a varredura de consistência o reporta. Critérios de aceite: (a) expurgo pelo job de retenção → Job e purgedBy nulo; (b) expurgo pela rota → Authenticated, purgedBy do usuário, escopo e executor nulos; (c) eliminação repetida pelo job do consumidor, sem usuário e com o autor declarado → Declared, purgedBy do autor, e a lápide e a trilha mostram os dois: o escopo que declarou e o executor de serviço.

RN-GF-11Todo arquivo tem perfil, e todo perfil tem teto

Não existe upload sem perfil, nem perfil sem tamanho máximo, lista de tipos e modo de retenção padrão. O perfil é sempre explícito: não há perfil padrão para o qual cair por omissão, e perfil incompleto derruba a aplicação na inicialização.

RN-GF-12Emissão de URL de download vai para a trilha

Preparada pela IFileAuditTrail do módulo na outbox e entregue a IAuditStore.SaveAsync depois do commit, porque leitura não passa por SaveChanges e o interceptor não a veria. A gravação durável e isolada por tenant é do GrydAudit: o armazenamento padrão, NullAuditStore, não guarda nada, por isso o host que compõe o módulo com ele não sobe e a URL não é emitida sem trilha durável. Quem baixou o quê é a pergunta que uma auditoria de vazamento faz primeiro.

RN-GF-13A referência nasce na liberação, e o veredito chega ao consumidor depois do commit

Quem consome declara, no processo, uma referência esperada (expectedOwnerScope · expectedOwnerKey) — ao confirmar ou ao gravar conteúdo do servidor, e só sobre registro que o mesmo chamador reservou (RN-GF-22) —, que não referencia nada e nunca vem da rota HTTP. Na saída para Available, a mesma transação que muda o estado cria a FileReference e zera a referência esperada. Em toda transição de veredito grava-se, na mesma transação, o registro do veredito a entregar, que guarda o tenant do arquivo e o dono — ownerScope e ownerKey da referência esperada, ou de cada referência que o re-scan positivo liberou —, porque o veredito negativo zera a referência esperada e, sem o dono no registro, ninguém saberia a quem avisar. Todo desfecho — Available, Infected, reprovação e Failed — é entregue ao contrato de veredito do ownerScope depois do commit, pelo menos uma vez. O handler é idempotente e decide pelo estado atual do arquivo, não pelo conteúdo da mensagem. Roda fora de request, com o tenant gravado no registro do veredito restaurado e sem usuário: relê o arquivo pela IFileService.GetAsync, que não exige usuário (RN-GF-26), não executa comando em nome de pessoa (Ata B3) e falha fechado sem tenant; dono que não existe mais, ou que não aponta para este arquivo, é desfecho normal (libera a referência e confirma). Efeito do consumidor que falha não desfaz o veredito: a entrega se repete. ownerScope sem contrato registrado derruba a inicialização. Critérios de aceite: (a) processo derrubado entre o commit do veredito e a entrega → o handler recebe o veredito quando o processo volta; (b) a mesma entrega duas vezes → um efeito só; (c) entregas de Available e Infected do mesmo arquivo em ordem trocada → o consumidor termina em Infected; (d) handler sem tenant no contexto → nenhuma leitura, erro e nova tentativa; (e) Failed com tentativas esgotadas → o consumidor recebe o veredito; (f) veredito Infected sobre arquivo com referência esperada → a entrega chega ao ownerScope e à ownerKey declarados, embora o registro do arquivo já não guarde a referência esperada; (g) handler sem usuário no contexto e com tenant restaurado → relê o arquivo e conclui.

RN-GF-14Retenção é piso: data vencida não expurga arquivo em uso

Em RetainUntil, o expurgo automático exige retainUntil vencido e nenhuma referência ativa, com a carência contada da última liberação. Referência ativa segura o objeto qualquer que seja a data; a data segura o objeto qualquer que seja a contagem. Guarda contada de um fato do dono se escreve empurrando retainUntil quando o fato acontece e liberando a referência quando o vínculo acaba. Critérios de aceite: (a) retainUntil vencido e uma referência ativa → o job não expurga; (b) referência liberada depois da data → expurgo só depois da carência; (c) referência liberada antes da data → expurgo só depois da data.

RN-GF-15O predicado do expurgo automático

O job de retenção expurga um registro Available quando valem todas: nenhuma referência ativa; carência vencida desde a última liberação — ou desde o veredito, se nunca houve referência; retainUntil vencido, no modo RetainUntil; legalHold desligado; e modo diferente de Permanent. O predicado é reavaliado sob a trava do registro, no momento de apagar (RN-GF-20).

RN-GF-16Número sem regra vigente

Pertencia à unicidade do conteúdo do dedupe, que não faz parte da v1 (bloco v2 em § Hash e referência). Não é reaproveitado por outra regra.

RN-GF-17Failed não é terminal, e veredito limpo não é eterno

Esgotadas as três tentativas automáticas, POST /files/{id}/rescan recoloca o registro na fila — sem essa saída, falha de infraestrutura viraria arquivo morto que nenhum consumidor consegue abrir nem substituir. O acervo liberado é reprocessado pelo job de re-scan depois de atualização da base de assinaturas. Re-scan de Available não muda o estado: grava o FileScanAttempt, e o arquivo segue referenciável e baixável — inclusive quando a leitura é interrompida por erro de E/S, que é tentativa com erro e não veredito; só a detecção pelo motor ou a divergência de tamanho confirmada (RN-GF-18) o levam a Infected, liberando todas as referências ativas na mesma transação e entregando o veredito a cada ownerScope que as tinha. Critério de aceite: arquivo Available com duas referências ativas, re-scan com EICAR na base de teste → estado Infected, objeto na quarentena, as duas referências liberadas e dois vereditos entregues; durante um re-scan limpo, GET /files/{id}/download-url continua respondendo.

RN-GF-18O objeto servido é exatamente o objeto escaneado

Depois do veredito, nenhum caminho de escrita alcança o objeto que é servido e referenciado: a URL de upload, que continua válida até vencer, não troca o conteúdo de arquivo já liberado, e o sha256 gravado é o dos bytes que o download entrega. A URL de upload é assinada para um objeto, uma operação, o Content-Type e — onde o provedor assina tamanho — o tamanho declarado; onde não assina, a confirmação confere o tamanho. Em todo provedor, o scan lê no máximo sizeBytes + 1 bytes: leitura que passa de sizeBytes, ou que chega ao fim antes dele sem erro de E/S, recusa o arquivo com FILE_OBJECT_CHANGED_UNPROCESSABLE — um PUT depois da confirmação não passa por conferência nenhuma, e a SAS do Azure não assina tamanho. Objeto que mudou depois da leitura do scan não é liberado: o registro que ainda não foi liberado termina em Purged com rejectionCode = FILE_OBJECT_CHANGED_UNPROCESSABLE, e a ocorrência vai para a trilha. No re-scan de arquivo já liberado, nunca Purged: divergência de tamanho confirmada sobre Available é incidente de integridade. Confirmada quer dizer: a leitura passou de sizeBytes — interrupção nenhuma produz bytes a mais —, ou terminou antes dele sem erro de E/S e o tamanho que o storage informa para o objeto também difere de sizeBytes. Leitura cortada por erro de E/S, timeout ou queda de conexão não é divergência: grava a tentativa com erro e o arquivo segue Available (RN-GF-17). No incidente, o arquivo sai do download pelo caminho do re-scan positivo (Available → Infected, com o objeto lido copiado para a quarentena como evidência, rejectionCode = FILE_OBJECT_CHANGED_UNPROCESSABLE e o veredito entregue a cada dono), e a guarda continua valendo: retainUntil, legalHold e Permanent seguram o registro como seguram qualquer quarentena (RN-GF-14, RN-GF-24). Expurgar ali apagaria a prova justamente do arquivo que precisa de investigação. Vale também para a quarentena e para a lápide: o objeto copiado para a quarentena é a versão que o scan leu — cópia a partir de chave que nenhuma URL de escrita alcança, ou condicionada à versão lida no scan (ETag no S3 e no Azure, generation no GCS) —, e o sha256 que a lápide da quarentena guarda é o do conteúdo guardado. Cópia recusada pela condição não põe na quarentena conteúdo que o scan não leu: o registro fica Infected, nunca é servido, e a ocorrência vai para a trilha como incidente. Critérios de aceite, nos três provedores: (a) PUT repetido com a mesma URL depois de Available → o download entrega os bytes cujo sha256 está gravado; (b) PUT repetido entre a confirmação e o veredito → ou o conteúdo servido é o escaneado, ou o arquivo não é liberado — nunca liberado com conteúdo que o scan não leu; (c) PUT maior que o declarado, antes ou depois da confirmação → recusado pelo storage, na confirmação ou na leitura do scan, e nunca liberado nem servido; (d) PUT entre a leitura do scan positivo e a cópia para a quarentena → o objeto da quarentena tem o sha256 gravado, ou a cópia é recusada e o incidente está na trilha; (e) re-scan de Available com retainUntil futuro cuja leitura passa de sizeBytes → Infected, fora do download, incidente na trilha, retainUntil intacto e nenhum Purged; (f) re-scan de Available com a conexão com o storage cortada no meio da leitura → tentativa com erro gravada, arquivo Available e nenhum veredito entregue. O exemplo de implementação está em § Upload.

RN-GF-19Nenhum arquivo fica parado na fila

Registro em Scanning sem varredura em andamento nem agendada, ou em Failed com tentativa automática restante e nenhuma agendada, volta à fila sozinho depois de ScanRequeueAfterMinutes — padrão de 45 minutos, sempre maior que a soma das esperas de re-tentativa e dos timeouts (hoje cerca de 29 minutos), para não duplicar trabalho em curso. A recolocação é idempotente: o job de scan ignora registro que já tem veredito. Failed com as três tentativas esgotadas não volta sozinho: fica com alerta ao operador e sai pela RN-GF-17. Critério de aceite: processo derrubado entre o commit da confirmação e o enfileiramento → o arquivo recebe veredito sem ação humana, em até ScanRequeueAfterMinutes mais o intervalo da varredura.

RN-GF-20Referência nova, guarda nova e expurgo nunca se cruzam

Criar referência e expurgar disputam o mesmo registro e são mutuamente exclusivos: o expurgo trava o registro e reavalia o predicado sob a trava; a criação de referência trava o mesmo registro — ou confere a sua versão — e recusa com FILE_PURGED_CONFLICT se o expurgo venceu. Ligar e retirar guarda disputam o mesmo registro. Ligar ou retirar guarda — em processo ou pela rota — trava o registro, e o expurgo reavalia as guardas por detentor sob a trava, nunca com o retrato que a consulta de pré-condição devolveu (RN-GF-25): a consulta não reserva nada. Guarda ligada entre a consulta e o expurgo vence, e a recusa por guarda nova não é um erro qualquer: o expurgo responde FILE_LEGAL_HOLD_CONFLICT e a resposta devolve o estado — os detentores ativos e purgedAt nulo —, para o consumidor tratar o pedido como bloqueado, e nunca como concluído. O consumidor só publica a eliminação quando a resposta traz purgedAt; do lado do Nexio, é assim que o Anexo deixa de publicar attachment.erased sobre bytes presos. Retirar guarda corre a mesma trava. Sem ela, o expurgo concorrente recusaria trazendo um detentor que acabou de sair, e o consumidor trataria como bloqueado um pedido preso por guarda que já não existe: a lista de detentores que a recusa devolve é sempre a de depois da retirada que venceu a trava. E a retirada sobre registro que o expurgo já venceu é recusada com FILE_PURGED_CONFLICT, como a criação de referência — sobre lápide não há guarda a retirar e nada muda; a repetição sem efeito da RN-GF-27 vale sobre registro vivo. Critério de aceite: criação de referência e job de expurgo concorrentes sobre um arquivo sem referência e com carência vencida → ou a referência existe e o objeto também, ou o registro está Purged e a criação respondeu FILE_PURGED_CONFLICT; nunca referência ativa sobre objeto apagado. Ligar guarda e expurgo concorrentes sobre o mesmo arquivo → ou a guarda está ativa e o objeto também, com a recusa do expurgo trazendo o detentor e purgedAt nulo, ou o registro está Purged e o pedido de guarda respondeu FILE_PURGED_CONFLICT; nunca guarda ativa sobre objeto apagado, nem purgedAt gravado com guarda ativa. Retirar guarda e expurgo concorrentes sobre arquivo cuja última guarda é essa → ou a retirada gravou primeiro e o expurgo, reavaliando sob a trava, apagou, ou o expurgo gravou primeiro e a retirada respondeu FILE_PURGED_CONFLICT; a recusa do expurgo por guarda nunca lista detentor já retirado. Duas retiradas concorrentes sobre o mesmo arquivo — a repetição do mesmo detentor, ou dois detentores ao mesmo tempo — serializam na mesma trava: cada guarda sai uma vez, a segunda chamada do mesmo detentor não produz efeito novo e devolve o estado atual (RN-GF-27), e o legalHold derivado nunca fica ligado sem detentor ativo nem desligado com detentor ativo.

RN-GF-21Expurgo automático e reprovação apagam o nome original

Toda lápide gravada sem pedido de expurgo — pelos cinco caminhos de job (job:retencao, job:pending-expirado, job:quarentena-vencida, job:tipo-reprovado e job:objeto-alterado) e pela rejeição de tamanho na confirmação (rejeicao:tamanho-divergente) — sai sem originalFileName. No expurgo manual, o nome sai quando o pedido traz redactFileName, e sempre no ato de eliminação (RN-GF-25). O sha256 fica na lápide como prova de integridade (§ Hash e referência). Critério de aceite: depois de cada um desses seis caminhos, GET /files/{id} devolve a lápide com originalFileName nulo.

RN-GF-22Só quem reservou diz a quem o arquivo pertence

A referência esperada só é declarada sobre registro em Pending que o mesmo chamador reservou (createdBy), no mesmo tenant, na confirmação pela IFileService — ou na própria gravação pela IServerFileStore, que cria o registro. Referência direta sobre Available é só do mesmo chamador que reservou, ou propagação a partir de uma referência ativa do mesmo ownerScope no mesmo arquivo, que o chamador nomeia. Fora disso — registro fora de Pending, reservado por outra pessoa, já esperado por outro dono, ou Available sem posse nem referência de origem — a resposta é FILE_REFERENCE_NOT_ALLOWED_CONFLICT (409) e nada muda; arquivo de outro tenant continua respondendo FILE_NOT_FOUND (RN-GF-08). A mesma confirmação repetida pelo mesmo chamador, com o mesmo dono, devolve o estado atual. Sem esta regra, o fileId que chega do cliente amarra ao documento de uma pessoa o arquivo que outra subiu, e o CanRead do dono verdadeiro fica contornado. Vale também para a leitura em processo: IServerFileStore.OpenReadAsync recebe, além do tenant, o dono que lê — o par (ownerScope, ownerKey) — e só devolve bytes se esse par tem referência ativa no arquivo (a esperada vira ativa na liberação, e antes dela não há conteúdo a ler); sem ela, FILE_REFERENCE_NOT_ALLOWED_CONFLICT (409) e nenhum byte lido. O escopo sozinho não basta: outro dono do mesmo escopo — outra notificação, outro relatório — já pode ter referência no arquivo. Sem isso, o módulo que recebe fileId de um chamador e lê o conteúdo no servidor entrega por e-mail, ou em qualquer saída sua, arquivo que o chamador nunca subiu. Critérios de aceite: (a) A reserva e sobe; B confirma o mesmo fileId declarando um dono seu → 409 e nenhuma referência nasce para B; (b) arquivo Available que A subiu, B pede referência direta sem referência de origem → 409; (c) segundo dono sobre registro já esperado → 409 com o código, nunca 500; (d) A repete a confirmação com o mesmo dono → o mesmo estado, nenhum efeito novo; (e) OpenReadAsync com um par sem referência ativa no arquivo → 409 e nenhum byte; (f) e-mail com anexo cujo fileId outra pessoa subiu, com ou sem persistência da notificação → recusado antes do envio, e nenhum byte sai.

RN-GF-23Rota HTTP não mexe em referência de módulo

POST /files/{id}/references e DELETE /files/{id}/references/{referenceId} só criam e liberam referência de ownerScope que o registro do escopo declara gerido pela superfície HTTP — nenhum, por padrão. Escopo de módulo que consome em processo (nexio.*, gryd.report-execution, gryd.notification-attachment) é recusado com FILE_OWNER_SCOPE_FORBIDDEN (403), na criação e na liberação, qualquer que seja a permissão; e a criação pela rota obedece à RN-GF-22. Sem isso, quem tem upload:files prende um arquivo para sempre com dono inventado — e a eliminação por titular esbarra numa referência que ninguém solta — ou libera a referência do documento de outro módulo e entrega o arquivo ao expurgo. Contrapartida no produto: produto que autoriza arquivo pelo dono — o Nexio — não mapeia /files/* e nenhum papel de tenant recebe upload:files, read:files, retain:files ou purge:files; a pessoa sobe e baixa pelas rotas do próprio produto, que chamam a IFileService depois do CanRead do dono. As quatro têm alcance de tenant no catálogo, e compor papel só barra permissão de plataforma — hoje o administrador do tenant as põe num papel. Que o produto possa impedir isso é requisito do catálogo da plataforma, e está na demanda. Critérios de aceite: (a) POST /files/{id}/references com ownerScope nexio.attachment → 403 e nenhuma referência; (b) DELETE de uma referência gryd.report-execution pela rota → 403 e a referência continua ativa; (c) no produto que autoriza pelo dono, nenhum papel de tenant — semeado ou composto pelo administrador do tenant — tem upload:files, read:files, retain:files ou purge:files, e a composição é recusada; e nenhuma rota /files/* responde nos hosts dele. Como o GrydAuth cumpre. A opção GrydAuth:Roles:TenantAssignable:Deny é de adesão (vazia por padrão — o produto que autoriza pelo dono a declara no appsettings) e age ao conceder: recusa a composição do papel, a atribuição, a concessão direta e a vinculação, e derruba a subida se um template do seed concede um código negado; não limpa papel composto antes de ligá-la (DEC-GF-07-1 a 07-3).

RN-GF-24Retenção é piso também na quarentena

Registro Infected só é expurgado pela varredura quando valem todas: QuarantineRetentionDays contados do veredito positivo; retainUntil vencido, no modo RetainUntil; legalHold desligado; e modo diferente de Permanent (RN-GF-09). Vence o prazo mais tarde. A única exceção é o ato de eliminação a pedido do titular, com as conferências da RN-GF-25 — guarda de qualquer detentor continua recusando. A transição Available → Infected libera as referências ativas sem tocar em retentionMode, retainUntil nem legalHold, que já estão gravados no registro antes da liberação; liberar por infecção não encerra guarda nem inicia carência. O consumidor ainda pode estender retainUntil ou ligar legalHold sobre o Infected — a guarda, por ato de pessoa ou com o autor declarado (RN-GF-27); o handler do veredito, que roda sem usuário, não liga guarda (RN-GF-13). Sem isso, a prova sob guarda de um documento fechado some 365 dias depois de o re-scan achar uma assinatura nova. Critérios de aceite: (a) Infected há 400 dias com retainUntil daqui a três anos → não expurga, e expurga só depois da data; (b) Infected vencido com legalHold → não expurga; (c) Available com duas referências e retainUntil futuro, re-scan positivo → referências liberadas e retainUntil intacto no registro.

RN-GF-25Antes de retainUntil, e em Permanent, só a eliminação expurga

Expurgo manual de registro em RetainUntil com retainUntil futuro, ou de registro em Permanent, é recusado com FILE_RETENTION_NOT_EXPIRED_CONFLICT (409), a menos que o pedido seja o ato de eliminação: marcação explícita (erasure) e motivo obrigatório. Permanent não tem data que vença: fora do ato de eliminação não sai por caminho nenhum, nem com purge-permanent:files e um motivo qualquer. Estado. O expurgo manual sem a marcação alcança só Available. O ato de eliminação alcança também Infected — quarentena, inclusive incidente de integridade, antes de QuarantineRetentionDays e em qualquer modo — e Failed, com as mesmas conferências desta regra: o pedido do titular não espera a quarentena, que em RetainUntil dura até a data e em Permanent não vence. A prova do incidente que fica é a lápide — nome da assinatura ou rejectionCode, sha256 quando houver, as tentativas de varredura —; quem precisar dos bytes para perícia liga guarda legal por caso, e a guarda vence o titular. Pending e Scanning são recusados pelo estado (FILE_NOT_AVAILABLE_CONFLICT), e a consulta de pré-condição responde o mesmo, para o consumidor esperar. O expurgo automático da quarentena não muda (RN-GF-24). Quem pede a eliminação. Pela rota POST /files/{id}/purge, com purge:files; em Permanent, também com purge-permanent:files de pessoa no contexto (arquivo de produto: purge:product-files). Em processo, o consumidor registrado para o escopo (RN-GF-28), sem conferência de permissão: em RetainUntil, com o usuário autenticado ou o autor declarado (RN-GF-27); em Permanent, só com dupla custódia declarada — o pedido traz dois autores declarados, quem registrou o pedido e quem o executou, que precisam ser usuários diferentes (a comparação é por usuário: o módulo não conhece CPF nem conflito de interesse). Um autor só, ou os dois iguais, é recusado com FILE_ERASURE_DUAL_CUSTODY_CONFLICT (409), e a permissão de pessoa no contexto não substitui o segundo autor nesse caminho. Os dois autores são conferidos, não só comparados: cada um precisa ser usuário existente, ativo e do tenant do arquivo — id vazio, id que não existe, usuário inativo ou usuário de outro tenant é recusado com FILE_ERASURE_AUTHOR_INVALID_UNPROCESSABLE (422) antes de qualquer efeito. Em arquivo de produto, o tenant conferido é o de quem pede: arquivo de produto tem tenantId nulo (RN-GF-08) e usuário nenhum pertence a ele, de modo que exigir o tenant do arquivo não teria solução — nenhum autor passaria nunca. Nesse caso os dois autores são usuários do tenant do consumidor que pede, existentes, ativos, do mesmo tenant entre si e distintos, conferidos do mesmo jeito e com o mesmo código. Sem isso, dupla custódia é aritmética de dois guids diferentes, e um deles inventado. O tenant de quem pede é o tenant nomeado pelo consumidor (RN-GF-08): havendo tenant em vigor no contexto, o nomeado é igual a ele; não havendo — a repetição depois do commit roda sem request —, o escopo do nomeado é aberto antes da conferência. Pedido em processo que não nomeia tenant nenhum não tem contra o que conferir os autores e é recusado com FILE_ERASURE_AUTHOR_INVALID_UNPROCESSABLE, nada apagado. E em processo nenhuma permissão de módulo entra neste caminho. Pela rota, arquivo de produto continua exigindo purge:product-files de pessoa no contexto. Em processo, no ato de eliminação, a permissão não é conferida — ele é só de pessoa autenticada no contexto e nunca de autor declarado (RN-GF-28) —, e exigi-lo deixaria sem caminho justamente a repetição do job depois do commit, com os dois autores declarados e sem usuário. O que ocupa o lugar dele, onde não há tenant para separar quem alcança o quê, é a tríade da RN-GF-28 mais o vínculo: consumidor registrado para o escopo, dupla custódia declarada e ao menos um par do próprio escopo com referência ativa nomeado no pedido — eliminação em processo de arquivo de produto sem referência ativa do escopo de quem pede é recusada com FILE_OWNER_SCOPE_FORBIDDEN (403), porque sem tenant a referência é o único vínculo entre o consumidor e o arquivo. Com usuário no contexto, o executor é ele: o pedido nomeia como segundo autor declarado quem registrou o pedido, e esse autor declarado, diferente do usuário do contexto, não dá 401 — é a exceção declarada da RN-GF-27 (f). O executor gravado em purgedBy é o usuário do contexto, quem registrou vai em erasureRequestedBy e o ato fica gravado como autenticado. Sem usuário no contexto, os dois autores vêm declarados e o ato fica gravado como declarado (RN-GF-10). O expurgo grava os dois autores, o escopo que declarou e o executor de serviço (RN-GF-10). A eliminação apaga sempre o nome original e é ato de controle — registro isolado por tenant, gravado na transação do expurgo, com os autores, o motivo e a marcação (Ata B11; do lado do Nexio, é o ato "revogar para eliminação" do Anexo). Sem trilha durável não há eliminação: com a trilha padrão (NullAuditStore) o ato não teria registro, e é recusado com FILE_AUDIT_TRAIL_UNAVAILABLE (503), nada apagado. Referência ativa de outro dono (FILE_STILL_REFERENCED_CONFLICT) e guarda de qualquer detentor (FILE_LEGAL_HOLD_CONFLICT, RN-GF-27) recusam também a eliminação: a guarda legal vence o pedido do titular. O pedido em processo nomeia a lista de pares do próprio escopo — o ownerScope do consumidor e um ou mais ownerKey —, porque arquivo propagado tem mais de um par do mesmo escopo e liberar um só nunca chegaria a expurgar: as referências ativas de todos os pares nomeados são liberadas na mesma transação do expurgo e nenhuma conta como impedimento. Referência ativa de outro escopo continua recusando (FILE_STILL_REFERENCED_CONFLICT); referência ativa do próprio escopo que a lista não nomeia recusa igual — é o consumidor que diz quais cópias o pedido alcança; e par de outro escopo nomeado na lista é recusado com FILE_OWNER_SCOPE_FORBIDDEN (403, RN-GF-28). Arquivo referenciado por dois escopos não tem caminho automático — e na v1 não ocorre. Cada consumidor só nomeia os pares do próprio escopo, e a referência ativa do outro escopo recusa: dois consumidores com referência ativa no mesmo arquivo e um pedido de eliminação do titular é impasse, porque nenhum dos dois libera a referência do outro e o módulo não coordena os dois. Na v1 o caso não ocorre — o Nexio é o único consumidor de escopo de anexo —, e o que fica escrito é o risco conhecido: a recusa é FILE_STILL_REFERENCED_CONFLICT, não há caminho automático, e a saída é cada consumidor liberar a sua referência pelo seu próprio ato. Quando entrar o segundo consumidor de escopo, a coordenação entre consumidores é pedido de plataforma. Toda recusa vem antes de qualquer efeito — nenhuma referência liberada, nome apagado ou registro de ato —, mas a tentativa recusada do ato de eliminação vai à trilha, com o código, os autores e os detentores da guarda quando houver: o job que repete o ato e é recusado todos os dias não pode ficar sem rastro nenhum. A consulta de pré-condição não tem efeito e não gera tentativa. Pré-condição conferível antes de gravar: a IFileService responde em processo, sem efeito nenhum, se aquela eliminação seria aceita agora — com as mesmas conferências, na mesma ordem, e o mesmo código que o expurgo daria — no modo execução, que é o que confere tudo (estado, marcação e motivo, dupla custódia, referência de outro dono, guarda de qualquer detentor, trilha durável); no modo registro as conferências são o subconjunto do parágrafo seguinte —, para o consumidor conferir antes de gravar o próprio ato e nunca publicar eliminação que o módulo vai recusar. A consulta tem dois modos, e o modo vem no pedido. No modo registro — chamado antes de gravar o pedido, quando só existe quem está registrando — ela confere tudo menos a presença do segundo autor: estado, marcação e motivo, o autor ou os autores que vierem (existentes, ativos e do tenant conferido), a lista de pares, referência de outro escopo, guarda de qualquer detentor e trilha durável; a falta do segundo autor não é recusa, e FILE_ERASURE_DUAL_CUSTODY_CONFLICT não sai por ela nesse modo. Se os dois autores vierem já no registro, os dois são comparados, e dois autores iguais respondem FILE_ERASURE_DUAL_CUSTODY_CONFLICT também no modo registro: o que o modo dispensa é o segundo autor que ainda não existe, nunca a igualdade de dois que já vieram — responder aceite a um pedido que já se sabe recusável faria o consumidor gravar um pedido que o expurgo recusa depois. No modo execução — chamado antes de executar, com os dois autores já conhecidos — ela confere tudo, dupla custódia inclusive. Um modo só não serve: conferir a dupla custódia no registro recusaria todo pedido de titular, e não conferi-la na execução deixaria o consumidor publicar eliminação que o expurgo recusa. A consulta não reserva nada: guarda ligada por outro detentor entre a consulta e o expurgo vence, o expurgo responde FILE_LEGAL_HOLD_CONFLICT e o consumidor trata o pedido como bloqueado, nunca como concluído. Ordem das recusas. A guarda é conferida antes da referência (DEC-GF-19-7); só importa quando as duas recusas valem juntas. Critérios de aceite: (a) expurgo sem a marcação, com retainUntil futuro → 409 e nada apagado; (b) com a marcação e o motivo → Purged, originalFileName nulo e o ato na trilha com autor e motivo; (c) eliminação com referência ativa de outro dono → FILE_STILL_REFERENCED_CONFLICT; (d) arquivo Permanent expurgado sem a marcação, com purge-permanent:files e motivo → 409 e nada apagado; (e) eliminação com a trilha padrão registrada → 503 e nada apagado; (f) eliminação de Permanent em processo com um autor declarado só, ou com quem registrou igual a quem executou → FILE_ERASURE_DUAL_CUSTODY_CONFLICT, nada apagado, a referência do par ativa e nada na trilha; (g) eliminação de Permanent em processo, sem usuário no contexto, com dois autores diferentes → Purged, lápide com Declared, quem executou em purgedBy, quem registrou em erasureRequestedBy, escopo e executor de serviço, e nenhuma permissão conferida; (h) eliminação sobre arquivo com guarda de qualquer detentor — do consumidor, de outro consumidor ou da plataforma — → FILE_LEGAL_HOLD_CONFLICT antes de qualquer efeito: referência do par ativa, nome intacto, nenhum registro de eliminação; (i) no modo execução, para cada caso de (a) a (h), a consulta de pré-condição com o mesmo pedido responde o mesmo código, ou o aceite, e não grava nada; no modo registro vale o mesmo para (a) a (e), (g) e (h), e o caso (f) responde o aceite quando o pedido não traz o segundo autor, porque nesse modo ele ainda não existe (m) — vindo os dois autores e iguais, o código sai também no modo registro (s); (j) Infected em quarentena há dez dias, em RetainUntil com data daqui a cinco anos, eliminação com a marcação, o motivo e os autores → Purged, lápide com a assinatura e o sha256, sem nome original; o mesmo pedido sem a marcação → recusado pelo estado e nada apagado; (k) Infected em Permanent com dois autores diferentes → Purged; com guarda de qualquer detentor → FILE_LEGAL_HOLD_CONFLICT; (l) Scanning → FILE_NOT_AVAILABLE_CONFLICT no expurgo e na consulta; (m) consulta no modo registro com um autor declarado só → responde o aceite, e a mesma consulta no modo execução → FILE_ERASURE_DUAL_CUSTODY_CONFLICT; (n) eliminação em processo com usuário no contexto e o autor do registro declarado, diferente dele → Purged, purgedBy o usuário do contexto, erasureRequestedBy o autor do registro, ato gravado como autenticado e nenhum 401; (o) eliminação com um dos dois autores inexistente, inativo ou de outro tenant → FILE_ERASURE_AUTHOR_INVALID_UNPROCESSABLE, nada apagado e nenhuma referência liberada; (p) arquivo com dois pares do mesmo escopo e a lista nomeando os dois → Purged com as duas referências liberadas na mesma transação; a mesma eliminação com a lista nomeando um par só → FILE_STILL_REFERENCED_CONFLICT e nada apagado; (q) tentativa recusada do ato de eliminação → a trilha registra a tentativa com o código, e nenhuma referência foi liberada — quando a recusa é sobre o que prende o arquivo (vínculo do produto, guarda, referência) e depois de os autores serem conferidos; recusa de pedido (autor, motivo, contrato), de estado, de marcação ou de trilha não grava linha, e o (f) é a exceção declarada (DEC-GF-19-4); (r) eliminação de arquivo de produto (tenantId nulo) com dois autores distintos do tenant do consumidor que pede → Purged; a mesma com um dos autores de outro tenant → FILE_ERASURE_AUTHOR_INVALID_UNPROCESSABLE e nada apagado; a mesma em processo, sem usuário no contexto e sem nenhuma permissão em lugar nenhum, com o tenant do consumidor nomeado e um par do próprio escopo com referência ativa na lista → Purged; em processo sobre arquivo de produto sem referência ativa do escopo de quem pede → FILE_OWNER_SCOPE_FORBIDDEN e nada apagado; pela rota, sem purge:product-files de pessoa no contexto → 403 e nada apagado; (s) consulta no modo registro com os dois autores já no pedido e iguais → FILE_ERASURE_DUAL_CUSTODY_CONFLICT e nada gravado; o mesmo pedido sem o segundo autor → aceite.

RN-GF-26Operação que a trilha nomeia exige pessoa identificada

Pela IFileService, reservar, confirmar, emitir URL de download, expurgar e ligar ou desligar legalHold exigem autor identificado: o usuário autenticado no contexto — ou, só para ligar e retirar guarda e para a eliminação, o autor declarado na chamada em processo (RN-GF-27), gravado sempre como declarado e nunca como autenticado (RN-GF-10). A consulta de pré-condição da eliminação (RN-GF-25) aceita os mesmos autores nos seus dois modos — no modo registro, com o autor que está registrando só — e não grava nada. Sem usuário no contexto e sem autor declarado, a operação é recusada com FILE_CALLER_UNAUTHORIZED (401) antes de qualquer efeito — nunca exceção sem código, nunca registro, guarda desligada ou trilha sem autor. Ler metadado (GetAsync) não exige usuário: é por ela que o handler do veredito relê fora de request (RN-GF-13). Conteúdo que o servidor gera ou recebe sem pessoa do outro lado entra pela IServerFileStore. Critério de aceite: ReserveAsync e GetDownloadUrlAsync chamados sem usuário → 401 com o código, nenhum registro criado e nenhuma URL emitida; desligar legalHold sem usuário e sem autor declarado → 401 e a guarda continua ligada; GetAsync sem usuário e com tenant → devolve o registro.

RN-GF-27A guarda é por detentor, e o autor do ato viaja como dado, marcado como declarado

Guarda por detentor. legalHold é derivado: ligado enquanto houver ao menos um detentor com guarda ativa. Detentor é o consumidor registrado que ligou a guarda em processo, pelo seu ownerScope (RN-GF-28), ou a plataforma, que liga pela rota PUT /files/{id}/retention. Cada detentor liga e retira a sua guarda, e só a sua: há no máximo uma guarda ativa por detentor, com autor, tipo de autor, motivo e quando. O módulo não conta casos — o consumidor que tem vários casos sobre o mesmo arquivo retira a sua guarda quando o último caso dele acaba. Ligar sobre a guarda de outro detentor acrescenta a sua e nunca troca nem apaga a do outro: ligar em processo sobre a guarda da plataforma deixa as duas ativas, e PUT legalHold: true sobre a guarda de um consumidor também. O mesmo detentor repetindo o mesmo ato não produz efeito novo, e a guarda já ativa mantém autor e motivo originais. Quem retira. Em processo, o consumidor retira a guarda do seu escopo sem release-hold:files — é o fim de um ato por caso que ele autorizou e registrou do lado dele (Ata B13) —; retirar quando o seu escopo não tem guarda ativa sobre registro vivo não produz efeito e devolve o estado atual, para a repetição do ato concluir; sobre registro que o expurgo já venceu, a retirada é recusada com FILE_PURGED_CONFLICT (RN-GF-20). Pela rota, legalHold: false retira a guarda da plataforma e exige release-hold:files; sobre arquivo cuja guarda restante é só de consumidor, é recusado com FILE_OWNER_SCOPE_FORBIDDEN (403) e nada muda — nenhuma permissão retira a guarda de outro detentor. A resposta da guarda e a recusa de expurgo listam os detentores ativos. O autor viaja como dado, e marcado. Ligar e retirar guarda e a eliminação (RN-GF-25), chamados em processo, aceitam o autor declarado — o usuário que praticou o ato já gravado pelo consumidor —, para que a repetição depois do commit, que roda sem usuário, conclua. O autor declarado não é identidade: não concede permissão, não habilita nenhuma outra operação e não existe na rota HTTP, onde o autor é sempre o usuário autenticado. A trilha e a lápide distinguem autor autenticado de autor declarado e, no declarado, gravam o escopo que o declarou e o executor de serviço que chamou — o job ou serviço do consumidor, também declarado por ele (RN-GF-10). Com usuário no contexto, o autor declarado, se vier, precisa ser ele — diferente → FILE_CALLER_UNAUTHORIZED (401) — e o ato fica gravado como autenticado. A eliminação com dupla custódia é a exceção declarada (RN-GF-25): ela leva dois autores, e com usuário no contexto ele é o executor; o outro autor — quem registrou o pedido — vem declarado e é diferente dele, sem 401. A exceção é só essa, e só para o autor do registro: qualquer outro autor declarado diferente do usuário do contexto segue 401. Conferir antes, registrar junto. Escopo, autor e trilha são conferidos antes de qualquer mudança, e recusa não deixa guarda retirada nem registro sem autor. Ligar e retirar são ato de controle (Ata B11): os dois vão à trilha, com o registro gravado na mesma transação da mudança, nunca depois do commit. Retirar exige a trilha durável da RN-GF-25 (FILE_AUDIT_TRAIL_UNAVAILABLE, 503); ligar nunca é recusado por falta de trilha, porque recusar deixaria a prova sem guarda. Onde a guarda vale. Ligar guarda é aceito em Available e Infected, únicos estados em que toda purga a honra; em Pending, Scanning e Failed a resposta é FILE_NOT_AVAILABLE_CONFLICT (409) (DEC-GF-18-1). Critérios de aceite: (a) o consumidor liga a guarda por ato de pessoa e a repetição depois do commit chama sem usuário, com o autor declarado → guarda do escopo ativa, trilha com o autor marcado como declarado, o escopo e o executor de serviço, nenhum 401; (b) o mesmo escopo retira em processo, sem release-hold:files, com o autor declarado → a guarda dele sai e a trilha registra o autor declarado; (c) PUT /files/{id}/retention com legalHold: false sobre arquivo só com guarda de consumidor, mesmo com release-hold:files → 403 e a guarda continua; (d) eliminação repetida sem usuário, com o autor declarado → Purged, lápide e trilha com o autor declarado, o escopo e o executor; (e) retirar sem usuário e sem autor declarado → 401 e nada gravado; (f) usuário no contexto e autor declarado diferente → 401 e nada gravado, menos na eliminação da RN-GF-25, onde o autor do registro vem declarado e diferente do usuário do contexto, que é o executor → aceita, sem 401, com o executor em purgedBy; (g) guarda da plataforma ativa e o consumidor liga em processo → as duas ativas; a plataforma retira pela rota → legalHold continua ligado pela guarda do consumidor, e o expurgo responde FILE_LEGAL_HOLD_CONFLICT; (h) guarda de consumidor ativa e PUT legalHold: true → as duas ativas; o consumidor retira a dele → legalHold continua ligado pela guarda da plataforma; (i) dois consumidores com guarda no mesmo arquivo e um retira → o arquivo continua preso pelo outro; (j) processo derrubado logo depois do commit de ligar ou de retirar → o registro do ato está na trilha.

RN-GF-28Em processo, o escopo é do consumidor registrado, e a permissão não é conferida

A IFileService e a IServerFileStore são chamadas em processo por módulo consumidor que já autorizou o seu chamador. Nelas permissão de módulo não é conferida — upload:files, read:files, retain:files e purge:files são policies da superfície HTTP —, e o módulo não define identidade de serviço: nenhum job ou serviço "carrega" permissão para chamar a porta. O que vale em processo são três coisas: (1) consumidor registrado — todo ownerScope que chega à IFileService, na referência esperada, na referência direta, na guarda e na eliminação, só é aceito do consumidor registrado para ele (o mesmo registro do contrato de veredito, RN-GF-13), como a RN-GF-06 (b) já exige para a IServerFileStore; escopo sem registro é recusado com FILE_OWNER_SCOPE_FORBIDDEN (403) antes de qualquer efeito; (2) o ato — posse, guarda e eliminação (RN-GF-22, RN-GF-25, RN-GF-27); (3) o autor declarado marcado (RN-GF-10, RN-GF-27). A permissão específica (*:product-files, release-hold:files, purge-permanent:files) é conferida só onde esta página a nomeia e só de pessoa autenticada no contexto — arquivo de produto, guarda da plataforma, eliminação de Permanent pela rota —; nunca de autor declarado, nunca de serviço. Sem o item (1), qualquer assembly do processo que passe nexio.attachment retira a guarda do Anexo ou pede a eliminação de um anexo, e não há permissão nenhuma para barrar. E o escopo deixa de ser parâmetro de quem chama. O problema é que escopo passado como texto não se prova por teste: os escopos são constantes de compilação, o compilador as embute em cada chamador e nenhum teste de dependência entre assemblies vê quem passou nexio.attachment — a asserção “só o consumidor registrado passa o escopo” é inverificável enquanto o escopo for argumento. E porta alcançada por texto ou por Type repete o mesmo defeito um nível acima: se a porta de um escopo se resolve por chave de texto, por Type montado em execução ou por reflexão — IServiceProvider.GetService(Type) —, quem resolve também não deixa dependência entre assemblies, e nenhum teste estático vê quem pegou a porta de nexio.attachment. E o caminho não se fecha olhando só o escopo sem registro: o mesmo Type montado em execução carrega o tipo-marca de um escopo registrado — o nome do tipo em texto, resolvido por reflexão — e o contêiner devolve a porta, sem que o assembly que pediu referencie o do consumidor; verificação que se limite a escopo sem registro não alcança esse pedido, e o teste estático continua sem vê-lo. O padrão já existe no repositório do framework (JobExecutionAdapters.cs:235, :303 e HangfireJobActivator.cs:43, no HEAD e4eb5a4f) — não é hipótese. O requisito: o registro do consumidor devolve a ele uma porta amarrada ao seu escopo, e é por essa porta que ele reserva, confirma, declara referência, liga e retira guarda e pede eliminação; nas assinaturas dessa porta o escopo não aparece, e a porta sem escopo deixa de ser alcançável pelo consumidor. A porta de cada escopo só se resolve por um tipo-marca do próprio consumidor — um tipo que mora no assembly registrado para o escopo, de modo que resolver aquela porta obrigue a referenciar esse assembly e a dependência apareça; chave de texto e Type montado em execução não resolvem porta de escopo nenhum. Como a plataforma amarra o escopo à porta, e por qual mecanismo o tipo-marca chega ao registro, é decisão dela (Ata A1) — a spec pede o efeito, não a forma. O que o teste de arquitetura confere muda junto: não quem passa o texto, mas quem resolve a porta de cada escopo — os assemblies que resolvem a porta de nexio.attachment são exatamente o consumidor registrado para ele; produto que registra escopo repete a asserção no próprio repositório. Critérios de aceite: (a) assembly fora do registro que resolve a porta do escopo nexio.attachment → o teste de arquitetura falha, e continua falhando se esse assembly declarar a mesma constante de escopo por conta própria — o teste olha a resolução da porta pelo tipo-marca do consumidor, não o texto, e a resolução por reflexão não escapa dele, porque não devolve porta nenhuma (e); (b) chamada em processo com ownerScope sem registro → 403 e nada gravado; (c) eliminação completa em processo, sem usuário e sem purge:files em lugar nenhum → aceita; a mesma pela rota, por usuário sem purge:files → 403; (d) nenhuma assinatura da porta entregue ao consumidor tem parâmetro de escopo, e o consumidor não alcança, por caminho nenhum, uma porta em que o escopo seja argumento; (e) em execução, falha — na resolução quando o contêiner distingue o pedido, e na primeira chamada quando o pedido é por Type montado em execução, porque o contêiner devolve o mesmo Type nos dois casos e a porta confere, a cada chamada, que o assembly chamador referencia o do tipo-marca (DEC-GF-09-1) — e nenhuma chamada chega à IFileService com aquele escopo — em dois casos: pedir ao contêiner a porta de um escopo não registrado, por tipo-marca de quem não registrou, por chave de texto ou por Type montado em execução; e pedir a porta de um escopo registrado — nexio.attachment — por Type montado em execução, por tipo-marca obtido em execução ou pelo tipo-marca de quem não registrou aquela porta, que é o pedido sem referência estática ao assembly do consumidor: é a verificação que o teste estático não dá, porque o que ela reprova não aparece como dependência entre assemblies.

Detalhamento

Dezesseis decisões de detalhamento

A spec foi validada contra o backlog do Epic 1376, item a item. A cobertura de escopo estava completa — cada seção tinha história dona e nenhuma história inventava escopo. O que faltava eram decisões que o documento não tomava e que seriam tomadas por quem pegasse o cartão: seis contradições e dez valores em branco. Estão fechadas abaixo, e já refletidas nas seções acima.

O que mudou e por quê
#DecisãoOnde
D1Permissões na forma {ação}:{recurso}, recurso no plural: upload:files, read:files, retain:files, purge:files, como no código. Recurso antes do verbo seria lido pelo PermissionCodeParser como ação. Permissão de plataforma é o admin:system que já existe§ Permissões
D2sha256 anulável: nulo em Pending, Scanning e Failed, gravado na saída para Available, para Infected e na lápide de reprovação por tipo — no código desde o commit 5dcb5eed§ Campos
D3Localização preservada no registro expurgado, para a lápide responder onde o objeto esteve§ Campos
D4Campo rejectionCode, porque a reprovação por tipo é assíncrona e não tem request para devolver 422. Estado Rejected separado foi avaliado e recusado pelo custo de reescrita§ Erros
D5Core estendido com ResponseContentDisposition e ResponseContentType na URL pré-assinada, nos três provedores. O nosniff sai da URL — é impossível nos três — e vira recomendação de bucket/CDN; a defesa passa a ser attachment mais application/octet-stream§ Download
D6Rota GET /files/scanner-status como chamador real do FILE_SCAN_UNAVAILABLE, alimentada pelo PING. Sem ela o código nasceria sem chamador; o sufixo _UNAVAILABLE → 503 é um dos onze vigentes§ Endpoints
D7Sniffing incondicional, com regra por classe de tipo: assinatura conhecida decide; container ZIP é aberto para resolver o tipo OOXML; text/* valida como texto. Sem isso, fail-closed puro reprovaria todo CSV e todo xlsx legítimo§ Validação
D8Campo lastReferenceReleasedAt para a carência. Agregar FileReference por candidato é o que o referenceCount existe para evitar, e a carga cresceria para sempre§ Retenção
D9QuarantineRetentionDays: 365, global e nunca por perfil. Um ano cobre auditoria e reanálise forense sem virar depósito de malware; a assinatura fica na lápide para sempre§ Retenção
D10Migração do GrydNotifications sem conversão de dado: o anexo deixou de ser byte[] com a migration regenerada numa só (20260910223916_NotificationsInitialMigration, commit ba6dff72), e nenhum conteúdo foi convertido. Se um ambiente com anexo gravado no banco precisar migrar, o plano é este: cada anexo sobe pela IServerFileStore com a referência esperada da notificação; anexo que o scan acusa vai à quarentena, não cria referência e entra no relatório, sem bloquear os demais; a coluna só sai depois de a verificação fechar migrados + infectados + reprovados = origem, com a lista assinada—
D11Download de relatório muda de stream para URL, como quebra assumida, sem shim nem período de convivência. Um proxy de compatibilidade manteria justamente a violação da decisão 2 que o épico existe para corrigir—
D12UseDefaultProfile removido e perfis nexio.* movidos para o appsettings do produto. Perfil padrão é API sem chamador; perfil de produto no template do framework contraria a decisão 1§ Perfis
D13Valores do clamd.conf: StreamMaxLength e MaxFileSize 64M, MaxScanSize 400M, MaxRecursion 16, MaxFiles 10000. O padrão do clamd é 25M — exatamente o maior perfil, sem folga para o enquadramento do INSTREAM§ Antivírus
D14Retry de 1, 5 e 15 minutos; timeout de 120 s. As três tentativas cobrem ~21 minutos, que absorvem reinício de contêiner e recarregamento de base§ Antivírus
D15O clamd.conf é gerado a partir do appsettings. Não há comando do protocolo que leia a configuração do daemon, então validar contra um valor espelhado atestaria algo que pode não ser verdade§ Antivírus
D16purgedBy nulo em expurgo automático, com a origem no purgeReason. O framework não tem usuário de sistema, e inventar um GUID reservado criaria identificador sem linha em Users§ Campos

Consequência de sequenciamento. A validação também mostrou que a Feature de antivírus depende da Feature de infraestrutura: as histórias do IFileScanner e do ClamAvFileScanner exigem um clamd em execução, que só é entregue pelo contêiner da F8. A F8 precisa ser executada antes da F4, e o teste de EICAR contra motor real pertence à história do provedor, não à da abstração — que entrega interface e fake.

Referência

Referência de mercado

ProdutoO que fazO que aproveitamos
Oracle E-Business Suite / FusionSepara FND_DOCUMENTS (o documento) de FND_ATTACHED_DOCUMENTS (o vínculo, com ENTITY_NAME + PK1..PK5_VALUE), e exige registro prévio da entidade que pode receber anexo. O conteúdo em si vive no repositório de documentosAs duas camadas e o registro do consumidor. As cinco colunas de chave existem porque as chaves do EBS são compostas; aqui é um Guid
SAPAnexo sai do módulo de negócio e vai para repositório externo por ArchiveLink/DMS. O documento guarda a referência, não o arquivoA ideia de que arquivo é infraestrutura, não domínio — e de que o módulo de negócio nunca deveria conhecer storage
SAP AribaTeto de 100 MB de anexo, elevado a 2 GB especificamente para contratoA confirmação de que limite é por contexto, não global. É a origem do perfil
Amazon S3 · presigned URLURL assinada com escopo de operação, objeto e prazo, para tirar o binário do caminho da aplicaçãoO desenho inteiro do fluxo de duas fases — que o Gryd.Infrastructure já implementa nos três provedores
Amazon GuardDuty for S3Scan gerenciado no bucket, com cotas de 100 GB por objeto, 100.000 arquivos extraídos e 25 buckets por conta e regiãoProvedor alternativo de IFileScanner, não o padrão. As cotas por conta são o limite prático para plataforma multi-cliente
ClamAVMotor de assinatura com daemon residente, protocolo INSTREAM e base atualizada por freshclamO provedor padrão. Ver § Antivírus

Limites

Fora de escopo

AssuntoPor quê
Versionamento de documento"A versão 3 do contrato" é conceito de negócio, com aprovação e vigência. Aqui, versão nova é arquivo novo com referência nova. Quem encadeia é o consumidor
Preview, miniatura e conversãoRenderizar PDF e gerar miniatura pede biblioteca pesada e é superfície de ataque conhecida. Volta como módulo próprio se aparecer demanda real
OCR e extração de conteúdoOutro problema, com outro custo e outra precisão. O gancho é o sha256: dá para indexar depois sem tocar no módulo
Assinatura eletrônica e ICP-BrasilGancho declarado. Assinar documento é fluxo com signatário, ordem e carimbo de tempo — módulo próprio, que consumiria este
Criptografia por chave do clienteGancho declarado. Hoje vale a criptografia em repouso do provedor. Chave por tenant em KMS muda o expurgo — destruir a chave passa a ser uma forma de apagar — e merece decisão própria
Upload resumível e multipartGancho declarado. Necessário acima de alguns GB; com o teto atual dos perfis, complexidade sem retorno
Perfil configurável por tenantNasce em appsettings. Vira tabela quando existir o primeiro cliente que precise de teto diferente — e não antes
CDN e link público permanenteConflita com URL de vida curta, que é o controle de acesso do módulo. Ativo público é outro problema
Antivírus como serviço multi-tenant isoladoUm clamd compartilhado atende todos os tenants: ele vê bytes, não contexto, e não guarda nada. Isolar por cliente multiplicaria por N os 4 GiB de memória sem ganho de segurança

Registro

Histórico de revisões

VersãoDataO que mudou
v1.1602/11/2026Conformidade conferida no Gryd.IO 11.1.0: a trilha é preparada pelo ponto único do framework, e o nome 10.2.0 se lê v11.0.0, porque essa tag não existe.
v1.1530/10/2026Conformidade com o fechamento da 10.2.0 no Gryd.IO. Limitação fechada: o host que compõe o módulo sem store durável de auditoria não sobe, em qualquer ambiente (FileAuditTrailRequirement), e o download de arquivo de tenant exige trilha durável. Portas: GetAsync, AddReferenceAsync e UpdateRetentionAsync com o tenant nomeado; RescanAsync exige autor. Fora do módulo: catálogo de autorização contribuído pelo produto e utilitário de CNPJ alfanumérico. Retira a menção à API antiga de auditoria.
v1.1430/10/2026Estado da implementação atualizado para o Gryd.IO 10.2.0. Arquivo de produto: trilha e veredito v2 por adesão à rota de plataforma, e read:product-files. Limitações fechadas: IFileScanner direto derruba a subida; a mesma pessoa nos dois papéis da dupla custódia vai à trilha (DEC-GF-19-11); a rota de tentativas pagina no banco; OpenReadAsync faz streaming. Limitação registrada: download de arquivo de tenant com NullAuditStore não deixa linha durável. Corrige a menção a um fake de IFileScanner que o módulo não traz.
v1.1323/10/2026Conformidade refeita no develop (d6ecf107), no lugar de e4eb5a4f, que não é ancestral dele. Permissões: § Permissões passa ao modelo do ADR 0011, D10, já no código (5ff4c74a) — seis de tenant, com release-hold:files e purge-permanent:files, e três de plataforma sobre arquivo de produto; admin:system sai do módulo, com a regra de leitura das menções antigas no rodapé. Leitura sem tenant: passa a conforme no Core (2969ea6c); fica parcial só o escopo de sistema do FileScanJob. Linha nova "Permissão em processo": a IFileService confere concessões da sessão, contra a RN-GF-28 — decisão D-01. Cada linha não conforme aponta a história GF-xx do backlog de conformidade (backlog/backlog-grydfiles-conformidade.md no Gryd.IO). Nenhuma regra, código de erro, campo ou enum novo.
v1.1213/09/2026Correção da validação R7 da onda 1 (ataque 6, de controle; ataque 10, de LGPD; e os menores de T8). O critério em execução alcança o escopo registrado (RN-GF-28): o critério (e) passa a reprovar, além da porta de escopo não registrado, a porta de escopo registrado pedida por Type montado em execução, por tipo-marca obtido em execução ou pelo tipo-marca de quem não registrou aquela porta — o pedido sem referência estática ao assembly do consumidor, que o teste estático não vê; o problema da regra passa a nomear esse caminho, com o padrão que existe no repositório do framework em e4eb5a4f. Eliminação em arquivo de produto tem caminho (RN-GF-25): a tabela de erros do FILE_ERASURE_AUTHOR_INVALID_UNPROCESSABLE passa a citar o tenant conferido — o do arquivo e, em arquivo de produto, o do consumidor que pede —; o tenant de quem pede é o nomeado pelo consumidor (RN-GF-08), e pedido sem tenant nomeado é recusado; em processo, no ato de eliminação, admin:system não é conferido — autorizam o consumidor registrado, a dupla custódia declarada e ao menos um par do próprio escopo com referência ativa, e sem essa referência a eliminação de arquivo de produto é recusada com FILE_OWNER_SCOPE_FORBIDDEN —, enquanto pela rota ele continua exigido; critério (r) estendido. Menores: a consulta no modo registro compara os dois autores quando os dois vêm no pedido, e dois iguais recebem FILE_ERASURE_DUAL_CUSTODY_CONFLICT também nesse modo (critério (s) novo, critério (i) ajustado); a RN-GF-27 limita a retirada sem efeito a registro vivo, com FILE_PURGED_CONFLICT sobre lápide; e a RN-GF-20 ganha critério para duas retiradas concorrentes. Nenhum código de erro, permissão, campo ou enum novo.
v1.1112/09/2026Correção da validação R6 da onda 1 (ataques 11, 12, 24 e 25) e a EV-33. A consulta de pré-condição não recusa mais o registro pela porta do critério (RN-GF-25): a frase-guarda e o critério (i) passam a valer no modo execução — no modo registro o caso da dupla custódia responde o aceite, e não o código. Eliminação em arquivo de produto: tenantId nulo não tem usuário, então os dois autores são do tenant do consumidor que pede, com o critério (r). EV-33: arquivo referenciado por dois escopos fica escrito como risco conhecido — recusa com FILE_STILL_REFERENCED_CONFLICT, sem caminho automático, e na v1 o caso não ocorre (o Nexio é o único consumidor de escopo de anexo); a coordenação entre consumidores vira pedido de plataforma quando entrar o segundo. Retirar guarda entra na trava do expurgo (RN-GF-20): a recusa nunca lista detentor já retirado, e a retirada sobre registro expurgado responde FILE_PURGED_CONFLICT. A porta amarrada ao escopo deixa de ser resolvível por reflexão (RN-GF-28): ela só se resolve por tipo-marca do próprio consumidor, e o critério ganha a verificação em execução — o contêiner recusa a porta de escopo não registrado (critério (e)). Também: a conformidade da leitura em processo registra que a Arquitetura já nomeia o OpenReadAsync como exceção declarada da asserção 9; o ciclo de vida deixa de dizer que o nome original sai só quando o autor é job.
v1.1012/09/2026Correção da validação R5 da onda 1 (ataques 13, 14, 20, 21, 22 e 27), com as citações de código em e4eb5a4f. Eliminação que agora se registra e se executa (RN-GF-25): a consulta de pré-condição tem dois modos — registro, que confere tudo menos o segundo autor, e execução, que confere tudo —, porque a consulta de um modo só recusava no registro todo pedido de titular; com usuário no contexto o executor é ele e o autor do registro vem declarado e diferente, sem o 401 da RN-GF-27 (f), que ganhou a exceção declarada. Arquivo propagado (RN-GF-25): o pedido nomeia a lista de pares do próprio escopo e todas as referências nomeadas saem na transação do expurgo; referência de outro escopo, e do próprio escopo fora da lista, continuam recusando. Autores conferidos (RN-GF-25): os dois precisam ser usuários existentes, ativos e do tenant do arquivo, além de distintos — código novo FILE_ERASURE_AUTHOR_INVALID_UNPROCESSABLE (422). Guarda × expurgo (RN-GF-20): a regra deixa de cobrir só referência × expurgo; ligar guarda trava o mesmo registro, o expurgo reavalia as guardas sob a trava, e a recusa por guarda nova devolve os detentores e purgedAt nulo, para o consumidor tratar o pedido como bloqueado e só publicar a eliminação com purgedAt. Posse do escopo (RN-GF-28): escopo como texto não se prova por teste — constante que o compilador embute —, então o consumidor registrado recebe uma porta amarrada ao seu escopo, o escopo sai das assinaturas e o teste de arquitetura confere quem resolve a porta; o mecanismo é da plataforma. Tenant nomeado (RN-GF-08): as duas portas em processo recebem o tenant nomeado — contrato único, que substitui "lê o tenant do contexto" na IFileService —, igual ao que está em vigor quando há um, e com o escopo do tenant aberto antes da leitura quando não há. Também: tentativa recusada do ato de eliminação vai à trilha; conformidade corrigida (a repetição sem usuário liga a guarda, FileService.cs:653–724, :715–717; trava do expurgo só no job, LockedFilePurge; EfStoredFileStore.InTenant com IgnoreQueryFilters() sobre o tenant do parâmetro); expurgo manual não espera a carência; purgedBy nulo só no autor Job; re-scan de Available também muda por divergência confirmada; citação GrydFilesBuilder.cs:45–50.
v1.911/09/2026Correção da validação R4 da onda 1 (EV-23, EV-24), conformidade reconferida no commit e4eb5a4f (branch feature/grydfiles-scanner-obrigatorio; o módulo entrou pelo squash d9781a63, de árvore idêntica a d6c5e9fa, e os commits citados até a v1.8 ficam como evidência histórica fora do HEAD). Eliminação de Permanent (RN-GF-25): até a v1.8 exigia admin:system de pessoa no contexto, que o autor declarado não substituía — o Anexo revogava e o expurgo ficava pendente para sempre; agora, em processo, é aceita com dois autores declarados diferentes (quem registrou e quem executou), grava os dois, o escopo e o executor de serviço, e um autor só recebe FILE_ERASURE_DUAL_CUSTODY_CONFLICT (código novo); pela rota continua com admin:system; a referência do par do consumidor é liberada na transação do expurgo; guarda de qualquer detentor recusa antes de qualquer efeito; consulta de pré-condição em processo responde o mesmo que o expurgo. Autor marcado (RN-GF-10 reescrita): saiu "purgedBy nulo quando e somente quando o autor foi um job" como regra única; a lápide e a trilha distinguem Job, Authenticated e Declared (campos purgeAuthorKind, purgeDeclaredScope, purgeServiceExecutor, erasureRequestedBy). Guarda por detentor (RN-GF-27 reescrita): legalHold passa a derivado de guardas por detentor (consumidor registrado ou plataforma); saíram o campo legalHoldScope e o 409 ao ligar sobre guarda de outro escopo; ligar em processo sobre guarda da plataforma e PUT legalHold: true sobre guarda de consumidor acrescentam; ligar também vai à trilha, e o registro vai na transação da mudança. Em processo (RN-GF-28, nova): escopo aceito só do consumidor registrado, com teste de arquitetura; permissão de módulo não é conferida e o módulo não define identidade de serviço. Também: RN-GF-04, RN-GF-17 e RN-GF-18 separam leitura interrompida por E/S (tentativa, segue Available) de divergência confirmada; RN-GF-05 com motor obrigatório e EICAR no monitor; exemplo de configuração com AddGrydObjectStorage, AddGrydFiles(configuration) e AddGrydFilesClamAv; tabela dos modos com SIGNED_CONTRACT em Permanent; conformidade com a trilha gravada depois do commit, ligar guarda fora da trilha, guarda de um bit, escopo em processo sem registro, motor falso aceito por UseScanner<T>() e a documentação HTML v1.2 do framework. Quarentena (RN-GF-24, RN-GF-25), no mesmo dia: o ato de eliminação a pedido do titular alcança também Infected e Failed, com as mesmas conferências, e a lápide fica como prova do incidente; Pending e Scanning recusados pelo estado; conformidade com FileService.cs:786 (só Available).
v1.811/09/2026Correção da validação R3 da onda 1, conformidade conferida no commit 25775a68. Guarda por escopo e autor declarado (RN-GF-27, nova): legalHold ligado em processo grava o escopo de quem ligou (legalHoldScope, campo novo); o mesmo escopo desliga sem admin:system, que fica para a guarda sem escopo; guarda de escopo não sai pela rota (403); ligar sobre guarda de outro escopo → 409; guarda e eliminação aceitam o autor do ato já gravado como dado, e o 401 da RN-GF-26 só vale sem usuário e sem autor declarado; conferir antes de gravar. Leitura em processo (RN-GF-22): OpenReadAsync só lê para o par com referência ativa. Permanent (RN-GF-25): só sai como ato de eliminação, com admin:system de pessoa; eliminação e desligar guarda exigem trilha durável — código novo FILE_AUDIT_TRAIL_UNAVAILABLE (503). Re-scan com tamanho divergente (RN-GF-18): incidente, Available → Infected, nunca Purged. RN-GF-23 (c): impedir as quatro permissões do módulo em papel de tenant é requisito do catálogo da plataforma, com retain:files e purge:files; deixa de citar asserção da Arquitetura. RN-GF-24: guarda sobre Infected pelo consumidor, por pessoa ou autor declarado. Tipo semeado do Anexo em RetainUntil generalizado. Jobs: exclusividade pela trava de linha ([DisableConcurrentExecution] inerte sob o GrydJobs, ADR 0010). Conformidade: linhas novas Leitura em processo, Guarda por escopo e autor declarado e Documentação do framework; constatação da liberação de guarda gravada antes de conferir o usuário; alcance de tenant das permissões.
v1.711/09/2026Correção da validação R2 da onda 1, conformidade conferida no commit 25775a68. Posse da referência: referência esperada só sobre Pending reservado pelo mesmo chamador, e referência direta só por quem reservou ou por propagação (RN-GF-22, FILE_REFERENCE_NOT_ALLOWED_CONFLICT). Rotas HTTP de referência só para escopo gerido pela superfície HTTP, com a contrapartida do produto que autoriza pelo dono (RN-GF-23, FILE_OWNER_SCOPE_FORBIDDEN). Integridade: a RN-GF-18 alcança a quarentena e a lápide, e o scan lê no máximo sizeBytes + 1 bytes. Retenção: piso também na quarentena (RN-GF-24); antes de retainUntil, só o ato de eliminação expurga (RN-GF-25, FILE_RETENTION_NOT_EXPIRED_CONFLICT). Veredito: o registro a entregar guarda tenant e dono, e o handler relê sem usuário e com o tenant restaurado (RN-GF-13). Identidade: operação que a trilha nomeia exige usuário (RN-GF-26, FILE_CALLER_UNAUTHORIZED); leitura sem tenant falha fechada (RN-GF-08). RN-GF-06 reescrita para o que o teste prova; RN-GF-21 cobre todo expurgo automático e a rejeição de tamanho; sha256 na lápide de tipo reprovado (D2); trilha pela IFileAuditTrail e pelo IAuditStore no lugar da API antiga de auditoria; D6 sem "sufixo novo"; D10 com o plano. Conformidade com oito linhas novas.
v1.611/09/2026Correção pós-validação da onda 1. Conformidade conferida no commit 5289db35: referência esperada e porta de servidor já em código, consumidores da plataforma migrados, mapeador com dez sufixos. Nome: o par pendente (pendingOwnerScope · pendingOwnerKey) passa a ser a referência esperada (expectedOwnerScope · expectedOwnerKey), como no código; declarada só no processo, nunca pela rota HTTP de confirmação, e o handler trata dono que não aponta para o arquivo como dono inexistente. Porta IServerFileStore registrada em §Endpoints e na RN-GF-06 como exceção declarada, com quando o Nexio usa cada porta. Contrato de erros alinhado aos dez sufixos e à precedência do código exato. Notas datadas saíram do corpo: o perfil gryd.notification-attachment nasceu em 03/09/2026 pela spec de Notificação; os perfis nexio.item-photo, nexio.item-document e nexio.supplier-document, em 02/09/2026; a precedência de USER_EMAIL_UNAVAILABLE foi escrita em 09/09/2026; o comportamento 400 do FILE_SCAN_UNAVAILABLE tinha sido conferido em 08/09/2026, quando o sufixo não existia; o detalhamento é de 08/09/2026. D2, D3 e D10 com âncora certa.
v1.510/09/2026Validação de 10/09. Veredito: deixa de ser chamado "na mesma unidade de trabalho" (que atravessava dois DbContext e rodava sem tenant) e passa a ser entregue depois do commit, pelo menos uma vez, a handler idempotente com tenant restaurado; Failed também é entregue (RN-GF-13). Integridade: o objeto servido é o escaneado, e o upload tem tamanho assinado ou conferido (RN-GF-18, FILE_OBJECT_CHANGED_UNPROCESSABLE). Antivírus na v1, antes da primeira Requisição com anexo — a entrega partida em registro na v1 e antivírus na v1.1 saiu. Retenção é piso: retainUntil vencido deixa de liberar referência ativa (RN-GF-14 e RN-GF-15 reescritas). Dedupe sai da v1: canonicalFileId, a unicidade só entre liberados e o predicado sobre o conjunto vão para o bloco v2; RN-GF-16 fica sem regra vigente. Re-scan de Available não muda o estado, e o positivo propaga a todas as referências (RN-GF-17, transições Available → Available e Available → Infected). Novas: fila parada volta sozinha (RN-GF-19, ScanRequeueAfterMinutes), referência × expurgo (RN-GF-20), nome apagado em expurgo automático (RN-GF-21). legalHold por caso, com motivo. Rota :rescan → /rescan, na forma das outras ações da plataforma. Permissões no plural, como no código. StoredFile herda de BaseEntity e implementa IOptionalTenant; o Core já tem a sobreposição de cabeçalho de resposta. Seção Estado da implementação. Jobs: 6 → 7.
v1.409/09/2026Rodapé de permissões reescrito; precedência do código explícito USER_EMAIL_UNAVAILABLE sobre o sufixo _UNAVAILABLE; o uso de Npgsql na plataforma registrado como constatação, não como premissa.
v1.309/09/2026Validação de 08/09. Decisão 9: a referência nasce na liberação, com o par pendente. Contrato de veredito na unidade de trabalho da transição (substituído na v1.5). Retenção vencida como liberação implícita (substituída na v1.5). Predicado do expurgo sobre o conjunto canônico e unicidade só entre os liberados (com o dedupe, v2 desde a v1.5). Rota e job de re-scan. Entrega partida em registro e antivírus (desfeita na v1.5).
v1.208/09/2026Dezesseis decisões de detalhamento contra o backlog do Epic 1376 (§ Detalhamento): permissões na forma ação:recurso, rejectionCode, lastReferenceReleasedAt, GET /files/scanner-status, valores do clamd.conf, UseDefaultProfile removido e perfis nexio.* fora do template. nosniff deixou de ser prometido na URL.
v1.103/09/2026Perfil de plataforma gryd.notification-attachment, pedido pela spec de Notificação.
v1.002/09/2026Primeira versão: o módulo de arquivo da plataforma, com registro, upload em duas fases, ClamAV, retenção declarada e expurgo com lápide.
v1.13.125/10/2026Revisão pós-implementação (GF-01 a GF-22 do Gryd.IO). Estado da implementação refeito sobre o código entregue: nenhuma linha "Divergente" ou "A implementar", e a seção ganha as decisões que a implementação tomou diferente do texto. Correções de texto: seis consumidores da porta de servidor (RN-GF-06 b, DEC-GF-06-1); falha da RN-GF-28 (e) na primeira chamada quando o pedido é por Type (DEC-GF-09-1); permissões específicas no lugar de admin:system (release-hold:files, purge-permanent:files, *:product-files); a (f) é exceção da (q) na RN-GF-25 e a guarda vem antes da referência (DEC-GF-19-4, 19-7); guarda só em Available e Infected (DEC-GF-18-1); onze sufixos e onze rotas; quatro entidades (FileLegalHold); o 422 do expurgo é só em processo; download-url exige purpose e scan-attempts recusa page menor que 1; a opção de catálogo da RN-GF-23 (c) é de adesão e age ao conceder. Nenhum código de erro, permissão ou campo novo além dos já previstos.