Especificação · Transversal

Notificação

Canal, template, fila, agendador, retry, in-app, push e e-mail já existem no GrydNotifications e não são remodelados aqui. O que falta é o que a plataforma não pode saber: quais avisos este produto emite, quem recebe cada um, o que pode ir dentro deles e o que o usuário tem direito de desligar. Uma tabela de preferência, um catálogo de vinte e quatro slugs extraído das nove specs já publicadas, cinco resolvedores de audiência — e a regra que impede o número que a tela esconde de sair pelo e-mail.

consome GrydNotifications · GrydJobs · GrydAudit desbloqueia Requisition · fecha a decisão 8 do Attachment 1 agregado · 2 entidades de estado · 2 registros em código · 24 slugs v1.7 · 08/09/2026

Ponto de partida

Oito decisões

Fechadas antes de o documento existir. As três primeiras decidem o tamanho da spec; a quarta é a que impede uma regra de segurança já escrita em outra spec de virar um if espalhado por vinte disparos.

Decisão 1 · Preferência

Usuário × slug × canal, e a linha só existe quando difere do padrão. NotificationPreference grava exceção, não estado. Ausência de linha significa "o que o catálogo declarou", então slug novo entra ligado ou desligado conforme o próprio catálogo — sem migração, sem linha órfã, e sem o caso que sempre morde: o usuário que configurou as preferências em março e deixa de receber, em silêncio, o aviso publicado em agosto.

Decisão 2 · Obrigatoriedade

isMandatory com canal e motivo. O slug não some da tela e o canal que carrega o controle não desliga; canais adicionais ficam a critério do usuário. O motivo vem do catálogo e aparece ao lado do controle desabilitado, porque controle que a pessoa não entende vira ruído — e ruído acaba desligado por outro caminho. Obrigatória não significa todos os canais: push às duas da manhã para um controle que o e-mail já cumpre é como se ensina alguém a ignorar.

Decisão 3 · Destinatário

Resolvedor por tipo de audiência. O disparo manda audience: {type, params}; quem vira lista de pessoas é um IAudienceResolver registrado por tipo, e ele mora no módulo dono do conceito — o rollup de donos do nó é do centro de custo, não do módulo de notificação. A alternativa era cada consumidor entregar a lista pronta, e o preço seria o mesmo rollup reimplementado em três módulos, divergindo no primeiro bug. Foi a decisão mais cara das oito, e é a que evita que "notificação" vire o lugar onde regra de negócio de outros módulos se acumula.

Decisão 4 · Payload

Notificação é ponteiro, não relatório. O corpo carrega identificação, contexto não sensível e link — nunca valor sob permissão. A RN-O22 do Orçamento já dizia que sem read:budget-amount os valores não vão "nem na API, nem no CSV, nem na notificação"; aqui isso deixa de ser regra do orçamento e vira regra do produto. O catálogo declara as variáveis de cada slug, e nenhuma delas pode ser sensível: a checagem acontece na semeadura, não em tempo de execução.

Decisão 5 · Convenção

nexio.<entidade>.<verbo-no-passado>, e o AuditLog fecha junto. O slug é literalmente a ação da trilha com o prefixo do produto: a ação attachment.revoked tem o aviso nexio.attachment.revoked. Fecha na mesma conversa a convenção de nomes de ação que a spec de Anexo propôs e não fechou. O prefixo não é enfeite: NotificationTemplate é tabela global do framework, e vai conviver com os slugs do próximo produto Gryd.

Onde a ação mora, já que o AuditLog não tem coluna para ela. A convenção vale só para evento de negócio — o que se registra por IAuditLogService.RecordAsync, como "baixou o anexo" ou "aprovou o passo". Esses gravam a ação em AdditionalData, sob a chave fixa action, e a consulta por ação é filtro sobre esse metadado; índice sobre ele é decisão de infraestrutura, para quando o banco estiver escolhido. A entrada automática do interceptor de diff não tem ação: ela tem EntityType, EntityId e ChangeType, e é outra coisa — quem quer "o que mudou na linha" lê o diff, quem quer "o que a pessoa fez" lê a ação. Coluna própria Action no AuditLog é o lar definitivo e é spec de plataforma, no molde do GrydFiles; até lá nada no Nexio depende dela, porque a chave do metadado é contrato do produto.

Decisão 6 · Ritmo

v1 imediata com throttle; agrupamento declarado e não construído. O catálogo já nasce com digestEligible e throttleWindow. A v1 implementa só o throttle — a mesma tupla usuário + slug + entidade não repete dentro da janela —, que resolve barato o ruído de lembrete de SLA. O agrupador entra depois sem migração, porque os campos que ele precisa já existem e já estão preenchidos.

Decisão 7 · Locale

Usuário, depois tenant, depois o template global. Cascata de três, o mesmo desenho da resolução de taxa em Moeda e Câmbio e da herança de política em Orçamento. O campo Locale já existe no NotificationTemplate; o que faltava era dizer quem escolhe.

Decisão 8 · Scanning

Aviso e fila, com a fila opcional a cada tentativa. Fecha o que a spec de Anexo deixou declarado em aberto. Quem tenta submeter um documento com anexo em Scanning escolhe entre ser avisado quando liberar ou deixar a submissão acontecer sozinha. Auto-submissão é ato com consequência — dispara aprovação e movimenta orçamento —, então ela é pedida, é cancelável, e desiste sozinha se o veredito do antivírus não for limpo.

O que não foi decidido aqui, porque já estava decidido. A fila do aprovador não é notificação: é ApprovalAssignment, especificada no Fluxo de Aprovação. A notificação de tarefa nova é um empurrão para uma fila que existe sem ela — e é por isso que ela pode ser desligada, agrupada em resumo e perdida sem que nada se perca. Confundir as duas é como se constrói um sistema em que a decisão mora na caixa de entrada.

Camadas

A fronteira com o GrydNotifications

