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 sete 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 · 27 slugs v1.10 · 09/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-amounts 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 sete 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
audienceParamsobjetocondOs parâmetros do resolvedor escolhido, declarados no catálogo e não no disparo: role para TenantRole, includeWatchers e rollup para CostCenterOwners, channel para SupplierFinanceContact. Obrigatório quando o resolvedor declara parâmetro sem padrão; parâmetro faltando ou desconhecido derruba a inicialização, na mesma varredura da § Semeadura. userIds e approvalRequestId não moram aqui — são do disparo, que é quem sabe de quem se trata. Novo em v1.8
audienceMustResolveboolobrigPadrão false. Verdadeiro nos avisos de governança — aqueles cujo conteúdo é "um controle não rodou" ou "alguém de fora do tenant precisa confirmar". Audiência vazia neles cai no papel de administrador do tenant e é registrada como falha de configuração, nunca como entrega de zero. Sete dos vinte e sete, e são estes: approval-step.authority_exceeded, approval-step.resolved_out_of_scope, approval-step.resolved_empty_on_activation, budget.approval_requirement_unmet, supplier-bank-account.change_requested, attachment.revoked e attachment.infected — os quatro primeiros porque o conteúdo deles é "um controle não rodou", os três últimos porque falam com quem está fora do tenant ou dependem de um descritor para achar o destinatário. Novo em v1.8
allowedChannelsenum[]obrigEmail · InApp · Push · Sms. 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. Sms é dependência declarada do GrydNotifications, que hoje entrega três canais: entra no enum agora porque é um slug só que o usa e ele é o controle antifraude bancário, e enquanto o canal não existir o aviso sai por e-mail com a ausência do segundo canal registrada, nunca silenciosa. Mesmo molde do sufixo _UNAVAILABLE e da coluna Action: escrito, com dono, e nada depende dele para funcionar. Novo em v1.8
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 sete slugs, e cada um com o motivo escrito
mandatoryChannelsenum[]condNão vazio quando isMandatory; vazio caso contrário. Normalmente um canal só — o que carrega o controle —, e os demais canais permitidos continuam desligáveis. Plural desde 09/09/2026, por um caso e por uma razão: o aviso bancário no canal antigo vale contra uma caixa de e-mail comprometida, e um controle cuja redundância não cabe no campo que a declara é um controle que a semeadura não sabe conferir. Era mandatoryChannel, singular
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, cada uma com a sua classeIdentifier · Label · DateOrCount · Link. Confrontadas com RequiredVariables do template semeado, na inicialização. Classe ausente ou desconhecida derruba a inicialização, e texto livre digitado por uma pessoa não tem classe — ver § Payload e permissão. Revisto em v1.8
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 sete 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 sete 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
modeenumobrigNotifyOnly | Submit. As duas escolhas da tela criam a fila, em modos diferentes: gatilho, expiração, cancelamento, faixa no documento e índice único são idênticos, e a única diferença é o que acontece quando o último anexo libera — em Submit a fila chama o dono, em NotifyOnly dispara o nexio.attachment.scan_completed e vai a Completed sem submeter. Novo em v1.8: "só avisar" dizia não criar nada, mas "registrado para aquele usuário e documento" é estado — e estado sem linha se perde no primeiro reinício. Campo, não segunda tabela: uma tabela nova duplicaria cinco mecanismos para variar um
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. Novo em v1.8: Submitting → Waiting, quando o SubmitPolicyOf do dono recusa a submissão — passo sem aprovador, Block de orçamento, anexo obrigatório faltando, exercício arquivado. Grava lastAttemptAt, lastRefusalReason (código do dono, texto opaco para a fila) e incrementa attemptCount. Recusa não é erro da fila e quase sempre se resolve sozinha; esgotado maxSubmitAttempts ou a expiração, vai a Abandoned por OwnerRefused
abandonReasonenum?condObrigatório em Abandoned: FileInfected · FileScanFailed · PermissionLost · OwnerChanged · OwnerDiscarded · Expired · OwnerRefused. É o texto do aviso, e é o que a pessoa lê para saber o que fazer. OwnerRefused é novo em v1.8 e cobre o caso mais comum de todos, que não tinha valor: o dono recusou a submissão — antes disso a fila ficava presa em Submitting, que o índice único considera viva, e a pessoa não conseguia nem tentar de novo
attemptCount · lastAttemptAt · lastRefusalReasonint · timestamptz? · string(80)?condPreenchidos a cada volta de Submitting para Waiting. lastRefusalReason é o código que o módulo dono devolveu, guardado como texto opaco — a fila não o interpreta, só o exibe na faixa e o entrega ao aviso de abandono. O teto é maxSubmitAttempts, constante do produto. Novo em v1.8
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. O role é declarado por slug em audienceParams — ver o card ao lado
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ços, no plural: e-mail e telefone, quando o contato registrou consentimento para o segundo. channel = Previous lê o contato de papel Finance vigente antes da alteração proposta — qualquer que seja o changeKind dela. Corrigido em 09/09/2026: "antes da alteração" era lido como antes da alteração bancária, e o ataque em dois passos sobrevivia — reescreve-se o contato num PUT, abre-se a proposta bancária em seguida, e o "canal anterior" já é o do atacante. Ver § Obrigatória
Qual papel — e por que o produto pode nomeá-lo

