Ponto de partida
Onze 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
O vínculo nasce cedo; a referência de arquivo espera o veredito. Nova em 09/09/2026. POST /attachments cria o Attachment e declara ao GrydFiles a quem o arquivo vai pertencer — não cria FileReference. A liberação é que cria a referência e devolve o veredito ao Nexio pelo contrato registrado, na mesma transação. Até o veredito, o anexo aparece e mostra o estado, e não é referenciável, baixável nem propagável. É o que faz a invariante do módulo de arquivo continuar verdadeira no passo normal do único consumidor que ele tem.
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?
| Assunto | Quem resolve | O que o Nexio faz com isso |
|---|---|---|
| Bytes, bucket, chave do objeto | GrydFiles | Nunca 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 download | GrydFiles | Pede, repassa ao navegador e esquece. TTL vem do perfil |
sha256, tamanho real, tipo verificado | GrydFiles, no servidor | Declara o esperado e recebe o verificado. Não recalcula nada |
| Antivírus, quarentena, veredito | GrydFiles · ClamAV | Lê status e decide o que a tela e o fluxo fazem com ele |
| Dedupe por conteúdo | GrydFiles, por tenant | Ignora. Dois anexos podem compartilhar blob sem que nada no domínio mude |
| Contagem de referências e expurgo | GrydFiles | Cria e libera FileReference. Nunca apaga arquivo — não existe rota de exclusão do outro lado |
| Quanto tempo guardar | Nexio decide, GrydFiles executa | Declara retentionMode, retainUntil e legalHold no upload, resolvidos do AttachmentType |
| Quem pode ver este documento | Nexio, antes de pedir a URL | read: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 é | Nexio | Attachment · AttachmentType · ownerType. O GrydFiles guarda só ownerScope e ownerKey em texto opaco |
Como o Nexio fica sabendo do veredito. Novo em 09/09/2026 — até 08/09 nada declarava isto, e três comportamentos dependiam dele. O GrydFiles não publica evento, por decisão dele: o despacho de evento pós-commit não existe na plataforma e o DomainEventDispatcher publica dentro da transação. O mecanismo é o contrato de veredito: o Nexio registra uma implementação de IFileOutcomeHandler para o escopo nexio.attachment, e o job de scan a chama ao gravar o veredito, na mesma unidade de trabalho. É a mesma regra que vale no desfecho de aprovação — efeito na transação, evento só para notificar, depois do commit. Se o efeito do Nexio falha, o veredito não é gravado e a tentativa se repete; nada fica meio aplicado.
O que o handler faz, e é a lista inteira: em liberado, marca o anexo como utilizável e destrava quem esperava por ele; em infectado, revoga com revocationSource = FileInfected (RN-ATT-20) e dispara o aviso obrigatório; em reprovado por tipo, descarta o anexo com o rejectionCode que veio do módulo. Os três eram, até 08/09, reações a uma transição que acontece dentro de outro módulo, sem nenhum caminho escrito para chegar até aqui.
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 cria o Attachment e confirma no GrydFiles (POST /files/{id}/complete) declarando o par pendente ownerScope = "nexio.attachment" · ownerKey = attachment.id. Nenhuma FileReference é criada aqui — revisto em 09/09/2026. O anexo existe, aparece na lista e mostra o estado real; o que ele ainda não é, é referenciável, baixável ou propagável. 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 é a liberação: o GrydFiles cria a FileReference do par pendente e chama o contrato de veredito do Nexio na mesma transação. Só então o anexo libera download, envio ao fornecedor, propagação e submissão para aprovação. Infected e a reprovação por tipo (Purged) chegam pelo mesmo contrato, sem referência nenhuma para desfazer: o anexo é descartado — revogado com motivo próprio e sem direito a arquivo —, e a tela e o aviso dizem o que aconteceu.
O que a inversão comprou. Até 08/09/2026 este passo 3 criava a referência sobre um arquivo em Scanning, e isso tornava falsa a invariante central do módulo de arquivo — a que diz que nada é referenciável antes do veredito. Pior: as duas saídas negativas ficavam inconsistentes por construção. Reprovação por tipo apagava um objeto com referência ativa apontando para ele; Infected deixava uma referência viva apontando para blob em quarentena. Nenhuma das duas tinha regra escrita, porque nenhuma das duas deveria ser possível.
Por que o Attachment nasce antes do veredito, e a referência não
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.
A referência é outra coisa, e ela espera. Revisto em 09/09/2026. Criar o vínculo cedo é decisão de produto; criar a FileReference cedo era violação da regra de quem guarda o arquivo. As duas foram separadas: o Attachment continua nascendo no passo 3, e a referência nasce sozinha na liberação. Para o usuário nada muda — ele vê o anexo e vê "verificando". O que muda é que o estado intermediário deixou de ser uma promessa sobre um arquivo que ainda podia ser recusado.
Modelo
Mapa de entidades
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.
IOptionalTenant: nulo é tipo global do produto.
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.
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.
(storedFileId, "nexio.attachment", attachmentId). É o que impede o expurgo enquanto alguém precisa do arquivo — e o que cada cópia cria para si.
storedFileId em vez de URL — a dívida que sai junto com esta spec.
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
| Campo | Tipo | Regra | |
|---|---|---|---|
| id | Guid | gerado | Atribuído pelo agregado no construtor. É o ownerKey da FileReference do outro lado, então precisa existir antes de a referência ser criada. |
| tenantId | Guid | gerado | Do token, nunca aceito do cliente. |
| storedFileId | Guid | obrig | Sem 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. |
| attachmentTypeId | Guid | obrig | → AttachmentType. 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. |
| ownerType | enum | obrig | Enum fechado, ver § O dono polimórfico. Imutável. Valor sem descritor registrado não compila conceitualmente: a inicialização falha. |
| ownerId | Guid | obrig | A chave do dono no mundo dele. Sem FK, imutável. Reamarrar anexo a outro documento é copiar, ver § Propagação. |
| lastKnownOwnerState | enum | gerado | Novo em 09/09/2026. Draft · InProgress · Closed — o último estado do dono que o Nexio viu. Gravado pelo próprio dono, na transição, no mesmo método que muda o estado dele; nunca por leitura preguiçosa e nunca pelo módulo de anexo. Existe porque a varredura de órfãos precisa decidir se libera a referência de um documento que não existe mais — e a essa altura não há a quem perguntar. Sem a coluna, a terceira mitigação do polimorfismo não era executável: ou a varredura nunca libera (e o blob fica preso e pago para sempre), ou libera sempre (e mata prova de documento fechado). |
| companyId | Guid? | gerado | Derivado 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. Anulável desde 09/09/2026: nulo é a resposta do CompanyOf dos donos que são do tenant e não de uma empresa — item, fornecedor, proposta de alteração bancária e a instância de aprovação desses dois. Nulo significa anexo do tenant, e o filtro o deixa passar; não significa empresa desconhecida. |
Espelho do arquivo · o que não muda depois da confirmação
| Campo | Tipo | Regra | |
|---|---|---|---|
| originalFileName | string(255) | gerado | Copiado 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á. |
| contentType | string(120) | gerado | O 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. |
| sizeBytes | bigint | gerado | O 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. |
| status | — | gerado | Nã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
| Campo | Tipo | Regra | |
|---|---|---|---|
| visibility | enum | obrig | Internal · 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:attachments. Ver § Visibilidade. |
| description | string(300)? | opc | Por 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. |
| copiedFromAttachmentId | Guid? | gerado | O 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. |
| rootAttachmentId | Guid? | gerado | A 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
| Campo | Tipo | Regra | |
|---|---|---|---|
| uploadedByKind | enum | obrig | User · 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. |
| uploadedByUserId | Guid? | cond | Obrigatório com User. Nulo nos outros dois. |
portalUserId | Guid? | condicional | Obrigatório em uploadedByKind=PortalUser, derivado da sessão. Nulo para autor interno. Registra identidade convidada, sem presumir prova da pessoa física. |
| supplierId | Guid? | cond | Obrigató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. |
| uploadedAt | DateTime | gerado | Quando 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
| Campo | Tipo | Regra | |
|---|---|---|---|
| revokedAt | DateTime? | gerado | Preenchido é revogado, e revogado é terminal — não existe "desrevogar". O caminho de volta é anexar de novo, o que cria outro registro e outra referência. |
| revokedBy | Guid? | gerado | Quem revogou. Nulo quando revocationSource não é User. |
| reasonCodeId | Guid? | cond | → ReasonCode, 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. |
| revocationSource | enum? | gerado | User · 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. |
| fileReferenceReleased | bool | gerado | Se 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.
| Campo | Tipo | Regra | |
|---|---|---|---|
| tenantId | Guid? | gerado | Nulo é 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. |
| code | string(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. |
| name | string(120) | obrig | Como o tipo aparece no seletor de anexo. |
| description | string(300)? | opc | Explica quando usar. É o texto que evita todo mundo escolher "Geral". |
| allowedOwnerTypes | enum[] | obrig | Onde 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. |
| defaultVisibility | enum | obrig | Internal · Supplier. O padrão da tela. Anexo do portal ignora este campo e nasce Internal sempre, ver § Portal. |
| allowsSupplierVisibility | bool | obrig | Se 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. |
| propagates | bool | obrig | Se 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. |
| retentionMode | enum | obrig | UntilReleased · RetainUntil · Permanent. É o campo que só existe aqui. O GrydFiles executa o que recebe no upload, e quem preenche é este tipo. Ver § Retenção. |
| retentionYears | int? | Condicional | Obrigatório com RetainUntil. Define o piso inicial da política, não um prazo legal universal. Registrar policyVersion, fundamento, marco de contagem e retainUntil resolvido; fechamento, obrigação contratual ou legalHold podem estender a guarda. Tipos fiscais exigem política validada pelo responsável fiscal da empresa. |
| legalHold | bool | obrig | Liga 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. |
| maxCountPerOwner | int? | opc | Teto 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 · sortOrder | bool · int | obrig | Inativo 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. |
Semente global · tenantId nulo
| Código | Retenção | Visibilidade | Por que existe |
|---|---|---|---|
| GENERAL | UntilReleased | Internal | O anexo solto. Morre quando o documento morre — é o caso mais comum e o mais barato |
| TECHNICAL_SPEC | UntilReleased | Supplier | Desenho, memorial, especificação. Nasce visível ao fornecedor porque é o que a cotação precisa mandar |
| SUPPLIER_PROPOSAL | RetainUntil · 5 anos | Internal | A proposta que o fornecedor enviou. É prova do que foi combinado e sustenta contestação de preço |
| QUOTE_COMPARISON | RetainUntil · 5 anos | Internal | O mapa comparativo. allowsSupplierVisibility = false — é o arquivo que nunca pode sair |
| DELIVERY_PROOF | RetainUntil · 5 anos | Internal | Canhoto, foto da entrega, romaneio. Prova de recebimento em divergência |
| FISCAL_DOCUMENT | RetainUntil · 5 anos + legalHold | Internal | DANFE, boleto, comprovante. Cinco anos é o prazo de decadência do CTN, e o legal hold é o que impede o expurgo de acidente |
| SIGNED_CONTRACT | Permanent | Supplier | Só expurgo manual, com motivo e permissão própria. Contrato assinado não tem data de morte automática |
| APPROVAL_EVIDENCE | RetainUntil · 5 anos | Internal | O que sustentou uma aprovação de exceção. É o primeiro arquivo que a auditoria interna pede |
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 de onze perguntas. As respostas são declaradas aqui, nesta página — é o que faz dela um registro e não uma lista; a implementação vive no módulo dono, que é quem sabe consultar o próprio agregado. As duas coisas são separadas de propósito: onze respostas escritas por seis mãos em semanas diferentes é como um registro fica incoerente, e a resposta declarada é o contrato que a implementação tem de cumprir.
O guard confere a implementação, não a declaração: todo valor do enum tem de ter um descritor registrado na inicialização, e o que falta 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, e é verificável: percorre-se o enum e pergunta-se ao contêiner. Revisto em 09/09/2026: a formulação anterior falava em "o módulo dono publicou o seu descritor", que descreve uma pessoa cumprindo um combinado e não tem como ser conferida por código.
| O que o descritor responde | Para 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 |
| AllowedTypes | Os AttachmentType aceitos naquele dono. Cruza com allowedOwnerTypes do tipo, e vale a interseção |
| AllowsSupplierVisibility | Se aquele dono pode ter anexo visível ao fornecedor. Requisição interna, por exemplo, não pode |
| PropagatesTo | Para quais ownerType a propagação é oferecida. Requisição → pedido; pedido → recebimento; cotação → pedido |
| MaxCount · MaxTotalBytes | Teto 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 |
| OpenApprovalOf(ownerId) | A décima, acrescentada em 09/09/2026. O approvalRequestId da instância de aprovação aberta daquele documento, ou nulo. Existe porque o aviso de anexo revogado vai para os aprovadores pendentes daquela instância — e nenhuma das nove perguntas anteriores sabia dizer qual instância é. Sem ela, o aviso obrigatório de uma revogação no meio da aprovação não tinha destinatário resolvível e falhava em silêncio |
| ResponsibleOf(ownerId) | A décima-primeira, acrescentada em 09/09/2026. O usuário interno que responde pelo documento — solicitante da requisição, comprador da cotação e do pedido, conferente do recebimento. Nunca vazio. Existe para o caminho do portal: quando o arquivo veio de fora, uploadedByUserId é nulo, e "avisar quem anexou" não avisa ninguém. A proposta infectada de um fornecedor precisa chegar a alguém dentro da empresa, e é esta pergunta que diz a quem |
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 lastKnownOwnerState = Draft — a coluna que o próprio dono grava na transição, porque a essa altura não há mais a quem perguntar. Revisto em 09/09/2026: até 08/09 a guarda citava um dado que não existia em lugar nenhum, e a varredura não tinha como decidir sem ou liberar sempre, ou nunca. 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
| Valor | Propaga para | Observação |
|---|---|---|
| Requisition | PurchaseOrder · QuotationRequest | O caso de entrada. Orçamento prévio, foto do que quebrou, especificação do solicitante |
| RequisitionItem | QuotationRequestItem · PurchaseOrderItem | Anexo de linha existe porque a requisição de dez itens tem um desenho por item, não um por documento. A propagação passa pela cotação desde 09/09/2026 — antes ela pulava direto para o pedido, e o desenho não chegava a quem precisava cotá-lo |
| QuotationRequest | PurchaseOrder | Edital, planilha modelo, condições. Tipicamente Supplier |
| QuotationResponse | PurchaseOrder | Onde o anexo do portal cai. supplierId obrigatório, nasce Internal |
| PurchaseOrder | Receipt · Invoice | Pedido assinado, aceite do fornecedor, alteração negociada |
| PurchaseOrderItem | — | Herda o desenho da linha de requisição |
| Receipt | Invoice | Canhoto, foto da carga, laudo de conferência. É o tipo com mais anexo por documento no mundo real |
| Invoice | — | DANFE, boleto, divergência. Retenção fiscal com legal hold |
| Contract | PurchaseOrder | O contrato assinado é Permanent. Versionamento é do Contract, não daqui |
| QuotationRequestItem | PurchaseOrderItem | Novo em 09/09/2026. A linha da cotação, que é onde o desenho por item de fato serve — o fornecedor cota aquele item |
| ApprovalRequest | — | Novo em 09/09/2026. A evidência que o aprovador junta à decisão: parecer, cotação de terceiro, autorização por fora. O documento em aprovação não aceita mais alteração — a instância aceita, e é ela o dono. É onde o tipo semeado APPROVAL_EVIDENCE passa a ter uso; até 08/09/2026 ele não tinha onde ser usado |
| Supplier | — | Novo em 09/09/2026. Evidência avulsa do cadastro: ata de visita técnica, correspondência, e-mail de negociação, parecer que não tem número nem órgão emissor. Certidão, contrato social e cartão de CNPJ não entram aqui — são SupplierDocument, ver a regra abaixo |
| Item | — | Novo em 09/09/2026. Evidência avulsa do cadastro: laudo, catálogo do fabricante, e-mail de especificação. Foto e ficha técnica do item continuam sendo entidade do catálogo, com perfil próprio no GrydFiles — a fronteira da decisão 1 não muda |
| Budget | — | Novo em 09/09/2026. Planilha de premissas, ata que aprovou a verba, ofício de suplementação. A revisão orçamentária é decisão cara e sem prova nenhuma até aqui |
| BankAccountChangeProposal | — | Novo em 09/09/2026. Comprovante de titularidade da conta. É o anexo do controle antifraude bancária — quem aprova a troca de conta precisa olhar alguma coisa, e essa coisa precisa ter existido antes da aprovação |
Os seis últimos entraram em 09/09/2026, e entraram agora de propósito. Mexer em enum fechado depois de haver dado gravado é migração, e as seis instâncias que faltavam — aprovação, fornecedor, item, orçamento, alteração bancária e linha de cotação — são justamente as que mais precisam de prova. Quatro delas passam por aprovação e nenhuma aceitava um único arquivo. As onze respostas dos seis estão declaradas logo abaixo, escritas de uma vez e em um lugar só — acrescentar o valor sem as respostas apenas trocaria o buraco de "APPROVAL_EVIDENCE não tem onde ser usado" por "seis valores que não sobem".
Renomeados em 06/09/2026: RequisitionLine → RequisitionItem e PurchaseOrderLine → PurchaseOrderItem, pela Decisão 5 do Cadastro de Item — linha de documento é <Documento>Item. Os descritores e as respostas não mudam.
As onze respostas dos seis donos que entraram em 09/09/2026
Declaradas aqui, de uma vez. Cada módulo dono implementa a sua coluna; nenhum deles reescreve a resposta. Onde a resposta é decisão e não preenchimento, ela está escrita como regra logo abaixo da tabela.
| Pergunta | QuotationRequestItem | ApprovalRequest | Supplier | Item | Budget | BankAccountChangeProposal |
|---|---|---|---|---|---|---|
| CompanyOf | A empresa da cotação pai | Condicional: a empresa do documento alvo quando ele tem uma (requisição, pedido, orçamento); nula quando o alvo é fornecedor ou item | Nula — cadastro do tenant | Nula — vocabulário do tenant | A empresa do orçamento | Nula — a proposta é do fornecedor, e fornecedor é do tenant |
| StateOf | Segue a cotação pai: Draft enquanto a RFQ é montada, InProgress depois de enviada, Closed na adjudicação ou no cancelamento | InProgress com a instância aberta, Closed quando conclui. Nunca Draft | InProgress enquanto ativo, Closed quando inativado. Nunca Draft | Draft no item que nasceu em rascunho pela importação do catálogo, InProgress ativo, Closed inativado | Draft antes de publicado, InProgress no exercício corrente, Closed com o exercício arquivado — nunca Missing | Draft enquanto é montada, InProgress pendente de aprovação, Closed aprovada ou recusada |
| DisplayNumberOf | O número da cotação mais a posição da linha — COT-2026-00118 · item 4 | O número da instância mais o documento alvo — APR-2026-00931 · REQ-2026-00412 | Razão social mais CNPJ formatado | Código do item mais a descrição curta | Código do orçamento mais o exercício | O fornecedor mais os quatro últimos da conta proposta |
| AllowedTypes | TECHNICAL_SPEC · GENERAL | APPROVAL_EVIDENCE · GENERAL | GENERAL na semente | TECHNICAL_SPEC · GENERAL | APPROVAL_EVIDENCE · GENERAL | APPROVAL_EVIDENCE · GENERAL |
| AllowsSupplierVisibility | Sim — é a linha que o fornecedor precisa ver para cotar | Não — evidência de decisão é interna | Não na v1 | Não | Não — orçamento não sai da empresa | Não — é a prova que o antifraude olha |
| PropagatesTo | PurchaseOrderItem | — | — | — | — | — |
| MaxCount · MaxTotalBytes | 5 · 25 MB | 10 · 50 MB | 20 · 100 MB | 10 · 50 MB | 10 · 50 MB | 3 · 15 MB |
| CanWrite | Quem pode editar a cotação | O aprovador pendente da rodada corrente, e mais ninguém — não é "quem enxerga a instância" | Quem pode editar o cadastro do fornecedor | Quem pode editar o item | Quem pode editar o orçamento daquele exercício | Quem pode editar a proposta enquanto ela está aberta; depois de submetida, ninguém |
| SubmitPolicyOf | Delega à cotação pai — a linha não submete sozinha | Não submete. Ver a regra abaixo | Submete o cadastro para o processo de homologação | Submete o item para aprovação | Submete a revisão orçamentária | Submete a proposta de alteração bancária |
| OpenApprovalOf | Delega à cotação pai | Reflexivo — é ele mesmo. Ver a regra abaixo | A instância aberta do processo de fornecedor, ou nulo | A instância aberta do processo de item, ou nulo | A instância aberta da revisão, ou nulo | A instância aberta da alteração bancária, ou nulo |
| ResponsibleOf | O comprador da cotação | Quem abriu a instância, nunca os aprovadores — eles são audiência do aviso, não donos do documento | O comprador responsável pelo cadastro | O responsável pelo catálogo | O responsável pelo orçamento daquele exercício | O comprador que registrou a proposta; vinda do portal, o responsável pelo cadastro do fornecedor |
Sobre o AllowedTypes: o tipo carrega a retenção, então escolher o tipo é escolher quanto tempo a prova dura. GENERAL é UntilReleased — o arquivo morre quando o documento morre —, e APPROVAL_EVIDENCE é RetainUntil de cinco anos. Por isso Budget e BankAccountChangeProposal aceitam os dois: o comprovante de titularidade que sustentou uma troca de conta e a ata que sustentou uma suplementação de verba são exatamente o primeiro arquivo que a auditoria pede, e deixá-los em GENERAL os faria morrer junto com o documento que eles justificam. É a mesma escolha que já valia para ApprovalRequest.
E onde a semente não chega, ela não deve chegar. Os oito tipos globais não têm um "documento do fornecedor" nem um "laudo do item", e isso é a decisão 1 funcionando: arquivo com metadado próprio — número, órgão, vigência — não é anexo. O que sobra para o tenant acrescentar por sobreposição (RN-ATT-25) é a etiqueta local, e ela varia por segmento e por cliente; um tipo global que serve metade dos tenants é pior do que não existir.
Duas evidências, dois donos: a que o aprovador junta e a que sustenta a proposta
Novo em 09/09/2026. As duas usam APPROVAL_EVIDENCE e não são a mesma coisa, e confundi-las é o jeito mais fácil de perder prova.
A que sustenta a proposta nasce antes da decisão, e o dono é o próprio agregado. O comprovante de titularidade da conta é parte da proposta de alteração bancária; a ata que aprovou a verba é parte da revisão orçamentária. Existiriam mesmo que ninguém precisasse aprovar nada — são o porquê que o autor apresenta. Ficam em ownerType = BankAccountChangeProposal e Budget, e entram na conta da decisão 8: enquanto o anexo não tiver veredito, o documento não é submetido, porque o aprovador não pode receber uma proposta cuja prova ele não consegue abrir.
A que o aprovador junta nasce depois da submissão, e o dono é a instância. Parecer, cotação de terceiro, autorização por fora: só existem porque alguém teve de decidir. Não cabem no documento — ele já não aceita alteração —, e é para isso que o ownerType = ApprovalRequest existe. Nenhuma regra bloqueia a decisão por causa delas; o que vale é a RN-ATT-26, e enquanto o arquivo não liberar o aprovador simplesmente não o abre.
O teste, quando a fronteira ficar dúbia: este arquivo existiria se não houvesse instância de aprovação? Se sim, o dono é o agregado. Se ele só nasceu porque alguém precisou decidir, o dono é a instância. As duas convivem na mesma aprovação, e é comum que convivam — a proposta traz o comprovante, o aprovador junta o parecer, e a auditoria de daqui a três anos precisa dos dois, com os mesmos cinco anos de guarda.
Certidão de fornecedor não é anexo — ela vive no SupplierDocument
Novo em 09/09/2026, e é correção: até a tabela acima ser escrita, a linha do Supplier dizia "certidão, contrato social, cartão de CNPJ", o que contraria a decisão 1 desta própria página e o que a spec de Fornecedor já dizia. Certidão tem número, órgão emissor, emissão e vencimento, e o status dela é derivado da data — alguém digita esses campos, alguém filtra por eles, e o sistema avisa antes de vencer. Isso é entidade de domínio, com ownerScope = "nexio.supplier-document", FileReference própria e liberação quando o documento é substituído.
O que sobra para o ownerType = Supplier é a evidência sem campo para digitar: ata de visita técnica, correspondência, e-mail de negociação, parecer solto. O mesmo corte vale para o Item — laudo com número e órgão é ItemDocument, e foto e ficha técnica são entidade do catálogo, com perfil próprio no GrydFiles.
O teste é o mesmo de sempre: alguém vai ter de digitar um campo sobre este arquivo e depois filtrar por ele? Se sim, é entidade e não anexo. E a passagem entre os dois lados já está escrita: a certidão que chega anexada numa cotação pode ser promovida a documento de cadastro, porque o supplierId do anexo do portal diz de quem ela é.
companyId nulo é resposta, não falta de resposta
Novo em 09/09/2026. Três dos seis donos novos não têm empresa: Item e Supplier são vocabulário e cadastro do tenant, não da empresa, e BankAccountChangeProposal pende de um fornecedor, que também é do tenant. O companyId denormalizado do anexo nasce nulo nesses casos, e nulo tem significado declarado: anexo do tenant, visível em qualquer empresa que o usuário enxergue — não anexo de empresa nenhuma.
É a mesma forma que GLAccountCompany e BudgetPolicy já usam (linha sem empresa vale para todas) e a mesma lição do escopo por empresa: ele restringe escrita, nunca apaga leitura. Sem esta linha escrita, o filtro por UserCompanyScope decidiria sozinho, e das duas maneiras possíveis: comparação estrita faz a ata de visita ao fornecedor sumir para todo mundo; comparação frouxa a faz aparecer para todo mundo, empresa alheia inclusive. As duas falham em silêncio, que é o pior modo de falhar de um filtro de linha.
E a resposta do ApprovalRequest é condicional, o que é o caso mais fácil de errar. A instância herda a empresa do documento que ela aprova: requisição, pedido e orçamento têm empresa; fornecedor e item não têm. O mesmo ownerType devolve Guid em três processos e nulo em dois, e isso é correto — o descritor pergunta ao alvo, não a uma tabela de de-para.
Instância de aprovação não é documento submetível
Novo em 09/09/2026. O SubmitPolicyOf do ApprovalRequest responde "não submete", e responde isso explicitamente — não é um método sem implementação, nem um que devolve nulo e deixa quem chama adivinhar. A instância é a submissão de outro documento; submetê-la seria submeter a submissão. A fila de submissão diferida chama esta pergunta sem saber o que tem do outro lado, e uma resposta ausente é exatamente o tipo de coisa que ela invocaria por engano no dia em que alguém anexasse um parecer a uma instância cujo anexo ainda está em Scanning.
O OpenApprovalOf da instância é a própria instância
Novo em 09/09/2026. Para ownerType = ApprovalRequest, o ownerId já é o approvalRequestId — a resposta é reflexiva e não custa consulta nenhuma: devolve o próprio ownerId se a instância estiver aberta, e nulo se estiver concluída. Está escrito porque a alternativa é alguém implementar "procure a instância aberta deste dono" com o dono sendo uma instância, e descobrir a recursão em produção, no dia em que um anexo de parecer for revogado no meio de uma rodada.
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.
Contrato vigente · revisão consolidada
Visibilidade e isolamento do fornecedor
Internal/Supplier indica publicação pelo comprador; supplierId indica o dono fornecedor da resposta. São dimensões distintas. Sempre exigir vínculo válido com a cotação/rodada, tenant correto, estado permitido e arquivo Available.
| Pessoa / anexo | Internal | Supplier | Revogado |
|---|---|---|---|
| Usuário interno autorizado no dono e na empresa | Sim | Sim | Histórico, com permissão. |
| Usuário fora do escopo | 404 | 404 | 404 |
| Fornecedor: próprio supplierId e própria resposta | Sim, inclusive para conferir o upload antes do envio | Sim | Não |
| Fornecedor: outro supplierId | Não | Não, mesmo que outra resposta tenha visibility=Supplier | Não |
| Fornecedor: anexo comum do QuotationRequest | Não | Sim, se publicado para sua rodada/audiência | Não |
Predicado do portal: vínculo válido AND (anexo da própria resposta/fornecedor OR anexo comum publicado da cotação). Não é um OR que dispense validar alvo/rodada. Anexo de proposta concorrente nunca vira anexo comum só por trocar visibility; eventual publicação exige cópia autorizada, revisão do conteúdo e novo dono comum.
Promover visibilidade exige tipo, dono, permissão share-with-supplier:attachments e estado editável. Depois de enviado, visibilidade trava; revogar registra exposição anterior. Download passa pelo descritor do negócio antes de GrydFiles e fixa a versão de bytes aprovada.
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.
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.
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 Available — já 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.
| Estado do dono | O anexo | A FileReference | O blob |
|---|---|---|---|
Rascunho (Draft) | Revogado, com revokedAt e revokedBy. Motivo é opcional | Liberada | Entra na fila de expurgo e some no fim da carência, se ninguém mais aponta |
| Em curso ou fechado | Revogado, com motivo obrigatório. Sai da tela, não vale como documento, não vai ao fornecedor | Mantida | Continua preso. Quem decide a morte passa a ser a retenção |
| Descartado (evento) | Revogado com revocationSource = OwnerDiscarded | Liberada | Como no rascunho — é o mesmo caso, chegando por evento |
| Ausente (varredura) | Revogado com revocationSource = OrphanSweep e reportado | Liberada só se lastKnownOwnerState = Draft | Na dúvida, preso. Guardar demais é barato; perder prova, não |
Arquivo Infected | Revogado com revocationSource = FileInfected. A tela diz o que houve | Nunca existiu — ela nasceria na liberação, que não aconteceu. Nada a liberar | Nã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 evidência de documento submetido aciona explicitamente, na mesma transação, a reavaliação do conteúdo relevante pelo motor de aprovação. Se a evidência sustentava uma decisão, essa decisão não permanece válida sem reavaliação. A notificação durável informa os envolvidos e complementa o controle. A versão efetiva de um pedido emitido permanece preservada durante a aprovação da alteração.
Retenção é resolvida por política e pode ser estendida
O Nexio resolve retenção por tipo, empresa e política versionada no upload, e estende a data quando o marco de contagem ou a obrigação exigir. Pedido e nota são documentos distintos, ligados por referências; um não muda de natureza para virar o outro. O padrão de cinco anos é uma configuração inicial a validar, não autorização automática de expurgo. O CTN, arts. 173, 174 e 195, parágrafo único, distingue decadência, prescrição e conservação de documentos; não fornece uma regra universal de cinco anos após o upload. Guardar fundamento, marco e versão da política; litígio ou obrigação ainda vigente impede expurgo.
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: Pending → Scanning → Available · 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. O veredito chega pelo contrato registrado; esta seção é sobre o que o produto faz com a janela.
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, Scanning ou Failed. A submissão é recusada com ATTACHMENT_SCAN_PENDING_CONFLICT, e a resposta nomeia quais anexos estão pendentes.
Failed entrou na lista em 09/09/2026, e a razão é a mesma dos outros dois. Arquivo em Failed não tem veredito, e o GrydFiles recusa o download dele — o aprovador receberia na fila um documento cuja prova ele não consegue abrir. Até 08/09 a regra bloqueava só os dois primeiros estados: a fila diferida desistia em Failed, mas a submissão manual passava. As duas portas passam a fechar juntas. A saída não é esperar indefinidamente: o GrydFiles ganhou POST /files/{id}:rescan, e a tela oferece "tentar verificar de novo" no lugar de um anexo que não serve para nada.
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 liberar — nexio.attachment.scan_completed — e deixar a submissão acontecer sozinha, por uma DeferredSubmission que ele pode cancelar enquanto ela espera. As duas escolhas são a mesma fila. Revisto em 09/09/2026: "só avisar" foi escrito como se não criasse nada, mas "registrado para aquele usuário e aquele documento" é estado, e sem linha em lugar nenhum o pedido se perdia no primeiro reinício. A fila ganha um modo — avisar, ou avisar e submeter —, e não uma segunda tabela: o gatilho, a expiração, o cancelamento e o índice de unicidade são idênticos nos dois casos, e a única diferença é o que acontece quando o último anexo libera. 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 comportamento já especificado que permanece
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.
Contrato vigente · revisão consolidada
Autoria e anexos do portal
O portal da v1 usa PortalUser criado pelo convite. No upload, supplierId, portalUserId, portalAccessLinkId e sessão são obrigatórios e resolvidos no servidor; não são aceitos como autoridade a partir do corpo. ownerType é QuotationResponse nesta versão.
Anexo nasce Internal para não publicar a proposta aos concorrentes. O próprio fornecedor pode consultar seus anexos e obter download quando Available, inclusive depois de enviar a proposta enquanto tiver vínculo de leitura vigente. Rascunho permanece invisível ao comprador; resposta enviada torna-se visível conforme regras da cotação.
GrydFiles registra a credencial técnica de serviço; Nexio registra identidade convidada, canal verificado, sessão, instante e vínculo com a resposta. Isso comprova o meio usado, não necessariamente a pessoa física que controlava o e-mail. Comprador que lança proposta recebida por e-mail tem enteredBy=Buyer e autor interno explícito; não é atribuído ao fornecedor como acesso ao portal.
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.
E há a terceira resposta, que não é nem "é seu" nem "não é seu": companyId nulo. Novo em 09/09/2026. Anexo de item, de fornecedor e de proposta de alteração bancária não pertence a empresa nenhuma, porque o dono deles é do tenant. O filtro por UserCompanyScope passa a ser "a empresa está no escopo do usuário ou o anexo não tem empresa" — a mesma forma de GLAccountCompany e BudgetPolicy, em que a linha sem empresa vale para todas. Escrever o filtro sem essa segunda metade faz a ata de visita ao fornecedor desaparecer para todos os usuários, e nenhum erro é registrado em lugar nenhum.
Permissão de anexo não substitui permissão no dono
manage:attachments é 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ão | Alcance | Observação |
|---|---|---|
| read:attachments | Listar, ver metadado e pedir URL de download | Sempre recortada pelo escopo da empresa do dono e pela visibility. Todo papel que abre documento precisa dela |
| manage:attachments | Anexar, descrever, trocar o tipo em rascunho e propagar | Não basta: exige também poder escrever no dono. Ver acima |
| revoke:attachments | Revogar anexo de outra pessoa, e revogar fora de rascunho | Quem anexou revoga o próprio anexo em rascunho só com manage. Retirar prova de documento em curso é outro ato |
| share-with-supplier:attachments | Marcar visibility = Supplier | Separada de propósito — é a permissão cujo erro vaza. Rebaixar para Internal não exige nada |
| manage:attachment-types | Administrar o catálogo do tenant | Administração de configuração. Mexe em retenção, então é de administrador, não de comprador |
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.
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.
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.
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.
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.
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.
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.
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.
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
| Rota | Permissão | Devolve | Erros |
|---|---|---|---|
| GET /attachments | read:attachments | Os anexos de um dono (ownerType + ownerId, obrigatórios), com o status do arquivo já resolvido em lote. Revogados só com includeRevoked | 400 · 404 |
| POST /attachments/upload-intent | manage:attachments | fileId, uploadUrl, uploadExpiresAt e os cabeçalhos do PUT. Valida dono, tipo, escopo e limites antes de reservar | 400 · 404 · 409 · 422 |
| POST /attachments | manage:attachments | AttachmentDto. Confirma no GrydFiles, cria o anexo e a FileReference. Idempotente por fileId: chamar duas vezes devolve o mesmo anexo | 400 · 404 · 409 · 422 |
| GET /attachments/{id} | read:attachments | AttachmentDto com o número legível do documento dono, resolvido pelo descritor | 404 |
| GET /attachments/status | read:attachments | A 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 chamadas | 400 |
| GET /attachments/{id}/download-url | read:attachments | URL assinada de leitura, TTL do perfil. Autoriza primeiro — escopo, visibilidade, revogação — e só então chama o GrydFiles. Registra documento e anexo na trilha | 404 · 409 · 503 |
| PUT /attachments/{id} | manage:attachments | Altera description e, em rascunho, attachmentTypeId entre tipos de mesma retenção. storedFileId, ownerType e ownerId são recusados quando divergem | 404 · 409 · 422 |
| POST /attachments/{id}/visibility | share-with-supplier:attachments | Promove a Supplier. Rebaixar para Internal aceita só manage:attachments. Estado por rota própria, nunca por PUT | 404 · 409 · 422 |
| POST /attachments/{id}/revoke | revoke:attachments | Revoga. Libera a referência só com o dono em rascunho. Motivo obrigatório fora dele | 404 · 409 · 422 |
| POST /attachments/copy | manage:attachments | Propagação explícita, em lote: lista de attachmentId e o dono de destino. Cria um anexo e uma FileReference por item, herdando a visibilidade | 400 · 404 · 409 · 422 |
| GET /attachments/copy-candidates | read:attachments | O que a tela de propagação mostra: elegíveis, já marcados, com o motivo de cada inelegível. A tela não reimplementa a regra | 400 · 404 |
| GET /attachments/{id}/chain | read:attachments | A cadeia de propagação até a raiz, mais os anexos que compartilham o mesmo storedFileId dentro do escopo do usuário | 404 |
| GET /attachments/owner-types | read:attachments | O registro, do jeito que a tela precisa: tipos aceitos, limites, se aceita visibilidade de fornecedor, para onde propaga | — |
| POST /portal/quotations/{id}/attachments | token da RFQ | O caminho do fornecedor, com upload-intent e confirmação embutidos. supplierId vem do token; nasce Internal; ownerType é sempre QuotationResponse | 400 · 401 · 409 · 422 |
| GET /attachment-types | read:attachments | Os 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 seletor | 400 |
| POST PUT /attachment-types | manage:attachment-types | Administraçã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 uso | 400 · 404 · 409 · 422 |
| PATCH /attachment-types/{id}/deactivate | manage:attachment-types | Some 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 uso | 404 · 409 |
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ódigo | HTTP | Quando |
|---|---|---|
| ATTACHMENT_NOT_FOUND | 404 | Não existe neste tenant — ou o dono está fora do escopo do usuário. Os dois casos respondem igual, de propósito |
| ATTACHMENT_OWNER_NOT_FOUND | 404 | O documento dono não existe ou está fora do escopo. Vem do descritor, não de uma FK |
| ATTACHMENT_OWNER_TYPE_UNKNOWN | 400 | ownerType 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_CONFLICT | 409 | Anexar 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_FOUND | 404 | Tipo inexistente neste tenant, contando os globais |
| ATTACHMENT_TYPE_INACTIVE_UNPROCESSABLE | 422 | Tipo desativado. Anexo antigo com esse tipo continua válido — só o novo é recusado |
| ATTACHMENT_TYPE_NOT_ALLOWED_FOR_OWNER_UNPROCESSABLE | 422 | O 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_UNPROCESSABLE | 422 | Troca de tipo para outro com retenção diferente. Mudaria a data de expurgo de um arquivo já sob guarda |
| ATTACHMENT_LIMIT_EXCEEDED_CONFLICT | 409 | Teto 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_UNPROCESSABLE | 422 | Supplier 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_CONFLICT | 409 | Alterar visibilidade depois de o documento ter ido ao fornecedor. O caminho é revogar, não fingir que ele não viu |
| ATTACHMENT_ALREADY_REVOKED_CONFLICT | 409 | Revogação é terminal. Não existe "desrevogar" — o caminho de volta é anexar de novo |
| ATTACHMENT_REVOCATION_REASON_REQUIRED_UNPROCESSABLE | 422 | Revogar fora de rascunho sem reasonCodeId. Retirar prova de documento em curso precisa de motivo nomeado |
| ATTACHMENT_SCAN_PENDING_CONFLICT | 409 | A decisão 8. Submeter para aprovação com anexo em Pending, Scanning ou Failed — os três desde 09/09/2026. A resposta lista quais e em que estado, porque "espere" e "mande verificar de novo" são conversas diferentes |
| ATTACHMENT_FILE_NOT_AVAILABLE_CONFLICT | 409 | Baixar, propagar ou enviar ao fornecedor arquivo que ainda não passou pelo antivírus — ou seja, anexo que ainda não tem FileReference. É o estado normal de "espere um pouco" |
| ATTACHMENT_FILE_INFECTED_CONFLICT | 409 | Separado do anterior de propósito: a tela precisa dizer o que houve e o suporte precisa distinguir fila de incidente |
| ATTACHMENT_FILE_PURGED_CONFLICT | 409 | O anexo existe, o conteúdo não. Traz purgedAt e o motivo, vindos da lápide do GrydFiles |
| ATTACHMENT_COPY_SOURCE_NOT_ELIGIBLE_UNPROCESSABLE | 422 | Propagar anexo revogado, com arquivo indisponível, ou de tipo com propagates = false |
| ATTACHMENT_COPY_TARGET_MISMATCH_UNPROCESSABLE | 422 | Destino de outra empresa, ou ownerType fora do PropagatesTo da origem. Anexo não atravessa empresa |
| ATTACHMENT_SUPPLIER_REQUIRED_UNPROCESSABLE | 422 | uploadedByKind = PortalUser sem supplierId. Não deveria acontecer — o token resolve — e existe para nunca acontecer em silêncio |
| ATTACHMENT_PORTAL_OWNER_NOT_ALLOWED_UNPROCESSABLE | 422 | Caminho do portal com ownerType diferente de QuotationResponse |
| ATTACHMENT_TYPE_CODE_ALREADY_EXISTS | 409 | Có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_CONFLICT | 409 | Alterar code ou retentionMode de tipo já usado, ou tentar excluí-lo. O caminho é desativar |
| ATTACHMENT_TYPE_RETENTION_UNPROCESSABLE | 422 | retentionYears ausente em RetainUntil, ou presente nos outros modos |
| ATTACHMENT_DESCRIPTION_TOO_LONG | 400 | Acima de 300 caracteres |
Invariantes
Regras de negócio
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.
IDs entre contextos evitam dependência de navegação. FK estratégica é permitida quando aplicável e registrada pela arquitetura; dono polimórfico exige descritor, validação na transação, atualização explícita de estado e reconciliação. Varredura posterior não substitui uma invariante atômica.
Falha no startup, não em produção. Reformulada em 09/09/2026: a verificação percorre AttachmentOwnerType e exige um descritor registrado para cada valor — é sobre o enum, que o código enxerga, e não sobre "o módulo dono publicou", que ele não enxerga. É o que faz o enum ser realmente fechado: entrar na lista custa exatamente o que custa responder às onze perguntas do registro, e as respostas dos quinze valores estão declaradas nesta página.
AttachmentNã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.
CompanyOf deriva do dono e filtra visibilidade; UserCompanyRole e teto da plataforma autorizam a operação. Fora do escopo retorna 404. Dono sem empresa segue seu contrato de recurso compartilhado, sem conceder acesso a documento empresarial.
manage:attachments é 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.
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. Desde 09/09/2026 a primeira referência não é criada aqui: o anexo declara o par pendente na confirmação e o módulo de arquivo cria a referência na liberação. A cópia, sim, cria a sua diretamente — ela só é oferecida sobre arquivo já liberado.
AttachmentTyperetentionMode, 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.
"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.
Tipo permite, dono permite, usuário tem share-with-supplier:attachments, anexo é editável. Rebaixar para Internal não exige nada — reduzir exposição nunca deveria ter atrito.
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.
Fornecedor autorizado vê anexo de sua própria resposta, inclusive Internal, e anexos comuns Supplier publicados da cotação. Nunca vê anexo de outro fornecedor por visibility isolada. Validar alvo, rodada e vínculo antes de ambos os caminhos.
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.
Destino de outra empresa é 422. E revogar a origem não revoga as cópias: cada documento responde pelo que anexou.
AvailableMultiplicar 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.
Existe revogação, que é registro e é terminal. Sem DELETE na API, pelo mesmo motivo que não há DELETE /files/{id} no GrydFiles.
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.
Retirar evidência material da revisão em aprovação invalida a decisão dependente e exige reavaliação. Preservar hash, versão, motivo e relação com assinaturas anteriores. Pedido efetivo não é cancelado silenciosamente; alteração e ação corretiva seguem seu processo.
Anexar durante o scan é permitido; submeter não. Vale para Pending, Scanning e — desde 09/09/2026 — Failed: arquivo sem veredito não abre, e o aprovador não pode receber documento cuja prova ele não consegue ler. 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.
Infected revoga o anexo automaticamenteCom revocationSource = FileInfected, pelo contrato de veredito e na mesma transação. O aviso vai a quem anexou e, quando não há quem anexou — o caminho do portal, em que uploadedByUserId é nulo —, ao ResponsibleOf do documento: a proposta infectada de um fornecedor não pode deixar de avisar alguém dentro da empresa. Infected é terminal no GrydFiles e não há liberação manual, então não há estado intermediário a modelar aqui.
supplierId e portalUserId obrigatórios são derivados da sessão convidada, com link e método de verificação. Nasce Internal mas permanece visível ao próprio fornecedor. Identidade convidada não é prova absoluta da pessoa física autora.
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.
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.
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.
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.
Nova em 09/09/2026. POST /attachments declara o par pendente e não cria FileReference. Enquanto o veredito não vem, o anexo é listado com o estado real e não é referenciável, baixável, propagável nem enviável ao fornecedor. É o que mantém verdadeira, no passo normal, a invariante do GrydFiles de que nada é referenciável antes do veredito.
IFileOutcomeHandler participa da DbConnection e DbTransaction de GrydFiles. Falha reverte veredito, referência e efeito Nexio. Bancos separados não cumprem este contrato e exigem recusar configuração até haver protocolo alternativo aprovado.
Nova em 09/09/2026. lastKnownOwnerState é escrito no mesmo método que muda o estado do documento, nunca por leitura preguiçosa e nunca pelo módulo de anexo. É o único dado que a varredura de órfãos tem quando o dono já não existe, e sem ele a terceira mitigação do polimorfismo não é executável.
Nova em 09/09/2026. O descritor responde OpenApprovalOf para o aviso que vai aos aprovadores pendentes e ResponsibleOf — nunca vazio — para o que vai a quem responde pelo documento. Audiência que não resolve entrega a zero pessoas sem erro, e um aviso obrigatório que falha em silêncio é pior do que não existir.
companyId nulo é anexo do tenant, e o escopo por empresa não o escondeNova em 09/09/2026. Quando o CompanyOf do descritor devolve nulo — item, fornecedor, proposta de alteração bancária, e a instância de aprovação cujo alvo é um desses —, o anexo é do tenant e é visível em qualquer empresa que o usuário enxergue. O filtro é "empresa no escopo ou empresa nula", como em GLAccountCompany e BudgetPolicy. É a mesma lição do escopo por empresa em toda a base: ele restringe escrita, nunca apaga leitura. Sem a regra escrita, as duas implementações possíveis falham em silêncio — a estrita faz o anexo de cadastro do fornecedor sumir para todos, a frouxa o mostra a quem não deveria vê-lo.
Contexto
Referência de mercado
| Produto | Modelo | O que aproveitamos |
|---|---|---|
| Oracle Fusion · iProcurement | Duas tabelas: FND_DOCUMENTS guarda o arquivo e FND_ATTACHED_DOCUMENTS amarra em ENTITY_NAME + PK1_VALUE, com categoria de anexo por entidade | A 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 visibilidade | To Buyer, To Supplier, To Approver, Internal to Requisition | A 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 Ariba | Anexo na requisição propaga para o pedido; sinalizador de visível ao fornecedor por documento | A 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 |
| Coupa | Anexos em requisição, pedido e fatura, com caixa "visível ao fornecedor" e comentário com anexo | A 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 acoplado | SharePoint, Documentum, GED próprio: o anexo é um link para um repositório documental externo, com workflow e versionamento próprios | Recusado. 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 arts. 173, 174 e 195 | Prazos e marcos dependem da obrigação; conservação não se reduz a uma contagem única após upload | O padrão de cinco anos requer validação fiscal por tipo e empresa, com política versionada e bloqueio de expurgo enquanto houver obrigação ou legalHold. |
| LGPD · art. 16 | Guarda permitida para cumprimento de obrigação legal ou regulatória, mesmo após o fim do tratamento | A 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
- 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
ItemDocumenteSupplierDocument, pela regra de corte da decisão 1. - Importação em massa e relatório gerado. Usam
StoredFilecom retenção curta e nunca criamAttachment— não são prova de nada, são insumo e saída.
- 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
Invoicee 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.
Contrato vigente · revisão consolidada
Versão de bytes e evidência
Attachment guarda referência à versão selada, SHA-256 e identidade da evidência usados pela aprovação, além do nome/tipo/tamanho. Veredito e download apontam para os mesmos bytes. Nova versão de arquivo é nova evidência e não substitui conteúdo já assinado.
Remover anexo durante scan não deve permitir callback tardio recriar referência revogada: o desfecho verifica versão esperada do consumidor. Submissão diferida revalida revisão, autorização empresarial, evidências, orçamento e estado do documento antes de agir.