O módulo da plataforma foi lido no código em 02/09/2026 e entrega mais do que o documento de arquitetura descreve. Nada do que está na coluna da esquerda é remodelado, reembrulhado ou reimplementado — esta spec consome.

O que existe de cada lado, e por quê
AssuntoQuem resolveDetalhe
Entrega, retry e canalGrydNotificationsNotification, NotificationRecipient, NotificationDeliveryAttempt, UserNotification, DeviceToken; in-app por IInAppNotificationSender, push por Firebase, e-mail por SendGrid, cada provedor num projeto de infraestrutura próprio
Template e renderizaçãoGrydNotificationsNotificationTemplate com Slug, SubjectTemplate, HtmlBodyTemplate, TextBodyTemplate, Locale, Version, RequiredVariables e TenantId? — nulo é template do produto, preenchido é override do tenant. Mesmo padrão que o AttachmentType já pratica
Fila e agendamentoGrydNotifications · GrydJobsINotificationQueue, INotificationScheduler e a política de retry. O digest da v2 se apoia neste agendador, e é por isso que ele não precisa de infraestrutura nova
Quais avisos existemNexioCatálogo de slugs. A plataforma sabe entregar um template; ela não tem como saber que "orçamento estourou dentro da tolerância" é um aviso deste produto, que ele vai para os donos do nó por rollup e que ele nunca carrega valor
Quem recebeNexioResolvedores de audiência. Rollup de centro de custo, aprovadores pendentes e contato financeiro do fornecedor são conceitos do domínio de compras
Quem quer receberNexioNotificationPreference. Não existe entidade de preferência no framework — é a única lacuna de tabela do módulo, e a razão de esta spec ter uma tabela em vez de nenhuma
Alerta de operadorPlataformaFila de scan alta e base de assinaturas velha, do GrydFiles, avisam o operador da plataforma — não um usuário do tenant. Slug de operador não é nexio.*, não aparece na tela de preferências e não passa por NotificationPreference. Ver § Escopo

A dívida que sai junto. NotificationAttachment guarda byte[] Content numa coluna do banco. É exatamente o que o GrydFiles corrige, e é a mesma troca que Item e Supplier fizeram em 02/09: o campo vira storedFileId, com perfil próprio gryd.notification-attachment declarado no appsettings — retenção curta, porque anexo de e-mail enviado não é prova de nada depois que o e-mail chegou. Ninguém no Nexio depende dessa coluna hoje, então a troca é barata agora e cara depois de o primeiro cliente ter dois anos de e-mails.

Modelo

Mapa de entidades

NotificationPreference agregado · por usuário A única lacuna de tabela do módulo. Uma linha por exceção: usuário, slug, canal, ligado ou desligado. Ausência de linha é o padrão do catálogo, e é isso que faz slug novo funcionar sem migração.
NotificationThrottle estado · sem valor de negócio A memória curta do ritmo: última entrega por usuário, slug e entidade. Existe para o lembrete de SLA não virar e-mail diário. Varrida por job; perder a tabela reenvia, não corrompe.
DeferredSubmission agregado · nasce da decisão 8 A fila de "pronto para enviar". Guarda o documento, quem pediu e o estado. Não sabe submeter nada — pede ao dono, pelo descritor, e a permissão é reavaliada na hora da execução.
NotificationCatalog registro em código · não é tabela Uma NotificationDefinition por slug: audiência, canais, padrão, obrigatoriedade, variáveis, throttle. É do produto e muda com release, não com o tenant. Mesmo precedente do AttachmentOwnerRegistry.
AudienceResolverRegistry registro em código · cinco tipos Um IAudienceResolver por tipo de audiência, implementado pelo módulo dono do conceito. Tipo sem resolvedor derruba a inicialização, pela mesma razão que ownerType sem descritor derruba.
NotificationTemplate GrydNotifications · global Slug, Locale, Version, RequiredVariables e TenantId?. O Nexio semeia um template por slug e por locale suportado, e nunca cria a tabela.
Notification · UserNotification GrydNotifications · entrega Instância, destinatários, tentativas e caixa in-app. É onde a notificação realmente existe depois que este módulo decidiu o que mandar e para quem.
ApprovalAssignment Fluxo de Aprovação · não é notificação A fila do aprovador. Está aqui porque a confusão entre as duas é o erro que esta spec existe para não cometer: a tarefa é fonte da verdade, o aviso é empurrão.

Convenção de nomes: o documento é escrito em português e todo identificador é em inglês — entidade, atributo, valor de enum, slug, rota, permissão e código de erro.

Nomes

Convenção de slug e de ação

Duas dívidas fecham com uma regra. A spec de Anexo propôs <entidade>.<verbo-no-passado> para as ações do GrydAudit e deixou a convenção sem dono. O slug de notificação adota a mesma cauda e acrescenta o prefixo do produto.

A forma

nexio.<entidade>.<verbo-no-passado> — entidade em kebab-case, no singular, com o nome da classe do modelo; verbo em snake_case, no particípio, descrevendo o que aconteceu, nunca o que se quer que a pessoa faça. nexio.attachment.revoked, não nexio.attachment.please_review.

Por que o prefixo

NotificationTemplate é tabela do framework, com TenantId anulável e sem coluna de produto. O segundo produto Gryd que semear supplier.approved colide com o nosso em silêncio, e o efeito é um cliente recebendo o texto do outro produto. Três segmentos custam nada agora.

A ação de auditoria

É o mesmo string sem o prefixo: attachment.revoked. Onde um aviso e uma ação de trilha descrevem o mesmo fato, os dois nomes são o mesmo nome — e a busca por "o que aconteceu com este documento" atravessa os dois módulos sem tabela de-para.

Nem toda ação vira aviso

A recíproca não vale e não deve valer. A trilha registra attachment.downloaded; ninguém precisa ser notificado disso. O catálogo é um subconjunto das ações, escolhido pela pergunta "alguém precisa agir ou saber agora?".

Domínio

Campos · NotificationDefinition

