Ponto de partida
Nove decisões
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.
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.
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.
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.
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.
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.
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.
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.
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.
| Assunto | GrydFiles | Consumidor (ex.: Nexio) |
|---|---|---|
| Bytes, bucket, chave | Sim — é a razão de existir | Nunca vê objectKey nem nome de bucket |
| Hash, tamanho, tipo | Calcula e valida | Declara o esperado e recebe o verificado |
| Antivírus e quarentena | Sim, com o resultado no registro | Só lê status |
| Quanto tempo guardar | Executa e cobra a declaração | Decide — é regra de negócio e de lei |
| Quem pode subir e baixar | Permissão genérica do módulo | Permissão de domínio, antes de chamar |
| O que o arquivo é | Não sabe e não quer saber | Attachment, ItemDocument, relatório, planilha de importação |
| Visibilidade para terceiro | Fora — o módulo só emite URL curta para quem já foi autorizado | visibility = Internal | Supplier vive no anexo |
| Vínculo com o dono | FileReference em texto opaco | Sabe 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ça | Onde | O que o GrydFiles faz com ela |
|---|---|---|
IObjectStorageService | Gryd.Application | UploadAsync, 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 |
ObjectStorageSignedUrlOperation | Gryd.Application | Upload e Download. É o que torna a decisão 2 possível sem escrever nada de provedor |
ObjectStoragePresignedUrlRequest | Gryd.Application | Já 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 |
| Provedores | Gryd.Infrastructure | AmazonS3 · AzureBlobStorage · GoogleCloudStorage, resolvidos por IObjectStorageProviderResolver. O ServiceUrl + ForcePathStyle do S3 cobre MinIO e R2 |
ObjectStorageOptions | seção GrydStorage | Bucket, região, credencial e DefaultPrefix. O GrydFiles acrescenta a seção GrydFiles com os perfis, e não duplica nada de provedor |
ObjectStorageObjectDescriptor | Gryd.Application | Devolve SizeBytes e ContentType reais — é com ele que a confirmação do upload confere o que o cliente declarou |
Padrão do GrydReports | Modules/Reports | ConfigurableReportFileStore (local × nuvem) e ReportFilePathGenerator (tenant/aaaa/MM/dd/id.ext) são o molde da chave e do modo local de desenvolvimento |
GrydJobs | Modules/Jobs | Sete 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) |
IAuditStore | Gryd.Observability | SaveAsync, 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
AddGrydFilesprecisa de umIAuditStoredurável (AddGrydAuditou um próprio); com oNullAuditStorea subida falha, e o download sem trilha durável é recusado (FILE_AUDIT_TRAIL_UNAVAILABLE). - Sobrecargas com o tenant nomeado.
IFileStore<TScope>ganhaGetAsync,AddReferenceAsynceUpdateRetentionAsynccomGuid? tenantId(RN-GF-08), que funcionam sem request. As assinaturas existentes não mudam. RescanAsyncexige autor. Sem usuário no contexto respondeFILE_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 chamaReconcileAuthorizationCatalogAsyncdepois das migrations.POST /permissionse a edição recusam nome exclusivo de plataforma. - CNPJ.
Gryd.Core.Documents.Cnpjnormaliza, valida, formata e mascara o CNPJ numérico e o alfanumérico;SensitiveDataMasker.MaskCnpjdelega a ele.
| Item | No código | Situação | Bloqueia |
|---|---|---|---|
| Entidades | StoredFile : 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ões | Seis 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, PermissionCatalogTests | Conforme, 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á-la | O produto que autoriza pelo dono (Nexio) liga a opção antes de abrir /files/* |
| Permissão em processo | A 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írus | O 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 download | ResponseContentDisposition (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, PresignedResponseHeaderOverrideTests | Conforme | — |
| Porta de servidor | IServerFileStore<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 erros | O 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: FileErrorStatusRoutingTests | Conforme | — |
| Referência na liberação | StoredFile.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, RecordVerdictStepTests | Conforme | — |
| Posse da referência | CreatedBy 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, NotificationAttachmentsTests | Conforme | — |
| Leitura em processo | OpenReadAsync(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, NamedTenantStoreTests | Conforme | — |
| Rotas HTTP de referência | POST/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), ReferenceSurfacePolicyTests | Conforme | — |
| Usuário na IFileService | FileActorPolicy: 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), FileActorPolicyTests | Conforme | — |
| Guarda por detentor e autor declarado | Tabela 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ção | Conforme. 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 consumidor | AddGrydFilesConsumer<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, AddGrydFilesTests | Conforme (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 tenant | O 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_*, CandidateScopesTests | Conforme (DEC-GF-21-1) | — |
| Contrato de veredito | IFileOutcomeHandler 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, ReportsConsumerTests | Conforme. 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 |
| Rotas | Onze 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 OpenAPI | Conforme | — |
| Jobs | Seis 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, FileScanPipelineTests | Conforme (sete jobs, como a spec) | — |
| Integridade do objeto servido | URL 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 adaptadores | A regra de ciclo de vida de _transit/ (expirar em um dia) é do bucket do host |
| Retenção | PurgeEligibility é 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 guarda | A 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 data | PurgeFileRequest.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 422 | O host registra o adaptador de autores (existe só no AdminDevHost) |
| Dedupe | Retirado: 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 × expurgo | IStoredFileLock (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, LockedFilePurgeHoldTests | Conforme | — |
| Nome no expurgo automático | Os 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_*, NegativePathsOverHttpTests | Conforme | — |
| sha256 na lápide de tipo reprovado | RejectContentType 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_end | Conforme | — |
| legalHold com motivo | PUT /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, LegalHoldHoldersTests | Conforme | — |
| Trilha do módulo | IFileAuditTrail 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, RescanTests | Conforme, 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 erros | Vinte 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…, FileErrorStatusRoutingTests | Conforme | — |
| Documentação do framework | docs/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 verdes | Conforme | — |
| Histórico de tentativas | GET /files/{id}/scan-attempts paginado (20 por página, attemptNumber decrescente, PagedResult); page < 1 → 400 sem código. Testes: GetFileAndScanAttemptsHandlerTests | Conforme, 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 acervo | Item de desempenho quando o acervo crescer |
| Re-scan e fila parada | POST /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), GrydFilesOptionsValidatorTests | Conforme | — |
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 dizadmin:system, leia: retirar a guarda da plataforma →release-hold:files(RN-GF-27 e a tabela de rotas); eliminar arquivoPermanentpela rota →purge-permanent:files(RN-GF-25); arquivo de produto → a permissão de plataforma*:product-filesda 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 → Infecteddo 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 donosUnknown. - 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.ExporteGrydAudit.Integrity(DEC-GF-06-1). - RN-GF-28 (e). O pedido, em execução, de uma porta de escopo por
Typemontado em tempo de execução falha na primeira chamada, não na resolução: o contêiner devolve o mesmoTypenos 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
AvailableeInfected; emPending,ScanningeFailedé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 emPermanent. - Expurgo ordinário respeita o piso.
POST /files/{id}/purgeeIFileStore.PurgeAsyncsem a marcação de eliminação recusamretainUntilfuturo ePermanentcomFILE_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 respondemFILE_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
sha256, provedor, bucket, chave, estado, resultado do scan, retenção e lápide de expurgo.
ownerScope + ownerKey. Nasce com o vínculo e é liberada quando ele cai. É o que autoriza o expurgo.
FileReference ao nascer.
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.
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.| De | Para | Quem dispara | Regra |
|---|---|---|---|
| — | Pending | POST /files | Valida 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. |
| Pending | Scanning | POST /files/{id}/complete | Confere 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. |
| Pending | Purged | Job de varredura | uploadExpiresAt vencido sem confirmação. O objeto órfão, se existir, é apagado. É o caminho do usuário que desistiu no meio. purgedBy nulo (autor é job). |
| Scanning | Available | Job de scan | Resposta 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. |
| Scanning | Purged | Job de scan | Reprovaçã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. |
| Scanning | Infected | Job de scan | Resposta 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. |
| Scanning | Failed | Job de scan | Erro 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. |
| Failed | Scanning | Job de scan · POST /files/{id}/rescan | Até 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. |
| Scanning | Scanning | Varredura de Pending vencido | Arquivo 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. |
| Available | Available | Job de re-scan de acervo · POST /files/{id}/rescan | Re-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). |
| Available | Infected | Job de re-scan de acervo · POST /files/{id}/rescan | Re-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. |
| Available | Purged | Job de retenção · POST /files/{id}/purge | Predicado 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. |
| Infected | Purged | Job de varredura de consistência | Quarentena 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 · Failed | Purged | IFileService em processo · POST /files/{id}/purge | Só 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.
| Campo | Tipo | Regra | |
|---|---|---|---|
| id | Guid | gerado | Atribuí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. |
| tenantId | Guid? | gerado | Do 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). |
| profile | string(40) | obrig | O 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. |
| originalFileName | string(255)? | obrig | Só 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). |
| contentType | string(120) | obrig | Declarado na criação e reconferido no scan contra a assinatura de conteúdo, inclusive quando ScanRequired: false. Divergência leva a Purged com rejectionCode. |
| declaredSizeBytes | bigint | obrig | O tamanho que o cliente promete. Serve para recusar antes de gastar rede, e para comparar depois. Zero é recusado. |
| sizeBytes | bigint? | gerado | O tamanho real, lido do ObjectStorageObjectDescriptor na confirmação. Divergiu do declarado, o arquivo não entra. |
| sha256 | char(64)? | gerado | Calculado 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. |
| storageProvider | enum? | gerado | AmazonS3 · AzureBlobStorage · GoogleCloudStorage. Guardado no registro porque a configuração padrão muda com o tempo e o objeto antigo continua onde está. |
| bucketName | string(120)? | gerado | Idem: o bucket efetivo no momento da gravação, não o configurado hoje. |
| objectKey | string(400)? | gerado | Ver § 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. |
| status | enum | gerado | Pending · Scanning · Available · Infected · Failed · Purged. Só o serviço muda; não existe rota que aceite estado do cliente. |
| expectedOwnerScope / expectedOwnerKey | string(60)? / string(80)? | opc | A 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. |
| rejectionCode | string(80)? | gerado | O 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. |
| uploadExpiresAt | DateTime | gerado | Fim 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. |
| scannedAt | DateTime? | gerado | Quando o veredito saiu. Junto com scanEngine e scanDatabaseVersion responde "com que base este arquivo foi liberado". |
| scanEngine | string(60)? | gerado | Ex.: ClamAV 1.4.x. Existe porque o provedor é plugável e a resposta de auditoria precisa dizer qual motor deu o parecer. |
| scanDatabaseVersion | string(40)? | gerado | A versão da base de assinaturas no momento do scan, obtida do comando VERSION do clamd. |
| scanSignature | string(160)? | gerado | O nome da assinatura quando houve detecção — a parte antes do FOUND na resposta. Nulo quando limpo. |
| retentionMode | enum | obrig | UntilReleased (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. |
| retainUntil | DateTime? | cond | Obrigató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. |
| legalHold | bool | gerado | Derivado: 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 detentor | coleção | gerado | No 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. |
| referenceCount | int | gerado | Denormalizado 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. |
| lastReferenceReleasedAt | DateTime? | gerado | Quando 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 / purgeReason | DateTime? / Guid? / string(200)? | gerado | A 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 / erasureRequestedBy | enum? / string(60)? / string(80)? / Guid? | gerado | Que 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
| Campo | Tipo | Regra | |
|---|---|---|---|
| storedFileId | Guid | obrig | O 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. |
| ownerScope | string(60) | obrig | Quem aponta, em texto opaco: nexio.attachment, nexio.item-document, gryd.report-execution. O módulo não interpreta — só agrupa e reporta. |
| ownerKey | string(80) | obrig | A chave do dono no mundo dele. Normalmente um Guid em texto. Mesmo polimorfismo que o AuditLog do framework já usa em EntityType + EntityId. |
| releasedAt / releasedBy | DateTime? / Guid? | gerado | Liberaçã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
| Campo | Tipo | Regra | |
|---|---|---|---|
| storedFileId · attemptNumber | Guid · int | obrig | Chave composta natural. Começa em 1. |
| engine · databaseVersion | string(60) · string(40) | gerado | Motor e base no momento da tentativa. |
| startedAt · finishedAt | DateTime · DateTime? | gerado | Também servem de métrica: tempo de scan por MB é o número que dimensiona as réplicas do clamd. |
| outcome | enum | gerado | Clean · Infected · Error · Timeout · SizeLimitExceeded. O último é a resposta INSTREAM size limit exceeded do clamd e é erro de configuração, não do arquivo. |
| message | string(400)? | gerado | A 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ério | Como pesa |
|---|---|
| Funciona nos três provedores | O 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. |
| Custo | Sem 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 rede | Proposta 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 lugar | Contêiner, on-premise, nuvem privada. O Gryd.IO não pode assumir nuvem pública em todo cliente. |
| Trocável | Fica 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âmetro | Como definir |
|---|---|
| StreamMaxLength | 64M. 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. |
| MaxFileSize | 64M, 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. |
| MaxScanSize | 400M. 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 · MaxFiles | 16 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. |
| ConcurrentDatabaseReload | Ligado, para o scan não parar durante a atualização — é ele que exige a folga de memória de 4 GiB. |
| IDSESSION | Reaproveita 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 scanner | 120s, 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ção | Por que não é a padrão |
|---|---|
| GuardDuty Malware Protection for S3 | Só 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 API | Custo por chamada e, sobretudo, o arquivo sai da nossa rede. Vira cláusula de contrato com cada cliente. |
| Appliance ICAP | Faz 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 escanear | Recusada. 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,.pptxe.zipcomeçam todos comPK\x03\x04— os quatro primeiros bytes não os distinguem. O detector abre o container e lê o[Content_Types].xmlpara resolver o tipo OOXML concreto, caindo paraapplication/zipquando 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 byteNUL— e o tipo declarado prevalece se estiver na lista do perfil. É o que barra o executável renomeado para.csv, porque binário carregaNUL, 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:
| Parte | Por quê |
|---|---|
tenantId na raiz | Isolamento 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. |
| Perfil | Anexo, 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. |
| Data | Evita diretório com milhões de objetos e dá um caminho barato para diagnóstico e para expurgo em massa. |
fileId, não o nome | Nome 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 tipo | Derivada do contentType verificado, não do nome enviado. Só ajuda ferramenta de diagnóstico; nada no sistema decide por ela. |
| Prefixo de quarentena | Separado, 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.
| Modo | Quando usar | Como morre |
|---|---|---|
| UntilReleased | Padrã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. |
| RetainUntil | Prazo 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. |
| Permanent | Exceçã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.
| Regra | Detalhe |
|---|---|
Só Available | Pending, 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 curta | Padrã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 chama | O 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çalho | Content-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 Core | O 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 quarentena | Nenhuma 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 trilha | IFileAuditTrail.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.
| Porta | Quem tem os bytes | Como funciona | No Nexio |
|---|---|---|---|
| IFileService | O cliente, do outro lado da rede | O 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 trilha | Tudo que a pessoa sobe pela tela — anexo, foto e documento de item, documento do fornecedor, planilha de importação — e todo download |
| IServerFileStore | O servidor, dentro do processo | StoreAsync 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 |
| Rota | Permissão | Devolve | Erros |
|---|---|---|---|
| POST /files | upload:files | fileId, uploadUrl pré-assinada, uploadExpiresAt e os cabeçalhos que o PUT precisa repetir | 400 · 403 · 422 |
| POST /files/{id}/complete | upload:files | StoredFileDto em Scanning. Idempotente: chamar duas vezes devolve o mesmo estado | 404 · 409 · 422 |
| GET /files/{id} | read:files | Metadado, estado, resultado do scan e rejectionCode. Nunca objectKey nem bucketName | 404 |
| GET /files/{id}/download-url | read:files | URL 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 plataforma | 400 · 403 · 404 · 409 · 503 |
| POST /files/{id}/references | upload:files | A referência criada. Idempotente por (escopo, chave). Só para ownerScope gerido pela superfície HTTP (RN-GF-23), com a posse da RN-GF-22 | 403 · 404 · 409 |
| DELETE /files/{id}/references/{referenceId} | upload:files | 204. 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-attempts | read:files | O 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 banco | 400 · 404 |
| PUT /files/{id}/retention | retain:files | Estende 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_CONFLICT | 403 · 404 · 409 · 422 · 503 |
| POST /files/{id}/purge | purge:files | Expurgo 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 autenticado | 403 · 404 · 409 · 503 |
| GET /files/scanner-status | read:files | Motor, 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 escuro | 503 |
| POST /files/{id}/rescan | upload:files | Pede 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 terminais | 404 · 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ódigo | HTTP | Quando |
|---|---|---|
| FILE_NOT_FOUND | 404 | Não existe neste tenant. Arquivo de outro tenant responde igual, de propósito |
| FILE_CALLER_UNAUTHORIZED | 401 | Operaçã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_FORBIDDEN | 403 | Rota 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_UNKNOWN | 400 | Perfil não está na configuração. Erro de programação do consumidor, não do usuário |
| FILE_CONTENT_TYPE_NOT_ALLOWED_UNPROCESSABLE | 422 | Tipo fora da lista do perfil — no declarado, ou no verificado durante o scan |
| FILE_TOO_LARGE_UNPROCESSABLE | 422 | Acima do teto do perfil. A mensagem carrega o teto, para a tela poder dizer qual é |
| FILE_SIZE_MISMATCH_UNPROCESSABLE | 422 | O tamanho real não bate com o declarado. O objeto é apagado e o registro vai para Purged |
| FILE_UPLOAD_EXPIRED_CONFLICT | 409 | Confirmação depois de uploadExpiresAt. O caminho é reservar de novo |
| FILE_NOT_UPLOADED_CONFLICT | 409 | Confirmação sem objeto no bucket — o PUT não aconteceu ou falhou |
| FILE_NOT_AVAILABLE_CONFLICT | 409 | Uso de arquivo que ainda está em Pending, Scanning ou Failed. É o estado normal de "espere um pouco" |
| FILE_INFECTED_CONFLICT | 409 | Código separado do anterior de propósito: a tela precisa dizer o que houve, e o suporte precisa distinguir fila de incidente |
| FILE_PURGED_CONFLICT | 409 | O 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_CONFLICT | 409 | Expurgo com referência ativa. A resposta lista os ownerScope que ainda apontam — sem isso ninguém descobre quem está segurando |
| FILE_LEGAL_HOLD_CONFLICT | 409 | Expurgo, 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_CONFLICT | 409 | Eliminaçã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_UNPROCESSABLE | 422 | Autor 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_CONFLICT | 409 | Referê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_CONFLICT | 409 | Expurgo 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_UNPROCESSABLE | 422 | Só 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_UNPROCESSABLE | 422 | Tentativa de puxar retainUntil para trás |
| FILE_SCAN_UNAVAILABLE | 503 | Dependê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_UNAVAILABLE | 503 | Download, 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_CONFLICT | 409 | Re-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ão | O que libera |
|---|---|
| upload:files | Reservar, 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:files | Ler 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:files | Estender retenção e ligar legal hold. Separada porque prende custo de armazenamento por anos |
| release-hold:files | Retirar 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:files | Expurgo manual pela rota. É a que destrói conteúdo |
| purge-permanent:files | Ato 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-files | Permissõ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
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.
Na mesma leitura do antivírus, a partir do objeto gravado. Hash informado pelo cliente é ignorado, mesmo quando enviado.
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.
Infected é terminalNã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.
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.
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.
objectKey e bucketName nunca saem do móduloNã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.
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.
retainUntil nunca é puxado para trás por API, e Permanent jamais entra no job de expurgo.
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.
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.
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.
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.
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.
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).
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.
Failed não é terminal, e veredito limpo não é eternoEsgotadas 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.
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.
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.
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.
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.
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.
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).
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.
retainUntil, e em Permanent, só a eliminação expurgaExpurgo 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.
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.
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.
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.
| # | Decisão | Onde |
|---|---|---|
| D1 | Permissõ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 |
| D2 | sha256 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 |
| D3 | Localização preservada no registro expurgado, para a lápide responder onde o objeto esteve | § Campos |
| D4 | Campo 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 |
| D5 | Core 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 |
| D6 | Rota 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 |
| D7 | Sniffing 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 |
| D8 | Campo lastReferenceReleasedAt para a carência. Agregar FileReference por candidato é o que o referenceCount existe para evitar, e a carga cresceria para sempre | § Retenção |
| D9 | QuarantineRetentionDays: 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 |
| D10 | Migraçã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 | — |
| D11 | Download 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 | — |
| D12 | UseDefaultProfile 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 |
| D13 | Valores 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 |
| D14 | Retry 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 |
| D15 | O 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 |
| D16 | purgedBy 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
| Produto | O que faz | O que aproveitamos |
|---|---|---|
| Oracle E-Business Suite / Fusion | Separa 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 documentos | As duas camadas e o registro do consumidor. As cinco colunas de chave existem porque as chaves do EBS são compostas; aqui é um Guid |
| SAP | Anexo 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 arquivo | A ideia de que arquivo é infraestrutura, não domínio — e de que o módulo de negócio nunca deveria conhecer storage |
| SAP Ariba | Teto de 100 MB de anexo, elevado a 2 GB especificamente para contrato | A confirmação de que limite é por contexto, não global. É a origem do perfil |
| Amazon S3 · presigned URL | URL assinada com escopo de operação, objeto e prazo, para tirar o binário do caminho da aplicação | O desenho inteiro do fluxo de duas fases — que o Gryd.Infrastructure já implementa nos três provedores |
| Amazon GuardDuty for S3 | Scan gerenciado no bucket, com cotas de 100 GB por objeto, 100.000 arquivos extraídos e 25 buckets por conta e região | Provedor alternativo de IFileScanner, não o padrão. As cotas por conta são o limite prático para plataforma multi-cliente |
| ClamAV | Motor de assinatura com daemon residente, protocolo INSTREAM e base atualizada por freshclam | O provedor padrão. Ver § Antivírus |
Limites
Fora de escopo
| Assunto | Por 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ão | Renderizar 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údo | Outro 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-Brasil | Gancho declarado. Assinar documento é fluxo com signatário, ordem e carimbo de tempo — módulo próprio, que consumiria este |
| Criptografia por chave do cliente | Gancho 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 multipart | Gancho declarado. Necessário acima de alguns GB; com o teto atual dos perfis, complexidade sem retorno |
| Perfil configurável por tenant | Nasce em appsettings. Vira tabela quando existir o primeiro cliente que precise de teto diferente — e não antes |
| CDN e link público permanente | Conflita 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 isolado | Um 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ão | Data | O que mudou |
|---|---|---|
| v1.16 | 02/11/2026 | Conformidade 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.15 | 30/10/2026 | Conformidade 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.14 | 30/10/2026 | Estado 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.13 | 23/10/2026 | Conformidade 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.12 | 13/09/2026 | Correçã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.11 | 12/09/2026 | Correçã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.10 | 12/09/2026 | Correçã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.9 | 11/09/2026 | Correçã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.8 | 11/09/2026 | Correçã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.7 | 11/09/2026 | Correçã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.6 | 11/09/2026 | Correçã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.5 | 10/09/2026 | Validaçã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.4 | 09/09/2026 | Rodapé 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.3 | 09/09/2026 | Validaçã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.2 | 08/09/2026 | Dezesseis 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.1 | 03/09/2026 | Perfil de plataforma gryd.notification-attachment, pedido pela spec de Notificação. |
| v1.0 | 02/09/2026 | Primeira 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.1 | 25/10/2026 | Revisã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. |