Papel é do tenant: quem cria, renomeia e povoa é o cliente, e o produto não tem como saber que a controladoria dele se chama "Governança & Compliance". Por isso o Nexio semeia três papéis funcionais no TenantCreated, com chave estável e rótulo editável — Controladoria, Compras e Catalogo —, e é a chave que o catálogo de slugs cita. O cliente muda o rótulo, escolhe quem está em cada um e acrescenta os papéis que quiser; o que ele não faz é apagar a chave. É a mesma mecânica de semente com sobreposição já usada em NotificationTemplate, AttachmentType e ReasonCode — a quarta repetição dela no acervo. Novo em v1.8

O silêncio que isto elimina

Até 08/09/2026 seis slugs diziam TenantRole e nenhum dizia qual papel, e a definição não tinha campo para dizer. Consequência medida na validação: approval-step.authority_exceeded e budget.approval_requirement_unmet — os dois avisos criados exatamente para dizer "o controle não rodou" — entregavam a zero pessoas, sem erro. Um aviso de governança que não chega é pior do que não existir, porque alguém acredita que ele existe.

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. Revisto em 09/09/2026: a exceção deixou de ser uma lista de duas audiências e passou a ser uma marca por slugaudienceMustResolve. Todo aviso de governança a leva, e ela obriga a duas coisas: fallback declarado para o papel de administrador do tenant, e registro do esvaziamento como falha de configuração na trilha, não como entrega de zero. O contrário — a lista fixa — foi o que deixou authority_exceeded e approval_requirement_unmet de fora, que eram justamente os dois avisos que existem para dizer que um controle não rodou.

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.

E a regra vale sobre o conteúdo, não sobre o nome da variável — revisto em 09/09/2026. A guarda era uma lista de nomes proibidos (amount, total, balance…), e uma lista de nomes só pega o que ninguém erraria. O vazamento real tem outro nome: reasonComment — o texto livre que a spec de Motivo torna obrigatório em toda rejeição, e cujo exemplo de venda é "Fora do orçamento — a verba de março já foi para o contrato de manutenção". Uma frase escrita por uma pessoa não tem como ser classificada por nome. Por isso a lista deixa de ser negativa e passa a ser positiva: uma variável de notificação só pode ser identificador, rótulo vindo de lista fechada (o reasonLabel, o nome do estado, o nome do processo), data ou contagem, ou link. Texto digitado por uma pessoa nunca é variável de notificação — nem comentário, nem justificativa, nem descrição, nem observação, qualquer que seja o nome do campo.

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

Cada variável da definição declara a sua classeIdentifier, Label, DateOrCount, Link —, e o confronto com o RequiredVariables do template semeado acontece na inicialização. Variável sem classe, ou de classe fora das quatro, não sobe; a lista de nomes proibidos continua, agora como segunda malha e não como a única. É a diferença entre "não deixe passar total" e "só deixe passar o que se sabe classificar" — a primeira depende de alguém prever o nome errado, a segunda não. Assim a regra é verificada uma vez por deploy, e não vinte vezes por dia em tempo de execução.

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 sete slugs, em duas famílias, e cada família tem um motivo diferente.

SlugCanais obrigatóriosMotivo exibido na tela
nexio.supplier-bank-account.change_requestedEmail · Sms"Este aviso vai para o contato financeiro cadastrado antes da alteração, por e-mail e por telefone. É 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. E por ser o controle, ele sai por dois canais: quem consegue abrir a proposta fraudulenta é, quase por definição, quem já tem a caixa de e-mail. 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