Não é tabela. É um registro em código, uma definição por slug, no mesmo padrão do AttachmentOwnerRegistry: o que é do produto e muda com release não vira linha que o tenant edita. O tenant personaliza o texto, pelo override de NotificationTemplate que o framework já oferece — não o comportamento.

Legenda: obrig sempre · cond conforme o caso · opc opcional · gerado derivado.

CampoTipoRegra
slugstringobrigChave. nexio.<entidade>.<verbo-no-passado>, ver § Convenção. Imutável depois de publicado: renomear slug é publicar outro e migrar as preferências, e a v1 não faz isso
categoryenumobrigApproval · Budget · Catalog · Supplier · Attachment · System. Só agrupa a tela. A preferência é por slug, não por categoria — a categoria existe para a lista de vinte e quatro itens ser legível, não para ser desligada em bloco
audienceTypeenumobrigUm dos cinco de § Resolução de destinatário. Valor sem resolvedor registrado derruba a inicialização
allowedChannelsenum[]obrigEmail · InApp · Push. Canal fora desta lista não aparece na tela e é recusado na API — não existe "ligar push" para um aviso que não faz sentido no celular
defaultChannelsenum[]obrigSubconjunto de allowedChannels que vem ligado. É o valor que a ausência de linha em NotificationPreference significa, e por isso mudar este campo num release muda o comportamento de quem nunca configurou nada — de propósito, e é a razão de o campo ser do produto
isMandatoryboolobrigPadrão false. Verdadeiro só nas duas famílias descritas em § Notificação que não se desliga. Três dos vinte e quatro slugs, e cada um com o motivo escrito
mandatoryChannelenum?condObrigatório quando isMandatory; nulo caso contrário. Um canal só — o que carrega o controle. Os demais canais permitidos continuam desligáveis
mandatoryReasonstring?condObrigatório quando isMandatory. Frase curta, em pt-BR, exibida ao lado do controle desabilitado. Não é texto de sistema: é a explicação que evita o usuário procurar outro jeito de desligar
variablesstring[]obrigAs variáveis que o disparo entrega ao template. Confrontadas com RequiredVariables do template semeado, na inicialização. Nenhuma pode ser sensível — ver § Payload e permissão
entityKeystring?opcQual variável identifica a entidade para efeito de throttle e de agrupamento futuro. Nulo desliga o throttle por entidade e mantém só o de usuário + slug
throttleWindowintervalobrigZero significa imediato e sem memória. Diferente de zero, a mesma tupla usuário + slug + entidade não repete dentro da janela. Ver § Ritmo
digestEligibleboolobrigDeclarado na v1, consumido na v2. Está aqui porque acrescentar o campo depois obrigaria a revisar vinte e quatro definições sob pressão de release — decidir agora custa uma coluna de tabela em documento
auditstring?opcA ação correspondente na trilha, quando existe: attachment.revoked. É o slug sem o prefixo, e serve de conferência automática da convenção

Domínio

Campos · NotificationPreference

Herda de TenantScopedEntity. Id e o bloco de auditoria e soft delete vêm do BaseEntity e não estão repetidos. A tabela guarda exceção, não estado — quem nunca mexeu em nada não tem uma linha sequer.

CampoTipoRegra
tenantIdGuidgeradoDo token, nunca aceito do cliente
userIdGuidobrigSempre o do próprio requisitante. Não existe editar a preferência de outra pessoa — administrador que pudesse fazer isso contornaria a decisão 2 por outro caminho
slugstringobrigPrecisa existir no catálogo. Slug desconhecido é 422, não 404: o cliente mandou algo que este release não conhece
channelenumobrigPrecisa estar em allowedChannels da definição
enabledboolobrigSó se grava quando difere de defaultChannels. Gravar um valor igual ao padrão apaga a linha em vez de criá-la — é o que faz a preferência acompanhar a mudança de padrão do produto

Índice único por (tenantId, userId, slug, channel). A leitura é uma varredura de vinte e quatro definições contra as poucas linhas do usuário, resolvida em memória — não há join, e a tela carrega com uma consulta.

NotificationThrottle

CampoTipoRegra
tenantId · userId · slugGuid · Guid · stringobrigQuem recebeu o quê
entityIdstringobrigO valor de entityKey naquele disparo, ou a string vazia quando a definição não tem entityKey
lastSentAttimestamptzgeradoUpsert na entrega. Índice único nas quatro colunas anteriores

Tabela de estado, não de negócio: um job diário apaga o que passou da maior janela do catálogo, e perder a tabela inteira reenvia avisos — não corrompe nada. É a única coisa neste documento que poderia ter ficado em cache; ficou em tabela porque um reinício de aplicação no meio de uma varredura de vencimentos mandaria o lote inteiro de novo.

DeferredSubmission

CampoTipoRegra
tenantIdGuidgeradoDo token
ownerType · ownerIdenum · GuidobrigO documento a submeter. Mesmo enum e mesmo registro de descritores do Anexo — sem chave estrangeira, porque atravessa bounded context
requestedByGuidobrigQuem pediu, e quem recebe os dois avisos do fim. Não é necessariamente quem anexou o arquivo
stateenumobrigWaiting · Submitting · Completed · Abandoned · Cancelled. Submitting existe para a fila não tentar duas vezes o mesmo documento em duas instâncias da aplicação
abandonReasonenum?condObrigatório em Abandoned: FileInfected · FileScanFailed · PermissionLost · OwnerChanged · OwnerDiscarded · Expired. É o texto do aviso, e é o que a pessoa lê para saber o que fazer
expiresAttimestamptzgerado72 horas após o pedido. Documento que espera mais que isso tem outro problema, e ficar em fila indefinidamente é como se acumula submissão fantasma

Índice único parcial por (tenantId, ownerType, ownerId) onde state IN (Waiting, Submitting): um documento tem no máximo uma fila viva. Pedir de novo devolve a mesma.

Decisão 3

Resolução de destinatário

O disparo não conhece pessoas. Ele diz que audiência quer, e o resolvedor daquele tipo devolve a lista — com o motivo de cada um estar nela, que vai para a trilha e responde depois a pergunta "por que eu recebi isto?".

