Especificação · Transversal

Anexo

O arquivo já foi resolvido: o GrydFiles guarda bytes, calcula hash, passa antivírus, conta referências e executa retenção — e não sabe o que o arquivo é. Esta spec escreve a outra metade, que é a única que o Nexio precisa saber: de qual documento este arquivo é prova, quem pode vê-lo, o que acontece com ele quando o documento avança, e o que acontece quando alguém o remove. Duas entidades, nenhuma linha de código de storage, e a resposta para os seis agregados que hoje resolveriam anexo cada um do seu jeito.

consome GrydFiles · perfil nexio.attachment desbloqueia Requisition · QuotationRequest · PurchaseOrder · Receipt · Invoice · Contract 1 agregado · 1 entidade de configuração · 1 registro em código v1.5 · 08/09/2026

Ponto de partida

Dez decisões

Fechadas antes de o documento existir. Cada uma responde a uma pergunta que, sem resposta escrita, vira campo improvisado no primeiro agregado que precisar de anexo — e depois vira seis campos improvisados diferentes.

Decisão 1 · Camadas

Duas camadas, e a regra de corte é o metadado. StoredFile é do GrydFiles e responde por bytes; Attachment é do Nexio e responde por vínculo. Arquivo com metadado próprio — número, órgão emissor, validade, status derivado da data — é entidade do domínio (ItemDocument, SupplierDocument); arquivo que é só evidência é Attachment. O precedente é o par FND_DOCUMENTS + FND_ATTACHED_DOCUMENTS da Oracle, que tem trinta anos de produção.

Decisão 2 · Dono

Amarração polimórfica: ownerType fechado mais ownerId, sem chave estrangeira. A arquitetura proíbe FK cruzando bounded contexts, e o AuditLog do framework já resolve assim. O preço é integridade referencial, e ele é pago com três mitigações obrigatórias — registro de tipos de dono, evento de domínio no descarte de rascunho e varredura diária. Nenhum agregado navega para Attachment.

Decisão 3 · Visibilidade

visibility = Internal | Supplier desde a v1. Decidir depois obrigaria a reclassificar arquivo já anexado, e o modo de falha dessa migração é vazamento — a planilha de custo interna aparecendo no portal do fornecedor. É o To Buyer / To Supplier do Oracle iProcurement, e o GrydFiles não avalia nada disso: read:file não é autorização de negócio.

Decisão 4 · Propagação

Propagar é copiar o vínculo, nunca o arquivo — e é sempre explícito. Requisição → pedido → recebimento cria um Attachment novo apontando para o mesmo storedFileId, com copiedFromAttachmentId, herdando a visibilidade e criando a sua própria FileReference. Propagação silenciosa é como anexo interno chega ao fornecedor sem ninguém ter decidido.

Decisão 5 · Revogação

Revogar em rascunho libera o arquivo; fora de rascunho, não. As duas regras existem pelo mesmo motivo: liberar a referência de um documento fechado faria o blob morrer no fim da carência e a auditoria receber FILE_PURGED_CONFLICT — guardaríamos a lápide da prova e perderíamos a prova. Fora de rascunho quem decide a morte do arquivo passa a ser a retenção, não a revogação.

Decisão 6 · Configuração

Duas camadas, e está escrito quem faz o quê. O perfil nexio.attachment é da plataforma, em appsettings: tamanho máximo, tipos permitidos, TTL das URLs. O AttachmentType é do tenant, em tabela, com IOptionalTenant — e é ele que declara o modo de retenção que o Nexio envia ao GrydFiles no upload. A exigência de anexo mora no consumidor, nunca aqui.

Decisão 7 · Escopo e trilha

companyId derivado do dono, fora de escopo é 404, e a trilha não é repetida. O recorte por empresa usa UserCompanyScope e responde igual para "não existe" e "não é seu" — regra do DeliveryLocation. O GrydFiles já registra a emissão de cada URL de download; o Nexio registra o que a plataforma não sabe: qual documento e qual anexo, porque lá só existe um fileId.

Decisão 8 · Scanning

Anexar durante a verificação é permitido; submeter não. Um aprovador não pode receber na fila um documento cujo anexo ainda pode virar Infected. E prender o usuário na tela esperando o antivírus é a solução ruim. A saída ficou declarada em aberto na v1.0 e foi fechada em 03/09/2026 pela spec de Notificação: aviso quando o arquivo liberar, mais uma fila de submissão diferida que a pessoa pede a cada tentativa e pode cancelar. Ver § Scanning.

Decisão 9 · Portal

Anexo de origem portal se amarra ao cadastro de fornecedor. supplierId é obrigatório e gravado denormalizado; portalUserId é opcional, porque o fornecedor pode responder por link de uso único. Autoria em uploadedByKind = User | PortalUser | System. E nasce Internal — é isso que sustenta o isolamento entre concorrentes numa cotação.

Decisão 10 · Espelho

Espelhar o imutável, consultar só o que muda. originalFileName, sizeBytes e contentType nunca mudam depois da confirmação, então são denormalizados. status muda, então é consultado — em lote. Listar vinte anexos não pode virar vinte chamadas ao módulo de arquivo.

Escopo

A fronteira com o GrydFiles

O GrydFiles é fonte da verdade da camada de arquivo. Nada do que está escrito lá é redecidido aqui: esta spec consome e cita. A pergunta que separa os dois lados é a mesma que o módulo usa para se definir — isso continuaria verdade num produto que não é o Nexio?

O que já está garantido lá, e por isso não aparece nos campos daqui
AssuntoQuem resolveO que o Nexio faz com isso
Bytes, bucket, chave do objetoGrydFilesNunca vê objectKey nem nome de bucket. O binário não passa pela API do Nexio em nenhum sentido
URL pré-assinada de upload e de downloadGrydFilesPede, repassa ao navegador e esquece. TTL vem do perfil
sha256, tamanho real, tipo verificadoGrydFiles, no servidorDeclara o esperado e recebe o verificado. Não recalcula nada
Antivírus, quarentena, vereditoGrydFiles · ClamAVstatus e decide o que a tela e o fluxo fazem com ele
Dedupe por conteúdoGrydFiles, por tenantIgnora. Dois anexos podem compartilhar blob sem que nada no domínio mude
Contagem de referências e expurgoGrydFilesCria e libera FileReference. Nunca apaga arquivo — não existe rota de exclusão do outro lado
Quanto tempo guardarNexio decide, GrydFiles executaDeclara retentionMode, retainUntil e legalHold no upload, resolvidos do AttachmentType
Quem pode ver este documentoNexio, antes de pedir a URLread:file é permissão de módulo, não autorização de negócio. A decisão é visibility + escopo + permissão do dono
O que o arquivo éNexioAttachment · AttachmentType · ownerType. O GrydFiles guarda só ownerScope e ownerKey em texto opaco

