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:files 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 nunca submetido libera o arquivo; fora dele, 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 dele, quem decide a morte do arquivo é o fim da guarda, contado do fechamento do dono — ou a eliminação a pedido de titular —, nunca 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. Na v1 a tela acompanha o veredito e a pessoa envia quando o arquivo libera; nenhum processo em segundo plano submete documento em nome de ninguém. Ver § Scanning.
Anexo de origem portal se amarra ao cadastro de fornecedor. supplierId é obrigatório e gravado denormalizado; portalUserId é opcional, porque o fornecedor pode entrar no portal sem conta — a forma de acesso é do Portal do Fornecedor. 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. O Attachment é gravado na reserva do upload e aparece na confirmação, quando o Nexio declara ao GrydFiles, no processo, a referência esperada — a quem o arquivo vai pertencer. Não cria FileReference: a liberação cria a referência na transação do GrydFiles, e o veredito chega ao Nexio depois do commit, pelo contrato registrado. 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 |
| Um objeto por upload | GrydFiles | Cada upload é um registro e um objeto próprios. Dois anexos só compartilham arquivo por propagação, que é ato do Nexio |
| Contagem de referências e expurgo | GrydFiles | Declara a referência esperada, cria a referência da cópia e libera referências — sempre depois do próprio commit (RN-ATT-35). Nunca apaga arquivo fora da eliminação a pedido de titular — não existe rota de exclusão do outro lado |
| Quanto tempo guardar | Nexio decide, GrydFiles executa | Declara retentionMode e retainUntil na reserva do upload, resolvidos do AttachmentType; no fechamento do dono estende a data; no fim da guarda libera a referência. Guarda legal e eliminação são atos do Nexio, que chegam ao arquivo como legalHold e expurgo |
| Quem pode ver este documento | Nexio, antes de pedir a URL | A decisão é visibility + escopo + CanRead do dono. Os hosts do Nexio não expõem /files/* — asserção 8 da Arquitetura, a lista branca de endpoints. read:files é permissão da rota HTTP do GrydFiles, que o Nexio não expõe; em processo a IFileService não confere permissão de módulo e o GrydFiles não define identidade de serviço — vale o consumidor registrado para o escopo nexio.attachment, o ato e o autor declarado marcado (contrato em processo do GrydFiles, RN-GF-28). Não ir a papel de tenant é requisito do catálogo de permissões da plataforma, na demanda — não é o que a asserção 8 verifica |
| 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. Pelo contrato de veredito do GrydFiles: o Nexio registra uma implementação de IFileOutcomeHandler para o escopo nexio.attachment. A transição de veredito é uma transação do GrydFiles — estado, sha256, a FileReference da referência esperada e o registro do veredito a entregar, gravados juntos —, e o veredito chega ao handler depois do commit, pelo menos uma vez. Não existe unidade de trabalho que atravesse os dois módulos: cada um tem o seu DbContext. A regra é a de todo o produto: O efeito que precisa ser atômico com o ato acontece na mesma transação — pelo próprio caso de uso, por chamada síncrona declarada entre contextos (.Contracts) ou pelo contrato de desfecho do motor; o evento de domínio é publicado depois do commit e serve para notificar, medir e integrar — nunca para produzir efeito de que o ato dependa. O veredito é verdade sem o Nexio; o que o Nexio faz com ele — revogar, avisar, destravar a tela — é reação que pode chegar depois, e nenhuma decisão do produto depende de ela já ter chegado (RN-ATT-19, RN-ATT-27).
O que o handler exige e faz, e é a lista inteira. É idempotente; decide pelo estado atual do arquivo, relido pela IFileService, e não pelo que a mensagem diz — duas entregas fora de ordem terminam no mesmo lugar; roda com o tenant do arquivo restaurado e, sem ele, falha fechado; anexo que não existe, ou cujo storedFileId não é o do arquivo, é desfecho normal — libera a referência, se houver, e confirma a entrega; anexo ainda em Reserved — a confirmação deu certo no GrydFiles e a gravação de Expected falhou — é tratado como Expected, porque o veredito só existe se a confirmação aconteceu (RN-ATT-36). Efeito que falha volta na próxima entrega; o veredito nunca é desfeito. Por desfecho: em Available, grava usableAt e fileReferenceStatus = Active — e, se o anexo foi revogado enquanto esperava, aplica à referência recém-criada a regra da revogação (RN-ATT-17); em Infected, revoga com revocationSource = FileInfected e avisa (RN-ATT-20) — se o arquivo já tinha sido liberado, o efeito segue o estado do dono (RN-ATT-37); na reprovação (Purged com rejectionCode), revoga com revocationSource = FileRejected, copia o rejectionCode e avisa; em Failed, não revoga nada — avisa quem anexou, ou o ResponsibleOf, que o arquivo precisa de nova verificação ou de remoção, uma vez por episódio.
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. Os hosts do Nexio não expõem as rotas /files/* — é a asserção 8 da Arquitetura, a lista branca de endpoints, verificada no CI. read:files, upload:files, retain:files e purge:files não vão a papel de tenant — são permissões das rotas HTTP do GrydFiles, e não ir a papel de tenant é requisito do catálogo de permissões da plataforma, registrado na demanda; a asserção 8 não o verifica. Em processo, a IFileService não confere permissão de módulo e o GrydFiles não define identidade de serviço; vale o consumidor registrado para o escopo, o ato e o autor declarado marcado. admin:system só de pessoa autenticada, só onde a spec o nomeia. É a frase única do contrato em processo (RN-GF-28), e ela vale igual aqui, na página do GrydFiles e na demanda de plataforma. Quem verifica que só o consumidor registrado resolve a porta do escopo nexio.attachment é a asserção 10 da Arquitetura, Quem resolve a porta de cada escopo é o consumidor declarado, em duas metades: teste estático entre assemblies e verificação em execução sobre o contêiner montado. Aqui o escopo é nexio.attachment, que nenhum outro assembly usa; o ato é o que a chamada declara — eliminação, guarda —; e o autor declarado vai marcado como declarado na trilha, ao lado do executor de serviço. As permissões de arquivo ficam na policy das rotas HTTP (constatação no código do Gryd.IO, FilesController.cs:242 @e4eb5a4f) e não são o que autoriza o Nexio. A SPA do Nexio fala só com /attachments, que é onde a autorização de negócio acontece. Com /files/{id}/download-url ao alcance do navegador, quem tivesse a permissão de módulo leria o comprovante bancário sem passar pelo CanRead do dono; com /files/{id}/references, prenderia arquivo alheio ou impediria a eliminaçã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 e CanWrite, confere que o tipo é aceito naquele dono e os limites, resolve a retenção, reserva o arquivo pela IFileService com o perfil nexio.attachment e grava o anexo em fileReferenceStatus = Reserved, com dono, tipo, retenção e quem reservou. A recusa cara acontece antes de gastar rede. A reserva não aparece na lista; se a gravação falhar depois de reservar, o arquivo vence sozinho no GrydFiles.
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 só aceita o fileId de uma reserva feita pelo mesmo usuário, no mesmo tenant, para o mesmo dono e o mesmo tipo — fora disso, ATTACHMENT_UPLOAD_NOT_RESERVED_CONFLICT (RN-ATT-36). Reconfere o CanWrite do dono, que pode ter mudado desde a reserva — documento que não aceita mais escrita responde ATTACHMENT_OWNER_NOT_WRITABLE_CONFLICT e nada é confirmado. Então confirma pela IFileService, declarando a referência esperada ownerScope = "nexio.attachment" · ownerKey = attachment.id — no processo, nunca por rota HTTP, que não aceita dono —, e grava Expected. Nenhuma FileReference é criada aqui. O anexo passa a aparecer na lista com o estado real; o que ele ainda não é, é referenciável, baixável ou propagável. A resposta volta com o arquivo em Scanning. Repetir a chamada repete a confirmação e devolve o mesmo anexo; reprovação na confirmação — tamanho divergente, reserva vencida — revoga o anexo com FileRejected e repassa o erro do GrydFiles.
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 da referência esperada na própria transação e, depois do commit, entrega o veredito ao Nexio. 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 com rejectionCode) chegam pelo mesmo contrato, sem referência nenhuma para desfazer: o anexo é descartado — revogado com FileInfected ou FileRejected, sem direito a arquivo —, e a tela e o aviso dizem o que aconteceu. Failed não descarta: pede nova verificação ou remoção.
O que a inversão compra. Criar a referência no passo 3, sobre um arquivo em Scanning, tornaria falsa a invariante central do módulo de arquivo — a que diz que nada é referenciável antes do veredito. E as duas saídas negativas ficariam inconsistentes por construção: a reprovação por tipo apagaria um objeto com referência ativa apontando para ele, e Infected deixaria uma referência viva apontando para blob em quarentena.
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. Criar o vínculo cedo é decisão de produto; criar a FileReference cedo seria violar a regra de quem guarda o arquivo. As duas estão separadas: o Attachment aparece 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 deixa de ser uma promessa sobre um arquivo que ainda pode 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 só escreve nele pelas chamadas em processo do próprio módulo — referência, retenção, guarda legal e expurgo —, sempre depois do próprio commit (RN-ATT-35).
(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. 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.
Quatro tabelas: core.attachments, core.attachment_types, core.attachment_legal_holds e core.attachment_erasure_requests. Í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), que só precisa cobrir as linhas com supplierId preenchido — índice parcial é exemplo de implementação, e um índice comum serve igual —, 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. O requisito é esse, e a verificação vale na aplicação; como o banco o garante — dois índices parciais, ou um único que trate nulos como iguais — é exemplo de implementação, e a escolha do banco é da Arquitetura. 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 · lastKnownOwnerStateAt | enum · DateTime | gerado | Draft · InProgress · Closed — o último estado do dono que o Anexo conhece, e o instante da transição que o produziu. Na criação vem do StateOf, com a hora da leitura; depois, do evento OwnerStateChanged que o dono publica depois do commit e o Anexo consome (RN-ATT-28) — o dono nunca escreve nesta tabela. Evento com occurredAt anterior ao gravado é ignorado. 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 —, e porque é o occurredAt do Closed que conta a guarda (RN-ATT-31). |
| 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: nulo é a resposta do CompanyOf dos donos que são do tenant e não de uma empresa — item, fornecedor, qualificação do fornecedor, proposta de alteração bancária e a instância de aprovação cujo alvo é um deles. 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? | opc | Opcional mesmo em anexo do portal: o fornecedor pode entrar no portal sem PortalUser cadastrado, pela forma de acesso que o Portal do Fornecedor define. Ver § Portal. |
| supplierId | Guid? | cond | Obrigatório quando uploadedByKind = PortalUser, resolvido pelo acesso do portal — autenticação e sessão são do Portal do Fornecedor — 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. Não é base de retenção: o piso retainUntil vai ao GrydFiles na reserva, contado da data da reserva (RN-ATT-08), e a guarda conta do fechamento do dono (RN-ATT-31). |
| retentionMode · retentionYears | enum · int? | gerado | Copiados do AttachmentType no upload e imutáveis. É o que o fechamento do dono usa para calcular o fim da guarda — mudar o tipo depois não alcança o anexo (RN-ATT-08). |
| guardUntil | date? | gerado | O fim da guarda: 31/12 do ano do fechamento do dono + retentionYears, gravado quando o dono vai a Closed; em UntilReleased, a própria data do fechamento; nulo em Permanent e enquanto o dono não fecha. Volta a nulo se o dono sair de Closed antes de a referência ser liberada. Ver RN-ATT-31. |
| fileRetainUntil | date? | gerado | O retainUntil que o GrydFiles confirmou para o arquivo deste anexo. Menor que guardUntil é extensão pendente, que o job repete (RN-ATT-35) — enquanto isso a referência mantida continua protegendo o arquivo. |
Veredito e revogação
| Campo | Tipo | Regra | |
|---|---|---|---|
| usableAt | DateTime? | gerado | Quando o veredito Available foi aplicado a este anexo — na cópia, quando a referência dela foi criada. É o "utilizável" do contrato de veredito, e nunca volta a nulo: separa o arquivo que foi prova e depois o re-scan achou infectado (RN-ATT-37) do que nunca chegou a ser. |
| 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 nas revogações de sistema: OwnerDiscarded, OrphanSweep, FileInfected e FileRejected. |
| 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 · FileRejected · Erasure. Separa o que uma pessoa decidiu do que o sistema concluiu — e é o que a tela usa para escrever a frase certa. FileRejected é a reprovação do arquivo pelo GrydFiles, na confirmação ou no scan. |
| rejectionCode | string(80)? | gerado | Só com revocationSource = FileRejected: o código do catálogo de erros do GrydFiles que reprovou o arquivo — FILE_CONTENT_TYPE_NOT_ALLOWED_UNPROCESSABLE, FILE_SIZE_MISMATCH_UNPROCESSABLE, FILE_OBJECT_CHANGED_UNPROCESSABLE, FILE_UPLOAD_EXPIRED_CONFLICT. Copiado, não traduzido: é o que a tela mostra e o suporte procura. |
| fileReferenceStatus | enum | gerado | Onde está a FileReference deste anexo, sem consultar o outro módulo. Reserved — reserva do upload, ainda não confirmada — ou confirmada no GrydFiles sem a gravação chegar, que o handler e o job reconhecem (RN-ATT-36) —, fora da lista · Expected — confirmada com a referência esperada, à espera do veredito · CreatePending — cópia gravada, referência ainda não criada · Active — a referência existe e segura o arquivo · ReleasePending — o Nexio decidiu liberar e o GrydFiles ainda não confirmou · Released — liberada · NeverCreated — veredito negativo antes da liberação, ou reserva que o GrydFiles nunca confirmou. A referência sai em seis casos, e só neles: revogação com o dono em rascunho nunca submetido, descarte do dono, órfão cujo último estado era rascunho, fim da guarda sem guarda legal ativa, eliminação e — pela plataforma, não pelo Nexio — arquivo liberado que o re-scan achou infectado. Ver § Revogação. |
| fileSyncAttemptedAt · fileSyncError | DateTime? · string(80)? | gerado | A última tentativa de uma escrita pendente no GrydFiles e o código do erro que ela recebeu. Nulos sem pendência. É o que a tela de saúde ordena por idade — pendência nunca some em silêncio (RN-ATT-35). |
| purgedAt | DateTime? | gerado | Só na eliminação: quando o GrydFiles confirmou o expurgo. Nulo com revocationSource = Erasure é expurgo pendente, que o job de guarda e a repetição do ato voltam a pedir (RN-ATT-33). |
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, alonga a retenção dos anexos novos ou desativa o que o produto entregou. A sobreposição só alonga: a retenção do global é o piso da linha do tenant (RN-ATT-25). Para cada code, vale a linha do tenant quando existe; senão, a global. A linha global nunca muda para ninguém; o release que a altere não alcança o rótulo nem a visibilidade de quem a sobrepôs, e, se alongar a retenção, a reserva do upload aplica a mais longa das duas. 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.
| 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. |
| isControlEvidence | bool | obrig | A marca de prova de controle: o arquivo sustenta um controle antifraude ou uma decisão de aprovação, e eliminá-lo a pedido de titular exige segunda pessoa (RN-ATT-38) — como exige o de tipo com retenção Permanent, marcado ou não. Na semente, verdadeira em BANK_OWNERSHIP_PROOF, SIGNED_CONTACT_CHANGE_REQUEST, IDENTITY_CORRECTION_PROOF e APPROVAL_EVIDENCE. Só liga: a sobreposição de um global não a desliga (RN-ATT-25), e desligá-la em tipo do tenant já usado é recusado (ATTACHMENT_TYPE_IN_USE_CONFLICT). |
| 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. Conta duas vezes: na reserva do upload é o piso declarado ao GrydFiles, retainUntil = data da reserva + retentionYears; no fechamento do dono é a guarda, 31/12 do ano 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. A semente usa 5. Ver § Retenção. |
| 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 | RetainUntil · 5 anos | Internal | O anexo solto, e o tipo mais escolhido. Guarda cinco anos depois do fechamento, como os de prova: o que alguém guardou como "Geral" costuma ser justamente o que a auditoria pede |
| TECHNICAL_SPEC | RetainUntil · 5 anos | 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 | Internal | DANFE e o documento fiscal que só chega em PDF — nota de serviço municipal, cupom. O XML da NF-e não é anexo: é o documento fiscal, com chave de acesso, emitente e protocolo, e é entidade da Invoice pela decisão 1. Boleto e comprovante de pagamento não entram — pagamento é do ERP. Cinco anos é o prazo de decadência do CTN, contado do fechamento. Guarda legal não é do tipo: é ato por caso (RN-ATT-32) |
| 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, e por isso é prova de controle (isControlEvidence) |
| BANK_OWNERSHIP_PROOF | RetainUntil · 5 anos | Internal | O comprovante de titularidade da conta bancária — documento do banco que mostra o titular e o documento dele. Só em BankAccountChangeProposal, com allowsSupplierVisibility = false e propagates = false. É prova de controle antifraude (isControlEvidence) e guarda cinco anos do fechamento da proposta. A comparação do titular com o fornecedor, e a recusa de titular diferente, são do Fornecedor |
| SIGNED_CONTACT_CHANGE_REQUEST | RetainUntil · 5 anos | Internal | O pedido do fornecedor para trocar e-mail ou telefone de contato de aviso ou da cadeia de reserva quando não há telefone confiável para o callback. Só em BankAccountChangeProposal de contato, com allowsSupplierVisibility = false, propagates = false e isControlEvidence — mesma retenção e mesma guarda do comprovante de titularidade. Quando ele é exigido é do Fornecedor; qual documento serve é da norma da empresa. O Nexio exige que ele exista, seja do tipo declarado e tenha veredito liberado, e grava quem anexou, quem decidiu, data e hora — e não confere assinatura digital, selo de cartório, apostila nem poderes do signatário |
| IDENTITY_CORRECTION_PROOF | RetainUntil · 5 anos | Internal | O documento comprobatório da correção de identidade de fornecedor já aprovado — CPF, CPF do titular ou natureza que nasceram errados. Só em Supplier, com allowsSupplierVisibility = false, propagates = false e isControlEvidence — mesma retenção e mesma guarda do comprovante de titularidade. Existe pelo mesmo motivo do pedido assinado: sem um tipo marcado como prova de controle, o documento que sustenta a correção cairia em GENERAL e uma pessoa sozinha poderia eliminá-lo. Quando ele é exigido é do Fornecedor; qual documento serve é da norma da empresa, e o Nexio exige a existência, o tipo e o veredito, e grava o rastro |
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 doze 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: doze 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, e só dos módulos presentes. Cada módulo dono registra, na inicialização, os valores de ownerType que são dele e o descritor de cada um. Todo valor registrado precisa ser valor do enum e ter um descritor completo — as doze respostas, nenhuma "não implementada" —, e o que falta derruba a aplicação no startup, não em produção às três da manhã. Valor do enum cujo módulo ainda não existe não é exigido: a base sobe com os donos que tem, sem stub, e o valor sem módulo não aceita anexo. É verificável: percorre-se o que os módulos registraram e pergunta-se ao contêiner (RN-ATT-03).
| 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. Draft é só o documento que nunca foi submetido — o que voltou para edição depois de passar por alguém é InProgress, porque alguém já pode ter decidido olhando para os anexos dele. Closed é o documento que não aceita mais movimento; se o dono tiver saída desse estado, a resposta volta a InProgress. Decide se a revogação libera a referência e de quando conta o fim da guarda (RN-ATT-17, RN-ATT-31) |
| 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 |
| CanRead(user, ownerId) | Delega ao dono a autorização de leitura. Quem pode ler o documento no nível de detalhe que o anexo revela pode ler os anexos dele — read:attachments e o escopo por empresa são necessários e não bastam, como na escrita. Existe porque o arquivo carrega o que a tela do dono esconde: a planilha de premissas carrega os valores do orçamento, o comprovante carrega a conta sem máscara. Exigida em toda leitura (RN-ATT-34); as respostas estão na matriz de leitura |
| OpenApprovalOf(ownerId) | 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 outras perguntas sabe 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 |
| PendingControlOf(ownerId) | Nulo, ou a decisão de controle pendente fora da instância aberta daquele documento: devolução à espera de reenvio, lote de carga inicial por confirmar, confirmação de segunda pessoa, carência de alteração já aplicada, contestação sem desfecho, correção de identidade em curso. Existe porque a eliminação a pedido de titular espera toda decisão de controle terminar, e OpenApprovalOf só enxerga a instância do motor — o comprovante de uma carga inicial ainda por confirmar não tem instância nenhuma (RN-ATT-33, RN-ATT-39). A resposta bloqueia; nunca é mostrada a quem pede a eliminação |
| ResponsibleOf(ownerId) | 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, depois do commit, OwnerDiscarded(ownerType, ownerId) quando um rascunho é descartado. O handler — idempotente, com o tenant do evento restaurado e falha fechada sem ele — revoga os anexos daquele dono com revocationSource = OwnerDiscarded e libera as FileReference depois do próprio commit (RN-ATT-35), porque descarte de rascunho é exatamente o caso em que o arquivo não precisa sobreviver. Anexo ainda sem veredito tem a referência esperada cancelada, e a liberação que chegar depois encontra o anexo revogado e solta a referência. É 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 registrado e pergunta ao descritor se o dono existe. Anexo de ownerType sem módulo presente não é varrido nem revogado — é contado no relatório; ausência de módulo não é ausência de dono. Ausente, o anexo é revogado com revocationSource = OrphanSweep; a referência é liberada apenas se lastKnownOwnerState = Draft — o último estado que o Anexo conheceu pelo evento do dono (RN-ATT-28), porque a essa altura não há mais a quem perguntar. O job roda por tenant, com o tenantId no payload. 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, que é onde o desenho chega a quem precisa cotá-lo |
| QuotationRequest | PurchaseOrder | Edital, planilha modelo, condições. Tipicamente Supplier |
| QuotationResponse | PurchaseOrder | Onde o anexo do portal cai. supplierId obrigatório, nasce Internal e nunca vira Supplier: seria a proposta de um concorrente legível aos outros (RN-ATT-12) |
| 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 e evidência de divergência. O XML da NF-e é entidade da própria Invoice — tem chave de acesso, emitente e protocolo para digitar e filtrar —, e boleto e comprovante de pagamento não são do Nexio. Retenção fiscal de cinco anos, contada do fechamento |
| Contract | PurchaseOrder | O contrato assinado é Permanent. Versionamento é do Contract, não daqui |
| QuotationRequestItem | PurchaseOrderItem | A linha da cotação, que é onde o desenho por item de fato serve — o fornecedor cota aquele item |
| ApprovalRequest | — | 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. É o uso principal do tipo semeado APPROVAL_EVIDENCE |
| Supplier | — | 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. E o documento comprobatório da correção de identidade (IDENTITY_CORRECTION_PROOF), que é prova de controle: o ato grava o anexo, quem anexou, quem decidiu, data e hora |
| Item | — | 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 |
| SupplierQualification | — | A evidência de um ciclo de homologação: questionário respondido, relatório de visita, plano de ação de ressalva. Cada ciclo é um registro próprio — a prova de que o fornecedor foi homologado neste ciclo não pode depender do ciclo seguinte. Certidão continua sendo SupplierDocument |
| Budget | — | Planilha de premissas e memória de cálculo do exercício. Suplementação, remanejamento e redução têm dono próprio — a ata e o ofício que os justificam são do BudgetChangeRequest |
| BankAccountChangeProposal | — | O comprovante de titularidade da conta nova (BANK_OWNERSHIP_PROOF), anexado com a proposta ainda em rascunho — e, na troca de contato sem telefone confiável, o pedido assinado do fornecedor (SIGNED_CONTACT_CHANGE_REQUEST), do mesmo jeito: a submissão exige o veredito Available, e depois dela a proposta não aceita anexo nem revogação; na carga inicial, a proposta do lote aceita anexo até a confirmação do lote, que pergunta pelos comprovantes (RN-ATT-19). É 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 dois são prova de controle: enquanto a janela de contestação do fornecedor não fecha, eliminá-los apagaria a prova da fraude que ele ainda vai contestar (RN-ATT-38) |
| BudgetChangeRequest | — | A justificativa da suplementação, do remanejamento ou da redução: ata, ofício, planilha. É a solicitação que passa pelo motor e muda a verba; o Budget guarda a prova do exercício aprovado |
Por que os documentos ainda não especificados estão na lista. Não é custo de migração — o enum é texto. É para que a propagação e os tipos permitidos sejam escritos desde já com o nome certo. Estar na lista não é ser dono: até a spec do documento existir e o módulo registrar o descritor, o valor não aceita anexo (ATTACHMENT_OWNER_TYPE_UNKNOWN).
As doze respostas dos donos com spec
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 · prévia | ApprovalRequest | Supplier | Item | Budget | BankAccountChangeProposal | SupplierQualification | BudgetChangeRequest |
|---|---|---|---|---|---|---|---|---|
| CompanyOf | A empresa da cotação pai | Condicional: a empresa do documento alvo quando ele tem uma (requisição, pedido, orçamento, solicitação de revisão); nula quando o alvo é do tenant (fornecedor, qualificação, proposta bancária, item) | Nula — cadastro do tenant | Nula — vocabulário do tenant | A empresa do orçamento | Nula — a proposta é do fornecedor, e fornecedor é do tenant | Nula — a homologação é do fornecedor, que é do tenant | A empresa do orçamento que ela altera |
| StateOf | Segue a cotação pai: Draft enquanto a RFQ nunca foi enviada, InProgress depois de enviada, Closed na adjudicação ou no cancelamento | InProgress em InApproval e Returned; Closed em Approved, Rejected e Cancelled; a reavaliação que reabre Approved volta a InProgress. Nunca Draft — a instância nasce submetida | Draft no cadastro nunca submetido — o único que aceita DELETE; InProgress em todo o resto, ativo, bloqueado ou inativo. Nunca Closed: cadastro não fecha e a inativação tem volta, então o anexo do cadastro vive enquanto o fornecedor existe | Draft no item nunca submetido; InProgress em PendingApproval, Approved, Rejected e no rascunho que voltou depois de rejeitado, em qualquer fase do ciclo de vida. Nunca Closed — fase é configuração do tenant e tem volta | Draft antes da primeira submissão; InProgress em InReview, Approved, Closed (ainda aceita realizado tardio) e no Draft que voltou por devolução; Closed em Archived. Nunca Missing | Draft até a submissão — é quando recebe o comprovante; InProgress da submissão (Open) até o desfecho — o fim da instância BANK_ACCOUNT_CHANGE ou, em ChainContact confirmado por segunda pessoa, sem instância, a confirmação ou a recusa dela; Closed depois: aplicada, rejeitada, recusada ou cancelada, inclusive por contestação. Na carga inicial a proposta nasce Open com o envio do lote: InProgress até o lote ser confirmado ou cancelado, e Closed depois | Draft antes de submeter; InProgress com a instância SUPPLIER aberta ou devolvida; Closed quando a instância termina. O ciclo seguinte é outro registro | Draft nunca submetida; InProgress em PendingApproval e devolvida; Closed em Approved, Rejected e Cancelled |
| 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 | O fornecedor mais a data do ciclo — razão social · homologação de 03/2026 | O número da solicitação mais o exercício |
| AllowedTypes | TECHNICAL_SPEC · GENERAL | APPROVAL_EVIDENCE · GENERAL | IDENTITY_CORRECTION_PROOF · GENERAL na semente | TECHNICAL_SPEC · GENERAL | APPROVAL_EVIDENCE · GENERAL | Por changeKind: BankAccount → BANK_OWNERSHIP_PROOF · GENERAL; NoticeContact e ChainContact → SIGNED_CONTACT_CHANGE_REQUEST · 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 | Não — parecer de homologação é interno | Não — orçamento não sai da empresa |
| PropagatesTo | PurchaseOrderItem | — | — | — | — | — | — | — |
| MaxCount · MaxTotalBytes | 5 · 25 MB | 10 · 50 MB | 20 · 100 MB | 10 · 50 MB | 10 · 50 MB | 3 · 15 MB | 20 · 100 MB | 10 · 50 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 | Só em Draft, pelo proponente — e, na carga inicial, com a proposta Open e o lote ainda por confirmar, por quem enviou o lote; depois da submissão ou da confirmação, ninguém: a proposta é imutável, e o dataHash cobre o comprovante | Quem conduz a qualificação, enquanto o ciclo não fecha | Quem pode editar a solicitação |
| CanRead | Declarado por dono na matriz de leitura, em § Visibilidade — uma resposta por linha, com o caso que ela fecha | |||||||
| 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 do processo BUDGET daquele exercício, ou nulo | A instância aberta da alteração bancária, ou nulo | A instância aberta do processo SUPPLIER, ou nulo | A instância aberta do processo BUDGET_CHANGE, ou nulo |
| PendingControlOf | Delega à cotação pai | Returned, à espera de reenvio; nulo no resto — em InApproval responde o OpenApprovalOf reflexivo | pendingIdentityCorrection preenchido — correção de identidade em curso, com ou sem a instância aberta —, ou alerta de conformidade ErpBankDivergence em Open; nulo no resto | Nulo — a decisão de controle do item é a instância, que o OpenApprovalOf já responde | O Draft que voltou por devolução, à espera de reenvio; nulo no resto | Proposta da carga inicial em Open, com o lote por confirmar; ChainContact em Open à espera da segunda pessoa; alteração aplicada ainda em carência (usableFrom no futuro — conta, ou contato pela instância com documento comprobatório); janela de contestação ainda aberta — o token de contestação entregue ao fornecedor continua válido, inclusive depois de usableFrom; contestação registrada ainda sem desfecho no Fornecedor. Nulo no resto | Devolvida, à espera de reenvio; nulo no resto | Devolvida, à espera de reenvio; nulo no resto — em PendingApproval responde o OpenApprovalOf |
| 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 proponente — na carga inicial, quem enviou o lote. O portal não cria proposta | Quem submeteu a qualificação | Quem criou a solicitação |
Sobre o AllowedTypes: na semente, escolher o tipo não muda quanto tempo a prova dura. Os onze tipos semeados guardam cinco anos depois do fechamento do dono — GENERAL inclusive —, menos SIGNED_CONTRACT, que é Permanent. O tipo decide classificação, visibilidade e propagação; retenção diferente só existe em tipo do tenant, e a sobreposição de um global só alonga (RN-ATT-25). Budget e BudgetChangeRequest aceitam APPROVAL_EVIDENCE para que a planilha de premissas do exercício e a ata da revisão apareçam classificadas como prova quando a auditoria as pedir; o comprovante de titularidade e o pedido assinado de troca de contato têm tipo próprio, BANK_OWNERSHIP_PROOF e SIGNED_CONTACT_CHANGE_REQUEST, porque é o tipo que a submissão da proposta confere.
E onde a semente não chega, ela não deve chegar. Os onze 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
As duas são prova 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 e a planilha que justificam uma suplementação são parte da solicitação de 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 BudgetChangeRequest e são anexadas com o dono em rascunho. A proposta bancária nasce em Draft, recebe o comprovante (BANK_OWNERSHIP_PROOF) e só é submetida quando ele tem veredito Available — sem ele o Fornecedor recusa a submissão, e por baixo vale a decisão 8; na carga inicial, o comprovante é anexado à proposta que o lote criou — em Open, aceitando anexo até a confirmação —, e a confirmação do lote pergunta ao Anexo pelos comprovantes como a submissão pergunta, e o Fornecedor decide: Failed, Infected, revogado ou eliminado é falta de evidência, com o código dele; sem falta, comprovante ainda em varredura (Pending) recusa com ATTACHMENT_SCAN_PENDING_CONFLICT (RN-ATT-19; RN-FOR-31 do Fornecedor). Submetida — ou confirmado o lote —, a proposta não aceita anexo novo nem revogação — nem a confirmação de reserva feita antes (RN-ATT-36): o aprovador decide sobre a prova que o proponente apresentou, e ela não muda debaixo da decisão.
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
A decisão 1 decide, e a spec de Fornecedor diz o mesmo. 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
Quatro donos com spec não têm empresa: Item e Supplier são vocabulário e cadastro do tenant, não da empresa, e SupplierQualification e BankAccountChangeProposal pendem 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 as empresas do escopo — e quem não tem empresa nenhuma no escopo não a vê. 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, orçamento e solicitação de revisão têm empresa; fornecedor, qualificação, proposta bancária e item não têm. O mesmo ownerType devolve Guid nos processos cujo alvo tem empresa e nulo nos outros, e isso é correto — o descritor pergunta ao alvo, não a uma tabela de de-para.
O OpenApprovalOf da instância é a própria instância
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.
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:attachments; 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:files libera pedir uma URL de download: é permissão da rota HTTP do GrydFiles, que os hosts do Nexio não expõem — em processo a IFileService não confere permissão de módulo e o GrydFiles não define identidade de serviço —, e não vai a papel de tenant — requisito do catálogo de permissões da plataforma, na demanda; o que a asserção 8 da Arquitetura verifica é que os hosts do Nexio não expõem /files/*. 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 autoria: o portal devolve ao fornecedor autenticado os anexos que ele mesmo subiu nas respostas dele (uploadedByKind = PortalUser e o supplierId dele), em qualquer visibilidade, mais os anexos Supplier do próprio QuotationRequest, que são para todos por definição. O que o comprador acrescentou à resposta de um fornecedor — mesmo com o supplierId preenchido — não volta ao portal, e anexo de resposta nunca vira Supplier. É uma comparação de coluna, não uma regra especial — e foi um dos três ganhos que motivaram a decisão 9.
A matriz de leitura · CanRead(user, ownerId)
Escopo por empresa e read:attachments dizem se a pessoa pode estar ali; o dono diz se ela pode ver o que o arquivo mostra. O arquivo não se recorta — a planilha de premissas traz todos os valores, o comprovante traz a conta inteira —, então a condição de leitura é a do nível de detalhe mais alto que o dono protege, não a da tela resumida. Toda leitura pergunta (RN-ATT-34); quem não passa recebe 404, e a listagem não traz a linha — nem o nome do arquivo, que também é dado. Todo dono que passa por aprovação inclui quem tem tarefa na instância dele, pendente ou decidida: é a evidência que sustenta a decisão.
| Tipo de dono | Lê o anexo, além de escopo e read:attachments | Não lê — e o que isso fecha |
|---|---|---|
| Budget | read:budget-amounts-all, ou tarefa na instância BUDGET daquele exercício | Quem tem só read:budgets, read:budget-states ou read:budget-amounts com recorte. A planilha de premissas não vaza valor para quem a tela do orçamento não mostra valor |
| BudgetChangeRequest | read:budget-amounts-all; read:budget-amounts cujo recorte cubra todas as linhas da solicitação; quem a criou; tarefa na instância BUDGET_CHANGE | Recorte que cobre só parte das linhas: a justificativa de um remanejamento fala das duas pontas |
| BankAccountChangeProposal | Quem tem read-unmasked:supplier-banks; o proponente, com a proposta em Draft ou Open — na carga inicial, quem enviou o lote —; quem tem tarefa ativa ou já decidida na instância daquela proposta; e, na carga inicial, para as propostas desse lote — antes da confirmação, o remetente e quem tem approve:supplier-banks com identidade verificada e não é o remetente — nem pelo usuário, nem pelo CPF; depois da confirmação, só o confirmador. É a lista da RN-FOR-36 do Fornecedor, copiada, e vale igual para os dois anexos da proposta — o comprovante de titularidade (BANK_OWNERSHIP_PROOF) e o pedido assinado (SIGNED_CONTACT_CHANGE_REQUEST) | Quem tem read:suppliers, ou read:supplier-banks com números mascarados; o proponente depois que a proposta termina; antes da confirmação do lote, quem tem approve:supplier-banks sem identidade verificada, e quem tem o CPF do remetente com outro usuário; depois dela, quem não confirmou aquele lote — o remetente inclusive. O comprovante de titularidade é do tenant (RN-ATT-30) e passa pelo escopo — não passa por aqui |
| Supplier · SupplierQualification | read:suppliers; na qualificação, também tarefa na instância SUPPLIER | Quem não abre o cadastro do fornecedor |
| Item | read:catalog | Quem não abre o catálogo |
| ApprovalRequest | Quem lê a instância — requisitante, quem tem tarefa nela, quem tem audit:approvals — e passa no CanRead do documento alvo | Quem lê a fila mas não o alvo: o parecer anexado à aprovação de um orçamento revela o orçamento |
| QuotationResponse | Ninguém do comprador antes do envio da proposta; depois dele, quem lê a cotação — e, em cotação com abertura sigilosa, só depois da abertura. No portal, só o que o próprio fornecedor subiu (RN-ATT-12) | O comprador que conduz a cotação, antes do envio ou da abertura. O anexo da proposta não quebra o sigilo que o preço tem. Regra mínima para a spec da Cotação |
| QuotationRequest · QuotationRequestItem | Quem lê a cotação | Regra mínima para a spec da Cotação |
| Requisition · RequisitionItem · PurchaseOrder · PurchaseOrderItem · Receipt · Invoice · Contract | Quem lê o documento, no nível de detalhe mais alto que a spec dele proteger | Regra mínima; cada spec declara a sua linha aqui ao nascer |
Os três casos que a matriz fecha, conferidos. (1) Pessoa com read:attachments e read:budgets, sem read:budget-amounts-all, pede a URL da planilha de premissas do orçamento: o escopo passa, o CanRead do Budget não — 404, e a linha some da lista. (2) Pessoa de qualquer empresa com read:attachments abre o comprovante bancário de um fornecedor: companyId nulo deixa passar o escopo, e só lê quem tem read-unmasked:supplier-banks, o proponente enquanto a proposta não termina, quem tem ou teve tarefa na instância, ou, na carga inicial e para aquele lote, quem tem approve:supplier-banks com identidade verificada e não é o remetente antes da confirmação e o confirmador depois — os demais recebem 404. (3) Comprador abre a resposta de um fornecedor antes do envio, ou antes da abertura numa cotação sigilosa: o CanRead do QuotationResponse é falso para todo usuário interno, e nem a cadeia de propagação mostra o elo.
| Quem | Internal | Supplier | Revogado |
|---|---|---|---|
Usuário com escopo na empresa do dono e CanRead do dono | Sim, com read:attachments | Sim | Só na aba de histórico, com marca |
Usuário com escopo, sem CanRead do dono | 404 | 404 | 404 |
| 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, o que ele mesmo subiu | Sim — nasce Internal e continua dele | Não se aplica: anexo de resposta nunca vira Supplier | Não |
| Fornecedor no portal, todo o resto | Não — inclusive o que o comprador anexou à resposta dele | Só os do documento pai, nunca o de outro fornecedor | Não |
Auditoria interna, com CanRead do dono | 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, criada depois do commit (RN-ATT-35).
Não copia bytes — é o mesmo objeto. Não acontece sozinha. Não atravessa empresa sem escopo nas duas. 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.
Entre empresas, só com escopo nas duas
A compra centralizada pode relacionar documentos de empresas diferentes do mesmo tenant; como a demanda de várias empresas se consolida é decisão da spec da Cotação. A propagação acompanha: o destino pode ser de outra empresa quando quem propaga lê a origem (escopo e CanRead do dono de origem) e escreve no destino (escopo e CanWrite do dono de destino), e o ownerType do destino está no PropagatesTo da origem. Faltando qualquer um, o documento responde 404, como todo documento fora de escopo. A cópia grava o companyId do destino e herda a visibilidade; a tela mostra, antes da confirmação, que o arquivo passa a outra empresa (RN-ATT-14).
Não é rateio. Cada documento continua de uma empresa só, e cada cópia responde ao escopo da empresa dela: quem enxerga só a empresa de destino vê a cópia, e não a origem.
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 nunca submetido (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 até o fim da guarda | Continua preso. A referência sai no fim da guarda contada do fechamento (RN-ATT-31), e não sai sob guarda legal |
| 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 ou reprovado, antes de liberado | Revogado com revocationSource = FileInfected ou FileRejected. A tela diz o que houve | Nunca existiu — nasceria na liberação, que não aconteceu. Nada a liberar | O infectado vai para a quarentena do GrydFiles; o reprovado é apagado, com lápide. Decisão dele |
| Arquivo liberado que o re-scan achou infectado | Revogado com revocationSource = FileInfected, em qualquer estado do dono, com o aviso que o estado pede (RN-ATT-37) | Liberada pelo GrydFiles, em todos os anexos do arquivo, na transação do re-scan | Vai para a quarentena, cujo expurgo respeita o retainUntil e o legalHold do arquivo. Não é eliminação |
| Eliminação a pedido de titular | Revogado com revocationSource = Erasure — ele e todas as cópias do mesmo arquivo —, com o dono em qualquer estado menos com aprovação em curso ou decisão de controle pendente, que bloqueiam a execução (RN-ATT-33). Prova de controle, arquivo Permanent ou anexo ainda sob guarda pede segunda pessoa (RN-ATT-38); o GrydFiles é consultado antes de gravar (RN-ATT-39) | Liberada em todos eles | Expurgo manual pedido na execução, como eliminação, com lápide (RN-ATT-33) |
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. Por isso a referência de documento que já passou por alguém só sai no fim da guarda, contado do fechamento — ou pela eliminação a pedido de titular, que é outro ato, com permissão própria.
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 quando o CanWrite do dono deixa — a proposta bancária submetida não deixa —, 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.
A guarda: piso no upload, fim contado do fechamento do dono
Na reserva do upload o Nexio resolve retentionMode e retainUntil do AttachmentType, envia ao GrydFiles pela IFileService e congela no anexo o modo e os anos. A data enviada é só o piso: o GrydFiles não expurga arquivo com referência ativa nem com retainUntil no futuro, e a referência é do Nexio.
O fim da guarda conta do fechamento do dono (RN-ATT-31). Quando o dono vai a Closed — o occurredAt do evento que o dono publica (RN-ATT-28) —, cada anexo dele com referência mantida, ativo ou revogado, e o que o re-scan tornou infectado depois de liberado, ganha guardUntil = 31/12 do ano do fechamento + retentionYears, e o retainUntil do arquivo é estendido até essa data no GrydFiles, depois do commit (RN-ATT-35) — e só anda para frente. A razão é aritmética e é fiscal: o prazo decadencial do art. 173, I do CTN começa no primeiro dia do exercício seguinte, e contar do upload deixaria o arquivo até dois anos curto num processo longo. O job diário attachment-guard-release libera a referência dos anexos cujo guardUntil passou, que não estão sob guarda legal e cujo dono, perguntado na hora, continua fechado; dali em diante o expurgo é regra do GrydFiles. Em UntilReleased — só em tipo do tenant — o fim da guarda é a data do fechamento; em Permanent não existe.
Dono que sai de Closed — a instância aprovada que a reavaliação reabre — zera o guardUntil dos anexos ainda referenciados, e o próximo fechamento recalcula. Dono que nunca fecha — cadastro de fornecedor e de item — mantém os anexos enquanto existir. Errar para mais custa armazenamento; errar para menos custa a prova. Encurtar guarda não é a rota de retenção, que recusa (FILE_RETENTION_SHORTENED_UNPROCESSABLE): é a eliminação a pedido de titular.
O arquivo compartilhado entre anexos, e o que isso muda
Dois anexos podem apontar para o mesmo storedFileId — por propagação. Cada cópia tem a sua referência e o seu guardUntil, contado do fechamento do dono dela, e as guardas podem divergir: o mesmo PDF numa requisição que fechou em 2026 e num pedido que fechou em 2027. Vale a mais longa, sem regra nova: o arquivo vive enquanto alguma referência existir, e retainUntil só anda para frente.
Guarda legal é ato por caso, nunca propriedade do tipo
Litígio, fiscalização e auditoria externa congelam um caso, não um tipo de documento. Guarda legal por tipo transformaria cinco anos em "nunca" para todo documento fiscal de todo cliente, e ninguém conseguiria encerrá-la. Por isso o AttachmentType não tem legalHold: quem tem create:attachment-legal-holds coloca a guarda sobre um anexo, ou sobre todos os anexos de um dono, com a referência do caso e o motivo. Cada colocação é uma linha de AttachmentLegalHold; retirar é outro ato, também com motivo, que encerra só aquela linha. Dois casos sobre o mesmo anexo são duas linhas, e encerrar um não solta o outro (RN-ATT-32). Colocar e retirar tomam a mesma trava do arquivo que a execução da eliminação toma — é o que impede a guarda de nascer no meio de uma eliminação, e a eliminação de correr por cima de uma guarda recém-posta (RN-ATT-39).
O efeito é a referência que não sai — e o legalHold do arquivo ligado. Com guarda ativa, o fim da guarda não libera a referência, a revogação em rascunho não a libera, e a eliminação é recusada (ATTACHMENT_UNDER_LEGAL_HOLD_CONFLICT). A referência mantida não basta sozinha: o re-scan que acha vírus num arquivo liberado libera todas as referências dele, e a quarentena tem prazo próprio. Por isso a primeira guarda ativa sobre qualquer anexo de um arquivo liga também o legalHold do StoredFile, com o caseReference como motivo e quem colocou como autor, e a retirada da última guarda ativa sobre os anexos daquele arquivo pede o desligamento — as duas chamadas depois do commit, idempotentes, com a pendência à vista em fileHoldAppliedAt e fileHoldReleasedAt (RN-ATT-35). O sinal do arquivo é um só; quem conta os casos é o Nexio. Desligar a guarda que o Nexio ligou é pedido do mesmo escopo, com quem retirou a guarda como autor declarado — também na repetição pelo job, que não tem usuário —, e não pede admin:system: é o requisito da RN-GF-27 do GrydFiles, registrado na demanda de plataforma. Enquanto ele não estiver entregue, a pendência fica aberta e o arquivo, preso — o erro possível é para mais. Colocar e retirar são atos de controle: registro isolado por tenant, gravado na transação do ato, e os eventos attachment-legal-hold.placed e attachment-legal-hold.lifted depois do commit. A linha da guarda não traz nome de arquivo nem conteúdo — gerir guarda não dá leitura de anexo —, e colocar, retirar e listar valem só no escopo da empresa do dono, com 404 fora dele.
Eliminação a pedido de titular (LGPD)
A LGPD preserva a guarda exigida por obrigação legal (art. 16) e manda eliminar o dado tratado sem base. O produto entrega as duas coisas. A resposta — quais anexos, em quais documentos, sob qual guarda — sai do índice por dono e por fornecedor. O ato é POST /attachments/{id}/erase, com permissão própria, erase:attachments: registra o pedido do titular e o executa — na hora, ou pela mão de uma segunda pessoa quando o arquivo é prova de controle ou ainda está sob guarda (RN-ATT-38) —, e é o único caminho que libera a referência de documento em curso ou fechado antes do fim da guarda (RN-ATT-33).
O ato exige a referência do pedido do titular, a data-limite de resposta a ele (dueAt) e um comentário com a avaliação de que não há base legal de guarda — a decisão jurídica é de quem tem a permissão, e fica registrada; o motivo é sempre DADO_PESSOAL_INDEVIDO. No registro, e de novo na execução, três condições recusam, e na recusa nada muda: algum dos anexos do tenant que apontam para o mesmo arquivo está sob guarda legal (ATTACHMENT_UNDER_LEGAL_HOLD_CONFLICT); o arquivo tem referência ativa de outro escopo além de nexio.attachment (ATTACHMENT_ERASURE_FILE_REFERENCED_CONFLICT — o documento de cadastro que aproveitou o arquivo é tratado no módulo dele; não há caminho automático entre consumidores, e na v1 o caso não ocorre, porque o Nexio é o único consumidor de escopo de anexo do GrydFiles — RN-GF-25); ou o GrydFiles não aceitaria o expurgo — guarda de qualquer detentor, inclusive a posta pela rota dele, sem escopo; estado do arquivo; e, na execução, em Permanent, dois autores distintos — no registro a consulta roda em modo registro, com um autor só —, conferido pela consulta de pré-condição em processo — que nomeia todos os pares do escopo nexio.attachment daquele arquivo, o original e as cópias — antes de qualquer gravação (RN-ATT-39). Três condições bloqueiam, e nenhuma delas recusa o registro. Duas são do dono: algum desses donos tem aprovação em curso (OpenApprovalOf diferente de nulo) ou decisão de controle pendente fora da instância (PendingControlOf diferente de nulo — lote de carga inicial por confirmar, contato à espera da segunda pessoa, carência, janela de contestação ainda aberta ou contestação sem desfecho, alerta de divergência com o ERP em aberto, devolução à espera de reenvio, correção de identidade em curso), qualquer que seja o CanWrite. A terceira é de alcance: quem registra tem escopo e CanRead no anexo em que agiu — sem isso a rota responde 404 —, mas não em todos os donos dos outros anexos do mesmo arquivo. Nesse caso o pedido também é registrado, bloqueado. Recusar era responder ao exercício de um direito com silêncio: nada gravado, nenhum prazo correndo, ninguém avisado, nenhuma saída declarada. Registrado, o pedido nasce Requested, o dueAt corre desde o registro, e o bloqueio nomeia o alcance que falta — as empresas fora do escopo e os tipos de dono cuja leitura foi negada, nunca o número nem o nome do documento —, para quem for resolver saber o que conceder. O aviso sai a quem registrou e ao titular, e o administrador do tenant é escalado no mesmo ato, porque conceder escopo é dele: enquanto ninguém tiver o alcance inteiro, não há quem execute. A quem registrou a resposta diz apenas que o pedido ficou bloqueado por alcance incompleto — que o arquivo tem anexo fora do alcance dele é o mínimo para ele não repetir o pedido, e é tudo o que ele fica sabendo. Na execução as três recusam: ATTACHMENT_ERASURE_APPROVAL_IN_PROGRESS_CONFLICT, ATTACHMENT_ERASURE_CONTROL_DECISION_PENDING_CONFLICT e ATTACHMENT_ERASURE_SCOPE_INCOMPLETE_FORBIDDEN — quem executa precisa do alcance inteiro, e é aí que a eliminação parcial é impedida: ela não elimina nada, porque o arquivo continuaria preso pela cópia que ficou. Eliminar a prova debaixo de uma decisão aberta tiraria de quem decide o que ele precisa olhar; a eliminação espera a decisão terminar, e o desfecho que depende da prova é recusado do lado do dono — o Fornecedor, para o comprovante bancário. E revogar, trocar o nome e publicar a eliminação de um arquivo que o GrydFiles vai recusar daria o dado por eliminado com os bytes guardados para sempre — por isso a consulta vem antes. Executada, numa transação do Nexio, com o arquivo travado — a mesma trava que a cópia e a guarda legal tomam — e cada dono travado como a submissão dele o trava (RN-ATT-39), todos esses anexos — o original e as cópias — são revogados com revocationSource = Erasure e marcados ReleasePending, originalFileName e description dão lugar a texto neutro — nome de arquivo também é dado pessoal — e o ato de controle é registrado com quem registrou e quem executou. Depois do commit (RN-ATT-35) sai attachment-erasure-request.executed — o ato foi executado, a eliminação não está concluída — e o job de guarda, sem usuário no contexto, libera as referências e pede o expurgo ao GrydFiles como eliminação (RN-GF-25), nomeando todos os pares do escopo nexio.attachment daquele arquivo — o original e as cópias, liberados na transação do expurgo —, com a marcação, o motivo, a referência do pedido e quem registrou e quem executou como autores declarados (RN-GF-27) — a lápide suprime o nome original. O evento attachment.erased só sai quando o GrydFiles grava purgedAt em todos esses pares: até lá o pedido fica Executed, com a pendência à vista no anexo, e só o job repete, com os autores gravados — nunca por usuário, que o GrydFiles recusaria por autor declarado diferente de quem está no contexto. Expurgo recusado — guarda de qualquer detentor, ou referência de outro escopo criada nesse meio-tempo — não vira pendência silenciosa: o pedido vai a PurgeBlocked, com o código — FILE_LEGAL_HOLD_CONFLICT, guarda de algum detentor, ou FILE_STILL_REFERENCED_CONFLICT, referência ativa de outro escopo — e a hora, à vista na fila ao lado do dueAt do titular, e o evento attachment-erasure-request.purge-blocked sai depois do commit dessa transição — nome próprio, que não se confunde com o attachment-erasure-request.blocked do registro bloqueado; o job segue repetindo, o aviso sai a quem registrou e ao titular, e, com o dueAt vencido, também ao administrador do tenant. Expurgo que ainda não voltou não é melhor do que expurgo recusado: pedido Executed com purgedAt nulo e dueAt vencido escala igual — mesmo aviso, mesma fila, mesmo destaque —, porque do lado do titular o prazo venceu do mesmo jeito, e falha transitória que ninguém repara é indistinguível de bloqueio. E pedido parado vence do mesmo jeito: pedido Requested bloqueado — por aprovação, por decisão de controle ou por alcance — com o dueAt vencido escala igual, com o mesmo aviso, a mesma fila e o mesmo destaque dos outros dois, e com o administrador do tenant na audiência. Bloqueio não para o relógio do titular: avisá-lo uma vez, no registro, e deixar o prazo correr em silêncio é a mesma pendência silenciosa do expurgo, um passo antes. E o bloqueio por alcance ganha saída declarada: quem concede escopo no tenant — update:company-scopes, da Empresa — pode encerrar o pedido bloqueado por alcance incompleto, com motivo escrito, pela própria rota de cancelamento: o pedido vai a Cancelled, o titular é avisado como em todo cancelamento, o ato fica registrado com quem encerrou, e o arquivo volta à propagação (RN-ATT-15). Sem essa saída, quem tivesse erase:attachments e um anexo legível registrava um pedido que ninguém pode executar — o alcance nunca fica inteiro por conta própria — e prendia POST /attachments/copy sobre o arquivo inteiro, sem prazo e sem fim. Por que encerramento por gente, e não caducidade por prazo: um relógio que cancela sozinho responde outra vez com silêncio ao exercício de um direito, e ainda por decurso, sem que ninguém tenha decidido nada; assim a conta fica com quem tem o poder de desfazer o bloqueio — conceder o escopo, ou encerrar o pedido dizendo por que não o concede —, e a escolha fica escrita. E o encerramento fecha o laço: enquanto o alcance de quem registra continuar incompleto, pedido novo bloqueado por alcance sobre o mesmo arquivo é recusado (ATTACHMENT_ERASURE_SCOPE_INCOMPLETE_FORBIDDEN) — o caminho é pedir escopo, não repetir o pedido; quem tem o alcance inteiro registra e executa normalmente. Depois do ato, o único instrumento que segura os bytes é a guarda legal: o job relê a guarda antes de cada chamada e não relê OpenApprovalOf nem PendingControlOf — esses são conferidos no registro e relidos sob a trava na execução (RN-ATT-39), não depois. A consequência fica escrita: decisão ou contestação aberta depois do commit do ato não segura o expurgo, e já não teria o que preservar do lado do Nexio — o nome foi neutralizado e as referências, liberadas; quem precisa dos bytes para uma decisão aberta põe guarda legal por caso, que vence o pedido do titular, e o pedido fica PurgeBlocked. Publicar a eliminação de bytes que continuam lá é o erro que não se corrige depois. É ato de controle.
Prova de controle, ou anexo ainda sob guarda: quem registra o pedido não executa
O registro grava um AttachmentErasureRequest e calcula, sobre todos os anexos do arquivo, se a eliminação pede segunda pessoa. Pede quando algum deles é de tipo com isControlEvidence = true — na semente, BANK_OWNERSHIP_PROOF, SIGNED_CONTACT_CHANGE_REQUEST e APPROVAL_EVIDENCE —, ou de tipo com retenção Permanent — SIGNED_CONTRACT, na semente —, qualquer que seja a marca, ou está dentro da guarda: com o dono fora de Draft, a referência ainda não liberada, ou guardUntil no futuro — o anexo de documento fechado que o re-scan achou infectado depois de liberado continua sob guarda (RN-ATT-37) —, ou o dono ainda não fechado. Sem segunda pessoa e sem bloqueio, o registro executa, e o pedido nasce Executed com o mesmo usuário nas duas pontas. Com segunda pessoa, ou com bloqueio (RN-ATT-33), o pedido nasce Requested e nada é revogado. Pedido bloqueado continua registrado: o titular tem prazo, e o registro é a prova de que o pedido foi recebido. A fila mostra o bloqueio — calculado na leitura, nunca gravado — e o dueAt; o aviso sai ao titular, pelo e-mail informado no registro, dizendo só que o pedido aguarda a conclusão de uma decisão em curso — sem nome de arquivo, de documento ou de decisão —, e, sem e-mail, a quem registrou, que responde pelo canal do pedido. O aviso de pedido pendente vai a quem pode executar — erase:attachments, escopo e CanRead em todos os donos, menos quem registrou quando o pedido pede segunda pessoa; sem ninguém assim, ao administrador do tenant, e o pedido fica parado. Executa POST /attachments/erasure-requests/{requestId}/execute — com segunda pessoa, por usuário diferente de quem registrou (ATTACHMENT_ERASURE_SAME_PERSON_FORBIDDEN); sem ela, também quem registrou, depois de o bloqueio cair —, com as condições reconferidas na hora e a marca recalculada — o que endureceu entre o registro e a execução vale; o que afrouxou, não. Quem registrou, ou outra pessoa com o mesmo alcance, cancela o pedido pendente com comentário (RN-ATT-38).
A razão é a do núcleo de controle bancário: uma pessoa que pede e executa sozinha a eliminação do comprovante de uma troca de conta fraudulenta já fechada apaga a prova da própria fraude, e o pedido do titular vira a porta; em Permanent, o que se apaga não tem data para deixar de ser prova. Nos demais casos — anexo de rascunho nunca submetido, sem a marca, de tipo com prazo, cuja guarda já terminou — o que está em jogo é o dado pessoal, não a prova, e uma pessoa basta, com o ato registrado.
A segunda pessoa é outro usuário. A comparação é por userId, como nas decisões do motor: sem comparação por CPF e sem regra de conflito de interesse — o proponente, o aprovador, o remetente do lote, o autor do callback ou quem anexou a prova podem registrar ou executar. Risco aceito, e registrado: dois usuários da mesma pessoa, ou o aprovador de uma troca fraudulenta, fazem as duas pontas. O que o mitiga fica: o ato de controle registrado com os dois autores, o aviso ao segundo antes de ele executar e a trilha do ato. Identidade verificada por CPF, como a do núcleo bancário, e regra de conflito de interesse foram consideradas e não entram.
Campos · AttachmentLegalHold
| Campo | Tipo | Regra | |
|---|---|---|---|
| id · tenantId | Guid · Guid | gerado | Uma linha por colocação de guarda sobre um anexo. |
| attachmentId | Guid | obrig | → Attachment, no mesmo contexto. Imutável. |
| caseReference | string(80) | obrig | O caso: número do processo, do auto de infração, da auditoria. Imutável. No máximo uma guarda ativa por (attachmentId, caseReference). |
| reason | string(500) | obrig | Por que a guarda existe. Texto de pessoa: nunca vai para variável de aviso. |
| placedByUserId · placedAt | Guid · DateTime | gerado | Quem colocou e quando. |
| liftedByUserId · liftedAt · liftReason | Guid? · DateTime? · string(500)? | cond | Preenchidos juntos, uma vez, na retirada. Nulos é guarda ativa. A linha nunca é apagada nem reescrita depois disso. |
| fileHoldAppliedAt · fileHoldReleasedAt | DateTime? · DateTime? | gerado | Quando o GrydFiles confirmou o legalHold do arquivo por esta guarda, e quando o arquivo deixou de depender dela — desligado, ou segurado por outra guarda ativa. fileHoldAppliedAt nulo com a guarda ativa, ou fileHoldReleasedAt nulo com a guarda retirada, é pendência (RN-ATT-35). |
Campos · AttachmentErasureRequest
| Campo | Tipo | Regra | |
|---|---|---|---|
| id · tenantId | Guid · Guid | gerado | Um pedido de eliminação de titular sobre um arquivo do tenant. |
| storedFileId · attachmentId | Guid · Guid | obrig | O arquivo, que é o que se elimina — com todas as cópias (RN-ATT-33) —, e o anexo pelo qual o pedido foi aberto. Imutáveis. No máximo um pedido Requested por (tenantId, storedFileId). |
| requestReference · comment | string(80) · string(1000) | obrig | A referência do pedido do titular e a avaliação de que não há base legal de guarda. Texto de pessoa: nunca vai para variável de aviso. |
| dueAt · requesterEmail | DateTime · string(254)? | obrig · cond | A data-limite de resposta ao titular, informada no registro, e o e-mail pelo qual ele recebe o aviso de pedido bloqueado — opcional; sem ele, o aviso vai a quem registrou. Imutáveis. O e-mail é dado pessoal do titular: só o lê quem tem erase:attachments e o alcance do pedido, e nunca vai a outra variável de aviso. |
| requiresSecondPerson | bool | gerado | Calculado no registro e de novo na execução: algum anexo do arquivo de tipo com isControlEvidence ou com retenção Permanent, ou dentro da guarda. Só passa de falso a verdadeiro (RN-ATT-38). |
| status | enum | gerado | Requested · Executed — ato gravado, expurgo pendente — · PurgeBlocked — o GrydFiles recusou o expurgo, e o job repete — · Purged · Cancelled. Terminais são Purged e Cancelled; Executed e PurgeBlocked alternam até o purgedAt chegar (RN-ATT-33). |
| requestedByUserId · requestedAt | Guid · DateTime | gerado | Quem registrou e quando. |
| executedByUserId · executedAt | Guid? · DateTime? | cond | Preenchidos juntos, uma vez, na execução. Com requiresSecondPerson, executedByUserId nunca é igual a requestedByUserId. Com requestedByUserId, são os dois autores declarados do expurgo (RN-ATT-33). |
| purgedAt · purgeBlockedSince · purgeBlockedCode | DateTime? · DateTime? · string(60)? | cond | purgedAt é gravado quando o GrydFiles confirma o expurgo de todos os pares do escopo, e é o que leva o pedido a Purged e publica attachment.erased (RN-ATT-33). Recusa do expurgo preenche purgeBlockedSince e purgeBlockedCode — o código que o GrydFiles devolveu, FILE_LEGAL_HOLD_CONFLICT (guarda de algum detentor) ou FILE_STILL_REFERENCED_CONFLICT (referência ativa de outro escopo) — e leva a PurgeBlocked; a repetição que passa os limpa. Nunca trazem nome de arquivo. |
| cancelledByUserId · cancelledAt · cancelComment | Guid? · DateTime? · string(500)? | cond | Preenchidos juntos, uma vez, no cancelamento. |
Decisão fechada
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. O estado vem do GrydFiles na hora da submissão, não do que o contrato de veredito já aplicou: anexo cujo arquivo já está Infected ou reprovado, e ainda não foi revogado, também bloqueia — e também a reserva de upload que ainda pode ser confirmada (Reserved): o arquivo que chegasse depois da submissão entraria num documento que não aceita mais escrita (RN-ATT-19, RN-ATT-36).
Failed bloqueia pela mesma razão 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. A saída não é esperar indefinidamente: o arquivo só chega a Failed depois das tentativas automáticas do GrydFiles, e a tela oferece "verificar de novo", que o Nexio pede ao GrydFiles pela IFileService (POST /attachments/{id}/rescan), ou "remover".
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 — um spinner que bloqueia até o veredito — é 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 na v1: a tela mostra o estado, e a pessoa envia quando libera
Ninguém fica preso: a pessoa continua preenchendo, sai da tela e volta. Quem tenta enviar com anexo sem veredito recebe ATTACHMENT_SCAN_PENDING_CONFLICT, com a lista dos anexos e o estado de cada um. A tela não precisa chegar a essa resposta: ela acompanha o estado pela consulta em lote (GET /attachments/status), mostra ao lado do botão de enviar o que ainda falta liberar e o torna disponível quando o último veredito chega. O 409 é a guarda do servidor — vale para a tela desatualizada, para a integração e para o clique que chega junto com o veredito. Anexo em Failed sai por "verificar de novo" (POST /attachments/{id}/rescan) ou por revogação; anexo em Infected já foi revogado pelo contrato de veredito (RN-ATT-20), e a pessoa anexa outro arquivo.
Nenhum processo em segundo plano submete documento. Submeter abre aprovação e, no desfecho, empenha verba; fazer isso fora da requisição de uma pessoa exigiria restaurar a identidade, as permissões e o escopo dela e registrar que o ato foi feito em nome dela — execução delegada, que a plataforma não oferece. Por isso o registro de descritores não tem pergunta sobre como submeter o dono: o dono submete a si mesmo, na requisição de quem clicou, e pergunta ao anexo se há algo pendente. Submeter sozinho quando o arquivo liberar está em § Fora de escopo, como v2.
FILE_SCAN_UNAVAILABLE não é erro do usuário. Quando o clamd está fora do ar, o GrydFiles devolve 503 — pelo sufixo _UNAVAILABLE, que o mapeador do Core leva a 503. 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
A porta do fornecedor — convite, conta, sessão, rotas e quem pode entrar — é da spec do Portal do Fornecedor, e esta página não a repete. O que é do anexo, e está fixado aqui, é a autoria: o anexo vindo do portal 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 entrar no portal sem PortalUser cadastrado — a forma de acesso é do Portal do Fornecedor —, e nesse caso não há usuário a registrar; mas sempre há fornecedor, porque o acesso do portal o identifica. supplierId é resolvido pelo portal 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 nem a nenhum outro no portal — além do próprio A, quem lê é o comprador. Marcar como Supplier depois não existe (ATTACHMENT_SUPPLIER_VISIBILITY_NOT_ALLOWED_UNPROCESSABLE): a proposta de um concorrente ficaria legível aos outros. O documento que o comprador quer mandar a todos, ele anexa ao QuotationRequest. E Internal não tira o anexo do próprio autor: o portal devolve ao fornecedor o que ele mesmo subiu (RN-ATT-12).
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 | A do Portal do Fornecedor, que define sessão e rota. A reserva do upload fica amarrada ao acesso do portal e ao supplierId (RN-ATT-36) |
uploadedByKind | User | PortalUser |
supplierId | Opcional | Obrigatório, resolvido pelo portal |
| Visibilidade inicial | Do AttachmentType | Sempre Internal, e nunca Supplier |
ownerType aceito | Os registrados pelos módulos presentes (RN-ATT-03) | 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.
E há a terceira resposta, que não é nem "é seu" nem "não é seu": companyId nulo. Anexo de item, de fornecedor, de qualificação do 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 as empresas do escopo — e, para quem não tem empresa nenhuma no escopo, para nenhuma. 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
create:attachments e update:attachments são necessárias e não suficientes: 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".
É evento de negócio do Nexio, isolado por tenant — 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, attachment.rescan-requested. Cada uma carrega ownerType, o número legível do documento e o attachmentId. O caminho do framework para gravar a trilha é demanda de plataforma, em correção pelo Gryd.IO (isolamento de tenant, permissão e gravação na transação do ato); esta página fixa o requisito, não o mecanismo.
A convenção dos nomes 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. Nem todo evento tem aviso: attachment.created, attachment.copied, attachment.visibility-changed, attachment.rescan-requested, attachment.quarantined, attachment-legal-hold.placed, attachment-legal-hold.lifted e attachment-erasure-request.executed são eventos de domínio sem linha no catálogo de avisos na v1 — alimentam a trilha e quem quiser consumi-los, e não têm destinatário nem slug. A ausência no catálogo é a declaração de que não há aviso, não esquecimento. O pedido de eliminação segue a mesma regra, com a entidade dele, e cada momento tem um nome só: attachment-erasure-request.registered (nasce livre, à espera da segunda pessoa), attachment-erasure-request.blocked (nasce travado), attachment-erasure-request.executed (ato gravado, expurgo pendente), attachment-erasure-request.purge-blocked (expurgo recusado) e attachment-erasure-request.cancelled; attachment.erased — os bytes saíram — é fato do arquivo. Os avisos são os slugs de mesma cauda na Notificação.
| Permissão | Alcance | Observação |
|---|---|---|
| read:attachments | Listar, ver metadado e pedir URL de download | Sempre recortada pelo escopo da empresa do dono, pelo CanRead do dono e pela visibility. Necessária e nunca suficiente. Todo papel que abre documento precisa dela |
| create:attachments | Anexar e propagar | Não basta: exige também poder escrever no dono. Ver acima |
| update:attachments | Descrever, trocar o tipo em rascunho, pedir nova verificação de anexo em Failed e rebaixar a visibilidade para Internal | Não basta: exige também poder escrever no dono. Ver acima |
| lookup:attachments | Alimentar o seletor de tipo de anexo (GET /attachment-types) | read e lookup não se implicam |
| revoke:attachments | Revogar anexo de outra pessoa, e revogar fora de rascunho | Quem anexou revoga o próprio anexo em rascunho só com create:attachments, a mesma de anexar. 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 |
| create:attachment-types | Criar tipo no catálogo do tenant, inclusive a sobreposição de tipo global | Administração de configuração. Mexe em retenção, então é de administrador, não de comprador |
| update:attachment-types | Alterar tipo do catálogo do tenant, inclusive materializar a sobreposição de tipo global | Mesma administração de configuração |
| delete:attachment-types | Desativar tipo do catálogo do tenant | Desativar é exclusão lógica e usa delete, mesmo sendo PATCH. Nunca invalida anexo existente |
| create:attachment-legal-holds | Colocar guarda legal, sobre um anexo ou sobre todos os anexos de um dono | Ato de controle. Sempre recortada pelo escopo da empresa do dono. Não dá leitura de anexo — a linha da guarda não traz nome de arquivo nem conteúdo, e o número legível do dono só vai a quem passa no CanRead dele |
| delete:attachment-legal-holds | Retirar a guarda legal, com liftReason | Ato de controle. Retira só a linha indicada; as outras guardas do anexo continuam |
| read:attachment-legal-holds | Listar guarda legal | Mesmo recorte pelo escopo da empresa do dono e mesma regra de CanRead para o número legível. read não dá colocar nem retirar |
| erase:attachments | Registrar, executar, cancelar e listar pedido de eliminação de titular | A mais restrita do módulo: é a única que tira prova antes do fim da guarda. Ato de controle; exige também o CanRead de todos os donos do arquivo, e prova de controle ou anexo sob guarda pedem duas pessoas com ela (RN-ATT-38) |
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.
Enquanto algum anexo não tem veredito limpo, o botão de enviar para aprovação fica indisponível, com a lista do que falta e o estado de cada um: a reserva ainda não confirmada diz "enviando"; Pending e Scanning dizem "verificando"; Failed traz duas ações na própria linha — "verificar de novo" e "remover"; o infectado e o reprovado já saíram da lista, com aviso. O botão volta sozinho quando o último veredito chega, pela consulta em lote. Se o clique chegar antes da tela, o 409 é a guarda do servidor e lista os mesmos anexos — 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 os que ele mesmo subiu — nunca os de outro fornecedor, nem o que o comprador acrescentou à resposta dele. 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 —, e sobrepor um global só aceita retenção igual ou mais longa.
O relatório da varredura: órfãos encontrados por tipo de dono, anexos em Failed, arquivos Infected nos últimos 90 dias — com os infectados depois de liberados à parte —, escritas pendentes no GrydFiles por idade (RN-ATT-35), 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 fila de quem executa: pedidos Requested com o dono, o número legível, a referência do pedido do titular, o comentário, quem registrou, a data-limite e se está bloqueado — e, ao lado, os pedidos PurgeBlocked, com o código do GrydFiles — FILE_LEGAL_HOLD_CONFLICT ou FILE_STILL_REFERENCED_CONFLICT —, desde quando e o dueAt vencido em destaque — e, no mesmo destaque, os Executed com purgedAt nulo e dueAt vencido, que são o mesmo problema sem código: o ato foi feito e os bytes continuam lá, então alguém precisa olhar. No mesmo destaque entram os Requested bloqueados com o dueAt vencido, qualquer que seja o bloqueio: bloqueio não para o relógio do titular. O pedido bloqueado por alcance incompleto aparece a quem o registrou e ao administrador do tenant, com o alcance que falta e sem dono nem número legível, porque é ele quem concede escopo — e é dele também o botão de encerrar o pedido, com motivo escrito, quando conceder o escopo não é o caminho (RN-ATT-38) — e o botão de executar indisponível para quem registrou quando o pedido pede segunda pessoa, e para todos enquanto está bloqueado, com o motivo. Aberto o pedido, a tela mostra as condições da RN-ATT-33 e a pré-condição do GrydFiles (RN-ATT-39) como estão agora, antes do clique; o servidor as reconfere de qualquer jeito.
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 | create:attachments | fileId, uploadUrl, uploadExpiresAt e os cabeçalhos do PUT. Valida dono, tipo, escopo, CanWrite e limites antes de reservar, e grava a reserva, que não aparece na lista | 400 · 404 · 409 · 422 |
| POST /attachments | create:attachments | AttachmentDto. Só sobre reserva do mesmo usuário, tenant, dono e tipo (RN-ATT-36), e reconfere o CanWrite do dono antes de confirmar. Confirma pela IFileService declarando a referência esperada; não cria FileReference. Repetir com o mesmo fileId repete a confirmação e devolve o mesmo anexo | 400 · 404 · 409 · 422 · 503 |
| 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, CanRead do dono, visibilidade, revogação — e só então chama o GrydFiles. Registra documento e anexo na trilha | 404 · 409 · 503 |
| PUT /attachments/{id} | update: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}/rescan | update:attachments | Pede ao GrydFiles, pela IFileService, nova verificação de anexo em Failed. Exige o CanWrite do dono; em dono que não aceita mais escrita, revoke:attachments e o CanRead. 200 com o anexo em Scanning | 404 · 409 · 503 |
| POST /attachments/{id}/visibility | share-with-supplier:attachments | Promove a Supplier. Rebaixar para Internal aceita só update: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 nunca submetido, depois do commit (RN-ATT-35). Motivo obrigatório fora dele | 404 · 409 · 422 |
| POST /attachments/{id}/erase | erase:attachments | Eliminação a pedido de titular: requestReference, dueAt e comment, requesterEmail opcional; exige escopo e CanRead no anexo em que age — 404 sem eles —, nenhuma guarda e a pré-condição do GrydFiles (RN-ATT-39); alcance incompleto nos demais donos bloqueia o pedido, não o recusa (RN-ATT-33). Grava o AttachmentErasureRequest. Sem segunda pessoa e sem bloqueio, executa — revoga o anexo e as cópias do mesmo arquivo e registra o ato; quem libera as referências e pede o expurgo é o job, depois do commit (RN-ATT-35) — e devolve 201 com o pedido Executed e o purgedAt nulo enquanto o expurgo está pendente; com segunda pessoa, ou com dono em aprovação, decisão de controle pendente ou alcance incompleto, 201 com o pedido Requested — bloqueado, se for o caso, com o motivo do bloqueio —, e nada revogado. Sobre anexo com expurgo pendente, 200 com o pedido: quem repete o expurgo é o job. RN-ATT-33, RN-ATT-38, RN-ATT-39 | 403 · 404 · 409 · 422 · 503 |
| POST /attachments/erasure-requests/{requestId}/execute | erase:attachments | Executa o pedido Requested — por usuário diferente de quem o registrou, quando pede segunda pessoa; reconfere na hora escopo, CanRead, guarda legal, referência de outro escopo, pré-condição do GrydFiles, aprovação em curso e decisão de controle pendente, com os donos travados (RN-ATT-39). 200 com o pedido Executed | 403 · 404 · 409 · 503 |
| POST /attachments/erasure-requests/{requestId}/cancel | erase:attachments | Encerra o pedido Requested, com comment. Pedido bloqueado por alcance incompleto é encerrado também por quem tem update:company-scopes — a saída do bloqueio que ninguém dentro do pedido derruba —, e esse encerramento fica fora da cadeia de cancelamentos (RN-ATT-38). Todo cancelamento avisa o titular; do segundo sobre o mesmo arquivo em diante, por usuário diferente do que cancelou o anterior, e o aviso alcança também o administrador do tenant (RN-ATT-38). 200 com o pedido Cancelled | 403 · 404 · 409 · 422 |
| GET /attachments/erasure-requests | erase:attachments | Os pedidos, filtráveis por status — inclusive PurgeBlocked, que é o que precisa de gente — só os de arquivo cujos donos todos passam no escopo e no CanRead de quem lista; os outros não vêm — com uma exceção: o pedido bloqueado por alcance incompleto vem também para quem o registrou e para o administrador do tenant, com a referência, o dueAt e o alcance que falta, e sem dono nem número legível. Traz dono, número legível, quem registrou, se pede segunda pessoa, se está bloqueado e por quê, e o dueAt — nunca nome de arquivo | 400 |
| POST /attachments/{id}/legal-holds | create:attachment-legal-holds | Coloca guarda legal no anexo: caseReference e reason. Só no escopo da empresa do dono; fora dele, 404. 201 com a linha, sem nome de arquivo | 404 · 409 · 422 |
| POST /attachments/legal-holds | create:attachment-legal-holds | Coloca guarda legal em todos os anexos de um dono (ownerType + ownerId) cuja referência não foi liberada, com o mesmo caseReference. Só no escopo da empresa do dono; fora dele, 404. Tudo ou nada. 201 com as linhas | 400 · 404 · 409 · 422 |
| POST /attachments/{id}/legal-holds/{holdId}/lift | delete:attachment-legal-holds | Retira aquela guarda, com liftReason. Só no escopo da empresa do dono; fora dele, 404. 200 com a linha. As outras guardas do anexo continuam | 404 · 409 · 422 |
| GET /attachments/legal-holds | read:attachment-legal-holds | As guardas, filtráveis por caseReference, dono e ativas. Só as de anexo no escopo de quem lista — o de companyId nulo, para quem tem alguma empresa no escopo (RN-ATT-30); as outras não vêm. Traz attachmentId; o dono (ownerType, ownerId) e o número legível só quando quem lista passa no CanRead dele — o da proposta bancária carrega os últimos dígitos da conta —; nunca nome de arquivo | 400 |
| POST /attachments/copy | create:attachments | Propagação explícita, em lote: lista de attachmentId e o dono de destino. Cria um anexo por item, herdando a visibilidade; a referência de cada um é criada depois do commit (RN-ATT-35). Destino de outra empresa exige escopo nas duas (RN-ATT-14). Arquivo com pedido de eliminação Requested, Executed ou PurgeBlocked é recusado, e a chamada toma a trava do arquivo (ATTACHMENT_ERASURE_IN_PROGRESS_CONFLICT, RN-ATT-15) | 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. Elo cujo dono o usuário não lê não aparece | 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 rota do fornecedor | acesso do portal | Rota, autenticação e sessão são definidas pelo Portal do Fornecedor. O que é do Anexo vale em qualquer rota: upload-intent e confirmação embutidos; supplierId resolvido pelo portal; nasce Internal; ownerType é sempre QuotationResponse; a reserva e a confirmação seguem a RN-ATT-36, com o acesso do portal no lugar do usuário | 400 · 401 · 409 · 422 |
| GET /attachment-types | lookup: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 | create:attachment-types (POST) · update:attachment-types (PUT) | 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; a sobreposição só aceita retenção igual ou mais longa que a do global (RN-ATT-25); code e retentionMode são imutáveis depois do primeiro uso, e isControlEvidence só liga | 400 · 404 · 409 · 422 |
| PATCH /attachment-types/{id}/deactivate | delete: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 código, nunca da mensagem. O mapeador do Core reconhece dez sufixos — _NOT_FOUND → 404 · _ALREADY_EXISTS, _ALREADY_ASSIGNED e _CONFLICT → 409 · _FORBIDDEN, _ACCESS_DENIED e _BLOCKED → 403 · _UNAUTHORIZED → 401 · _UNPROCESSABLE → 422 · _UNAVAILABLE → 503 · sem sufixo → 400 —, e código exato vence sufixo.
| 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 fora do enum, ou de módulo que não está registrado nesta aplicação. Com o módulo presente e o descritor faltando, a inicialização já teria falhado (RN-ATT-03) |
| ATTACHMENT_OWNER_NOT_WRITABLE_CONFLICT | 409 | Nas rotas de anexo — reserva, confirmação, alteração, visibilidade, revogação — sobre documento que o usuário não pode editar, ou que não aceita mais alteração, como a proposta bancária submetida. A confirmação reconfere, porque o dono pode ter mudado desde a reserva (RN-ATT-36). A regra é do dono; o código é deste módulo, e as páginas donas o citam |
| ATTACHMENT_UPLOAD_NOT_RESERVED_CONFLICT | 409 | POST /attachments com fileId que não é reserva do mesmo usuário — ou do mesmo acesso, no portal —, do mesmo tenant, para o mesmo dono e tipo: arquivo de outro anexo, reserva alheia, dono trocado. Nada é gravado (RN-ATT-36) |
| 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_TYPE_RETENTION_BELOW_GLOBAL_UNPROCESSABLE | 422 | Sobreposição de tipo global com retenção mais curta que a dele — no POST, no PUT ou ao reativar. A sobreposição só alonga (RN-ATT-25) |
| ATTACHMENT_TYPE_CONTROL_EVIDENCE_REQUIRED_UNPROCESSABLE | 422 | Sobreposição de tipo global que desliga isControlEvidence. A marca de prova de controle só liga (RN-ATT-25) |
| 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 |
| REASON_CODE_REQUIRED_UNPROCESSABLE | 422 | Do Motivo: revogar fora de rascunho sem reasonCodeId. Retirar prova de documento em curso precisa de motivo nomeado. Código de outro uso ou desativado responde REASON_CODE_USAGE_NOT_ALLOWED_UNPROCESSABLE ou REASON_CODE_INACTIVE_UNPROCESSABLE, também de lá |
| ATTACHMENT_UNDER_LEGAL_HOLD_CONFLICT | 409 | Eliminar anexo — ou outro anexo do mesmo arquivo — com guarda legal ativa, ou com o arquivo sob guarda de qualquer detentor no GrydFiles, inclusive a posta pela rota dele — é a tradução do FILE_LEGAL_HOLD_CONFLICT do GrydFiles (RN-ATT-39). A resposta diz só que há guarda; os caseReference ativos vêm apenas para quem tem read:attachment-legal-holds — o número do processo não é informação de quem pede a eliminação |
| ATTACHMENT_ERASURE_FILE_REFERENCED_CONFLICT | 409 | O arquivo tem referência ativa de outro escopo além de nexio.attachment. É a tradução do FILE_STILL_REFERENCED_CONFLICT do GrydFiles (RN-GF-25). Nada foi revogado; a resposta nomeia os escopos |
| ATTACHMENT_ERASURE_SCOPE_INCOMPLETE_FORBIDDEN | 403 | Executar eliminação com algum anexo do mesmo arquivo em dono fora do escopo ou do CanRead de quem executa. No registro não recusa — o pedido é gravado bloqueado por alcance, com o prazo correndo e o administrador do tenant escalado (RN-ATT-33) —, com uma exceção: depois de um pedido bloqueado por alcance sobre o mesmo arquivo ter sido encerrado por quem concede escopo, registro novo que siga sem o alcance inteiro recusa aqui, e o caminho é pedir escopo (RN-ATT-38). Nada foi revogado; a resposta não identifica o documento |
| ATTACHMENT_ERASURE_REQUEST_REQUIRED_UNPROCESSABLE | 422 | Eliminação sem requestReference, sem dueAt ou sem comment, ou com dueAt posterior ao do pedido cancelado sobre o mesmo arquivo, ou cancelamento de pedido sem comment. A avaliação jurídica fica escrita, ou o ato não acontece |
| ATTACHMENT_ALREADY_ERASED_CONFLICT | 409 | Eliminar anexo cujo expurgo já foi confirmado. Com expurgo pendente não é erro: o ato devolve o pedido, e só o job repete o expurgo |
| ATTACHMENT_ERASURE_IN_PROGRESS_CONFLICT | 409 | Propagar arquivo com pedido de eliminação Requested, Executed ou PurgeBlocked. Cópia nova sobre pedido em curso nasceria com o dado pessoal, ou prenderia o titular sem prazo (RN-ATT-15) |
| ATTACHMENT_ERASURE_APPROVAL_IN_PROGRESS_CONFLICT | 409 | Executar eliminação com aprovação em curso em algum dono dos anexos do arquivo (OpenApprovalOf não nulo). Nada foi revogado. No registro não é erro: o pedido nasce bloqueado e espera a instância terminar (RN-ATT-33) |
| ATTACHMENT_ERASURE_CONTROL_DECISION_PENDING_CONFLICT | 409 | Executar eliminação com decisão de controle pendente fora da instância em algum dono (PendingControlOf não nulo): lote de carga inicial por confirmar, contato à espera da segunda pessoa, carência, contestação, devolução à espera de reenvio, correção de identidade. Nada foi revogado; a resposta não identifica o documento. No registro não é erro: o pedido nasce bloqueado (RN-ATT-33) |
| ATTACHMENT_ERASURE_FILE_NOT_PURGEABLE_CONFLICT | 409 | A consulta de pré-condição do GrydFiles diz que o expurgo como eliminação seria recusado — arquivo em Pending ou Scanning, ou Permanent sem dois autores distintos (FILE_ERASURE_DUAL_CUSTODY_CONFLICT). Arquivo em quarentena não recusa por estado. Nada foi gravado; a resposta traz o código do GrydFiles. Guarda e referência de outro escopo têm os códigos próprios; trilha durável indisponível repassa FILE_AUDIT_TRAIL_UNAVAILABLE (503) (RN-ATT-39) |
| ATTACHMENT_ERASURE_SAME_PERSON_FORBIDDEN | 403 | Executar, com o mesmo usuário que o registrou, pedido que pede segunda pessoa (RN-ATT-38) |
| ATTACHMENT_ERASURE_CANCEL_SAME_PERSON_FORBIDDEN | 403 | Cancelar, pela segunda vez ou mais sobre o mesmo arquivo, com o usuário que cancelou o pedido anterior. O vaivém de registrar e cancelar sozinho deixava o pedido do titular sem nunca vencer (RN-ATT-38) |
| ATTACHMENT_ERASURE_REQUEST_ALREADY_EXISTS | 409 | Já existe pedido Requested para o mesmo arquivo. A ordem é declarada: o escopo e o CanRead do anexo em que se age são conferidos antes — sem eles a resposta é 404 —, e só quem passa recebe o id do pedido. O alcance nos demais donos não entra nessa ordem: ele bloqueia o pedido em vez de recusá-lo (RN-ATT-33). Devolver o id primeiro contava a quem não enxerga nem o anexo em que age que existe pedido, e sobre qual arquivo |
| ATTACHMENT_ERASURE_REQUEST_NOT_FOUND | 404 | Pedido inexistente neste tenant |
| ATTACHMENT_ERASURE_REQUEST_CLOSED_CONFLICT | 409 | Executar ou cancelar pedido que não está Requested: Executed, PurgeBlocked, Purged ou Cancelled (RN-ATT-38) |
| ATTACHMENT_LEGAL_HOLD_NOT_FOUND | 404 | Guarda inexistente neste tenant, ou de outro anexo |
| ATTACHMENT_LEGAL_HOLD_ALREADY_EXISTS | 409 | Já existe guarda ativa com o mesmo caseReference sobre o anexo. Na colocação por dono, recusa o lote inteiro e lista quais |
| ATTACHMENT_LEGAL_HOLD_LIFTED_CONFLICT | 409 | Retirar guarda já retirada. Retirada é terminal; o caminho de volta é colocar outra |
| ATTACHMENT_LEGAL_HOLD_REASON_REQUIRED_UNPROCESSABLE | 422 | Colocar sem caseReference ou reason, ou retirar sem liftReason |
| ATTACHMENT_SCAN_PENDING_CONFLICT | 409 | A decisão 8. Submeter para aprovação com anexo não revogado cujo arquivo, consultado na hora, não está Available, inclusive reserva Reserved ainda confirmável: Pending, Scanning, Failed, ou Infected e reprovado ainda não revogados pelo contrato. Na confirmação do lote da carga inicial bancária valem os estados que o Fornecedor declara, copiados: linha sem comprovante e sem callback Confirmed, comprovante revogado, arquivo eliminado, veredito Infected, anexo em estado reprovado, veredito Failed e reserva de envio Reserved ainda confirmável são falta de evidência, com o código dele (BANK_INITIAL_LOAD_EVIDENCE_MISSING_CONFLICT), que lista a linha e o estado de cada uma; sem nenhuma falta, Pending e Scanning são espera e recusam com este código. Failed é falta, não espera. A resposta lista quais e em que estado, porque "espere", "mande verificar de novo" e "anexe outro" são conversas diferentes |
| ATTACHMENT_RESCAN_NOT_FAILED_CONFLICT | 409 | Pedir nova verificação de anexo cujo arquivo não está em Failed. Re-scan de arquivo liberado é do acervo, feito pela plataforma |
| 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 recebeu o veredito Available. É 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 | ownerType do destino fora do PropagatesTo da origem. Destino de empresa em que quem propaga não tem escopo responde 404, como documento fora de escopo (RN-ATT-14) |
| ATTACHMENT_SUPPLIER_REQUIRED_UNPROCESSABLE | 422 | uploadedByKind = PortalUser sem supplierId. Não deveria acontecer — o portal 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, desligar isControlEvidence nele, 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.
Falha no startup, não em produção. Cada módulo dono registra os seus valores de AttachmentOwnerType e o descritor de cada um; a verificação percorre o que foi registrado e exige, para cada valor, ser valor do enum, ter exatamente um descritor e responder às doze perguntas. Valor sem módulo presente não é exigido e não aceita anexo (ATTACHMENT_OWNER_TYPE_UNKNOWN); anexo já gravado cujo dono é de módulo ausente não é lido nem varrido — sem descritor não há CanRead, e a resposta é 404. O enum persiste como texto: acrescentar valor não é migração.
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. O caso do companyId nulo está na RN-ATT-30, e não é exceção a esta regra: é dono que não tem empresa, não anexo fora de escopo.
create:attachments e update:attachments são necessárias e não suficientes: 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. A primeira referência não é criada pelo Nexio: o anexo declara a referência esperada na confirmação e o módulo de arquivo cria a referência na liberação (RN-ATT-26, RN-ATT-36). A cópia cria a sua depois do commit (RN-ATT-35) — ela só é oferecida sobre arquivo já liberado.
AttachmentType e congelada no anexoretentionMode e o piso retainUntil, contado da data da reserva, vão ao GrydFiles na reserva, pela IFileService; o modo e os anos ficam gravados no anexo. O Nexio decide, o GrydFiles executa. Mudar a configuração do tipo depois não altera o que já foi anexado. Guarda legal não vem do tipo (RN-ATT-32).
"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.
O portal devolve ao fornecedor autenticado, nas respostas dele, os anexos com uploadedByKind = PortalUser e o supplierId dele, em qualquer visibilidade e enquanto não revogados, mais os anexos Supplier do QuotationRequest. O que o comprador acrescentou à resposta — mesmo com supplierId preenchido — não volta ao portal. Anexo de QuotationResponse nunca vira Supplier (ATTACHMENT_SUPPLIER_VISIBILITY_NOT_ALLOWED_UNPROCESSABLE): seria a proposta de um concorrente legível aos outros. Isolamento entre fornecedores é comparação de coluna, não visibilidade.
Nunca automática. A cópia herda a visibility do original em vez de recalcular do padrão do tipo — recalcular trocaria uma decisão humana por um padrão de configuração, e o erro possível é o pior deles.
Destino de outra empresa do mesmo tenant é aceito quando quem propaga passa no escopo e no CanRead do dono de origem e no escopo e no CanWrite do dono de destino; faltando qualquer um, o documento responde 404. A cópia grava o companyId do destino. Não é rateio: cada documento continua de uma empresa. E revogar a origem não revoga as cópias: cada documento responde pelo que anexou.
Available, nem se propaga arquivo em eliminaçãoMultiplicar por dois documentos um arquivo que ainda pode virar Infected é criar dois problemas. Também não se propaga arquivo com pedido de eliminação Requested, Executed ou PurgeBlocked (ATTACHMENT_ERASURE_IN_PROGRESS_CONFLICT): cópia nova sobre pedido em curso ou nasceria ativa com o dado pessoal que o titular mandou eliminar, ou prenderia o pedido dele sem prazo. A cópia toma a trava do storedFileId (RN-ATT-39), então ou ela é anterior ao conjunto que a execução leu e é eliminada junto, ou espera e é recusada. 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 ao revogar em documento que já passou por alguém 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 do rascunho nunca submetido, o Nexio só libera a referência no fim da guarda (RN-ATT-31) ou pela eliminação (RN-ATT-33), e nunca sob guarda legal (RN-ATT-32). A liberação pelo re-scan que acha vírus é da plataforma, e a guarda legal a cobre pelo legalHold do arquivo (RN-ATT-37).
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. O dono pergunta ao Anexo na submissão, e o Anexo consulta o GrydFiles na hora — não o que o contrato de veredito já aplicou: bloqueia todo anexo não revogado cujo arquivo não está Available — Pending, Scanning, Failed, e também Infected ou reprovado ainda não revogado. Anexo em Reserved conta como pendente: a reserva que ainda pode ser confirmada, e a que o GrydFiles confirmou sem a gravação chegar (RN-ATT-36); só não bloqueia a reserva cujo arquivo, relido, continua Pending com a URL de upload vencida, que não se confirma mais. Sem isso, reservar em rascunho, submeter e confirmar depois punha anexo novo em documento que não aceita mais escrita. Arquivo sem veredito não abre, e o aprovador não pode receber documento cuja prova ele não consegue ler. A recusa (ATTACHMENT_SCAN_PENDING_CONFLICT) nomeia os anexos e o estado de cada um; a tela deixa o botão indisponível antes do clique, e o 409 é a guarda do servidor. A pergunta é exposta em processo, no .Contracts do Anexo, para uma lista de donos, e vale igual para a confirmação do lote da carga inicial bancária, que é a submissão das propostas do lote (RN-FOR-31 do Fornecedor): a pergunta devolve o estado de cada comprovante, e quem decide o que cada estado significa é o Fornecedor, que declara: linha sem comprovante e sem callback Confirmed, comprovante revogado, arquivo eliminado, veredito Infected, anexo em estado reprovado, veredito Failed e reserva de envio Reserved ainda confirmável valem como falta de evidência, com o código dele, BANK_INITIAL_LOAD_EVIDENCE_MISSING_CONFLICT, que lista a linha e o estado de cada uma; sem nenhuma falta, Pending e Scanning valem como espera e recusam a confirmação com o código do Anexo, ATTACHMENT_SCAN_PENDING_CONFLICT. Failed é falta, não espera — sair dele depende de :rescan, que ninguém é obrigado a pedir, e a janela de 60 dias corre contra o controle. O Anexo responde o estado e não amplia nem reduz a lista.
Infected revoga o anexo automaticamenteCom revocationSource = FileInfected, pelo handler do contrato de veredito, depois do commit do GrydFiles (RN-ATT-27). 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. Arquivo que já tinha sido liberado e o re-scan achou infectado segue a RN-ATT-37.
supplierId e nasce InternalsupplierId resolvido pelo acesso do portal e gravado denormalizado, congelando a autoria; portalUserId opcional, porque o acesso sem conta não tem usuário. Rota e autenticação são do Portal do Fornecedor. 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, como evento de negócio do Nexio isolado por tenant. O caminho do framework para gravá-lo é demanda de plataforma; a trilha é requisito desta página, o mecanismo não. 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. A retenção do global é o piso da sobreposição, na ordem UntilReleased < RetainUntil (mais anos, mais longo) < Permanent: POST e PUT de sobreposição com retenção mais curta, e a reativação de uma sobreposição assim, respondem ATTACHMENT_TYPE_RETENTION_BELOW_GLOBAL_UNPROCESSABLE; e a reserva do upload aplica a mais longa entre a sobreposição e o global vigente — o que cobre desativar e recriar e o release que alonga o global. Nenhum caminho do tenant encurta a guarda de um tipo do produto — nem desliga a marca de prova de controle dele: sobreposição com isControlEvidence = false sobre global marcado responde ATTACHMENT_TYPE_CONTROL_EVIDENCE_REQUIRED_UNPROCESSABLE. Mesma mecânica do NotificationTemplate e do ReasonCode.
POST /attachments declara a referência esperada 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.
O Nexio registra o IFileOutcomeHandler do escopo nexio.attachment. Os quatro desfechos — Available, Infected, reprovação (Purged com rejectionCode) e Failed — chegam depois do commit do GrydFiles, pelo menos uma vez. O handler relê o arquivo pela IFileService e decide pelo estado atual; roda com o tenant do arquivo restaurado e falha fechado sem ele; anexo inexistente, ou que não aponta para o arquivo, libera a referência, se houver, e confirma; anexo em Reserved é tratado como Expected (RN-ATT-36); aplicar duas vezes dá o mesmo resultado. Available grava usableAt e Active — e, em anexo revogado enquanto esperava, aplica a RN-ATT-17 à referência; Infected revoga (RN-ATT-20, RN-ATT-37); reprovação revoga com FileRejected e o rejectionCode; Failed só avisa. Efeito que falha volta na próxima entrega; o veredito nunca é desfeito, e nenhuma decisão do produto depende de o handler já ter rodado (RN-ATT-19).
Todo dono publica, depois do commit de cada transição que muda o estado mapeado, OwnerStateChanged(ownerType, ownerId, state, occurredAt), com state em Draft · InProgress · Closed e occurredAt no instante da transição. O Anexo consome: grava lastKnownOwnerState e lastKnownOwnerStateAt em todos os anexos do dono; ignora evento com occurredAt anterior ao gravado, então a ordem de chegada não importa; é idempotente; roda com o tenant do evento restaurado e falha fechado sem ele. Anexo criado depois grava o StateOf do momento, com a hora da leitura. Closed calcula a guarda pelo occurredAt (RN-ATT-31); a saída de Closed a zera. O dono nunca escreve em anexo, e o fechamento não depende do Anexo: atraso na entrega só alonga a guarda, porque até o evento chegar a referência fica.
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 vale para as empresas do escopoQuando o CompanyOf do descritor devolve nulo — item, fornecedor, qualificação do 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 para quem tem ao menos uma empresa no escopo", como em GLAccountCompany e BudgetPolicy: linha sem empresa vale para as empresas do escopo. Quem não tem empresa nenhuma no escopo não lê anexo nenhum, nem os de companyId nulo — escopo vazio é nenhuma empresa, e conjunto vazio nunca vira ausência de filtro (RN-ATT-05). 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.
Quando o dono vai a Closed — pelo occurredAt do OwnerStateChanged (RN-ATT-28) —, cada anexo com referência mantida, e o que o re-scan tornou infectado depois de liberado (RN-ATT-37), ganha guardUntil = 31/12 do ano do occurredAt + retentionYears congelado no anexo — o ano tomado em UTC, que num fuso a oeste de Greenwich nunca é anterior ao local, então o erro possível é para mais —, e o retainUntil do arquivo é estendido até lá depois do commit, confirmado em fileRetainUntil (RN-ATT-35). Em UntilReleased vale a data do occurredAt; em Permanent, nada. O job diário attachment-guard-release roda por tenant, com o tenantId no payload e o escopo aberto explicitamente, e libera a referência quando guardUntil passou, não há guarda legal ativa e o StateOf do dono, perguntado na hora, é Closed ou Missing — senão zera o guardUntil e não libera. Dono que sai de Closed zera o guardUntil dos anexos ainda referenciados; anexo criado com o dono já fechado nasce com ele calculado. Saber do fechamento com atraso só alonga a guarda.
legalHold do arquivoColocar exige create:attachment-legal-holds, caseReference e reason, sobre um anexo ou sobre todos os anexos de um dono, tudo ou nada; retirar exige delete:attachment-legal-holds e liftReason, e encerra só a linha indicada. No máximo uma guarda ativa por (attachmentId, caseReference) (ATTACHMENT_LEGAL_HOLD_ALREADY_EXISTS); retirar duas vezes é 409. Com qualquer guarda ativa, nenhum caminho do Nexio libera a referência do anexo e a eliminação é recusada. Colocar e retirar tomam a mesma trava do storedFileId que a execução da eliminação toma (RN-ATT-39): guarda pedida enquanto uma eliminação corre espera a trava, e a eliminação que chega depois relê a guarda sob a trava e recusa. Depois do ato a promessa continua valendo — o job relê a guarda antes de pedir o expurgo e, com guarda ativa de qualquer detentor, não pede: o pedido fica PurgeBlocked (RN-ATT-33). Sem isso, "nenhum caminho libera" seria verdade até o ato e mentira depois dele. A primeira guarda ativa sobre qualquer anexo de um arquivo liga também o legalHold do StoredFile, com o caseReference como motivo e quem colocou como autor, e a retirada da última guarda ativa sobre os anexos daquele arquivo pede o desligamento, com quem retirou como autor — sem admin:system, pelo mesmo escopo que ligou (RN-GF-27); as duas chamadas depois do commit, com a pendência em fileHoldAppliedAt e fileHoldReleasedAt (RN-ATT-35). É o que protege a prova quando o re-scan libera as referências do arquivo. Colocar, retirar e listar valem só no escopo da empresa do dono: fora dele, 404, e a listagem não traz a linha; o dono e o número legível dele só vão a quem passa no CanRead do dono, e o caseReference de uma guarda nunca vai a quem não tem a permissão. Tipo nunca carrega guarda legal. Colocar e retirar são atos de controle.
POST /attachments/{id}/erase exige erase:attachments, requestReference, dueAt e comment (requesterEmail opcional), grava o AttachmentErasureRequest e executa na hora quando o pedido não pede segunda pessoa nem está bloqueado (RN-ATT-38); o motivo é DADO_PESSOAL_INDEVIDO. Recusam, no registro e de novo na execução, sem mudar nada: guarda legal ativa em algum dos anexos do tenant que apontam para o arquivo; referência ativa de outro escopo — sem caminho automático entre consumidores, caso que na v1 não ocorre, porque o Nexio é o único consumidor de escopo de anexo (RN-GF-25); e a pré-condição do GrydFiles que falha (RN-ATT-39). Bloqueiam, qualquer que seja o CanWrite: dono com aprovação em curso — OpenApprovalOf diferente de nulo —, dono com decisão de controle pendente fora da instância — PendingControlOf diferente de nulo; os donos cobertos e o que cada um responde estão na tabela de respostas do descritor — e alcance incompleto: quem registra passa no escopo e no CanRead do anexo em que age, sem os quais a resposta é 404, e não passa em todos os donos dos demais anexos do arquivo. No registro, nenhum dos três recusa: o pedido nasce Requested, bloqueado, com o dueAt correndo, e o titular é avisado (RN-ATT-38); o bloqueio por alcance nomeia, na leitura do pedido — não é campo, como nenhum bloqueio do registro —, as empresas e os tipos de dono que faltam, nunca o documento, e escala ao administrador do tenant, que é quem concede escopo, porque sem alcance inteiro não há quem execute. Na execução, os três recusam: ATTACHMENT_ERASURE_APPROVAL_IN_PROGRESS_CONFLICT, ATTACHMENT_ERASURE_CONTROL_DECISION_PENDING_CONFLICT e ATTACHMENT_ERASURE_SCOPE_INCOMPLETE_FORBIDDEN — quem executa precisa do alcance inteiro, ou a eliminação seria parcial e não eliminaria nada. Executada, numa transação do Nexio, com o arquivo e os donos travados (RN-ATT-39): revoga todos com Erasure, marca as referências ReleasePending, troca nome original e descrição por texto neutro e registra o ato de controle com quem registrou e quem executou. Depois do commit (RN-ATT-35) sai attachment-erasure-request.executed, e o job de guarda, sem usuário no contexto, libera as referências e pede o expurgo como eliminação (RN-GF-25) nomeando todos os pares do escopo nexio.attachment do arquivo — o original e as cópias, liberados na transação do expurgo —, com a marcação, o motivo, a referência do pedido e quem registrou e quem executou como autores declarados (RN-GF-27) — usuários distintos em Permanent e em todo pedido com segunda pessoa —, nome suprimido na lápide. attachment.erased só sai com purgedAt gravado em todos esses pares; até lá o pedido fica Executed, com a pendência à vista. Em processo a IFileService não confere permissão de módulo e o GrydFiles não define identidade de serviço (RN-GF-28): vale o consumidor registrado para nexio.attachment, o ato e os autores declarados marcados. Expurgo recusado — guarda de qualquer detentor, ou referência de outro escopo criada nesse meio-tempo — leva o pedido a PurgeBlocked, com o código — FILE_LEGAL_HOLD_CONFLICT ou FILE_STILL_REFERENCED_CONFLICT — e a hora, visível na fila ao lado do dueAt, e publica attachment-erasure-request.purge-blocked depois do commit — evento próprio, distinto do attachment-erasure-request.blocked que o registro bloqueado publica; o job segue repetindo, o aviso sai a quem registrou e ao titular, e ao administrador do tenant com o dueAt vencido. Pedido Executed com purgedAt nulo e dueAt vencido escala igual — mesmo aviso, mesma fila, mesmo destaque: do lado do titular o prazo venceu do mesmo jeito, e falha transitória que ninguém repara é indistinguível de bloqueio. Pedido Requested bloqueado com dueAt vencido escala igual, qualquer que seja o bloqueio — aprovação, controle ou alcance —: mesmo aviso, mesma fila, mesmo destaque, com o administrador do tenant na audiência. E o bloqueio por alcance tem saída: quem concede escopo no tenant (update:company-scopes) encerra o pedido com motivo escrito, pela rota de cancelamento (RN-ATT-38); enquanto o alcance de quem registra continuar incompleto, pedido novo bloqueado por alcance sobre o mesmo arquivo responde ATTACHMENT_ERASURE_SCOPE_INCOMPLETE_FORBIDDEN. A repetição é só do job, com os autores gravados: repetir o ato sobre anexo com expurgo pendente não abre pedido novo nem chama o GrydFiles — devolve 200 com o pedido e a pendência; sobre anexo já expurgado, ATTACHMENT_ALREADY_ERASED_CONFLICT. Enquanto houver pedido Requested, Executed ou PurgeBlocked sobre o arquivo, POST /attachments/copy o recusa (RN-ATT-15).
CanRead do dono, em toda rota de leituraEscopo por empresa e read:attachments são necessários e não bastam. Listagem, metadado, consulta de estado em lote, URL de download, cadeia de propagação, candidatos e origem de cópia, histórico e eliminação perguntam CanRead(user, ownerId) ao descritor. Falso responde 404, como fora de escopo (RN-ATT-05); na listagem a linha não vem. Leitura sobre vários donos pergunta por dono — a implementação pode responder em lote, a semântica é por dono. Dono sem descritor registrado responde falso. O portal não usa esta pergunta: lá valem supplierId e visibility (RN-ATT-12). As respostas estão na matriz de leitura.
O GrydFiles tem DbContext próprio, e nenhuma transação atravessa os dois. Criar a referência da cópia, liberar referência, estender retainUntil, ligar e desligar legalHold e pedir expurgo são chamadas em processo feitas depois do commit que decidiu o ato: o Nexio grava a decisão com a pendência — CreatePending, ReleasePending, fileRetainUntil abaixo de guardUntil, fileHoldAppliedAt ou fileHoldReleasedAt nulos, purgedAt nulo com Erasure —, chama, e grava a confirmação. Toda chamada é idempotente: referência por (escopo, chave), liberação de referência já liberada, data que só anda para frente. A que falha grava fileSyncAttemptedAt e fileSyncError e é repetida pelo job attachment-guard-release, por tenant, com o tenantId no payload. O job não age em nome de ninguém: nas chamadas que o GrydFiles registra com autor — ligar e desligar legalHold, pedir o expurgo da eliminação — ele leva como dado o autor do ato já gravado (quem colocou ou retirou a guarda; na eliminação, quem registrou e quem executou), e desligar a guarda que o escopo nexio.attachment ligou não pede admin:system. O expurgo da eliminação é pedido sempre pelo job, também a primeira vez, e nunca repetido por usuário (RN-ATT-33). Antes de cada chamada de expurgo o job relê a guarda legal — e só ela. OpenApprovalOf e PendingControlOf são conferidos no registro e relidos sob a trava na execução (RN-ATT-39), nunca depois: passado o commit do ato, o nome já foi neutralizado e as referências, liberadas, e o que resta a preservar são os bytes. A consequência é declarada — decisão ou contestação aberta depois do ato não segura o expurgo; quem precisa dos bytes para ela põe guarda legal por caso, o único instrumento que segura o expurgo depois do ato (RN-ATT-32), e o pedido fica PurgeBlocked. Pendência com prazo vencido escala: pedido Executed com purgedAt nulo e dueAt vencido recebe o mesmo aviso e o mesmo destaque na fila que o PurgeBlocked (RN-ATT-33). É o requisito da RN-GF-27 do GrydFiles e da demanda de plataforma; enquanto não estiver entregue, a pendência fica aberta e à vista, e o arquivo, preso. Pendência nunca some sozinha: a tela de saúde a mostra por idade. Nenhuma pendência solta arquivo antes da hora — o que prende pode atrasar sem perder prova, e o que solta só é chamado depois de a decisão estar gravada.
fileId só vira anexo na reserva de quem o reservou, e a referência esperada é declarada no processoPOST /attachments/upload-intent reserva o arquivo pela IFileService e grava o anexo em Reserved, com dono, tipo, retenção e quem reservou. POST /attachments só aceita fileId de reserva em Reserved ou Expected do mesmo usuário, no mesmo tenant, para o mesmo dono e o mesmo tipo; qualquer outro — arquivo já liberado de outro anexo, reserva de outra pessoa, dono trocado — responde ATTACHMENT_UPLOAD_NOT_RESERVED_CONFLICT, e nada é gravado. É o que impede anexar arquivo de outro dono e ler por aí o que o CanRead dele esconde. A confirmação reconfere o CanWrite do dono antes de chamar o GrydFiles: dono que deixou de aceitar escrita desde a reserva — a proposta bancária submetida — responde ATTACHMENT_OWNER_NOT_WRITABLE_CONFLICT, e nada é confirmado. Aceito, o Nexio confirma pela IFileService declarando a referência esperada ("nexio.attachment", attachment.id) — nunca por rota HTTP, que não aceita dono — e grava Expected; repetir a chamada repete a confirmação e devolve o mesmo anexo. Reprovação na confirmação revoga com FileRejected. Confirmação que deu certo no GrydFiles e não chegou a gravar Expected não deixa referência órfã: o handler do veredito trata Reserved como Expected (RN-ATT-27), e o job relê o arquivo pela IFileService antes de encerrar a reserva — arquivo que já saiu de Pending vai a Expected e segue o veredito; só a reserva que o GrydFiles não confirmou até vencer a URL de upload vai a NeverCreated, sem nunca ter aparecido na lista. No portal, a reserva fica amarrada ao acesso do portal e ao supplierId, no lugar do usuário.
O GrydFiles leva o arquivo de Available a Infected, libera todas as referências e entrega um veredito por anexo — o arquivo propagado em N documentos recebe N. O handler revoga cada anexo ainda ativo com FileInfected e grava Released; usableAt preenchido é o que diz que ele foi prova. O efeito segue o estado do dono. Draft: aviso a quem anexou, ou ao ResponsibleOf. InProgress: o mesmo aviso e mais os aprovadores pendentes do OpenApprovalOf — a prova de uma decisão em curso deixou de abrir, e decidir o que fazer com a instância é do motor e do dono, não do Anexo. Closed: aviso obrigatório ao ResponsibleOf e a quem anexou — e, se nenhum dos dois estiver ativo, a quem tem create:attachment-legal-holds no tenant, que decide se o caso pede guarda legal; o anexo continua no histórico com a marca "infectado depois do fechamento", guardUntil e as guardas legais continuam valendo, e o objeto fica na quarentena do GrydFiles, cujo expurgo respeita retainUntil e legalHold. Não é eliminação: nenhum expurgo pedido, nome e descrição preservados, nenhum ato de controle. O pedido de eliminação do titular continua possível sobre o arquivo em quarentena, pelo caminho de sempre (RN-ATT-33): o GrydFiles aceita o ato de eliminação também em Infected e Failed (RN-GF-25), e a lápide fica como prova do incidente. O Nexio não oferece o conteúdo em quarentena — acesso forense é da plataforma. O evento attachment.quarantined sai depois do commit.
Permanent ou de anexo ainda sob guarda exige segunda pessoa, que é outro usuárioO pedido pede segunda pessoa quando algum anexo do arquivo é de tipo com isControlEvidence = true, ou de tipo com retenção Permanent, qualquer que seja a marca, ou está dentro da guarda — dono fora de Draft e qualquer uma destas: referência ainda não liberada, guardUntil no futuro, dono ainda não fechado. Pedindo, nasce Requested e nada é revogado; só executa POST /attachments/erasure-requests/{requestId}/execute, com erase:attachments, por usuário diferente de quem registrou (ATTACHMENT_ERASURE_SAME_PERSON_FORBIDDEN), com as condições da RN-ATT-33 reconferidas e requiresSecondPerson recalculado na hora — só passa de falso a verdadeiro. A segunda pessoa é outro usuário: a comparação é por userId, sem CPF e sem regra de conflito de interesse. É risco aceito que dois usuários da mesma pessoa, ou o aprovador de uma troca fraudulenta, façam as duas pontas; mitigam o ato de controle registrado com os dois autores, o aviso ao segundo e a trilha do ato. Pedido bloqueado (RN-ATT-33) também nasce Requested; sem segunda pessoa, quem registrou pode executá-lo quando o bloqueio cair. No registro bloqueado o aviso sai ao titular pelo requesterEmail — sem nome de arquivo, de documento ou de decisão — ou, sem e-mail, a quem registrou; a fila mostra o bloqueio, calculado na leitura, e o dueAt. O registro publica uma de duas ações, nunca as duas: attachment-erasure-request.blocked quando o pedido nasce travado, com o bloqueio e o dueAt como variáveis, e attachment-erasure-request.registered quando nasce livre, à espera da segunda pessoa. A transição a PurgeBlocked é outro momento e tem nome próprio, attachment-erasure-request.purge-blocked (RN-ATT-33) — nenhum dos três compartilha nome. Pedido que nasce travado não avisa quem executa: ninguém executa enquanto o bloqueio não cai, e o bloqueio é calculado na leitura, sem evento de saída; o pedido espera na fila com o bloqueio à vista, e o dueAt vencido escala ao administrador do tenant (RN-ATT-33). Bloqueio por alcance incompleto é o único que quem registrou não derruba sozinho: o aviso vai também ao administrador do tenant, com as empresas e os tipos de dono que faltam e sem o documento, e executa quem tiver o alcance inteiro; até lá o pedido aparece na fila de quem registrou e na do administrador, sem dono nem número legível. No máximo um pedido Requested por arquivo (ATTACHMENT_ERASURE_REQUEST_ALREADY_EXISTS), conferido depois do escopo e do CanRead — o id do pedido não vai a quem não enxerga os donos; cancelar exige comment, e pedido Executed, PurgeBlocked, Purged ou Cancelled não se executa nem se cancela (ATTACHMENT_ERASURE_REQUEST_CLOSED_CONFLICT). Todo cancelamento avisa o titular. Cancelar devolve o arquivo à propagação (RN-ATT-15) e o anexo ao acervo: o pedido do titular deixou de existir, e não há primeiro cancelamento de rotina que ele possa ignorar. Depois do commit sai attachment-erasure-request.cancelled, e o aviso vai ao titular pelo requesterEmail — sem nome de arquivo nem de documento — ou, sem e-mail, a quem registrou, que responde pelo canal do pedido. Pedido bloqueado por alcance incompleto tem um encerrador a mais: a mesma rota de cancelamento aceita, além de quem tem erase:attachments, quem concede escopo no tenant (update:company-scopes) — e só sobre pedido Requested bloqueado por alcance —, com comment como em qualquer cancelamento e o ato registrado com quem encerrou; fora desse caso a rota exige erase:attachments, e sem ela responde 403 de permissão. É a saída do único bloqueio que ninguém dentro do pedido derruba, e ela não entra na cadeia abaixo: não exige usuário diferente do que cancelou o anterior nem aperta o dueAt do pedido seguinte, porque a cadeia existe contra quem registra e cancela para não executar, e quem encerra aqui não registrou e não podia executar. Depois dele, registro novo bloqueado por alcance sobre o mesmo arquivo é recusado enquanto o alcance continuar incompleto (ATTACHMENT_ERASURE_SCOPE_INCOMPLETE_FORBIDDEN) — sem isso o laço recomeçava e a propagação ficava presa de novo. Risco aceito: quem concede escopo pode encerrar pedidos de titular em série; mitigam o motivo escrito, o aviso ao titular em todo cancelamento e o ato registrado. Cancelar tem limite: do segundo cancelamento de pedido sobre o mesmo storedFileId em diante, cancelar exige usuário diferente do que cancelou o anterior (ATTACHMENT_ERASURE_CANCEL_SAME_PERSON_FORBIDDEN), e é a partir do segundo que o aviso alcança também o administrador do tenant, e o pedido novo sobre esse arquivo não aceita dueAt posterior ao do pedido cancelado (ATTACHMENT_ERASURE_REQUEST_REQUIRED_UNPROCESSABLE): sem limite, uma pessoa com o mesmo alcance registrava e cancelava para sempre, e o relógio do titular nunca corria. Depois do commit do registro livre, o evento attachment-erasure-request.registered leva o aviso a quem pode executar — erase:attachments, escopo e CanRead em todos os donos, exceto quem registrou —, e, sem ninguém assim, ao administrador do tenant: o pedido fica parado, e nunca volta à mesma pessoa. Sem segunda pessoa e sem bloqueio, o registro executa no mesmo ato e o pedido nasce Executed, com o mesmo usuário nas duas pontas e o ato registrado; sai só attachment-erasure-request.executed, porque não houve pedido à espera de ninguém. O pedido nunca é apagado nem reescrito.
Antes de qualquer gravação, o registro e a execução perguntam ao GrydFiles, em processo, pela pré-condição do expurgo como eliminação daquele arquivo, por todos os pares do escopo nexio.attachment — a consulta responde o mesmo que o expurgo responderia (RN-GF-25) —, em dois modos. No registro, modo registro: vai um autor só, quem está registrando, e a dupla custódia não é conferida — a segunda pessoa ainda não existe, e exigi-la aí recusaria todo pedido de titular sobre arquivo Permanent, que é justamente o que sempre pede segunda pessoa. Na execução, modo execução: vão os dois autores que o pedido levará, com o executor sendo o usuário do contexto e quem registrou indo como autor declarado — o GrydFiles isenta esse caminho da recusa por autor declarado diferente de quem está no contexto (RN-GF-27), sem a qual a execução por gente responderia 401 e o pedido do titular não se executaria nunca. Falha é recusa sem efeito: guarda legal de qualquer detentor — o escopo nexio.attachment, outro escopo ou a rota do GrydFiles, sem escopo —, que o GrydFiles devolve como FILE_LEGAL_HOLD_CONFLICT → ATTACHMENT_UNDER_LEGAL_HOLD_CONFLICT, que diz só que há guarda; referência ativa de outro escopo, FILE_STILL_REFERENCED_CONFLICT → ATTACHMENT_ERASURE_FILE_REFERENCED_CONFLICT; estado que não aceita a eliminação — Pending ou Scanning; Infected e Failed aceitam (RN-GF-25) —, ou, no modo execução, Permanent sem dois autores distintos (FILE_ERASURE_DUAL_CUSTODY_CONFLICT) → ATTACHMENT_ERASURE_FILE_NOT_PURGEABLE_CONFLICT, com o código do GrydFiles; trilha durável indisponível → FILE_AUDIT_TRAIL_UNAVAILABLE (503). Nenhuma revogação, troca de nome, registro de ato ou attachment.erased acontece antes dessa resposta. Passada a consulta, a execução trava, na sua transação, o storedFileId primeiro — a trava do arquivo, que a cópia (RN-ATT-15), a guarda legal (RN-ATT-32) e outra execução sobre o mesmo arquivo também tomam — e depois cada dono dos anexos com a mesma trava que a submissão daquele dono toma, em ordem de (ownerType, ownerId), para duas execuções não se cruzarem. Sob as travas ela relê o conjunto de anexos do arquivo, OpenApprovalOf, PendingControlOf e as linhas de AttachmentLegalHold, e só então revoga. A trava do arquivo é o que fecha as três janelas que a trava por dono deixava abertas: a cópia gravada entre a leitura do conjunto e o commit, que ficaria ativa com o dado pessoal; a guarda legal ligada no meio, que a execução não veria; e a segunda execução simultânea. A submissão concorrente de um dono comum (ex.: Budget) espera a trava e, ao seguir, encontra os anexos revogados; a execução que chega depois da submissão encontra a instância aberta e recusa. A trava é requisito, não recurso de banco: trava pessimista da linha do dono (por exemplo SELECT … FOR UPDATE) ou token de versão do dono incrementado na mesma transação, com a mesma consequência. Mudança no GrydFiles entre a consulta e o expurgo — guarda ou referência nova — não desfaz o ato: o pedido vai a PurgeBlocked com o código (RN-ATT-33). E o job relê a guarda legal antes de cada chamada: com guarda ativa de qualquer detentor ele não pede o expurgo, e o pedido fica PurgeBlocked — nenhum byte sai debaixo de guarda, que é o que a RN-ATT-32 promete. Dependência de plataforma, com o estado de hoje: a consulta de pré-condição, o expurgo de Permanent com dois autores declarados e o expurgo sem usuário no contexto são requisitos da RN-GF-25, da RN-GF-27 e da demanda de plataforma. Os dois modos, a lista de pares e a isenção do 401 para o autor declarado do ato de eliminação são requisitos da RN-GF-25 e da RN-GF-27, pedidos ao GrydFiles. No código do Gryd.IO (e4eb5a4f) a IFileService não tem a consulta; o expurgo exige admin:system de pessoa no contexto em Permanent e para desligar guarda (FileService.cs:687, :791–803), só expurga arquivo Available (:786), quando a RN-GF-25 abre a eliminação também para a quarentena, e, sem usuário, lança exceção em vez de responder (:831). A eliminação não vai a produção antes disso: sem a consulta, nenhuma execução acontece, e o pedido fica Requested, à vista — o erro possível é para mais. Critérios de aceite: (a) SIGNED_CONTRACT com guarda posta pela rota do GrydFiles → 409 na execução, nenhum anexo revogado, nome intacto, nenhum attachment.erased; (b) Permanent executado por quem registrou → 403 antes da consulta; (c) submissão do dono e execução simultâneas → ou a instância abre e a execução recusa com 409, ou a execução revoga antes e a submissão vê os anexos revogados — nunca instância aberta sobre anexo eliminado; (d) BANK_OWNERSHIP_PROOF de proposta de carga inicial com o lote por confirmar → execução 409 por PendingControlOf, e o pedido continua Requested; (e) POST /attachments/copy daquele arquivo durante a execução → ou espera a trava e é recusado com ATTACHMENT_ERASURE_IN_PROGRESS_CONFLICT, ou entrou no conjunto antes da leitura e é eliminado junto — nunca sobra cópia ativa com o dado pessoal; (f) guarda legal ligada durante a execução → ou ela espera a trava e o ato conclui, ou a execução a vê sob a trava e recusa com 409 — nunca sai attachment-erasure-request.executed com guarda ativa, e o job nunca expurga com guarda ativa; (g) decisão de controle aberta no dono depois do commit do ato → o job segue e o expurgo acontece, que é o comportamento declarado (RN-ATT-35); guarda legal posta antes de a chamada do job sair → nenhum byte apagado e o pedido em PurgeBlocked; (h) registro por quem tem escopo e CanRead no anexo em que age e não em todos os donos dos demais anexos do arquivo → o pedido nasce Requested, bloqueado por alcance, com o dueAt correndo, o titular avisado e o administrador do tenant escalado, e nada revogado; a execução por essa mesma pessoa responde ATTACHMENT_ERASURE_SCOPE_INCOMPLETE_FORBIDDEN; (i) pedido Requested bloqueado — por aprovação, por controle ou por alcance — com o dueAt vencido → sai o mesmo aviso do PurgeBlocked, com o administrador do tenant na audiência, e o pedido aparece no mesmo destaque da fila, sem que nada seja revogado; (j) encerramento, por quem tem update:company-scopes, de pedido bloqueado por alcance → o pedido fica Cancelled com o motivo registrado, o titular é avisado, POST /attachments/copy daquele arquivo volta a passar (RN-ATT-15) e registro novo bloqueado por alcance sobre o mesmo arquivo responde 403 enquanto o alcance continuar incompleto.
Anexo de tipo com isControlEvidence = true que perde o veredito avisa o dono — e não só pelo re-scan. Revogação (POST /attachments/{id}/revoke, qualquer revocationSource), eliminação a pedido de titular (RN-ATT-33) e reprovação do arquivo (FileRejected, com o rejectionCode) têm o mesmo efeito que a RN-ATT-37 dá ao veredito Infected: a prova que sustentava um ato de controle deixou de abrir. O aviso sai depois do commit, com a audiência da RN-ATT-37 pelo estado do dono — Draft: quem anexou, ou o ResponsibleOf; InProgress: isso e os aprovadores pendentes do OpenApprovalOf; Closed: obrigatório ao ResponsibleOf e a quem anexou, com fallback a quem tem create:attachment-legal-holds — e leva o tipo, o dono e por que a prova saiu, nunca nome de arquivo. É esse aviso que o dono usa de gatilho: no Fornecedor, o ControlEvidenceLost de BANK_OWNERSHIP_PROOF, SIGNED_CONTACT_CHANGE_REQUEST e IDENTITY_CORRECTION_PROOF — os dois últimos não têm conta bancária atrás e, sem esta regra, não tinham nada que os disparasse. O Anexo avisa; o que fazer com o ato é do dono — reabrir a instância, recusar o desfecho ou exigir prova nova é decisão dele, e o Anexo não muda estado de documento por causa disto. Critérios de aceite: (a) revogação de IDENTITY_CORRECTION_PROOF com a correção de identidade em curso → aviso ao dono, e nada muda do lado do Anexo; (b) eliminação a pedido de titular de BANK_OWNERSHIP_PROOF de lote já confirmado → aviso ao dono, sem nome de arquivo e sem o comentário do pedido; (c) FileRejected de anexo com a marca → o mesmo aviso, com o rejectionCode; (d) anexo sem a marca isControlEvidence → nenhum aviso novo, em nenhum dos três caminhos.
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 da semente, contado do fechamento do dono. É 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 faz a guarda vencer o pedido de eliminação — e, sem base, o ato de eliminação (RN-ATT-33). 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, 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. - XML da NF-e. É o documento fiscal — chave de acesso, emitente, protocolo — e é entidade da
Invoice, pela mesma regra de corte. O DANFE, que é a representação, pode ser anexo. - Boleto e comprovante de pagamento. Pagamento é do ERP; o Nexio não guarda nem um nem outro.
- 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.
- Submeter sozinho quando o anexo liberar — v2. A pessoa pediria, e um processo em segundo plano enviaria o documento para aprovação depois do veredito. Depende de execução delegada — identidade, permissões e escopo de quem pediu, restaurados e reavaliados fora da requisição, com a marca "executado em nome de" —, requisito registrado na demanda de plataforma. Quando voltar, volta com uma pergunta de submissão no descritor do dono; na v1 a pessoa envia (ver § Scanning).
- 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á.
Nenhuma decisão em aberto. A de § Scanning está fechada, e o ReasonCode está especificado: reasonCodeId aponta 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.
Registro
Histórico de revisões
| Versão | Data | O que mudou |
|---|---|---|
| v1.17 | 30/10/2026 | Permissões padronizadas em read/lookup/create/update/delete (decisão de 30/10/2026). manage:attachments → create:attachments (POST /attachments/upload-intent, POST /attachments, POST /attachments/copy) e update:attachments (PUT /attachments/{id}, POST /{id}/rescan, rebaixar visibilidade); manage:attachment-types → create:attachment-types (POST), update:attachment-types (PUT) e delete:attachment-types (PATCH /deactivate); manage:attachment-legal-holds → create:attachment-legal-holds (colocar), delete:attachment-legal-holds (retirar, /lift) e read:attachment-legal-holds (listar); manage:company-scopes, citada da Empresa, → update:company-scopes (quem concede escopo). GET /attachment-types, que popula o seletor, passa de read:attachments a lookup:attachments. A tabela de permissões vai de sete a treze linhas. |
| v1.16 | 21/09/2026 | Validação de 18/09, achado 25. Um nome por momento na eliminação a pedido do titular. A execução publicava attachment.erasure-requested, o mesmo nome que a Notificação usava para o aviso do registro; passa a publicar attachment-erasure-request.executed, sem aviso. Decisão do Roger: o registro publica uma de duas ações, nunca as duas — attachment-erasure-request.registered quando o pedido nasce livre, à espera da segunda pessoa, e attachment-erasure-request.blocked quando nasce travado; o registro que executa na hora publica só o .executed. Pedido que nasce travado não avisa quem executa; espera na fila, e o dueAt vencido escala como antes (RN-ATT-33, RN-ATT-38, critério (f) da RN-ATT-39, nota da convenção de nomes). |
| v1.15 | 20/09/2026 | Recuos da validação de 18/09 (achados 11 e 12). Portal: rota, autenticação e sessão do fornecedor saem desta página e passam a ser citadas do Portal do Fornecedor — a linha de endpoint vira “rota do fornecedor”, e “token da RFQ” e “link de uso único” saem de campo, regra e erro; ficam aqui só a autoria e o isolamento (RN-ATT-12, RN-ATT-21, RN-ATT-36). Entre empresas (RN-ATT-14): sai a descrição de como a cotação adjudica; como a demanda de várias empresas se consolida é da spec da Cotação. |
| v1.14 | 13/09/2026 | Correção pós-validação R7. Prazo do titular: o dueAt vencido escala também no pedido Requested bloqueado — por aprovação, por controle ou por alcance —, com o mesmo aviso, a mesma fila e o mesmo destaque do PurgeBlocked e do Executed com purgedAt nulo, e com o administrador do tenant na audiência: bloqueio não para o relógio (RN-ATT-33, RN-ATT-39 critério (i)). Saída do bloqueio por alcance: quem concede escopo no tenant (manage:company-scopes) encerra o pedido bloqueado por alcance incompleto, com motivo escrito, pela rota de cancelamento — fora da cadeia de cancelamentos —, e registro novo bloqueado por alcance sobre o mesmo arquivo é recusado enquanto o alcance continuar incompleto; escolhido o encerramento por gente em vez de caducidade por prazo (RN-ATT-33, RN-ATT-38, RN-ATT-39 critério (j), ATTACHMENT_ERASURE_SCOPE_INCOMPLETE_FORBIDDEN). Códigos do GrydFiles nomeados: FILE_LEGAL_HOLD_CONFLICT ao lado de ATTACHMENT_UNDER_LEGAL_HOLD_CONFLICT e FILE_STILL_REFERENCED_CONFLICT ao lado de ATTACHMENT_ERASURE_FILE_REFERENCED_CONFLICT, também no purgeBlockedCode, na fila e na RN-ATT-39. Prova de controle: RN-ATT-40 nova — revogação, eliminação a pedido de titular e reprovação de anexo isControlEvidence avisam o dono, como já fazia o re-scan, e é esse aviso que dispara o ControlEvidenceLost do Fornecedor. Menores: a citação do contrato em processo ganha o número e o nome da asserção 10 da Arquitetura; declarado que os sete eventos de domínio do Anexo sem linha no catálogo são eventos sem aviso na v1. |
| v1.13 | 12/09/2026 | Correção pós-validação R6. Eliminação: pedido do titular com alcance incompleto deixa de ser recusado no registro — nasce Requested, bloqueado, com o dueAt correndo, o alcance que falta nomeado na leitura, aviso ao titular e a quem registrou e escalonamento ao administrador do tenant; o ATTACHMENT_ERASURE_SCOPE_INCOMPLETE_FORBIDDEN passa a valer só na execução, e a fila e o GET mostram esse pedido a quem o registrou e ao administrador. Prazo vencido: pedido Executed com purgedAt nulo escala como o PurgeBlocked. Cancelamento: o titular é avisado em todos; o aviso ao alcance de controle continua a partir do segundo. Depois do ato só a guarda legal segura os bytes — o job não relê OpenApprovalOf nem PendingControlOf, e a consequência está escrita. Eventos: a transição a PurgeBlocked ganha nome próprio, attachment-erasure-request.purge-blocked, distinto do attachment-erasure-request.registered do registro bloqueado; o cancelamento publica attachment-erasure-request.cancelled. Lote da carga inicial: a lista de falta e de espera passa a ser a que o Fornecedor fecha na RN-FOR-31, copiada literalmente. EV-33 citada (arquivo referenciado por dois escopos não é caso da v1). Erro ATTACHMENT_ERASURE_REQUEST_CLOSED_CONFLICT alinhado aos quatro estados terminais. Fonte sem link de página, como o par declara. |
| v1.12 | 12/09/2026 | Correção pós-validação R5. Eliminação: no ato sai attachment.erasure-requested, e attachment.erased só quando o GrydFiles grava purgedAt em todos os pares; recusa do expurgo vira estado próprio PurgeBlocked, com código, hora, dueAt à vista, repetição pelo job e aviso a quem registrou, ao titular e — vencido o prazo — ao administrador (RN-ATT-33, campos purgedAt/purgeBlockedSince/purgeBlockedCode). A consulta de pré-condição roda em dois modos — registro, com um autor só e sem dupla custódia; execução, com o executor do contexto e quem registrou como autor declarado, isento do 401 — e nomeia todos os pares do escopo, original e cópias, liberados na transação do expurgo (RN-ATT-39). Travas: a execução trava o storedFileId antes dos donos; colocar e retirar guarda legal e POST /attachments/copy tomam a mesma trava; a execução relê AttachmentLegalHold sob ela e o job não expurga com guarda ativa (RN-ATT-32, RN-ATT-39, critérios (e) e (f)). Propagação recusada sobre arquivo com pedido em curso (ATTACHMENT_ERASURE_IN_PROGRESS_CONFLICT, RN-ATT-15). Controle pendente: PendingControlOf passa a incluir a janela de contestação com token válido e o alerta ErpBankDivergence em aberto. Prova de controle: o anexo do ato de troca de canal perde a conferência de assinatura — o sistema exige o anexo, o tipo e o veredito e grava o rastro — e nasce IDENTITY_CORRECTION_PROOF para a correção de identidade (onze tipos semeados). Contratos copiados: matriz de leitura com a frase literal do Fornecedor ("não é o remetente — nem pelo usuário, nem pelo CPF") e o remetente na coluna certa; estados do lote exatamente como o Fornecedor os declara; asserção 8 é lista branca de endpoints; frase única do contrato em processo (RN-GF-28). Menores: a rota erase não libera nem expurga — quem faz é o job; o id do pedido só vai depois do escopo e do CanRead; cancelamento repetido exige usuário diferente (ATTACHMENT_ERASURE_CANCEL_SAME_PERSON_FORBIDDEN) e o pedido novo não estica o dueAt. |
| v1.11 | 11/09/2026 | Correção pós-validação R4. Eliminação: arquivo de tipo Permanent sempre com segunda pessoa; o registro e a execução consultam a pré-condição de expurgo do GrydFiles antes de gravar — guarda de qualquer detentor, inclusive a sem escopo, estado, dois autores em Permanent — (RN-ATT-39, ATTACHMENT_ERASURE_FILE_NOT_PURGEABLE_CONFLICT); pergunta nova do descritor PendingControlOf (doze perguntas) e recusa na execução com decisão de controle pendente fora da instância (ATTACHMENT_ERASURE_CONTROL_DECISION_PENDING_CONFLICT); o registro com aprovação ou controle pendente é aceito bloqueado, com dueAt e requesterEmail e aviso ao titular; a execução trava os donos como a submissão; "dentro da guarda" inclui guardUntil futuro e dono não fechado; segunda pessoa é outro usuário, risco aceito registrado (RN-ATT-33, RN-ATT-38). GrydFiles: em processo não se confere permissão — consumidor registrado, ato e autor declarado marcado; citação RN-GF-26 → RN-GF-27; expurgo pedido e repetido só pelo job, com os dois autores. Proposta bancária: carga inicial Open = InProgress e aceita anexo até a confirmação; ChainContact sem instância; comprovante do lote sem veredito → ATTACHMENT_SCAN_PENDING_CONFLICT, Failed/Infected/revogado/eliminado → falta, código do Fornecedor; matriz de leitura com a lista da RN-FOR-36 (antes e depois da confirmação). GET /attachments/legal-holds omite o dono sem CanRead. Conferido no código do Gryd.IO (e4eb5a4f): permissões de arquivo só na policy das rotas; admin:system ainda conferido em processo; sem a consulta de pré-condição e sem expurgo sem usuário — a eliminação espera a plataforma (RN-ATT-39). No mesmo dia: a eliminação alcança arquivo em quarentena (Infected, Failed), alinhada à RN-GF-25; citações à RN-GF-28 e a FILE_ERASURE_DUAL_CUSTODY_CONFLICT. |
| v1.10 | 11/09/2026 | Correção pós-validação R3. Eliminação: recusada com aprovação em curso em algum dono do arquivo (ATTACHMENT_ERASURE_APPROVAL_IN_PROGRESS_CONFLICT); dupla custódia quando o arquivo é prova de controle ou ainda está sob guarda — entidade AttachmentErasureRequest, rotas POST /attachments/erasure-requests/{requestId}/execute e …/cancel e GET /attachments/erasure-requests, marca isControlEvidence no AttachmentType (RN-ATT-33, RN-ATT-38, RN-ATT-25); o expurgo é pedido como eliminação, com quem executou como autor. Confirmação: reconfere o CanWrite do dono; Reserved conta como pendente na submissão e na confirmação do lote da carga inicial bancária; o handler trata Reserved como Expected e o job relê o arquivo antes de NeverCreated (RN-ATT-19, RN-ATT-27, RN-ATT-36). Leitura: o comprovante bancário segue a lista do Fornecedor — proponente em Draft ou Open, tarefa ativa ou decidida, confirmador do lote; linha sem empresa vale para as empresas do escopo, e escopo vazio não lê nada (RN-ATT-30); rotas de guarda legal no escopo da empresa do dono, caseReference só para quem gere guarda (RN-ATT-32). Plataforma: o job leva o autor do ato como dado e desligar guarda posta pelo próprio escopo não pede admin:system (RN-ATT-35); hosts sem /files/* (asserção 8) separados de permissão de arquivo fora de papel de tenant (catálogo, na demanda). Semente com dez tipos: entra SIGNED_CONTACT_CHANGE_REQUEST. Piso de retenção contado da reserva; índices sem premissa de banco. |
| v1.9 | 11/09/2026 | Validação de 10/09 e correções pós-validação da onda 1. Leitura e escrita: CanRead entra no descritor no lugar de SubmitPolicyOf, com a matriz de leitura (RN-ATT-34); a guarda de inicialização vale sobre os módulos registrados, e o enum persiste como texto (RN-ATT-03); StateOf pelos status reais das specs donas, com SupplierQualification e BudgetChangeRequest como donos. Arquivo: o veredito chega depois do commit do GrydFiles a um handler idempotente, com os quatro desfechos (RN-ATT-27); o par pendente vira referência esperada, declarada no processo, e o fileId só vira anexo na reserva do mesmo usuário, tenant, dono e tipo (RN-ATT-36); toda escrita no GrydFiles acontece depois do commit do Nexio, com pendência visível (RN-ATT-35); arquivo liberado que o re-scan acha infectado revoga o anexo com efeito por estado do dono, sem ser eliminação (RN-ATT-37); rota POST /attachments/{id}/rescan; o módulo de arquivo não faz mais dedupe. Colunas: fileReferenceReleased dá lugar a fileReferenceStatus; novas retentionMode · retentionYears, guardUntil, fileRetainUntil, usableAt, rejectionCode, fileSyncAttemptedAt · fileSyncError, purgedAt e lastKnownOwnerStateAt; revocationSource ganha FileRejected e Erasure. Estado do dono: chega pelo evento OwnerStateChanged, e o occurredAt do fechamento conta a guarda (RN-ATT-28). Guarda: fim contado do fechamento do dono (RN-ATT-31), GENERAL e TECHNICAL_SPEC com cinco anos; guarda legal por caso em AttachmentLegalHold, que liga também o legalHold do arquivo (RN-ATT-32); eliminação a pedido de titular, alcançando todas as cópias ou nenhuma (RN-ATT-33); a sobreposição do tenant só alonga a retenção (RN-ATT-25). Donos e portal: propagação entre empresas com escopo nas duas (RN-ATT-14); o portal devolve o que o próprio fornecedor subiu, e anexo de resposta nunca vira Supplier (RN-ATT-12); BankAccountChangeProposal em Draft até a submissão, escrita só pelo proponente em rascunho e leitura por read-unmasked:supplier-banks; tipo semeado BANK_OWNERSHIP_PROOF; XML da NF-e é entidade da Invoice, e boleto sai da semente. Contratos: submissão diferida fora da v1 (v2 em § Fora de escopo); read:files no plural; dez sufixos de erro; REASON_CODE_REQUIRED_UNPROCESSABLE do Motivo no lugar do erro próprio; notas de data saem do corpo. |
| v1.5 – v1.8 | 08–09/09/2026 | Validação de 08/09. A FileReference deixa de nascer na confirmação e passa a nascer na liberação; o contrato de veredito é declarado; lastKnownOwnerState para a varredura de órfãos; perguntas OpenApprovalOf e ResponsibleOf; companyId nulo para dono do tenant (RN-ATT-30); donos ApprovalRequest, Supplier, Item, Budget, BankAccountChangeProposal e QuotationRequestItem com respostas declaradas; Failed bloqueia a submissão; a propagação da linha de requisição passa pela cotação; certidão de fornecedor volta a ser SupplierDocument. |
| v1.1 – v1.4 | 03–06/09/2026 | Decisão de § Scanning fechada com a spec de Notificação, que trouxe a nona pergunta do descritor; reasonCodeId passa a apontar para o ReasonCode, uso AttachmentRevoke; sobreposição do tipo global pelo tenant (achado A6 da contra-análise); RequisitionLine → RequisitionItem e PurchaseOrderLine → PurchaseOrderItem — linha de documento é <Documento>Item. |
| v1.0 | 02/09/2026 | Primeira versão: duas camadas com a regra de corte pelo metadado, dono polimórfico com registro de descritores e três mitigações, visibilidade Internal | Supplier, propagação explícita com referência por cópia, revogação conforme o estado do dono, retenção declarada pelo tipo, anexo do portal amarrado ao supplierId. |