Cinco resolvedores para seis audiências — e a sexta era papel disfarçado
TipoParâmetrosQuem implementaDevolve
UseruserIds[]o próprio móduloAs pessoas nomeadas pelo consumidor, filtradas por tenant e por usuário ativo. É o caso trivial, e cobre "quem submeteu", "quem criou" e "quem importou" — o consumidor sabe quem foi
TenantRoleroleAuth · grydTodos os usuários ativos com o papel no tenant. É aqui que "responsáveis pelo catálogo" mora: o levantamento contava seis audiências, e essa era papel com outro nome
CostCenterOwnersnodeId · rollup · includeWatchersCentro de CustoOs CostCenterAssignment vigentes no nó — Owner, Deputy quando não há Owner, e Watcher quando pedido. Com rollup, sobe o path até a raiz. Este resolvedor mora no módulo de centro de custo, e é a razão inteira da decisão 3
PendingApproversapprovalRequestIdFluxo de AprovaçãoOs ApprovalAssignment em Pending ou Active da instância. Nunca os já decididos, nunca os obsoletos
SupplierFinanceContactsupplierId · channelFornecedorNão devolve usuário — devolve endereço. channel = Previous lê o contato financeiro antes da alteração proposta, que é o ponto inteiro do controle bancário. Ver § Obrigatória

O resolvedor mora no dono do conceito

O módulo de notificação declara a interface e o registro; quem implementa CostCenterOwners é o centro de custo, e quem implementa PendingApprovers é o fluxo. Sem isso, "notificação" vira o depósito onde regra de negócio de outros módulos se acumula — e o rollup do path passa a ter duas implementações que divergem no primeiro nó com vigência vencida.

Tipo sem resolvedor derruba a inicialização

Mesma disciplina do AttachmentOwnerRegistry: a validação acontece no startup, não em produção às três da manhã com a fila cheia. Acrescentar um tipo de audiência custa exatamente o que custa implementar o resolvedor dele.

Lista vazia não é erro

Nó de centro de custo sem Owner vigente, instância sem aprovador pendente, papel sem ninguém: o disparo registra recipients: 0 com o motivo e não lança exceção. Mas as duas audiências de controle — a bancária e a de câmbio — têm fallback declarado para o papel de administrador, porque para elas "ninguém" é uma falha de configuração, não um estado normal.

Deduplicação é do resolvedor combinado

Um disparo pode pedir mais de uma audiência — o lembrete de SLA vai ao aprovador e a quem submeteu. A união é feita por userId antes da preferência ser consultada, então a mesma pessoa nas duas listas recebe uma vez, e o motivo registrado traz as duas razões.

Decisão 4

Payload e permissão

Uma notificação sai do servidor para uma caixa de entrada que ninguém controla, para uma pessoa cuja permissão não foi checada no momento em que o texto foi montado. A tela sabe esconder o valor; o e-mail já saiu. O Orçamento escreveu a regra primeiro, para o caso dele. Aqui ela vira regra do produto.

A regra, e é uma só. O corpo de uma notificação carrega identificação — número do documento, nome do centro de custo, nome do fornecedor, data —, contexto não sensível — o que aconteceu, o que se espera de quem lê — e um link. Nunca carrega valor sob permissão: quantia, saldo, preço unitário, total do documento, limite de alçada. "O pedido PC-2026-0341 ficou fora do orçamento no CC Engenharia · abrir" é a notificação inteira. O número está do outro lado do link, onde a permissão é checada uma vez, no lugar em que ela já era checada.

Por que não renderizar por destinatário

A alternativa era marcar variáveis como sensíveis e montar um corpo por pessoa, cortando o que ela não pode ver. Custa renderizar N vezes por notificação, resolver permissão fora do contexto de uma requisição — onde o token não existe — e ainda assim não resolve nada: o e-mail correto, entregue à pessoa certa, é encaminhado no dia seguinte para quem não podia ver. Um controle que depende de o destinatário não repassar não é controle.

A checagem acontece na semeadura

variables da definição é confrontado com uma lista de nomes proibidos e com o RequiredVariables do template semeado, na inicialização. Um slug que declara amount, total, balance, unitPrice ou approvalLimit não sobe. Assim a regra é verificada uma vez por deploy, e não vinte vezes por dia em tempo de execução — e ninguém precisa lembrar dela ao escrever o vigésimo disparo.

Consequência aceita: o e-mail é menos útil. Um gestor que recebe "estourou" e quer saber quanto precisa abrir o sistema. É o preço, foi escolhido de olhos abertos, e vale porque o erro contrário — o número no e-mail de quem não devia vê-lo — não tem correção depois de acontecer.

Decisão 2

Notificação que não se desliga

Três dos vinte e quatro slugs, em duas famílias, e cada família tem um motivo diferente.

SlugCanal obrigatórioMotivo exibido na tela
nexio.supplier-bank-account.change_requestedEmail"Este aviso vai para o contato financeiro cadastrado antes da alteração. É o que detecta uma troca de conta feita por quem tomou a caixa de e-mail nova."
nexio.attachment.infectedInApp"Um arquivo que você anexou foi recusado pelo antivírus. Sem este aviso, o documento fica parado sem ninguém saber por quê."
nexio.deferred-submission.abandonedInApp"A submissão que você pediu não aconteceu. Um aviso que a pessoa pode desligar transformaria isso num documento esquecido em rascunho."

Família 1 — o aviso é o controle

O aviso bancário no canal antigo não acompanha um controle: ele é o controle. Desligável, o controle simplesmente não existe, e o cliente descobre isso depois do primeiro pagamento desviado. Vale a pena notar que este destinatário não é usuário do tenant, e por isso nem chega à preferência — ver abaixo.

Família 2 — o ato que não aconteceu