mandatoryChannels tem um canal só em dois dos três — e dois no bancário, onde a redundância é o controle. 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 sete 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 nas duas escolhas, com mode = Submit ou mode = NotifyOnly. Corrigido em 09/09/2026: dizia que "só avisar" não criava nada, mas "registrado para aquele usuário e documento" é estado, e não havia tabela para ele — o pedido se perdia no primeiro reinício e o aviso nunca saíaUma 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-preferencestodos 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-catalogsAdministradorA tela de catálogo — slug, audiência, canais, obrigatoriedade, variáveis e janela. Leitura pura: o catálogo é código
manage:deferred-submissionstodos 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 cada 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-catalogs. 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-preferencesAs vinte e sete 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-preferencesLote 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-preferencesApaga 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-catalogsO catálogo inteiro como o código o declara, incluindo variables e audienceType. Não é paginado: são vinte e sete itens e o número muda por release
POST /deferred-submissionsmanage:deferred-submissions{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-submissionsA 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-submissionsCancela. 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 um canal de mandatoryChannels. 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-01Aviso não substitui estado de trabalho

Fila e documentos mantêm seus estados independentemente do canal. Exceção explícita: carência de mudança bancária exige envio registrado, conforme Fornecedor. Ausência de destinatário ou entrega obrigatória é pendência; não autoriza ultrapassar controle.

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 sem classe declarada derruba a inicialização

Toda variável declara Identifier, Label, DateOrCount ou Link, e a lista de nomes proibidos continua como segunda malha. Revista em 09/09/2026: era só a lista de nomes, e lista de nomes não vê conteúdo. 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-13Empresa e permissão restringem a audiência

Após resolver audiência, aplicar escopo e concessões locais da operação, além do teto da plataforma, antes da preferência. Resolver Role no tenant sozinho não autoriza receber dados da empresa.

RN-NOT-14Audiência vazia não é erro, exceto onde o slug diz que é

Registra recipients: 0 com o motivo. Nos slugs com audienceMustResolve, "ninguém" é falha de configuração: cai no papel de administrador do tenant e é registrado como tal. Revista em 09/09/2026 — era uma lista de duas audiências, e por isso não alcançava os dois avisos escritos para dizer que um controle não rodou.

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-16Throttle só suprime lembrete equivalente

Não suprimir operações distintas, convites reemitidos ou aviso obrigatório sem confirmação. Dedupe usa identidade do fato e destinatário; throttle usa a janela de lembretes declarada, sem substituir retry durável.

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.

RN-NOT-24Parâmetro de audiência é do catálogo e é conferido no startup

Todo resolvedor com parâmetro sem padrão exige audienceParams na definição do slug, e role só aceita chave de papel semeada. Parâmetro faltando, sobrando ou desconhecido derruba a inicialização — mesma disciplina do tipo sem resolvedor (RN-NOT-12) e da variável sensível (RN-NOT-10).

Por quê: seis slugs declaravam TenantRole sem dizer qual papel, e a definição não tinha onde dizer. O modo de falha não era um erro: era a entrega silenciosa a zero pessoas, num aviso que a spec descreve como obrigatório.

RN-NOT-25Texto escrito por uma pessoa não entra em corpo de notificação

Comentário, justificativa, descrição e observação — inclusive o reasonComment obrigatório em toda rejeição — nunca são variável de aviso. O aviso leva o ponteiro para o documento e o rótulo do motivo, que vem de lista fechada. Vale qualquer que seja o nome do campo, e é por isso que a classificação é por classe declarada e não por lista de nomes proibidos (RN-NOT-10).

Por quê: a RN-NOT-09 era verdadeira para amount e total, que ninguém erraria, e falsa exatamente onde o vazamento acontece. "Fora do orçamento — a verba de março já foi para o contrato de manutenção" é o exemplo que o acervo usa para vender o campo, e ele sai por e-mail para quem não pode ver nem a verba nem o contrato.

RN-NOT-26Recusa do dono devolve a fila a Waiting, não a Abandoned

Quando o SubmitPolicyOf do dono recusa a submissão, a fila volta a Waiting com lastRefusalReason e attemptCount incrementado. Só depois de maxSubmitAttempts — ou da expiração de 72 h — ela vai a Abandoned por OwnerRefused. A fila não interpreta o código de recusa: ele é texto opaco vindo do módulo dono.

Por quê: passo sem aprovador, Block de orçamento e anexo obrigatório faltando quase sempre se resolvem sozinhos em minutos. Sem a volta, a fila ficava presa em Submitting — que o índice único conta como viva —, Completed seria mentira, e a pessoa não conseguia nem pedir de novo.

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.

Contrato vigente · revisão consolidada

Entrega durável e controles

isMandatory exige intenção durável na transação produtora, com operationId e destino. Outbox, retry e resultado por canal evitam perda após commit; throttle não descarta fatos distintos nem um controle ainda não entregue. Apenas lembretes equivalentes podem ser suprimidos pela janela declarada.

Convites e aviso bancário têm resultados de envio distintos de intenção: Sent indica aceitação pelo provedor, e Delivered só é usado quando houver recibo correspondente. O aviso bancário inicia carência no marco documentado, nunca em handler em memória. Segredo do convite é criptografado e expurgável; corpo/log de notificação não deve expô-lo fora do canal autorizado.

Resolvedor aplica teto da plataforma e concessão local por empresa/operação antes de enviar. Audiência obrigatória vazia abre pendência operacional; reserva administrativo só recebe conteúdo autorizado. Aviso não substitui Block, alçada ou Supplemental: falta de controle impede novo compromisso mesmo quando alguém foi avisado.

Submissão diferida é comando durável com revisão e identidade da operação. Ao executar, revalidar autorização, dono, veredito da versão exata, orçamento e evidências. Recusa de negócio mantém estado e motivo previstos; retry não pode duplicar submissão.