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.
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.
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 |
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
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. |
| 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. |
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:attachment. 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? | opc | Opcional mesmo em anexo do portal: o fornecedor pode responder por link de uso único sem PortalUser cadastrado. Ver § Portal. |
| 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? | cond | Obrigató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. |
| 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, 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 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 |
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
| 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 | PurchaseOrderItem | Anexo de linha existe porque a requisição de dez itens tem um desenho por item, não um por documento |
| 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 |
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 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 | Internal | Supplier | Revogado |
|---|---|---|---|
| Usuário com escopo na empresa do dono | Sim, com read:attachment | Sim | Só na aba de histórico, com marca |
| Usuário fora do escopo da empresa | 404 | 404 | 404 |
| Aprovador na fila | Sim — é a evidência que sustenta a decisão | Sim | Não. Ver § Revogação |
Fornecedor no portal, mesmo supplierId | Não | Sim | Não |
Fornecedor no portal, outro supplierId | Não | Só o que é do documento pai, nunca o de outro fornecedor | Não |
| Auditoria interna | Sim | Sim | Sim, 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.
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 o último estado conhecido era rascunho | Na dúvida, preso. Guardar demais é barato; perder prova, não |
Arquivo Infected | Revogado com revocationSource = FileInfected. A tela diz o que houve | Liberada | 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 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: 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.
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 liberar — nexio.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.
| Aspecto | Anexo interno | Anexo do portal |
|---|---|---|
| Autenticação | Token do GrydAuth | Token da RFQ (PortalUser ou link de uso único) |
uploadedByKind | User | PortalUser |
supplierId | Opcional | Obrigatório, do token |
| Visibilidade inicial | Do AttachmentType | Sempre Internal |
ownerType aceito | Os nove | Só QuotationResponse na v1 |
| Perfil e antivírus | Idênticos. É o caminho mais importante do ClamAV — é o único em que um terceiro sobe arquivo | |
| Escopo por empresa | UserCompanyScope | Derivado 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ão | Alcance | Observação |
|---|---|---|
| read:attachment | 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:attachment | Anexar, descrever, trocar o tipo em rascunho e propagar | Não basta: exige também poder escrever no dono. Ver acima |
| revoke:attachment | 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:attachment | Marcar visibility = Supplier | Separada de propósito — é a permissão cujo erro vaza. Rebaixar para Internal não exige nada |
| manage:attachment-type | 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:attachment | 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:attachment | 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:attachment | 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:attachment | AttachmentDto com o número legível do documento dono, resolvido pelo descritor | 404 |
| GET /attachments/status | read:attachment | 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:attachment | 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:attachment | 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:attachment | Promove a Supplier. Rebaixar para Internal aceita só manage:attachment. Estado por rota própria, nunca por PUT | 404 · 409 · 422 |
| POST /attachments/{id}/revoke | revoke:attachment | Revoga. Libera a referência só com o dono em rascunho. Motivo obrigatório fora dele | 404 · 409 · 422 |
| POST /attachments/copy | manage:attachment | 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:attachment | 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:attachment | 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:attachment | O registro, do jeito que a tela precisa: tipos aceitos, limites, se aceita visibilidade de fornecedor, para onde propaga | — |
| POST /portal/rfq/{token}/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:attachment | 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-type | 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-type | 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 ou Scanning. A resposta lista quais, para a tela dizer o que falta |
| ATTACHMENT_FILE_NOT_AVAILABLE_CONFLICT | 409 | Baixar ou propagar arquivo que ainda não passou pelo antivírus. É 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.
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.
ownerType sem descritor derruba a inicializaçãoFalha 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.
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.
companyId é derivado do dono, e fora do escopo a resposta é 404Derivado 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.
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.
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.
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:attachment, 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.
supplierId, não visibilidadeO 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.
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.
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.
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.
Infected revoga o anexo automaticamenteCom 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.
supplierId e nasce InternalsupplierId 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.
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.
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 art. 173 | Cinco anos de prazo decadencial para a Fazenda constituir o crédito | O 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. 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.