Anexo recusado e fila que desistiu contam a mesma história: a pessoa acredita que fez alguma coisa, e não fez. Silêncio aqui não é economia de ruído, é um documento parado em rascunho que ninguém procura. Por isso o canal obrigatório é InApp — o mais barato de todos, o que não invade e o que aparece quando a pessoa volta.

Obrigatória não é em todos os canais

mandatoryChannel é um canal só. Os demais canais permitidos continuam sendo escolha do usuário: quem quiser o aviso de arquivo infectado também por e-mail liga, e quem não quiser push desliga. Obrigar os três seria o caminho mais curto para a pessoa criar uma regra de caixa de entrada e nunca mais ver nenhum.

Quem não é usuário não tem preferência

O contato financeiro do fornecedor não tem conta, não tem tela e não tem linha em NotificationPreference. Para essa audiência o pipeline pula a consulta de preferência inteira — não "consulta e não acha", pula, porque perguntar a preferência de quem não pode ter uma é como se cria um caminho para o controle falhar silenciosamente quando alguém, um dia, criar a linha.

Decisões 6 e 7

Throttle, digest e locale

Throttle — o que a v1 constrói

Antes de entregar, o pipeline consulta NotificationThrottle pela tupla usuário + slug + entidade. Dentro da janela declarada, a entrega é descartada — não adiada, não enfileirada, não somada. É o comportamento certo para um lembrete: a informação da segunda cópia é a mesma da primeira, e ela ainda está lá.

O caso que o justifica é o lembrete de SLA. Um aprovador com quarenta tarefas vencendo recebe, sem throttle, quarenta e-mails; recebe, com janela de 24 horas por instância, um por instância por dia — que ainda é muito, e é exatamente por isso que digestEligible já está marcado neste slug.

Digest — o que a v1 declara e não constrói

O que já está pronto para ele

digestEligible e entityKey em todas as vinte e quatro definições, NotificationThrottle registrando o que já saiu, e o INotificationScheduler do framework. O que falta da v2 é a janela por usuário, o template de resumo e a decisão de o que fazer com um aviso obrigatório dentro de um agrupado — e essa última é a razão principal de não fazer agora.

Por que não agora

Agrupamento é mais spec do que este documento inteiro, e o produto ainda não tem um cliente para dizer qual é a janela certa. O throttle resolve a maior parte do ruído por um custo de uma tabela de quatro colunas. Fazer o digest antes de existir volume é desenhar a solução com números inventados.

Locale

Cascata de três, resolvida no momento da renderização, por destinatário: preferência do usuário, depois padrão do tenant, depois o template global — que é o NotificationTemplate com TenantId nulo e o Locale de fábrica. O desenho é o mesmo da resolução de taxa em Moeda e Câmbio e da herança de política de orçamento: uma cascata declarada, sem exceção por chamador.

Para o contato financeiro do fornecedor, que não é usuário, a cascata começa no tenant. Fornecedor estrangeiro com aviso em português é um problema real e conhecido — o Supplier tem SupplierType = Foreign e poderia carregar um locale. Fica declarado como gancho e não implementado na v1, porque só o portal do fornecedor torna isso frequente.

Decisão 8

Scanning e a submissão diferida

A spec de Anexo decidiu que documento com anexo em Scanning não submeteATTACHMENT_SCAN_PENDING_CONFLICT —, escreveu que prender o usuário na tela é solução ruim, e deixou a saída declarada em aberto para ser fechada aqui. Está fechada: as duas coisas, com a segunda opcional a cada tentativa.

O que acontece quando a submissão é recusada
PassoO que o sistema fazO que a pessoa vê
1 · RecusaDevolve ATTACHMENT_SCAN_PENDING_CONFLICT com a lista de anexos que travam"Dois anexos ainda estão em verificação." Duas opções: avisar quando liberar ou enviar automaticamente
2 · PedidoCria DeferredSubmission em Waiting quando a pessoa escolhe enviar automaticamente. A opção "só avisar" não cria nada — é o slug nexio.attachment.scan_completed registrado para aquele usuário e documentoUma faixa no documento: "será enviado assim que a verificação terminar · cancelar"
3 · Veredito limpoÚltimo anexo em Available: o job pega a fila, marca Submitting, reavalia a permissão de quem pediu e chama o dono pelo descritornexio.deferred-submission.completed, com o número do documento e o resultado
4 · Veredito sujoInfected ou Failed: a fila vai a Abandoned com o motivo. Nunca submete documento com anexo sem veredito limponexio.deferred-submission.abandoned obrig, mais o nexio.attachment.infected para quem anexou, se for o caso
5 · Silêncio72 horas sem veredito: Abandoned por Expired. O documento continua em rascunho, intactoO mesmo aviso, com o motivo do tempo. E o operador da plataforma já foi alertado pela fila do GrydFiles, que é onde o problema realmente está

Quem recebe é quem tentou submeter

Não quem anexou. São pessoas diferentes com frequência — o comprador anexa a proposta, o gestor submete —, e quem está esperando é quem apertou o botão. O caso de quem anexou já tem dono: nexio.attachment.infected, que é obrigatório e vai para ele.

A permissão é reavaliada na execução

Entre pedir e submeter passam horas. Se a pessoa perdeu a permissão de submeter aquele documento nesse intervalo, a fila desiste com PermissionLost e avisa. Autorizar no pedido e executar depois é o padrão que transforma fila assíncrona em furo de alçada.

A fila não sabe submeter

Ela chama o dono. Isso obriga o AttachmentOwnerRegistry a ganhar uma nona pergunta — "como se submete este documento, e quem pode" —, aplicada em Anexo. Sem ela, a fila precisaria conhecer Requisition, PurchaseOrder e mais quatro agregados, que é exatamente a dependência que o descritor existe para não ter.

Documento alterado invalida a fila

Se o documento mudou depois do pedido, a fila desiste com OwnerChanged. O descritor devolve um carimbo de versão junto com a permissão; submeter automaticamente uma versão que a pessoa não viu é assinar em branco.

Operação

Semeadura e versão de template