Consumo em processo, não por HTTP. O GrydFiles roda no mesmo processo e expõe IFileService; o Nexio chama a interface diretamente, como o GrydReports já faz. As rotas /files/* existem para a SPA do próprio módulo, e a SPA do Nexio não as chama — ela fala com /attachments, que é onde a autorização de negócio acontece. Expor /files/{id}/download-url para a tela de requisição seria entregar ao navegador a única rota que não sabe o que é uma requisição.

O upload, ponta a ponta, em cinco passos

1. A tela chama POST /attachments/upload-intent com ownerType, ownerId, attachmentTypeId, nome, tipo e tamanho declarados. O Nexio resolve a empresa do dono, confere permissão, confere que o tipo é aceito naquele dono, resolve a retenção e só então chama o POST /files do GrydFiles com o perfil nexio.attachment. A recusa cara acontece antes de gastar rede.

2. O navegador faz o PUT direto no storage, com a URL e os cabeçalhos devolvidos. Nenhum byte passa pela API.

3. A tela chama POST /attachments com o fileId. O Nexio confirma no GrydFiles (POST /files/{id}/complete), cria o Attachment e cria a FileReference com ownerScope = "nexio.attachment" e ownerKey = attachment.id. A resposta volta com o arquivo em Scanning.

4. O antivírus roda no GrydFiles. O anexo já existe, já aparece na lista, e a lista mostra o estado real — não um spinner que some quando o usuário troca de aba.

5. Available libera download, envio ao fornecedor e submissão para aprovação. Infected é terminal: o anexo é revogado automaticamente, com motivo próprio, e a tela diz o que aconteceu.

Por que o Attachment nasce antes do veredito, e não depois

A alternativa seria criar o vínculo só quando o arquivo ficasse Available. Ela parece mais limpa e é pior em três frentes: o usuário não vê o que acabou de anexar; a falha do antivírus vira um arquivo órfão que ninguém consegue nomear na tela; e a segunda tentativa de subir o mesmo arquivo não tem como ser reconhecida como repetida. Criar cedo e deixar o estado à mostra é o que torna a espera honesta — e é o que permite a decisão 8 ser sobre submeter, não sobre anexar.

Modelo

Mapa de entidades

Attachment aggregate root · Nexio O vínculo. Aponta para um StoredFile e para um dono polimórfico, carrega visibilidade, autoria de negócio, o rastro da cópia e a revogação. Espelha o que é imutável no arquivo.
AttachmentType configuração · por tenant O catálogo. Rótulo, visibilidade padrão, donos aceitos, se propaga e — o que só ele decide — o modo de retenção declarado ao GrydFiles. IOptionalTenant: nulo é tipo global do produto.
AttachmentOwnerRegistry registro em código · não é tabela Um descritor por ownerType, implementado pelo agregado dono. Resolve empresa, estado, limites e o número legível do documento. Tipo sem descritor derruba a inicialização. Ver § O dono polimórfico.
StoredFile GrydFiles · plataforma Bytes, sha256, status, retenção e lápide. Nada aqui é remodelado, e o Nexio nunca escreve neste registro a não ser pelas rotas de retenção do próprio módulo.
FileReference GrydFiles · uma por anexo (storedFileId, "nexio.attachment", attachmentId). É o que impede o expurgo enquanto alguém precisa do arquivo — e o que cada cópia cria para si.
ItemDocument · SupplierDocument entidades do domínio · não são anexo Têm número, órgão, vigência e status derivado da data. Continuam existindo, e passam a guardar storedFileId em vez de URL — a dívida que sai junto com esta spec.
ReasonCode transversal · especificado O motivo da revogação, no uso AttachmentRevoke. Especificado em 03/09/2026: o anexo grava reasonCodeId mais o código e o rótulo congelados. O motivo de sistema continua viajando em revocationSource — são eixos diferentes. Ver Motivo.

Convenção de nomes. O documento é escrito em português e todo identificador é em inglês — entidade, atributo, valor de enum, rota, permissão e código de erro. A exceção são os dados de negócio brasileiros, que ficam como são no mundo real.

Duas tabelas: core.attachments e core.attachment_types. Índices: um em (tenantId, ownerType, ownerId) filtrando isDeleted = false, que é a consulta que a tela faz o tempo todo; um em (tenantId, storedFileId), para responder "quem mais aponta para este arquivo" sem consultar o outro módulo; um em (tenantId, supplierId) onde supplierId IS NOT NULL, que é o isolamento da cotação e a promoção a SupplierDocument; e, no tipo, unicidade em duas camadas — code único entre os globais (tenantId nulo) e (tenantId, code) único dentro de cada tenant: dois índices parciais, ou um único que trate nulos como iguais; depende do banco, e a verificação vale na aplicação. A mesma chave nas duas camadas não colide: é a sobreposição do tenant sobre o tipo global, ver § AttachmentType.

Domínio

Campos · Attachment

Herda de TenantScopedAggregateRoot. Id, CreatedAt, CreatedBy, UpdatedAt, UpdatedBy e o bloco de soft delete vêm do BaseEntity e não estão repetidos aqui.

Legenda: obrig sempre · cond conforme a operação · opc opcional · gerado calculado pelo sistema ou pelo banco.

Vínculo

CampoTipoRegra
idGuidgeradoAtribuído pelo agregado no construtor. É o ownerKey da FileReference do outro lado, então precisa existir antes de a referência ser criada.
tenantIdGuidgeradoDo token, nunca aceito do cliente.
storedFileIdGuidobrigSem chave estrangeira — é outro bounded context e outro DbContext. Imutável: trocar o arquivo de um anexo é criar outro anexo, porque o primeiro já pode ter sido lido, propagado e aprovado.
attachmentTypeIdGuidobrigAttachmentType. Decide visibilidade padrão, retenção e se propaga. Editável enquanto o dono é rascunho, e só entre tipos com a mesma retenção — mudar de tipo depois mudaria a data de expurgo de um arquivo já sob guarda.
ownerTypeenumobrigEnum fechado, ver § O dono polimórfico. Imutável. Valor sem descritor registrado não compila conceitualmente: a inicialização falha.
ownerIdGuidobrigA chave do dono no mundo dele. Sem FK, imutável. Reamarrar anexo a outro documento é copiar, ver § Propagação.
companyIdGuidgeradoDerivado do dono pelo descritor e denormalizado. Existe para o escopo por empresa caber no filtro de linha sem join entre contextos — mesmo padrão do DeliveryLocation.

Espelho do arquivo · o que não muda depois da confirmação

CampoTipoRegra
originalFileNamestring(255)geradoCopiado do StoredFile na confirmação. É o que a lista mostra e o nome sugerido no download. Nunca compõe chave de nada, nem aqui nem lá.
contentTypestring(120)geradoO tipo verificado pelo GrydFiles no scan, não o declarado pelo cliente. É o que decide o ícone e se a tela oferece pré-visualização do navegador.
sizeBytesbigintgeradoO tamanho real. Espelhado para a lista somar o peso do documento sem consultar o outro módulo — e é a soma que a regra de limite agregado usa.
statusgeradoNão é coluna. Muda com o tempo e por isso é consultado, sempre em lote, por GET /attachments/status. Espelhar estado que muda é como se cria a divergência que ninguém consegue explicar seis meses depois. Ver § Endpoints.

Classificação e negócio

CampoTipoRegra
visibilityenumobrigInternal · Supplier. Nasce do defaultVisibility do tipo. Supplier exige que o tipo permita, que o descritor do dono permita e que o usuário tenha share-with-supplier:attachment. Ver § Visibilidade.
descriptionstring(300)?opcPor que este arquivo está aqui. É o campo que transforma doc3_final_v2.pdf em informação. Editável enquanto o anexo não está revogado.
copiedFromAttachmentIdGuid?geradoO anexo de origem quando este nasceu de propagação. Nulo quando é original. Aponta para o anexo, não para o arquivo — o arquivo os dois já compartilham.
rootAttachmentIdGuid?geradoA origem da cadeia inteira, resolvida na criação. Existe para "onde este documento entrou no sistema" ser uma consulta, e não uma recursão de cinco níveis na tela de auditoria.

Autoria de negócio · quem, do ponto de vista de compras

CampoTipoRegra
uploadedByKindenumobrigUser · PortalUser · System. O uploadedBy do StoredFile continua sendo a identidade do tenant no GrydAuth — o portal não tem GrydAuth. A autoria de negócio é do anexo.
uploadedByUserIdGuid?condObrigatório com User. Nulo nos outros dois.
portalUserIdGuid?opcOpcional mesmo em anexo do portal: o fornecedor pode responder por link de uso único sem PortalUser cadastrado. Ver § Portal.
supplierIdGuid?condObrigatório quando uploadedByKind = PortalUser, resolvido do token da RFQ e gravado denormalizado. Opcional quando o comprador anexa algo que recebeu do fornecedor por fora. Congelado: contato muda, é desligado ou apagado, e a autoria fica.
uploadedAtDateTimegeradoQuando o vínculo foi confirmado, não quando a URL foi emitida. É a data que a retenção de RetainUntil usa como base.

Revogação

CampoTipoRegra
revokedAtDateTime?geradoPreenchido é revogado, e revogado é terminal — não existe "desrevogar". O caminho de volta é anexar de novo, o que cria outro registro e outra referência.
revokedByGuid?geradoQuem revogou. Nulo quando revocationSource não é User.
reasonCodeIdGuid?condReasonCode, uso AttachmentRevoke. Obrigatório quando a revogação é de usuário e o dono não é rascunho — remover prova de documento em curso precisa de motivo nomeado. Anulável porque revogação de rascunho e revogação de sistema não pedem motivo. Grava também reasonCode, reasonLabel e reasonComment?, ver Motivo.
revocationSourceenum?geradoUser · OwnerDiscarded · OrphanSweep · FileInfected. Separa o que uma pessoa decidiu do que o sistema concluiu — e é o que a tela usa para escrever a frase certa.
fileReferenceReleasedboolgeradoSe a FileReference foi liberada no GrydFiles. Verdadeiro só quando a revogação aconteceu com o dono em rascunho. É a coluna que responde, sem consultar o outro módulo, se este anexo ainda segura o blob. Ver § Revogação.

O que não está aqui, de propósito. Não existe fileUrl — URL é emitida na hora e vence. Não existe origin: uploadedByKind mais supplierId já respondem de onde veio, e um terceiro campo dizendo a mesma coisa é o começo de duas verdades. Não existe isRequired nem requiredAt: a exigência de anexo é regra do consumidor, e escrevê-la aqui criaria um motor de regra paralelo ao de aprovação. Não existe version: versionamento de documento é do Contract. E não existe expiresAt: arquivo com validade é ItemDocument ou SupplierDocument, pela regra de corte da decisão 1.

Configuração

Campos · AttachmentType

Herda de BaseEntity e implementa IOptionalTenant: tenantId nulo é tipo global do produto, o mesmo padrão que o NotificationTemplate do framework já pratica. O tenant não edita o tipo global — ele o sobrepõe: uma linha própria com o mesmo code vale no lugar da global só naquele tenant, e é assim que ele renomeia, muda a retenção dos anexos novos ou desativa o que o produto entregou. Para cada code, vale a linha do tenant quando existe; senão, a global. A linha global nunca muda para ninguém, e o release que a altere não alcança quem a sobrepôs. Anexo já gravado continua apontando para a linha que usou; a sobreposição vale para o seletor e para os anexos novos. Mesma mecânica do NotificationTemplate e do Motivo, fechada em 04/09/2026 pelo achado A6 da contra-análise.

CampoTipoRegra
tenantIdGuid?geradoNulo é tipo do produto, disponível para todos e mantido pela Gryd. Preenchido é do cliente — e, com o mesmo code de um global, é a sobreposição dele naquele tenant. Unicidade em duas camadas, ver a nota de índices em § Mapa.
codestring(40)obrigÚnico entre os globais e único dentro de cada tenant; igual ao de um global é sobreposição, não colisão. Imutável — na sobreposição, desde a criação —, normalizado em maiúsculas. É o que a integração e a semente usam; o rótulo é para gente.
namestring(120)obrigComo o tipo aparece no seletor de anexo.
descriptionstring(300)?opcExplica quando usar. É o texto que evita todo mundo escolher "Geral".
allowedOwnerTypesenum[]obrigOnde este tipo pode ser usado. Vazio é recusado — tipo que serve para tudo não classifica nada. A validação cruza com o que o descritor do dono aceita, e vale a interseção.
defaultVisibilityenumobrigInternal · Supplier. O padrão da tela. Anexo do portal ignora este campo e nasce Internal sempre, ver § Portal.
allowsSupplierVisibilityboolobrigSe o tipo pode ir ao fornecedor. Falso trava a promoção mesmo para quem tem a permissão — é como se escreve "planilha de custo interno nunca sai" uma vez, em vez de confiar em quem clica.
propagatesboolobrigSe o tipo entra na sugestão de propagação. Sugestão, não automação — a cópia continua sendo ato explícito. Ver § Propagação.
retentionModeenumobrigUntilReleased · RetainUntil · Permanent. É o campo que só existe aqui. O GrydFiles executa o que recebe no upload, e quem preenche é este tipo. Ver § Retenção.
retentionYearsint?condObrigatório com RetainUntil, recusado nos outros modos. No upload resolve-se o piso: retainUntil = uploadedAt + retentionYears. No fechamento fiscal do documento dono, o Nexio empurra a data para 31/12 do exercício do fechamento + retentionYears — a leitura literal do art. 173, I do CTN, em que o prazo decadencial só começa a correr no primeiro dia do exercício seguinte. Documento fiscal é 5. Ver § Retenção.
legalHoldboolobrigLiga o legalHold do StoredFile, que trava até o expurgo manual e o pedido de apagamento por titular. Ligar é decisão de tipo, e desligar depois exige permissão de plataforma, com trilha.
maxCountPerOwnerint?opcTeto por documento para este tipo. Nulo herda o teto do descritor do dono. Existe porque "20 notas de entrega" e "1 contrato assinado" não são o mesmo problema.
isActive · sortOrderbool · intobrigInativo some do seletor e não invalida anexo existente. Ordem é a do seletor, porque a lista alfabética coloca "Ata" antes de "Nota fiscal" em todo cliente.
Não existe allowedContentTypes nem maxSizeBytes no tipo. Tipo de arquivo e tamanho são do perfil nexio.attachment, em appsettings, porque quem valida é o GrydFiles e ele valida por perfil. Repetir aqui seria criar um segundo teto que diverge do primeiro no dia em que alguém mexer em um só.

Semente global · tenantId nulo

CódigoRetençãoVisibilidadePor que existe
GENERALUntilReleasedInternalO anexo solto. Morre quando o documento morre — é o caso mais comum e o mais barato
TECHNICAL_SPECUntilReleasedSupplierDesenho, memorial, especificação. Nasce visível ao fornecedor porque é o que a cotação precisa mandar
SUPPLIER_PROPOSALRetainUntil · 5 anosInternalA proposta que o fornecedor enviou. É prova do que foi combinado e sustenta contestação de preço
QUOTE_COMPARISONRetainUntil · 5 anosInternalO mapa comparativo. allowsSupplierVisibility = false — é o arquivo que nunca pode sair
DELIVERY_PROOFRetainUntil · 5 anosInternalCanhoto, foto da entrega, romaneio. Prova de recebimento em divergência
FISCAL_DOCUMENTRetainUntil · 5 anos + legalHoldInternalDANFE, boleto, comprovante. Cinco anos é o prazo de decadência do CTN, e o legal hold é o que impede o expurgo de acidente
SIGNED_CONTRACTPermanentSupplierSó expurgo manual, com motivo e permissão própria. Contrato assinado não tem data de morte automática
APPROVAL_EVIDENCERetainUntil · 5 anosInternalO que sustentou uma aprovação de exceção. É o primeiro arquivo que a auditoria interna pede
Oito tipos, e nenhum deles é "Outros". A semente é global; o cliente que precisar de "Laudo de Vibração" cria o seu, e não migra nada.

A escolha estrutural

O dono polimórfico e o registro dos tipos de dono

A alternativa que foi recusada, e por quê

A opção com integridade referencial seria uma tabela de ligação por dono — requisition_attachments, purchase_order_attachments, e assim por diante. Ela dá FK de verdade e recusa a decisão de arquitetura do produto: "cada bounded context tem suas tabelas, sem chaves estrangeiras cruzando contextos". Também multiplica por seis o número de tabelas, de repositórios e de telas, e faz "liste todos os anexos deste fornecedor" virar uma união de seis consultas que ninguém lembra de atualizar quando a sétima aparece.

O precedente interno decide: o módulo mais transversal do framework, o AuditLog, amarra em EntityType + EntityId sem FK. E o GrydFiles faz o mesmo em FileReference. Fazer diferente aqui seria o Nexio contrariar duas camadas abaixo dele.

O preço, dito com todas as letras

Sem FK, o banco não impede um anexo apontar para um documento que não existe mais. É um preço real, e ele é pago com três mitigações obrigatórias — nenhuma delas opcional, nenhuma delas "a fazer depois". Sem as três, esta decisão não se sustenta e o desenho vira dívida.

Mitigação 1 · o registro dos tipos de dono

Cada ownerType tem um descritor, implementado pelo agregado dono e registrado na inicialização. ownerType sem descritor derruba a aplicação no startup, não em produção às três da manhã — é a diferença entre um erro de programação e um incidente.

O que o descritor respondePara quê
CompanyOf(ownerId)Resolve o companyId denormalizado. É o que faz o escopo por empresa funcionar sem join entre contextos
StateOf(ownerId)Draft · InProgress · Closed · Missing. Decide se pode anexar, se pode revogar, e — a pergunta central — se a revogação libera a referência
DisplayNumberOf(ownerId)Traduz o Guid em "REQ-2026-00412". É o que torna a trilha legível e a tela de auditoria útil
AllowedTypesOs AttachmentType aceitos naquele dono. Cruza com allowedOwnerTypes do tipo, e vale a interseção
AllowsSupplierVisibilitySe aquele dono pode ter anexo visível ao fornecedor. Requisição interna, por exemplo, não pode
PropagatesToPara quais ownerType a propagação é oferecida. Requisição → pedido; pedido → recebimento; cotação → pedido
MaxCount · MaxTotalBytesTeto por documento, em quantidade e em peso somado. O peso vem do sizeBytes espelhado, sem consultar o GrydFiles
CanWrite(user, ownerId)Delega a autorização de escrita ao dono. Quem pode editar a requisição pode anexar nela — o anexo não inventa uma segunda regra de quem manda no documento
SubmitPolicyOf(user, ownerId)A nona pergunta, acrescentada em v1.1. Se aquele usuário pode submeter o documento agora, um carimbo de versão do documento, e como submetê-lo. Existe porque a fila de submissão diferida da spec de Notificação precisa submeter sem conhecer requisição, pedido nem contrato — e porque a permissão tem que ser reavaliada na execução, não no pedido
Um descritor por tipo, escrito no módulo do dono. O módulo de anexo não conhece requisição, pedido nem contrato — ele conhece a interface.

Mitigação 2 · evento de domínio no descarte

Todo agregado que aceita anexo publica OwnerDiscarded(ownerType, ownerId) quando um rascunho é descartado. O handler revoga os anexos daquele dono com revocationSource = OwnerDiscarded e libera as FileReference, porque descarte de rascunho é exatamente o caso em que o arquivo não precisa sobreviver. É o caminho normal; a varredura é a rede de segurança.

Mitigação 3 · varredura diária

Um job diário (attachment-orphan-sweep, sobre o GrydJobs) percorre os anexos ativos em lotes por ownerType e pergunta ao descritor se o dono existe. Ausente, o anexo é revogado com revocationSource = OrphanSweep; a referência é liberada apenas se o último estado conhecido do dono era rascunho. E o job reporta: contagem por tipo de dono no log e na tela de administração. Órfão não é ocorrência normal — é sintoma de um consumidor que esqueceu de publicar o evento, e o número precisa aparecer para alguém consertar a causa.

Os valores de ownerType na v1

ValorPropaga paraObservação
RequisitionPurchaseOrder · QuotationRequestO caso de entrada. Orçamento prévio, foto do que quebrou, especificação do solicitante
RequisitionItemPurchaseOrderItemAnexo de linha existe porque a requisição de dez itens tem um desenho por item, não um por documento
QuotationRequestPurchaseOrderEdital, planilha modelo, condições. Tipicamente Supplier
QuotationResponsePurchaseOrderOnde o anexo do portal cai. supplierId obrigatório, nasce Internal
PurchaseOrderReceipt · InvoicePedido assinado, aceite do fornecedor, alteração negociada
PurchaseOrderItemHerda o desenho da linha de requisição
ReceiptInvoiceCanhoto, foto da carga, laudo de conferência. É o tipo com mais anexo por documento no mundo real
InvoiceDANFE, boleto, divergência. Retenção fiscal com legal hold
ContractPurchaseOrderO contrato assinado é Permanent. Versionamento é do Contract, não daqui
Nove valores, enum fechado. Acrescentar um é escrever o descritor — e é por isso que o enum é fechado: o custo de entrar na lista é exatamente o custo de responder às nove perguntas do registro.

Renomeados em 06/09/2026: RequisitionLineRequisitionItem e PurchaseOrderLinePurchaseOrderItem, pela Decisão 5 do Cadastro de Item — linha de documento é <Documento>Item. Os descritores e as nove respostas não mudam.

Nenhum agregado navega para Attachment. Não existe requisition.Attachments no modelo EF, e a consulta é sempre ownerType + ownerId pelo serviço de anexo. É o que impede o polimorfismo de virar uma dependência circular entre módulos — e é o que faz o dia em que o anexo mudar de forma não tocar em seis agregados.

A regra que não pode falhar

Visibilidade

Dois valores, Internal e Supplier, desde a primeira versão. A razão de não adiar é o modo de falha: adiar significa que, no dia em que a visibilidade for criada, existirá um acervo de anexos sem classificação, e alguém terá que decidir em massa o que vai ao fornecedor. O erro de uma migração dessas é vazamento, e vazamento não tem rollback.

Quatro portas, e todas precisam estar abertas

Marcar um anexo como Supplier exige, ao mesmo tempo: o AttachmentType com allowsSupplierVisibility = true; o descritor do dono respondendo que aquele documento pode expor anexo; o usuário com a permissão share-with-supplier:attachment; e o anexo em estado editável. Falhar qualquer uma é 422 ou 409, nunca uma exposição parcial. Quatro portas parece excessivo até a primeira vez que a planilha de custo interno vaza numa RFQ.

O GrydFiles não participa desta decisão — e é bom que não participe

A permissão read:file libera pedir uma URL de download, e é permissão de módulo. Quem decide se este usuário pode ver este documento é o Nexio, antes de chamar o GrydFiles. O módulo de arquivo emitir URL curta para quem já foi autorizado é a fronteira correta: ele não sabe o que é fornecedor, cotação nem concorrente, e ensiná-lo isso seria mover regra de negócio para a camada errada.

A consequência prática é uma frase que vale para todo o produto: uma URL assinada emitida é uma autorização já concedida. O TTL curto do perfil é o que limita o dano de um link repassado, e é por isso que ele é curto.

Rebaixar é livre; promover é decisão

Passar de Supplier para Internal não precisa de permissão especial — reduzir exposição nunca deveria ter atrito. O caminho contrário é o controlado. E há um ponto sem volta: depois que o documento foi enviado ao fornecedor, a visibilidade fica travada (ATTACHMENT_VISIBILITY_LOCKED_CONFLICT). Rebaixar depois do envio seria fingir que o fornecedor não viu; o caminho honesto é revogar, o que retira o anexo da tela do fornecedor daqui para a frente e deixa registrado que ele existiu.

Isolamento entre fornecedores não é visibilidade

Numa cotação com cinco concorrentes, nenhum pode ver o anexo do outro — e isso não se resolve com visibility. Resolve-se com supplierId: o portal só devolve anexos cujo supplierId é o do token, mais os anexos Supplier do próprio QuotationRequest, que são para todos por definição. É uma comparação de coluna, não uma regra especial — e foi um dos três ganhos que motivaram a decisão 9.

Quem vê o quê
QuemInternalSupplierRevogado
Usuário com escopo na empresa do donoSim, com read:attachmentSimSó na aba de histórico, com marca
Usuário fora do escopo da empresa404404404
Aprovador na filaSim — é a evidência que sustenta a decisãoSimNão. Ver § Revogação
Fornecedor no portal, mesmo supplierIdNãoSimNão
Fornecedor no portal, outro supplierIdNãoSó o que é do documento pai, nunca o de outro fornecedorNão
Auditoria internaSimSimSim, e é o ponto — com quem revogou, quando e por quê

Ciclo do documento

Propagação entre documentos

A requisição vira pedido, o pedido vira recebimento, e o desenho técnico que o solicitante anexou precisa chegar até o fornecedor e até quem confere a carga. A pergunta é como.

O que a propagação faz

Cria um Attachment novo, com ownerType e ownerId do documento de destino, apontando para o mesmo storedFileId, com copiedFromAttachmentId preenchido, rootAttachmentId herdado, a mesma visibility, e uma FileReference própria no GrydFiles.

O que ela não faz

Não copia bytes — dedupe nem entra em cena, porque é o mesmo objeto. Não acontece sozinha. Não atravessa empresa. E não sobrevive à origem de forma dependente: revogar o anexo de origem não revoga as cópias, porque cada documento responde pelo que anexou.

Por que cada cópia cria a sua própria referência

Seria mais econômico manter uma referência só. Seria também errado: a FileReference é o que responde quem ainda precisa deste arquivo, e é o que segura o expurgo. Com uma referência só, descartar a requisição de origem liberaria o arquivo enquanto o pedido ainda o exibe — e o pedido descobriria isso trinta dias depois, recebendo FILE_PURGED_CONFLICT ao abrir um documento fechado. Uma referência por anexo é o que faz a contagem do GrydFiles significar o que ela diz significar.

Por que a herança de visibilidade é a regra, e não a redefinição

A cópia herda a visibility do original. A alternativa — recalcular do defaultVisibility do tipo no destino — trocaria uma decisão que uma pessoa tomou por um padrão de configuração, e o erro possível é justamente o pior: um anexo marcado Internal de propósito na requisição renascendo Supplier no pedido porque o tipo tem esse padrão. Herdar preserva a decisão; o destino pode rebaixar livremente e promover com as quatro portas da seção anterior.

Explícita, mas não trabalhosa

Explícito não significa um clique por arquivo. Ao criar o pedido a partir da requisição, a tela mostra os anexos elegíveis — os de tipo com propagates = true, não revogados, com arquivo Availablejá marcados, com a visibilidade de cada um à vista, e o usuário desmarca o que não vai. É uma confirmação, não uma digitação. O que ela impede é o caso em que ninguém olhou.

Anexo em Scanning aparece na lista desmarcado e desabilitado, com o motivo escrito. Propagar um arquivo que ainda pode virar Infected multiplicaria o problema por dois documentos.

A cadeia é rastreável em um passo

copiedFromAttachmentId dá o pai; rootAttachmentId dá a origem sem recursão. A tela de auditoria responde "este PDF entrou no sistema onde?" com uma consulta, e "quem mais tem este arquivo?" com o índice em (tenantId, storedFileId) — sem perguntar nada ao GrydFiles, que aliás não saberia responder em termos de documento.

O fim do anexo

Revogação e retenção

Não existe exclusão de anexo. Existe revogação, que é sempre registro, e existe retenção, que é do arquivo e não do vínculo. A parte difícil é que revogar tem dois efeitos diferentes conforme o estado do dono — e a diferença não é detalhe de implementação, é a decisão 5.

O que a revogação faz, conforme o estado do dono
Estado do donoO anexoA FileReferenceO blob
Rascunho (Draft)Revogado, com revokedAt e revokedBy. Motivo é opcionalLiberadaEntra na fila de expurgo e some no fim da carência, se ninguém mais aponta
Em curso ou fechadoRevogado, com motivo obrigatório. Sai da tela, não vale como documento, não vai ao fornecedorMantidaContinua preso. Quem decide a morte passa a ser a retenção
Descartado (evento)Revogado com revocationSource = OwnerDiscardedLiberadaComo no rascunho — é o mesmo caso, chegando por evento
Ausente (varredura)Revogado com revocationSource = OrphanSweep e reportadoLiberada se o último estado conhecido era rascunhoNa dúvida, preso. Guardar demais é barato; perder prova, não
Arquivo InfectedRevogado com revocationSource = FileInfected. A tela diz o que houveLiberadaNão é apagado pelo GrydFiles — vai para o prefixo de quarentena, e é decisão dele

Por que documento fechado não libera. Liberar a referência de um anexo de pedido fechado faria o blob morrer no fim da carência de trinta dias. Meses depois, a auditoria abre o pedido, pede o arquivo e recebe FILE_PURGED_CONFLICT — a lápide do GrydFiles, que informa corretamente que o conteúdo não existe mais. Teríamos guardado o registro da prova e perdido a prova. É uma troca entre custo de armazenamento e capacidade de responder a uma auditoria, e ela não é próxima: um PDF de dois megabytes por cinco anos custa menos que uma hora de alguém explicando por que o arquivo sumiu.

O que o aprovador vê depois de uma revogação

Anexo revogado não aparece para o aprovador na fila, porque não é mais evidência válida. Aparece na aba de histórico do documento, com quem revogou, quando, o motivo e a marca visual — e é lá que a auditoria olha. A distinção importa: esconder de quem decide agora, mostrar para quem confere depois.

Revogar anexo de documento já submetido e ainda em aprovação é permitido, com motivo, e notifica os aprovadores pendentes. Não invalida a aprovação — invalidar por conta própria seria o módulo de anexo tomando uma decisão do motor de aprovação. O que ele faz é avisar quem precisa saber.

Retenção é declarada uma vez, no upload

O Nexio resolve retentionMode, retainUntil e legalHold a partir do AttachmentType e envia no POST /files. Depois disso o GrydFiles executa sozinho, e o Nexio não volta a mexer — exceto em dois casos, ambos declarados. O primeiro é o documento que muda de natureza: um pedido que vira nota fiscal recebe anexos com retenção fiscal no momento em que são anexados àquele documento, e não retroativamente. O segundo, fechado em 08/09/2026, é o fechamento fiscal do documento dono: a data resolvida no upload é só o piso (uploadedAt + retentionYears), e no fechamento o Nexio empurra retainUntil para 31/12 do exercício do fechamento + retentionYears. A razão é aritmética e é fiscal: o prazo decadencial do art. 173, I do CTN começa a correr no primeiro dia do exercício seguinte, de modo que contar do upload deixa o arquivo até dois anos curto quando o processo é longo — e o anexo que falta numa fiscalização não volta. Errar para mais custa armazenamento; errar para menos custa a prova.

Estender é possível pela rota de retenção do GrydFiles, e só para frente — encurtar é recusado com FILE_RETENTION_SHORTENED_UNPROCESSABLE. O caminho para reduzir guarda não é a API: é o expurgo manual, com motivo e permissão própria.

O arquivo compartilhado entre anexos, e o que isso muda

Dois anexos podem apontar para o mesmo storedFileId — por propagação ou por dedupe do GrydFiles, que reconhece o mesmo sha256 dentro do tenant. As retenções podem divergir: o mesmo PDF anexado como GENERAL na requisição e como FISCAL_DOCUMENT na nota. Vale a mais longa, e isso é consequência natural do desenho do GrydFiles — retainUntil só anda para frente, e cada declaração de upload a empurra. Não é regra nova; é a regra existente produzindo o resultado certo.

LGPD, e onde ela cai

Pedido de eliminação por titular esbarra em legalHold e em guarda legal, e é assim que deve ser: a LGPD preserva a guarda exigida por obrigação legal ou regulatória. O que o Nexio precisa oferecer é a resposta — quais anexos contêm dado daquele titular, em quais documentos, sob qual base de retenção — e o índice por dono e por fornecedor é o que torna essa consulta possível. A execução do apagamento, quando cabível, é o expurgo manual do GrydFiles, que preserva a lápide.

Decisão fechada, saída fechada em v1.1

O estado Scanning e o envio para aprovação

O GrydFiles não libera arquivo sem veredito: PendingScanningAvailable · Infected · Failed. Para o Nexio isso produz uma janela — curta na maioria das vezes, longa quando o arquivo é grande ou o clamd está sob carga — em que o anexo existe e o arquivo ainda não pode ser usado.

A regra: anexar durante a verificação é permitido; submeter não

O usuário anexa, continua preenchendo o documento e vê o estado do arquivo na lista. O que ele não pode é enviar o documento para aprovação enquanto qualquer anexo estiver em Pending ou Scanning. A submissão é recusada com ATTACHMENT_SCAN_PENDING_CONFLICT, e a resposta nomeia quais anexos estão pendentes.

A razão é a fila do aprovador: um aprovador não pode receber um documento cujo anexo ainda pode virar Infected. Se virar, ou ele já leu, ou o documento muda de conteúdo debaixo de uma decisão em curso — e as duas saídas são piores que esperar.

O que não vamos fazer, e está escrito para não ser reinventado

Prender o usuário na tela esperando o antivírus é a solução ruim. Ela transforma um detalhe de infraestrutura em tempo de espera do comprador, e piora exatamente quando não deveria — arquivo grande, hora de pico, base de assinaturas recarregando. Um spinner bloqueante de cinco segundos é aceitável; de quarenta, é um defeito de produto. Esta spec registra a recusa para que a implementação não caia nela por ser a mais fácil de escrever.

A saída — fechada em v1.1, e são as duas coisas

Quem tenta submeter e recebe ATTACHMENT_SCAN_PENDING_CONFLICT escolhe entre ser avisado quando o arquivo liberarnexio.attachment.scan_completed — e deixar a submissão acontecer sozinha, por uma DeferredSubmission que ele pode cancelar enquanto ela espera. A peça de entrega já existia na plataforma: o GrydNotifications tem in-app pronto, com IInAppNotificationSender e UserNotification, além de push e e-mail.

As quatro perguntas que esta seção listava como sem resposta estão respondidas na spec de Notificação. Quem recebe é quem tentou submeter, não quem anexou — o caso de quem anexou já tem dono, que é o aviso obrigatório de Infected. Veredito demorado não ganha tratamento especial: a fila expira em 72 horas, o documento continua em rascunho e o alerta de operador do GrydFiles é que aponta para o problema real. A submissão automática é pedida, e é por isso que ela não precisa de confirmação depois. E ela aparece como uma faixa no próprio documento, com o que falta liberar e um cancelar. Veredito Infected ou Failed faz a fila desistir com motivo e avisar — nunca submeter.

Consequência para esta spec, e é a mudança real da v1.1: a fila não sabe submeter documento nenhum — ela pergunta ao dono. O registro de descritores ganhou por isso a nona pergunta, SubmitPolicyOf, sem a qual o módulo de submissão diferida teria que conhecer requisição, pedido, cotação, recebimento, nota e contrato — exatamente a dependência que o descritor existe para não ter.

O que a v1.0 já entregava, e continua valendo

O suficiente para não travar ninguém e não fechar nenhuma porta: o estado real na lista, sempre visível; a consulta em lote que a tela usa para atualizar sem vinte chamadas; a recusa de submissão com a lista dos anexos pendentes; e a revogação automática com aviso quando o veredito é Infected. Nenhuma dessas quatro coisas mudou com a decisão de mensageria fechada — ela acrescentou o aviso e a fila, e não substituiu o mecanismo.

FILE_SCAN_UNAVAILABLE não é erro do usuário. Quando o clamd está fora do ar, o GrydFiles devolve 503 — o sufixo _UNAVAILABLE que aquela spec propôs à plataforma e que o mapeador do Core ainda não tem. O Nexio repassa, não reembrulha em 400 nem em 409: a tela precisa dizer "o serviço de verificação está indisponível, tente em instantes", e o suporte precisa distinguir isso de um arquivo recusado. Reembrulhar erro de dependência em erro de cliente é como se perde uma tarde procurando um bug que não existe.

Origem externa

Anexo do portal do fornecedor

O portal ainda é decisão de escopo em aberto no mapa de domínio, mas o anexo vindo dele não pode ser modelado depois: é o caso que decide três campos, e acrescentá-los mais tarde significaria voltar em anexos já gravados sem saber de quem eles são. Metade da amarração já existe — a spec de Fornecedor diz que SupplierContact é opcionalmente ligado a um PortalUser.

supplierId obrigatório, portalUserId opcional

A assimetria é deliberada e vem do mundo real: o fornecedor pode responder a uma RFQ por link de uso único, sem PortalUser cadastrado, e nesse caso não há usuário a registrar — mas sempre há fornecedor, porque o token da RFQ o identifica. supplierId é resolvido do token e gravado denormalizado no anexo, nunca lido do contato: contato muda de empresa, é desligado ou apagado, e a autoria tem que ficar congelada. É a regra 1 do mapa de domínio aplicada à autoria.

Nasce Internal, sempre

O defaultVisibility do tipo é ignorado no caminho do portal. A proposta que o fornecedor A enviou não é visível ao fornecedor B, e não é visível a ninguém no portal — quem lê é o comprador. Marcar como Supplier depois é possível, com as quatro portas da seção de visibilidade, e é o caso raro de um documento que o comprador quer devolver a todos os concorrentes.

Os três ganhos que motivaram a decisão

Trilha legível. "Anexado por Fornecedor X" sem decodificar token nem cruzar tabela de sessão.

Isolamento barato. O que seria uma regra especial de visibilidade vira comparação de supplierId — a mesma coluna, o mesmo índice, nenhum caso particular no código de autorização.

Promoção a SupplierDocument. Uma certidão que chegou anexada numa cotação pode virar documento de cadastro porque já se sabe de quem ela é. Sem supplierId no anexo, essa promoção exigiria alguém digitar o fornecedor de novo — e digitar de novo é onde o dado erra.

Duas identidades, e nenhuma finge ser a outra

O uploadedBy do StoredFile continua sendo identidade do tenant no GrydAuth: o portal não tem GrydAuth, então quem aparece lá é o serviço que fez o upload. A autoria de negócio é do Attachment, em uploadedByKind, portalUserId e supplierId. Tentar forçar a identidade do fornecedor na camada de plataforma criaria usuário de tenant para quem não é do tenant — e é assim que se abre uma porta que ninguém queria abrir.

O que muda no caminho do portal
AspectoAnexo internoAnexo do portal
AutenticaçãoToken do GrydAuthToken da RFQ (PortalUser ou link de uso único)
uploadedByKindUserPortalUser
supplierIdOpcionalObrigatório, do token
Visibilidade inicialDo AttachmentTypeSempre Internal
ownerType aceitoOs noveQuotationResponse na v1
Perfil e antivírusIdênticos. É o caminho mais importante do ClamAV — é o único em que um terceiro sobe arquivo
Escopo por empresaUserCompanyScopeDerivado do documento da RFQ, não do fornecedor

Autorização

Escopo, permissões e trilha

O escopo vem do dono, e fora dele a resposta é 404

companyId é derivado do dono pelo descritor e denormalizado no anexo, e o filtro por UserCompanyScope roda na consulta, sem join entre contextos. Anexo de documento de outra empresa responde 404, não 403 — os dois casos, "não existe" e "não é seu", respondem igual de propósito, que é a regra do DeliveryLocation. Um 403 confirmaria a existência do documento a quem não deveria saber que ele existe.

Permissão de anexo não substitui permissão no dono

manage:attachment é necessária e não suficiente: quem anexa precisa também poder escrever no documento, e quem responde isso é o CanWrite do descritor. Sem essa delegação, o anexo viraria uma porta lateral para escrever em documento alheio — e teríamos duas regras diferentes sobre quem manda numa requisição, o que sempre termina com as duas divergindo.

A trilha: o Nexio registra o que a plataforma não sabe

O GrydFiles já grava no AuditLog a emissão de cada URL de download. O Nexio não repete isso — repetir daria duas linhas para o mesmo ato, com risco de divergirem. O que ele registra é o que lá não existe: qual documento e qual anexo, porque do lado da plataforma só há um fileId, e um fileId não responde "quem baixou o anexo do pedido PED-2026-00871".

Usa o IAuditLogService.RecordAsync do framework, que é a via de evento de negócio manual — o interceptor de diff pega a mudança de linha, não o ato de ler. Ações registradas: attachment.created, attachment.downloaded, attachment.visibility-changed, attachment.copied, attachment.revoked. Cada uma carrega ownerType, o número legível do documento e o attachmentId.

A convenção que estes cinco nomes propuseram fechou na spec de Notificação: <entidade>.<verbo-no-passado> é a ação da trilha, e o slug do aviso é a mesma cauda com o prefixo do produto. A ação vai em AdditionalData["action"] do RecordAsync, porque o AuditLog do GrydAudit não tem coluna de ação — dar-lhe uma é pendência de plataforma, no molde do GrydFiles, e está registrada no mapa de domínio.

PermissãoAlcanceObservação
read:attachmentListar, ver metadado e pedir URL de downloadSempre recortada pelo escopo da empresa do dono e pela visibility. Todo papel que abre documento precisa dela
manage:attachmentAnexar, descrever, trocar o tipo em rascunho e propagarNão basta: exige também poder escrever no dono. Ver acima
revoke:attachmentRevogar anexo de outra pessoa, e revogar fora de rascunhoQuem anexou revoga o próprio anexo em rascunho só com manage. Retirar prova de documento em curso é outro ato
share-with-supplier:attachmentMarcar visibility = SupplierSeparada de propósito — é a permissão cujo erro vaza. Rebaixar para Internal não exige nada
manage:attachment-typeAdministrar o catálogo do tenantAdministração de configuração. Mexe em retenção, então é de administrador, não de comprador
Cinco permissões, e nenhuma que libere download de arquivo em Scanning, Infected ou Purged. Como no GrydFiles, a ausência é a decisão.

Interface

Telas

Anexo não tem tela própria — ele é um componente que aparece dentro do documento dono. As exceções são as duas telas de administração.

Documento · painel de anexos

Lista com ícone por contentType, nome, tipo, tamanho, quem anexou, quando, e o estado do arquivo à mostra — não um spinner. Anexo Supplier leva marca visível na linha, porque a pergunta "o fornecedor vê isso?" precisa ser respondida sem abrir nada. Arraste solta arquivo direto; a escolha do tipo é o único campo obrigatório.

Documento · o que está pendente

Quando algum anexo está em Scanning, o botão de enviar para aprovação fica desabilitado com o motivo escrito e a lista de quais faltam — nunca um erro depois do clique. Mesma tese da rota de blocking documents do local de entrega: a tela pergunta antes de agir.

Documento · propagação

Ao gerar o pedido a partir da requisição, um passo mostra os anexos elegíveis já marcados, com a visibilidade de cada um visível e editável ali. Os em Scanning aparecem desabilitados com o motivo. Desmarcar é um clique; a confirmação é o ato explícito.

Documento · histórico

Aba separada com os revogados: quem, quando, por quê, e se o arquivo ainda existe. É a tela que a auditoria abre, e é a razão de a revogação ser registro e não exclusão.

Portal · resposta de cotação

O fornecedor vê os anexos Supplier do documento pai e só os seus. Sobe proposta e certidão sem saber que existe um AttachmentType — o tipo é decidido pelo contexto da tela. Estado do antivírus visível também aqui, com a mesma honestidade.

Admin · tipos de anexo

Catálogo do tenant, com os tipos globais listados e não editáveis. A coluna de retenção é a primeira depois do nome, porque é o campo com consequência de anos. Trocar retenção avisa que não afeta anexos existentes — só o que vier depois.

Admin · saúde dos anexos

O relatório da varredura: órfãos encontrados por tipo de dono, anexos em Failed, arquivos Infected nos últimos 90 dias, e o peso total por empresa. Órfão não é ocorrência normal — é sintoma de consumidor que esqueceu de publicar o evento, e o número precisa ter dono.

Auditoria · rastro do arquivo

A partir de um anexo: a cadeia até o rootAttachmentId, os outros anexos que compartilham o mesmo storedFileId, e a trilha de quem baixou. Responde "onde este PDF entrou e para onde ele foi" numa tela.

API

Endpoints

RotaPermissãoDevolveErros
GET /attachmentsread:attachmentOs anexos de um dono (ownerType + ownerId, obrigatórios), com o status do arquivo já resolvido em lote. Revogados só com includeRevoked400 · 404
POST /attachments/upload-intentmanage:attachmentfileId, uploadUrl, uploadExpiresAt e os cabeçalhos do PUT. Valida dono, tipo, escopo e limites antes de reservar400 · 404 · 409 · 422
POST /attachmentsmanage:attachmentAttachmentDto. Confirma no GrydFiles, cria o anexo e a FileReference. Idempotente por fileId: chamar duas vezes devolve o mesmo anexo400 · 404 · 409 · 422
GET /attachments/{id}read:attachmentAttachmentDto com o número legível do documento dono, resolvido pelo descritor404
GET /attachments/statusread:attachmentA rota da decisão 10. Recebe até 100 ids e devolve o status de cada arquivo numa chamada. Listar vinte anexos não vira vinte chamadas400
GET /attachments/{id}/download-urlread:attachmentURL assinada de leitura, TTL do perfil. Autoriza primeiro — escopo, visibilidade, revogação — e só então chama o GrydFiles. Registra documento e anexo na trilha404 · 409 · 503
PUT /attachments/{id}manage:attachmentAltera description e, em rascunho, attachmentTypeId entre tipos de mesma retenção. storedFileId, ownerType e ownerId são recusados quando divergem404 · 409 · 422
POST /attachments/{id}/visibilityshare-with-supplier:attachmentPromove a Supplier. Rebaixar para Internal aceita só manage:attachment. Estado por rota própria, nunca por PUT404 · 409 · 422
POST /attachments/{id}/revokerevoke:attachmentRevoga. Libera a referência com o dono em rascunho. Motivo obrigatório fora dele404 · 409 · 422
POST /attachments/copymanage:attachmentPropagação explícita, em lote: lista de attachmentId e o dono de destino. Cria um anexo e uma FileReference por item, herdando a visibilidade400 · 404 · 409 · 422
GET /attachments/copy-candidatesread:attachmentO que a tela de propagação mostra: elegíveis, já marcados, com o motivo de cada inelegível. A tela não reimplementa a regra400 · 404
GET /attachments/{id}/chainread:attachmentA cadeia de propagação até a raiz, mais os anexos que compartilham o mesmo storedFileId dentro do escopo do usuário404
GET /attachments/owner-typesread:attachmentO registro, do jeito que a tela precisa: tipos aceitos, limites, se aceita visibilidade de fornecedor, para onde propaga
POST /portal/rfq/{token}/attachmentstoken da RFQO caminho do fornecedor, com upload-intent e confirmação embutidos. supplierId vem do token; nasce Internal; ownerType é sempre QuotationResponse400 · 401 · 409 · 422
GET /attachment-typesread:attachmentOs tipos ativos, já resolvidos — para cada code, a linha do tenant quando existe, senão a global —, filtráveis por ownerType. É o que popula o seletor400
POST PUT /attachment-typesmanage:attachment-typeAdministração do catálogo do tenant. Tipo global não muda: POST com o code de um global cria a sobreposição do tenant, e PUT sobre id global a materializa com o corpo enviado, devolvendo o id dela; code e retentionMode são imutáveis depois do primeiro uso400 · 404 · 409 · 422
PATCH /attachment-types/{id}/deactivatemanage:attachment-typeSome do seletor. Em id global, materializa a sobreposição do tenant com isActive = false — o global continua ativo nos outros tenants. Não invalida anexo existente — nunca há exclusão de tipo com uso404 · 409
Não existe DELETE /attachments/{id}, pelo mesmo motivo que não existe DELETE /files/{id} no GrydFiles: apagar é ato com consequência, e dar a ele o verbo mais fácil da API é convidar ao acidente. O que existe é revogar, que é registro.

Contrato

Contrato de erros

O status HTTP é derivado do sufixo do código, nunca da mensagem: o mapeador do Core reconhece nove sufixos — _NOT_FOUND → 404 · _ALREADY_EXISTS, _ALREADY_ASSIGNED e _CONFLICT → 409 · _FORBIDDEN, _ACCESS_DENIED e _BLOCKED → 403 · _UNAUTHORIZED → 401 · _UNPROCESSABLE → 422 · sem sufixo → 400. _UNAVAILABLE → 503 seria o décimo: é extensão pedida ao Core pelo épico do GrydFiles, ainda não vigente — conferido no código da plataforma em 08/09/2026.

CódigoHTTPQuando
ATTACHMENT_NOT_FOUND404Não existe neste tenantou o dono está fora do escopo do usuário. Os dois casos respondem igual, de propósito
ATTACHMENT_OWNER_NOT_FOUND404O documento dono não existe ou está fora do escopo. Vem do descritor, não de uma FK
ATTACHMENT_OWNER_TYPE_UNKNOWN400ownerType sem descritor registrado. Erro de programação do consumidor — em produção isso não chega, porque a inicialização já teria falhado
ATTACHMENT_OWNER_NOT_WRITABLE_CONFLICT409Anexar ou alterar em documento que o usuário não pode editar, ou que não aceita mais alteração. A regra é do dono, não daqui
ATTACHMENT_TYPE_NOT_FOUND404Tipo inexistente neste tenant, contando os globais
ATTACHMENT_TYPE_INACTIVE_UNPROCESSABLE422Tipo desativado. Anexo antigo com esse tipo continua válido — só o novo é recusado
ATTACHMENT_TYPE_NOT_ALLOWED_FOR_OWNER_UNPROCESSABLE422O tipo não está na interseção entre allowedOwnerTypes e o que o descritor aceita. A mensagem lista os tipos válidos ali
ATTACHMENT_TYPE_RETENTION_CHANGE_UNPROCESSABLE422Troca de tipo para outro com retenção diferente. Mudaria a data de expurgo de um arquivo já sob guarda
ATTACHMENT_LIMIT_EXCEEDED_CONFLICT409Teto de quantidade ou de peso somado, do tipo ou do descritor. A mensagem diz qual dos dois e qual é o teto
ATTACHMENT_SUPPLIER_VISIBILITY_NOT_ALLOWED_UNPROCESSABLE422Supplier num tipo com allowsSupplierVisibility = false, ou num dono que não expõe anexo. A mensagem nomeia qual das duas portas fechou
ATTACHMENT_VISIBILITY_LOCKED_CONFLICT409Alterar visibilidade depois de o documento ter ido ao fornecedor. O caminho é revogar, não fingir que ele não viu
ATTACHMENT_ALREADY_REVOKED_CONFLICT409Revogação é terminal. Não existe "desrevogar" — o caminho de volta é anexar de novo
ATTACHMENT_REVOCATION_REASON_REQUIRED_UNPROCESSABLE422Revogar fora de rascunho sem reasonCodeId. Retirar prova de documento em curso precisa de motivo nomeado
ATTACHMENT_SCAN_PENDING_CONFLICT409A decisão 8. Submeter para aprovação com anexo em Pending ou Scanning. A resposta lista quais, para a tela dizer o que falta
ATTACHMENT_FILE_NOT_AVAILABLE_CONFLICT409Baixar ou propagar arquivo que ainda não passou pelo antivírus. É o estado normal de "espere um pouco"
ATTACHMENT_FILE_INFECTED_CONFLICT409Separado do anterior de propósito: a tela precisa dizer o que houve e o suporte precisa distinguir fila de incidente
ATTACHMENT_FILE_PURGED_CONFLICT409O anexo existe, o conteúdo não. Traz purgedAt e o motivo, vindos da lápide do GrydFiles
ATTACHMENT_COPY_SOURCE_NOT_ELIGIBLE_UNPROCESSABLE422Propagar anexo revogado, com arquivo indisponível, ou de tipo com propagates = false
ATTACHMENT_COPY_TARGET_MISMATCH_UNPROCESSABLE422Destino de outra empresa, ou ownerType fora do PropagatesTo da origem. Anexo não atravessa empresa
ATTACHMENT_SUPPLIER_REQUIRED_UNPROCESSABLE422uploadedByKind = PortalUser sem supplierId. Não deveria acontecer — o token resolve — e existe para nunca acontecer em silêncio
ATTACHMENT_PORTAL_OWNER_NOT_ALLOWED_UNPROCESSABLE422Caminho do portal com ownerType diferente de QuotationResponse
ATTACHMENT_TYPE_CODE_ALREADY_EXISTS409Código repetido no tenant — inclusive quando a linha existente é a sobreposição de um global. Coincidir com um global não é este erro: é sobreposição
ATTACHMENT_TYPE_IN_USE_CONFLICT409Alterar code ou retentionMode de tipo já usado, ou tentar excluí-lo. O caminho é desativar
ATTACHMENT_TYPE_RETENTION_UNPROCESSABLE422retentionYears ausente em RetainUntil, ou presente nos outros modos
ATTACHMENT_DESCRIPTION_TOO_LONG400Acima de 300 caracteres
Os erros do GrydFiles são repassados sem reembrulho: FILE_TOO_LARGE_UNPROCESSABLE, FILE_CONTENT_TYPE_NOT_ALLOWED_UNPROCESSABLE, FILE_SIZE_MISMATCH_UNPROCESSABLE, FILE_UPLOAD_EXPIRED_CONFLICT e FILE_SCAN_UNAVAILABLE (503) chegam ao cliente com o código de lá. Traduzir "arquivo grande demais" em ATTACHMENT_SOMETHING só faria a mensagem perder o teto que a tela precisa mostrar.

Invariantes

Regras de negócio

RN-ATT-01Todo anexo tem arquivo, tipo e dono

storedFileId, attachmentTypeId, ownerType e ownerId são obrigatórios e imutáveis. Não existe anexo solto no tenant: alguma coisa sempre responde por ele, e é essa coisa que decide quase todo o resto.

RN-ATT-02Nenhuma chave estrangeira cruza o contexto

Nem para o StoredFile, que é de outro módulo, nem para o dono, que é de outro bounded context. A integridade é mantida pelo registro de descritores, pelo evento de descarte e pela varredura diária — as três, sempre. Duas delas não bastam.

RN-ATT-03ownerType sem descritor derruba a inicialização

Falha no startup, não em produção. É o que faz o enum ser realmente fechado: entrar na lista custa exatamente o que custa responder às nove perguntas do registro.

RN-ATT-04Nenhum agregado navega para Attachment

Não existe coleção de anexos no modelo EF de requisição, pedido ou contrato. A consulta é sempre pelo serviço de anexo, por ownerType e ownerId. É o que impede o polimorfismo de virar dependência circular entre módulos.

RN-ATT-05companyId é derivado do dono, e fora do escopo a resposta é 404

Derivado pelo descritor, denormalizado, filtrado por UserCompanyScope. "Não existe" e "não é seu" respondem igual — um 403 confirmaria a existência de um documento a quem não deveria saber que ele existe.

RN-ATT-06Permissão de anexo não substitui permissão no dono

manage:attachment é necessária e não suficiente: quem anexa precisa poder escrever no documento, e quem responde isso é o CanWrite do descritor. Duas regras sobre quem manda numa requisição sempre terminam divergindo.

RN-ATT-07Cada anexo cria e mantém a sua própria FileReference

(storedFileId, "nexio.attachment", attachmentId). Cópia inclusive. É o que faz a contagem de referências do GrydFiles significar o que ela diz significar, e o que impede um documento perder o arquivo porque outro descartou o dele.

RN-ATT-08Retenção é declarada no upload, resolvida do AttachmentType

retentionMode, retainUntil e legalHold vão no POST /files. O Nexio decide, o GrydFiles executa. Mudar a configuração do tipo depois não altera o que já foi anexado.

RN-ATT-09A exigência de anexo mora no consumidor

"Requisição acima de X exige três orçamentos" é regra da requisição ou do motor de aprovação, nunca do anexo. Escrevê-la aqui criaria um segundo motor de regra, paralelo e desalinhado com o primeiro.

RN-ATT-10Visibilidade exige quatro portas abertas

Tipo permite, dono permite, usuário tem share-with-supplier:attachment, anexo é editável. Rebaixar para Internal não exige nada — reduzir exposição nunca deveria ter atrito.

RN-ATT-11Depois do envio ao fornecedor, a visibilidade trava

409. Rebaixar depois do envio seria fingir que o fornecedor não viu. O caminho honesto é revogar — que o retira da tela dele daqui para a frente e deixa registrado que ele existiu.

RN-ATT-12Isolamento entre fornecedores é supplierId, não visibilidade

O portal devolve os anexos do próprio supplierId mais os Supplier do documento pai. Comparação de coluna, sem caso particular no código de autorização.

RN-ATT-13Propagação é explícita e herda a visibilidade

Nunca automática. A cópia herda a visibility do original em vez de recalcular do padrão do tipo — recalcular trocaria uma decisão humana por um padrão de configuração, e o erro possível é o pior deles.

RN-ATT-14Propagação não atravessa empresa nem cria vínculo entre documentos

Destino de outra empresa é 422. E revogar a origem não revoga as cópias: cada documento responde pelo que anexou.

RN-ATT-15Não se propaga nem se baixa arquivo que não está Available

Multiplicar por dois documentos um arquivo que ainda pode virar Infected é criar dois problemas. Na tela, o item aparece desabilitado com o motivo — não some.

RN-ATT-16Não existe exclusão de anexo

Existe revogação, que é registro e é terminal. Sem DELETE na API, pelo mesmo motivo que não há DELETE /files/{id} no GrydFiles.

RN-ATT-17Revogar em rascunho libera a referência; fora de rascunho, não

A invariante central do fim de vida. Liberar em documento fechado faria o blob morrer em trinta dias e a auditoria receber a lápide — guardaríamos o registro da prova e perderíamos a prova. Fora de rascunho, quem decide a morte é a retenção.

RN-ATT-18Anexo revogado sai da decisão e fica no histórico

Não aparece para o aprovador na fila, porque não é mais evidência válida; aparece na aba de histórico com quem, quando e por quê. Esconder de quem decide agora, mostrar para quem confere depois.

RN-ATT-19Não se envia para aprovação com anexo em verificação

Anexar durante o scan é permitido; submeter não. A recusa nomeia quais anexos estão pendentes, e a tela desabilita o botão antes do clique em vez de mostrar erro depois dele.

RN-ATT-20Arquivo Infected revoga o anexo automaticamente

Com revocationSource = FileInfected, aviso a quem anexou e ao documento. Infected é terminal no GrydFiles e não há liberação manual — então não há estado intermediário a modelar aqui.

RN-ATT-21Anexo do portal exige supplierId e nasce Internal

supplierId resolvido do token e gravado denormalizado, congelando a autoria; portalUserId opcional, porque link de uso único não tem usuário. O defaultVisibility do tipo é ignorado neste caminho.

RN-ATT-22Espelha-se o imutável; o que muda é consultado em lote

originalFileName, contentType e sizeBytes são colunas. status não é — e a consulta aceita até cem ids por chamada. Espelhar estado que muda é como se cria a divergência que ninguém consegue explicar depois.

RN-ATT-23A trilha do Nexio complementa a do GrydFiles, não a repete

Lá fica a emissão da URL, por fileId. Aqui fica qual documento e qual anexo, por IAuditLogService.RecordAsync. Duas linhas para o mesmo ato acabariam divergindo.

RN-ATT-24Erro de dependência é repassado, não reembrulhado

FILE_SCAN_UNAVAILABLE chega como 503. Transformar indisponibilidade externa em erro de cliente custa uma tarde de investigação de um bug que não existe.

RN-ATT-25Tipo global não muda — o tenant sobrepõe

Nenhuma escrita do tenant alcança a linha global. Editar ou desativar um tipo global materializa a linha do tenant com o mesmo code, e é ela que passa a valer ali; os outros tenants continuam vendo o global. Mesma regra do NotificationTemplate e do ReasonCode.

Contexto

Referência de mercado

Como os produtos da amostra resolvem anexo
ProdutoModeloO que aproveitamos
Oracle Fusion · iProcurementDuas tabelas: FND_DOCUMENTS guarda o arquivo e FND_ATTACHED_DOCUMENTS amarra em ENTITY_NAME + PK1_VALUE, com categoria de anexo por entidadeA tese inteira. Duas camadas, amarração polimórfica com o tipo do dono em texto, e categoria como configuração. Trinta anos de produção validando o desenho
Oracle · categorias de visibilidadeTo Buyer, To Supplier, To Approver, Internal to RequisitionA ideia, simplificada. Quatro níveis viram dois: quem é do tenant vê tudo o que o escopo permite; o de fora vê o que foi marcado. Separar aprovador de comprador criaria uma matriz que ninguém mantém
SAP AribaAnexo na requisição propaga para o pedido; sinalizador de visível ao fornecedor por documentoA propagação ao longo da cadeia, e a confirmação de que ela precisa ser vista por alguém — no Ariba a reclamação recorrente é anexo que chegou ao fornecedor sem que o comprador percebesse
CoupaAnexos em requisição, pedido e fatura, com caixa "visível ao fornecedor" e comentário com anexoA tese de que anexo vive dentro do documento e não tem tela própria. O comentário com anexo é a extensão natural quando existir comentário no Nexio
ERPs com DMS acopladoSharePoint, Documentum, GED próprio: o anexo é um link para um repositório documental externo, com workflow e versionamento própriosRecusado. Traz pasta, permissão paralela e versionamento — três coisas que o Nexio não precisa e que criam um segundo sistema de autorização ao lado do primeiro. O GrydFiles é deliberadamente menor que um GED
Guarda fiscal · CTN art. 173Cinco anos de prazo decadencial para a Fazenda constituir o créditoO retentionYears = 5 dos tipos fiscais, com legalHold. É o número que aparece em auditoria, e por isso está na semente e não numa configuração que alguém esquece de preencher
LGPD · art. 16Guarda permitida para cumprimento de obrigação legal ou regulatória, mesmo após o fim do tratamentoA base que sustenta o legalHold vencer o pedido de eliminação. O que o produto precisa entregar é a resposta — quais anexos, em quais documentos, sob qual retenção

Limites

Fora de escopo

Fora — é de outro módulo
  • Versionamento de documento. "Contrato v1, v2, v3" é do Contract, que tem aditivo, vigência e assinatura. Aqui, arquivo novo é anexo novo.
  • Tudo o que é do GrydFiles: upload, hash, antivírus, quarentena, dedupe, expurgo, lápide, chave do objeto e provedor de storage.
  • Arquivo com validade própria. FISPQ, certidão, laudo com número e órgão emissor são ItemDocument e SupplierDocument, pela regra de corte da decisão 1.
  • Importação em massa e relatório gerado. Usam StoredFile com retenção curta e nunca criam Attachment — não são prova de nada, são insumo e saída.
Fora agora, gancho previsto
  • Assinatura eletrônica ICP-Brasil. O campo de evidência de assinatura cabe no Contract; o anexo continua sendo o arquivo. Nada implementado.
  • OCR e extração. Ler a DANFE anexada para conferir a nota é caso de Invoice e de three-way match, e depende de decisões que ainda não existem.
  • Pré-visualização e edição. O navegador já exibe PDF e imagem pela URL assinada. Visualizador embutido é custo sem retorno enquanto isso for verdade.
  • Comentário em anexo. Só faz sentido quando existir comentário no documento — e aí é o comentário que ganha anexo, não o contrário.
  • Pasta e estrutura hierárquica. O documento é a pasta. Hierarquia dentro do anexo é o começo de um GED, e a decisão de não construir um já foi tomada.
  • E-mail para anexo. Encaminhar uma mensagem e ela virar anexo do pedido depende de identificar o documento pelo assunto — e de decidir o que fazer quando não dá.

Uma decisão a menos, e a que sobrou é pequena. A de § Scanning estava declarada em aberto na v1.0 e foi fechada em 03/09/2026 pela spec de Notificação — que também trouxe a nona pergunta do registro de descritores. O ReasonCode saiu no mesmo dia: reasonCodeId deixou de ser gancho órfão e passou a apontar para o catálogo, no uso AttachmentRevoke. Continua anulável — rascunho e revogação de sistema não pedem motivo —, e o revocationSource continua enum fechado, porque motivo de sistema não é motivo de usuário. Esta spec não tem mais nenhuma decisão em aberto.