O Nexio semeia, no startup e de forma idempotente, um NotificationTemplate global — TenantId nulo — por slug e por locale suportado. A semeadura é o momento em que quatro coisas são verificadas de uma vez.

1 · Todo slug tem template

Definição sem template semeado para o locale padrão derruba a inicialização. O modo de falha que isso evita é o pior de todos: o disparo acontece, a fila aceita, o renderizador não acha o template e o aviso desaparece sem erro visível.

2 · As variáveis batem

variables da definição é comparado com RequiredVariables do template. Variável exigida pelo template e não declarada pela definição derruba; declarada e não usada apenas registra aviso, porque texto encurtado é normal.

3 · Nenhuma variável é sensível

A lista de nomes proibidos de § Payload é aplicada aqui. É onde a decisão 4 deixa de depender de alguém lembrar dela.

4 · A convenção é conferida

Slug fora do formato nexio.<entidade>.<verbo-no-passado>, ou audit que não seja o slug sem o prefixo, derruba. Convenção conferida por máquina é convenção; conferida por revisão é intenção.

O tenant personaliza o texto, não o comportamento. O override por tenant já existe no framework: uma linha de NotificationTemplate com TenantId preenchido para o mesmo slug ganha do template global, e o Nexio não precisa escrever uma linha de código para isso funcionar. O que o tenant não muda é audiência, canal padrão, obrigatoriedade, janela ou variáveis — esses são do produto, e é a diferença entre um catálogo e uma tela de configuração que ninguém consegue suportar.

Segurança

Escopo, permissões e trilha

Escopo por empresa

A audiência é resolvida dentro do escopo do documento. Um aviso sobre um documento da empresa B não alcança quem tem UserCompanyScope só na empresa A, mesmo que a pessoa tenha o papel certo — o filtro é aplicado depois do resolvedor e antes da preferência. É a mesma regra que faz o documento responder 404 fora do escopo, e não uma regra nova.

Permissões

PermissãoSemeada emObservação
manage:notification-preferencetodos os papéis operacionaisEditar as próprias preferências. Não existe editar a de outra pessoa, e essa ausência é deliberada: seria o caminho de contorno da decisão 2
read:notification-catalogAdministradorA tela de catálogo — slug, audiência, canais, obrigatoriedade, variáveis e janela. Leitura pura: o catálogo é código
manage:deferred-submissiontodos os papéis operacionaisPedir e cancelar a fila do próprio pedido. Não substitui a permissão de submeter o documento, que é do módulo dono e é reavaliada na execução

Trilha

Três ações no GrydAudit, na convenção fechada em § Convenção: notification-preference.changed — com slug, canal, valor anterior e novo —, deferred-submission.requested e deferred-submission.abandoned. A entrega em si não é auditada aqui: NotificationDeliveryAttempt do framework já guarda tentativa, canal, momento e falha, e duplicar isso na trilha seria escrever duas versões da mesma verdade.

Alerta de operador não é notificação de tenant. A fila de scan alta e a base de assinaturas com mais de 48 horas do GrydFiles, e qualquer alerta de infraestrutura, vão para o operador da plataforma. Eles não têm slug nexio.*, não aparecem na tela de preferências de ninguém, não passam por NotificationPreference e não são resolvidos por audiência de tenant. A fronteira precisa estar escrita porque os dois usam o mesmo GrydNotifications e a tentação de reaproveitar o catálogo é imediata — e o resultado seria um usuário de compras podendo desligar o alerta que mantém o antivírus vivo.

Interface

Telas

Minhas notificações · preferências

Dezenove linhas agrupadas pelas seis categorias, três colunas de canal com toggle. O que a categoria faz é agrupar, não desligar em bloco — não há interruptor de categoria, porque ele juntaria a sanção de fornecedor com o lembrete de cadastro.

Linha obrigatória mostra o canal travado com cadeado e o mandatoryReason ao lado, em texto normal — não em tooltip. Os demais canais dessa linha continuam clicáveis.

Nada de "salvar": cada toggle é um PUT. E o rodapé diz a frase que evita o chamado mais comum: "avisos novos entram ligados ou desligados conforme o padrão do produto; o que você mudou aqui continua valendo."

Admin · catálogo de avisos

Somente leitura, com read:notification-catalog. Uma linha por slug com audiência, canais, janela, obrigatoriedade e as variáveis declaradas. Existe para responder, sem abrir código, as duas perguntas que o suporte recebe: "por que o fulano recebeu isso?" e "por que ninguém recebeu?".

A segunda pergunta é a que a coluna de audiência responde: nó sem Owner vigente entrega a zero pessoas, e isso é configuração do cliente, não defeito.

Faixa no documento · submissão diferida

Aparece quando existe DeferredSubmission viva: "será enviado assim que a verificação terminar", com o que falta e um cancelar. É a única presença desta spec dentro de um documento transacional, e ela é uma faixa e um botão.

Depois de Abandoned, a faixa vira o motivo, com a ação que resolve — trocar o anexo infectado, ou pedir de novo.

O que não tem tela

Não existe tela de composição, de envio manual, de reenvio nem de caixa de saída. A caixa in-app é do UserNotification, que é do framework e já tem a sua. Escrever uma segunda seria o começo de um cliente de e-mail dentro de um sistema de compras.

API

Endpoints

RotaPermissãoDevolveErros
GET /notifications/preferencesread:notification-preferenceAs vinte e quatro definições já resolvidas contra as linhas do usuário: slug, categoria, rótulo, canais permitidos, estado atual de cada um, isMandatory e mandatoryReason. A tela não precisa saber o que é exceção e o que é padrão
PUT /notifications/preferencesmanage:notification-preferenceLote de {slug, channel, enabled}. Grava, apaga ou ignora cada item conforme ele difira do padrão — o cliente manda o estado desejado e não pensa nisso. Sempre do próprio usuário400 · 422
DELETE /notifications/preferencesmanage:notification-preferenceApaga todas as exceções do usuário: volta tudo ao padrão do produto. Uma linha na tela, e o caminho de saída de quem se perdeu nos toggles
GET /notifications/catalogread:notification-catalogO catálogo inteiro como o código o declara, incluindo variables e audienceType. Não é paginado: são vinte e quatro itens e o número muda por release
POST /deferred-submissionsmanage:deferred-submission{ownerType, ownerId}. Cria ou devolve a fila viva daquele documento — idempotente. Recusa se o documento não tem anexo em Scanning, porque aí a submissão normal funciona400 · 404 · 409 · 422
GET /deferred-submissionsmanage:deferred-submissionA fila viva de um documento, ou as do próprio usuário. Estado, o que falta liberar e expiresAt400 · 404
POST /deferred-submissions/{id}:cancelmanage:deferred-submissionCancela. Só quem pediu, e só em WaitingSubmitting já está a caminho e cancelar ali seria uma corrida sem vencedor definido403 · 404 · 409

Não há rota de disparo. Notificação é emitida em processo, por INotificationDispatcher, pelo módulo que sabe que o fato aconteceu — nunca por chamada HTTP de fora. Uma rota de envio seria, no primeiro dia, o caminho para mandar e-mail em nome do produto a partir de qualquer lugar.

Contrato

Contrato de erros

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

CódigoHTTPQuando
NOTIFICATION_SLUG_UNKNOWN_UNPROCESSABLE422Slug que este release não conhece. É 422 e não 404 de propósito: o recurso não some, o cliente é que está desatualizado
NOTIFICATION_CHANNEL_NOT_ALLOWED_UNPROCESSABLE422Canal fora de allowedChannels daquele slug
NOTIFICATION_CHANNEL_MANDATORY_UNPROCESSABLE422Tentativa de desligar o mandatoryChannel. A resposta traz o mandatoryReason, para a tela não ter que carregar o texto duas vezes
NOTIFICATION_PREFERENCE_FORBIDDEN403userId diferente do requisitante. Não é 404: negar a existência aqui não protege nada e confunde o cliente
DEFERRED_SUBMISSION_NOT_FOUND404Não existe, já terminou, ou o documento está fora do escopo do usuário — os três respondem igual
DEFERRED_SUBMISSION_ALREADY_EXISTS409Só quando o pedido vem de outra pessoa. Do mesmo usuário é idempotente e devolve 200 com a fila existente
DEFERRED_SUBMISSION_NOT_APPLICABLE_CONFLICT409O documento não tem anexo em Scanning. Enfileirar aqui esconderia um erro de submissão real atrás de uma espera que nunca termina
DEFERRED_SUBMISSION_NOT_CANCELLABLE_CONFLICT409Estado diferente de Waiting
DEFERRED_SUBMISSION_OWNER_TYPE_UNKNOWN400ownerType sem descritor, ou com descritor que não responde à nona pergunta. Em produção não chega: a inicialização já teria falhado
ATTACHMENT_SCAN_PENDING_CONFLICT409Repassado sem reembrulho pelo módulo de anexo, e é o erro que abre esta conversa inteira. A resposta traz a lista de anexos que travam, que é o que a tela precisa para oferecer as duas opções

Invariantes

Regras de negócio

RN-NOT-01Notificação não é fila de trabalho

Nenhuma decisão, prazo ou estado depende de um aviso ter sido entregue. A fila do aprovador é ApprovalAssignment; o extrato do orçamento é BudgetEntry; o vencimento é ItemCompliance. Um tenant com o e-mail quebrado por uma semana perde avisos e não perde nada mais.

RN-NOT-02Todo slug tem definição e template, conferidos no startup

Definição sem template para o locale padrão, ou template sem definição, derruba a inicialização. O modo de falha evitado é o silencioso: disparo aceito, template não encontrado, aviso que desaparece sem erro.

RN-NOT-03O slug é a ação da trilha com o prefixo do produto

nexio.<entidade>.<verbo-no-passado>, conferido por máquina na semeadura. A recíproca não vale: nem toda ação auditada tem aviso.

RN-NOT-04Ausência de preferência é o padrão do catálogo

Nunca "desligado" e nunca "ligado" por convenção do código. É o que faz um slug publicado depois funcionar para quem configurou antes — sem migração e sem linha órfã.

RN-NOT-05A preferência guarda exceção

Gravar um valor igual ao padrão apaga a linha. Uma tabela que guardasse o estado inteiro congelaria cada usuário no catálogo do dia em que ele mexeu.

RN-NOT-06Ninguém edita a preferência de outra pessoa

Não há rota, não há permissão e não há tela. Um administrador que pudesse fazê-lo teria, por consequência, o poder de desligar um aviso obrigatório de outra pessoa.

RN-NOT-07O canal obrigatório não desliga; os outros desligam

Um canal por slug obrigatório, com motivo escrito e exibido. Obrigar todos os canais é o caminho mais curto para a regra de caixa de entrada que apaga tudo.

RN-NOT-08Quem não é usuário do tenant não passa pela preferência

O contato financeiro do fornecedor não tem conta nem linha. O pipeline pula a consulta — não consulta e ignora o resultado.

RN-NOT-09Nenhum payload carrega valor sob permissão

Quantia, saldo, preço, total e limite de alçada ficam do outro lado do link. Generaliza a RN-O22 do Orçamento para o produto inteiro.

RN-NOT-10Variável sensível derruba a inicialização

A lista de nomes proibidos é aplicada na semeadura. A regra 09 não depende de alguém lembrar dela ao escrever o vigésimo disparo.

RN-NOT-11O resolvedor de audiência mora no módulo dono do conceito

Rollup de centro de custo é do centro de custo; aprovador pendente é do fluxo. O módulo de notificação declara o contrato e nunca a regra.

RN-NOT-12Tipo de audiência sem resolvedor derruba a inicialização

Mesma disciplina do ownerType sem descritor no Anexo: falha no startup, não em produção.

RN-NOT-13O escopo por empresa é aplicado depois do resolvedor

E antes da preferência. Papel certo em empresa errada não recebe, pela mesma regra que faz o documento responder 404.

RN-NOT-14Audiência vazia não é erro, exceto nas de controle

Registra recipients: 0 com o motivo. As duas audiências de controle — bancária e câmbio — caem no papel de administrador, porque ali "ninguém" é configuração errada, não estado normal.

RN-NOT-15A mesma pessoa recebe uma vez

União por userId antes da preferência, com os motivos somados. O lembrete de SLA para quem é aprovador e requisitante do mesmo documento é um e-mail, não dois.

RN-NOT-16O throttle descarta, não adia

Dentro da janela, a segunda cópia é jogada fora. Adiar acumularia uma fila que entrega tudo junto no fim da janela, que é o pior dos dois mundos.

RN-NOT-17Não existe disparo por HTTP

A emissão é em processo, pelo módulo que sabe que o fato aconteceu. Rota de envio é, no primeiro dia, o caminho para mandar e-mail em nome do produto de qualquer lugar.

RN-NOT-18A fila nunca submete documento com anexo sem veredito limpo

Infected, Failed ou tempo esgotado levam a Abandoned com motivo. Não existe modo degradado, pela mesma razão que o GrydFiles não libera arquivo sem veredito.

RN-NOT-19A permissão da submissão diferida é reavaliada na execução

Autorizar no pedido e executar horas depois é como fila assíncrona vira furo de alçada. Permissão perdida no intervalo abandona a fila e avisa.

RN-NOT-20Documento alterado invalida a fila

O descritor devolve um carimbo de versão junto com a permissão. Submeter automaticamente uma versão que a pessoa não viu é assinar em branco.

RN-NOT-21Uma fila viva por documento

Índice único parcial. Pedido repetido do mesmo usuário devolve a fila existente; de outra pessoa, 409.

RN-NOT-22Alerta de operador não passa por preferência de tenant

Fila de scan, base de assinaturas velha e falha de infraestrutura são da plataforma. Não têm slug nexio.* e não aparecem em tela nenhuma do cliente.

RN-NOT-23A entrega é auditada pelo framework

NotificationDeliveryAttempt já guarda tentativa, canal, momento e falha. A trilha do Nexio registra só o que é decisão do usuário: mudança de preferência e pedido ou abandono de fila.

Aferição

Referência de mercado

Nenhuma das decisões acima é original, e é bom que não seja. O que o mercado mostra, quando se olha o que os sistemas grandes fazem de notificação em compras e em fluxo de trabalho:

Catálogo declarado, não improvisado

ServiceNow, Jira e Oracle Fusion tratam a notificação como um registro de produto — evento, destinatário e template declarados —, não como uma chamada de e-mail espalhada pelo código. É a diferença entre conseguir responder "quais avisos este sistema emite?" abrindo uma tela e ter que rodar uma busca no repositório.

O erro que o Jira ensina

O notification scheme do Jira amarra evento a papel, e é excelente — até o cliente ter oito esquemas parecidos e ninguém saber qual projeto usa qual. A lição aplicada aqui: a audiência é do slug, não de um esquema configurável por tenant. Flexibilidade de destinatário é a origem mais comum de "não sei por que fulano recebeu".

Lembrete e escalonamento

Ariba e Coupa mandam lembrete de aprovação pendente e escalonam por tempo — e é justamente aí que aparece a fadiga de e-mail que faz o comprador criar uma regra de caixa de entrada. Daí vir o throttle na v1 e o digest declarado: o problema é conhecido antes de o produto ter o primeiro cliente.

Preferência é opt-out com padrão bom

GitHub, Slack e as suítes de RH convergiram no mesmo desenho: o padrão vem do produto, o usuário ajusta o que incomoda, e uma minoria mexe. É a razão de a tabela guardar exceção — e de a tela ter vinte linhas em vez de um assistente de configuração.

O que não se copia: a caixa de "notificações de sistema" que alguns ERPs usam como segundo canal de mensagens internas, com resposta e encaminhamento. É um cliente de e-mail dentro do ERP, e ninguém o usa depois do primeiro mês.

Limites

Fora de escopo

Fora — é da plataforma
  • Canal, entrega, retry e caixa in-app. Tudo do GrydNotifications: e-mail, push, in-app, tentativas, agendador e provedores.
  • Template e renderização. Incluindo o override por tenant, que já funciona por TenantId no NotificationTemplate.
  • Alerta de operador. Infraestrutura, fila de scan, base de assinaturas: da plataforma, e não passa por este catálogo.
  • Editor de template. O texto se altera por semeadura ou por override, não por WYSIWYG dentro do produto.
Fora agora, gancho previsto
  • Digest. digestEligible, entityKey e o agendador já existem; falta janela por usuário, template de resumo e o tratamento do obrigatório dentro do agrupado. Entra sem migração.
  • Horário de silêncio. "Nada de push entre 20h e 7h" é preferência de canal com janela, e cabe na mesma tabela — depois de existir push em uso real.
  • SMS e WhatsApp. Canal novo é do framework. Aqui custaria uma linha em allowedChannels, e é isso que o enum de canal está preparado para receber.
  • Notificação ao fornecedor pelo portal. Hoje só o controle bancário fala com quem está de fora. O resto depende da decisão sobre portal, que é a maior de escopo em aberto.
  • Locale do fornecedor estrangeiro. SupplierType = Foreign poderia carregar o dele; a cascata já tem onde encaixar.

O que esta spec deixa amarrado em outro documento. Duas coisas, e as duas são pequenas de propósito. A primeira é a nona pergunta do AttachmentOwnerRegistry — "como se submete este documento, e quem pode" —, sem a qual a fila de submissão diferida precisaria conhecer seis agregados; ela saiu em Anexo, publicado no mesmo dia, junto com a reescrita da decisão 8. A segunda é o ReasonCode: abandonReason é enum fechado nesta spec e continua enum quando o transversal existir — motivo de sistema não é motivo de usuário, e essa é a fronteira entre os dois. Nenhuma das duas muda tabela, contrato de erro ou slug.