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 sessenta e quatro slugs extraído das specs já publicadas, nove 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, que o Nexio não mapeia até a correção da plataforma desbloqueia Requisition · fecha a decisão 8 do Attachment 1 agregado · 1 entidade de estado · 2 registros em código · 64 slugs v1.16 · 21/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

O documento espera, e ninguém submete no lugar da pessoa. Fecha o que a spec de Anexo deixou declarado em aberto, e fecha pelo lado curto: submeter documento com anexo em varredura é recusado, pelo 409 que o Anexo já devolve; a tela acompanha a liberação e reenvia; veredito sujo faz a pessoa trocar o anexo ou pedir varredura nova. Não existe fila que submeta sozinha. Submeter dispara aprovação e movimenta orçamento, e um job que fizesse isso precisaria executar um comando com a permissão e o escopo de quem pediu — contexto que a plataforma não oferece. Execução delegada é requisito de v2, escrito na demanda de plataforma.

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 (uma string anulável, não um vetor — a lista é desmembrada na conferência) 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. O GrydAuth tem UserPreference, e ela guarda preferência de conta — idioma, entre outras coisas; o que não existe no framework é preferência por aviso e por canal, que é uma matriz usuário × slug × canal e depende de um catálogo que só o produto tem. É essa a lacuna, 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 lacuna de tabela do módulo: o framework guarda preferência de conta em UserPreference, não preferência por aviso e por canal. 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.
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 · nove 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 e verbo os dois em kebab-case, a entidade no singular e com o nome da classe do modelo, o verbo no particípio, descrevendo o que aconteceu, nunca o que se quer que a pessoa faça. nexio.attachment.revoked e nexio.approval-step.authority-exceeded, não nexio.attachment.please-review nem nexio.approval-step.authority_exceeded.

Uma grafia só, e é esta. Duas convenções fariam a conferência por máquina da RN-NOT-03 não ter o que conferir, e deixariam dois slugs para o mesmo fato sobreviver lado a lado. A cauda do slug é a mesma do nome da ação na trilha, e ela também é kebab.

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 · Currency · Catalog · Company · Supplier · Attachment · System. Só agrupa a tela. A preferência é por slug, não por categoria — a categoria existe para a lista de sessenta e quatro itens ser legível, não para ser desligada em bloco. Currency e Company entraram com os avisos de câmbio e de escopo por empresa: oito grupos para sessenta e quatro itens é uma tela que se lê
audienceTypeenumobrigUm dos nove de § Resolução de destinatário, ou uma combinação deles — vários avisos de controle pedem mais de uma audiência, e a união é feita antes da preferência. 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, permission para PermissionHolders, companyId para CompanyScopeAdmins. 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
audienceMustResolveboolobrigPadrão false. Audiência vazia nos slugs marcados cai no TenantAdmin e é registrada como falha de configuração, nunca como entrega de zero. Deixou de ser lista escrita à mão e passou a ser regra conferida na semeadura: é verdadeiro em todo aviso isMandatory cuja audiência interna for calculada — por papel, permissão, escopo ou descritor —, e falso quando ela é um User nomeado pelo próprio disparo. Audiência externa fica fora da derivação — cadeia do fornecedor, contato contratual, endereço do titular do pedido —, porque nela não existe recurso ao administrador: quem trata o vazio é a RN-NOT-29, recusando o ato na origem. No slug que tem as duas, a marca vale sobre a parte interna. Definição que viole a regra derruba a inicialização, na mesma varredura da § Semeadura. Uma lista escrita à mão já errou uma vez, e errou exatamente nos dois avisos que existiam para dizer que um controle não rodou; com o catálogo em sessenta e quatro slugs, erraria de novo em silêncio.
allowedChannelsenum[]obrigEmail · InApp · Push — os três que o GrydNotifications entrega. 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 saiu do enum: SMS e WhatsApp estão fora da v1 por decisão de produto, não por falta de implementação — são os canais mais atacados, por clonagem de número e por troca de chip, e o único slug que os usaria é justamente o controle antifraude bancário. O aviso bancário sai só por e-mail, e a redundância que ele perdeu é reposta pelo callback telefônico, que é obrigatório sempre e é evidência da tarefa — não canal de notificação. Ver Fornecedor.
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 nas quatro famílias descritas em § Notificação que não se desliga, cada uma com o motivo escrito. A conta cresceu com o catálogo: quase tudo o que as rodadas de validação acrescentaram é aviso de controle
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, 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
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 classe — Identifier · 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
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 o catálogo inteiro sob pressão de release — e o catálogo já passou de vinte e sete para sessenta e quatro, que é a melhor prova de que a decisão estava certa
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 do catálogo 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.

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?".

Nove resolvedores — cinco do desenho original e quatro que as rodadas de validação exigiram
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 de e-mail, e só isso. Consentimento não é condição: o aviso é prevenção a fraude, não comunicação de marketing. A cadeia é a do Fornecedor, nesta ordem: contato Finance → PortalAdmin → o contato Commercial designado → o contato designado do estabelecimento principal. Outro contato Commercial e o e-mail do endereço do fornecedor nunca entram. Audiência vazia não cai no administrador do tenant — o administrador recebe cópia, nunca substitui o fornecedor —, e sem nenhum endereço a proposta não abre, com recusa do Fornecedor. channel = Previous lê o contato Finance vigente antes da alteração proposta, qualquer que seja o changeKind dela: sem isso, reescreve-se o contato num PUT e abre-se a proposta bancária em seguida, e o "canal anterior" já é o do atacante. Ver § Obrigatória
TenantAdmin—Auth · grydOs administradores do tenant no GrydAuth. É o destinatário de último recurso de todo aviso de governança — o que a marca audienceMustResolve exige que exista — e o destinatário próprio de dezessete avisos em que não há dono de negócio: virada de exercício travada, passo parado, vigência de membro de grupo a vencer, pedido de eliminação sem executor elegível. Depende de consulta que só a plataforma pode responder — "quem administra este tenant" —, pedida na demanda de plataforma. Enquanto ela não existir, o recurso não tem para onde ir, e é por isso que o pedido está escrito em vez de contornado
PermissionHolderspermissionAuth · grydQuem resolve a permissão hoje no tenant — por atribuição direta ou por papel —, e não quem tem um papel com aquele nome. É a audiência de quase todo aviso bancário (approve:supplier-banks) e do pedido de eliminação (erase:attachments). A diferença para TenantRole é a que importa em controle: papel é rótulo do cliente e muda de conteúdo sem avisar; permissão é o que de fato abre a porta. Mesma dependência de plataforma do TenantAdmin, e a mesma do evento de concessão e retirada de papel que os dois avisos de papel de controle consomem
CompanyScopeAdminscompanyIdEmpresaQuem tem linha de escopo vigente naquela empresa e a permissão de administrar escopo — as duas coisas ao mesmo tempo. Não é PermissionHolders filtrado depois: a linha vigente é o que faz a pessoa enxergar a empresa, e resolver a permissão sem ela devolveria gente que não pode agir. Mora na Empresa, pela mesma razão que o rollup mora no centro de custo. Devolve vazio exatamente no caso que os dois avisos dela existem para tratar — e aí o recurso é o TenantAdmin
TenantContractContact—Auth · grydNão devolve usuário — devolve o endereço de quem assinou o contrato com a R&G, que está fora do tenant e não tem tela. Um consumidor só, e ele justifica o tipo: é o único destinatário que um administrador comprometido não consegue silenciar, e o aviso que ele recebe é o de alguém ganhar poder de aprovar conta bancária. Depende da plataforma — o Nexio não guarda quem assinou o contrato — e está na demanda. Sem o resolvedor, esse aviso não teria como sair do tenant, que é o ponto inteiro dele
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

O silêncio que isto elimina

Sem o campo que diz qual papel, um slug com TenantRole entrega a quem o resolvedor encontrar — e pode entregar a ninguém. Foi o que a validação mediu em approval-step.authority-exceeded e budget.approval-requirement-unmet, os dois avisos criados exatamente para dizer "o controle não rodou": 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, PendingApprovers é o fluxo, CompanyScopeAdmins é a empresa. 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.

E quando o dono do conceito é a plataforma, o resolvedor não se improvisa aqui. Três dos nove — administrador do tenant, detentores de uma permissão e contato contratual — respondem a perguntas que só o GrydAuth sabe responder. O Nexio não deduz nenhuma delas por conta própria: não varre papéis procurando quem parece administrador, não infere permissão por nome de papel, e não guarda uma segunda cópia de quem assinou o contrato. O que ele faz é declarar o requisito, com critério de aceite, na demanda de plataforma — e deixar o aviso registrado como não entregue por falta de resolvedor até ela existir, em vez de entregar a alguém parecido.

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. A exceção é uma marca por slug — audienceMustResolve. 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. Uma lista de nomes proibidos (amount, total, balance…) 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 classe — Identifier, 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

Vinte e nove dos sessenta e quatro, em quatro famílias — e a proporção subiu de propósito. Quase tudo o que as rodadas de validação acrescentaram é aviso de controle, e aviso de controle desligável é controle que não existe. A lista slug a slug está no catálogo, na coluna de obrigatoriedade; aqui ficam as famílias, porque é o motivo que decide, não o nome.

SlugCanais obrigatóriosMotivo exibido na tela
1 · O aviso é o controleEmailTroca de conta e de canal de aviso, contestação, permissão bancária concedida, carga inicial, divergência com o ERP, correção de identidade. Motivo exibido: "Este aviso vai para o contato cadastrado antes da alteração. É o que detecta uma troca feita por quem tomou a caixa de e-mail nova."
2 · O ato que não aconteceuInAppAnexo infectado, antes ou depois de liberado. Motivo exibido: "Um arquivo que você anexou foi recusado pelo antivírus. Sem este aviso, o documento fica parado sem ninguém saber por quê."
3 · O controle não rodouEmail · InAppPolítica publicada, configuração crítica efetivada, taxa sobrescrita, desfecho recusado, passo parado, delegação criada por terceiro, empresa sem administrador, virada travada. Motivo exibido: "Este aviso diz que um controle deste sistema não funcionou como desenhado. Quem responde por ele precisa ver."
4 · O prazo é de leiEmailPedido de eliminação a pedido do titular: registrado, travado, expurgo recusado, concluído, cancelado de novo. Motivo exibido: "Alguém pediu a eliminação dos próprios dados e há um prazo para responder. Este aviso não se desliga."

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.

E ele sai por um canal só, de propósito. A objeção é justa: quem consegue abrir a proposta fraudulenta é, quase por definição, quem já tem a caixa de e-mail. A resposta da v1 não é um segundo canal de aviso — SMS e WhatsApp foram cortados porque são o que o mesmo fraudador toma em seguida, e um segundo canal fraco dá confiança sem dar controle. A resposta é o callback de saída: uma ligação para telefone confiável, obrigatória, registrada como evidência da tarefa no Fornecedor, que nenhum aviso substitui. Redundância fora de banda de verdade, e não mais um e-mail com outro nome.

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

Anexo recusado pelo antivírus conta uma história só: 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.

Família 3 — o controle não rodou

Um passo que parou, uma política publicada, uma configuração crítica efetivada, uma taxa sobrescrita, um desfecho recusado, uma empresa que ficou sem administrador. O conteúdo destes avisos é sempre o mesmo: o desenho previa uma segunda pessoa, e a segunda pessoa não apareceu — ou apareceu por um caminho que ninguém escolheu. São os avisos cujo destinatário é, por construção, quem não se beneficia do silêncio; e é por isso que eles são também os que mais precisam da marca audienceMustResolve: entregar a zero pessoas aqui é o modo de falha que a validação encontrou duas vezes.

Família 4 — o prazo é de lei

A eliminação a pedido do titular tem prazo, e quem espera está fora do tenant: não tem conta, não tem tela e não tem como perguntar. Os avisos deste caminho contam as quatro coisas que podem acontecer — o pedido foi recebido, está travado por uma decisão em curso, o expurgo foi recusado, os bytes saíram. O que eles nunca carregam é nome de arquivo, número de documento, código de recusa ou comentário: o destinatário é externo, e o aviso que protege um dado pessoal não pode vazar outro.

Obrigatória não é em todos os canais

mandatoryChannels tem um canal só em todos eles: o que carrega 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 definições do catálogo, 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: o documento espera

A spec de Anexo decidiu que documento com anexo em Scanning não submete — ATTACHMENT_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, e sem fila: a recusa continua, a tela acompanha a liberação pela consulta em lote do Anexo e reenvia, e é a pessoa que reenvia.

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." Nada a escolher: a tela mostra o que falta e o botão de enviar volta a funcionar quando faltar nada
2 · EsperaNada é criado. O estado que interessa já existe e é do Anexo: o veredito de cada arquivo, lido pela consulta em loteA lista dos anexos que faltam, atualizada na própria tela do documento
3 · Veredito limpoOs anexos ficam Available e a submissão normal passa — pela pessoa, com a permissão dela, no contexto de uma requisiçãoO botão de enviar volta a funcionar. Nenhum aviso: quem está na tela vê, e quem saiu volta e reenvia
4 · Veredito sujoInfected, Failed ou recusado: o anexo não libera, e a submissão continua recusada pelo mesmo 409nexio.attachment.infected, nexio.attachment.scan-failed ou nexio.attachment.rejected a quem anexou, com a ação que resolve — remover o anexo ou pedir varredura nova
5 · SilêncioSem veredito, o documento fica em rascunho, intacto, e ninguém decidiu nada por ninguémO operador da plataforma já foi alertado pela fila do GrydFiles, que é onde o problema realmente está

Quem avisa é o anexo, não a submissão

Os avisos deste caminho são todos do Anexo e vão a quem anexou — ou ao responsável pelo documento, quando o arquivo veio pelo portal e uploadedByUserId é nulo. Não há aviso para "quem tentou submeter", porque não há mais nada registrando quem tentou: a tentativa é uma requisição recusada, não um pedido guardado.

Nenhum comando roda em nome de uma pessoa fora de uma requisição

É a razão inteira de a fila ter saído. Autorizar no pedido e executar horas depois, num job sem identidade, é como fila assíncrona vira furo de alçada — e a plataforma hoje não restaura sequer o tenant fora de requisição, quanto mais a permissão e o escopo por empresa de quem pediu. Enquanto isso não existir, submeter é ato de gente. O requisito de execução delegada está na página de Mensageria e na demanda de plataforma, para a v2.

O descritor não ganhou pergunta nova

A fila teria obrigado o AttachmentOwnerRegistry a saber "como se submete este documento, e quem pode". Sem ela, o descritor fica com as oito perguntas que já tinha, e o Anexo não carrega uma política de submissão que ninguém mais consome — o que é também a regra de contar o segundo consumidor antes de desenhar.

O que se perde, e por que vale

Perde-se a conveniência de sair da tela e o documento seguir sozinho. Ganha-se que nenhuma aprovação e nenhum empenho nascem de um processo sem gente: o pior caso da fila era um worker submetendo, aprovando por política e empenhando verba com a identidade de alguém que já tinha perdido a permissão. Conveniência contra alçada não é uma troca difícil.

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 seis 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.

5 · O parâmetro de audiência está lá

Resolvedor com parâmetro sem padrão exige audienceParams na definição, e role só aceita chave de papel semeada. Parâmetro faltando, sobrando ou desconhecido derruba (RN-NOT-24) — e tipo de audiência sem resolvedor registrado, também (RN-NOT-12).

6 · A marca de audiência bate com a regra

audienceMustResolve é derivado, não escrito à mão: definição cuja marca não bate com a regra da RN-NOT-28 derruba a inicialização. É o que impede a lista escrita à mão de errar de novo, em silêncio, num catálogo deste tamanho.

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
update:notification-preferencestodos os papéis operacionaisEditar as próprias preferências (PUT /notifications/preferences). Não existe editar a de outra pessoa, e essa ausência é deliberada: seria o caminho de contorno da decisão 2
delete:notification-preferencestodos os papéis operacionaisApagar todas as exceções das próprias preferências e voltar ao padrão do produto (DELETE /notifications/preferences). Também nunca vale para outra pessoa
read:notification-preferencestodos os papéis operacionaisLer as próprias preferências já resolvidas contra o catálogo. Separada da de escrita porque um endpoint declara exatamente uma permissão, e nada é implicado por outra
read:notification-catalogsAdministradorA tela de catálogo — slug, audiência, canais, obrigatoriedade, variáveis e janela. Leitura pura: o catálogo é código

Trilha

Uma ação no GrydAudit, na convenção fechada em § Convenção: notification-preference.changed, com slug, canal, valor anterior e novo. O GrydAudit está em correção pelo time do Gryd.IO — isolamento por tenant, permissão e gravação na mesma transação do ato —, e o Nexio não o mapeia até a correção da plataforma: nenhuma tela de ato de controle entra em produção antes dela. 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

Uma linha por slug do catálogo — sessenta e quatro hoje —, agrupadas pelas oito categorias, com três colunas de canal: e-mail, in-app e push, que são os três que existem. 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. Vinte e nove dessas linhas têm pelo menos um canal travado, e a tela mostra o cadeado e o motivo em cada um.

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.

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-preferencesO catálogo já resolvido 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/preferencesupdate: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/preferencesdelete: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 dezenas de itens e o número muda por release—

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, e o código exato vence o sufixo: o mapeador testa primeiro os códigos canônicos e só então os dez sufixos — _NOT_FOUND → 404 · _ALREADY_EXISTS, _ALREADY_ASSIGNED e _CONFLICT → 409 · _FORBIDDEN, _ACCESS_DENIED e _BLOCKED → 403 · _UNAUTHORIZED → 401 · _UNPROCESSABLE → 422 · _UNAVAILABLE → 503 · sem sufixo → 400.

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

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 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 — 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-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 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. A marca alcança só a audiência interna: audiência externa vazia não cai no administrador, e o ato que dependia do aviso é recusado na origem (RN-NOT-29).

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-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-27Audiência que só a plataforma sabe responder não é deduzida aqui

Administrador do tenant, detentores de uma permissão e contato contratual são perguntas do GrydAuth. O Nexio não varre papéis procurando quem parece administrador, não infere permissão por nome de papel e não guarda segunda cópia de quem assinou o contrato. Sem a consulta, o aviso é registrado como não entregue por falta de resolvedor — nunca entregue a alguém parecido.

Por quê: a aproximação erraria para os dois lados. Quem parece administrador e não é recebe aviso de poder de pagamento; quem é e não parece fica sem. Os dois erros são silenciosos, e o segundo é o que a fraude procura.

RN-NOT-28audienceMustResolve é derivado do catálogo, não escrito à mão

É verdadeiro em todo aviso isMandatory cuja audiência interna seja calculada — por papel, permissão, escopo ou descritor — e falso quando ela é um User nomeado pelo disparo. Audiência externa não entra na derivação: marcar um slug que só fala com o fornecedor ou com o titular mandaria ao administrador do tenant justamente o aviso que a RN-NOT-29 proíbe desviar. Num slug com as duas, a marca vale sobre a parte interna. A semeadura confere a regra e derruba a inicialização quando uma definição a viola.

Por quê: a lista escrita à mão já errou uma vez, e errou exatamente nos dois avisos que existiam para dizer que um controle não rodou. Com o catálogo em sessenta e quatro slugs, uma lista à mão erra de novo — e erra em silêncio, que é o modo de falha que esta marca existe para não ter.

RN-NOT-29Audiência externa não cai no administrador do tenant

Quando o destinatário é o fornecedor ou o titular de um pedido de eliminação, audiência vazia não é substituída por gente de dentro: o administrador recebe cópia, nunca no lugar. Sem nenhum endereço externo, o ato que dependia do aviso é recusado na origem pelo módulo dono — a proposta de conta não abre.

Por quê: o aviso ao canal anterior existe para o fornecedor legítimo dizer "não fui eu". Trocar esse destinatário por um usuário do tenant mantém a aparência do controle e remove a única pessoa que sabe que a conta não é dela.

RN-NOT-30Aviso a destinatário externo não carrega identificador interno

Nome de arquivo, número de documento, código de recusa, tipo de decisão pendente e o valor novo de um contato em troca nunca saem para fora do tenant. O aviso externo leva a referência do próprio pedido, a data, o prazo e o link.

Por quê: é a RN-NOT-09 aplicada ao destinatário, e não só à variável. Quem está fora não tem permissão nenhuma a checar do outro lado do link — e no caminho da eliminação o aviso que protege um dado pessoal não pode vazar outro.

RN-NOT-31Nenhum aviso substitui o callback

O aviso ao canal anterior do fornecedor sai por e-mail, e só — é audiência externa, e o endereço é a única coisa que o resolvedor devolve. Os avisos bancários internos continuam nos três canais da v1, porque quem os recebe tem conta e tela. A redundância fora de banda do BANK_ACCOUNT_CHANGE é a ligação de saída para telefone confiável, registrada como evidência da tarefa no Fornecedor — não é canal desta spec, não tem slug e não se desliga por preferência.

Por quê: SMS e WhatsApp saíram da v1 porque são o que o mesmo fraudador toma em seguida, por clonagem de número e por troca de chip. Um segundo canal fraco dá confiança sem dar controle, e a confiança é o que faz alguém parar de ligar.

RN-NOT-32Nada nesta spec executa comando em nome de uma pessoa

Não há fila que submeta, aprove ou empenhe. Todo ato com consequência acontece dentro de uma requisição, com a identidade, a permissão e o escopo de quem o faz. Enquanto a plataforma não restaurar esses três fora de requisição, execução delegada é v2, escrita como requisito na página de Mensageria e na demanda de plataforma.

Por quê: o pior caso da fila que saiu era um worker submetendo, aprovando por política e empenhando verba com a identidade de alguém que já tinha perdido a permissão — e sem sequer restaurar o tenant. Conveniência contra alçada não é uma troca difícil.

As RN-NOT-18 a 21 e a RN-NOT-26 eram da fila de submissão diferida e saíram com ela. Os números ficam vagos de propósito: regra não se renumera, porque o número é o endereço por onde outra página a cita.

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 ser uma lista agrupada 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. Cortados da v1 — são os canais mais atacados, por clonagem e por troca de chip, e um controle antifraude que se apoia neles apoia-se no que o fraudador toma primeiro. Canal novo é do framework; entrar aqui custaria uma linha em allowedChannels, e é isso que o enum está preparado para receber quando a decisão mudar.
  • Notificação ao fornecedor pelo portal. Hoje só o controle bancário fala com quem está de fora. Os três avisos do portal estão reservados no catálogo pelo nome, com audiência, canal e conteúdo definidos pelo Portal do Fornecedor; o resolvedor do convidado entra aqui quando aquela proposta for validada.
  • Locale do fornecedor estrangeiro. SupplierType = Foreign poderia carregar o dele; a cascata já tem onde encaixar.
  • Submissão em nome de outra pessoa. A fila que submetia sozinha saiu da v1. Volta quando a plataforma souber executar um comando com a identidade, a permissão e o escopo de quem pediu — requisito escrito na página de Mensageria e na demanda de plataforma.

O que esta spec deixa amarrado em outro documento. Duas coisas, e as duas são pequenas de propósito. A primeira é o contrato de audiência: quem implementa cada resolvedor é o módulo dono do conceito, e três deles — administrador do tenant, detentores de uma permissão e contato contratual — dependem de consulta que só o GrydAuth pode responder, pedida na demanda de plataforma. A segunda é o Motivo: os códigos que esta spec cita em texto de aviso são sempre o rótulo de lista fechada, nunca o comentário — e é a mesma fronteira entre motivo de sistema e motivo de pessoa que as duas páginas declaram com as mesmas palavras. Nenhuma das duas muda tabela, contrato de erro ou slug.

Registro

Histórico de revisões

VersãoDataO que mudou
v1.1730/10/2026Permissões padronizadas em read/lookup/create/update/delete (decisão de 30/10/2026). manage:notification-preferences vira, por ação: PUT /notifications/preferences → update:notification-preferences; DELETE /notifications/preferences → delete:notification-preferences. A tabela de permissões ganha uma linha.
v1.1621/09/2026Validação de 18/09, achado 25. A família da eliminação a pedido do titular volta à RN-NOT-03: o slug é a ação da trilha com o prefixo. nexio.attachment.erasure-requested avisava no registro, e a ação de mesmo nome saía na execução; passa a nexio.attachment-erasure-request.registered. Pela mesma regra, erasure-blocked, erasure-purge-blocked e erasure-cancelled passam a nexio.attachment-erasure-request.blocked, .purge-blocked e .cancelled, os nomes dos eventos que o Anexo já publicava; nexio.attachment.erased fica. Decisão do Roger: o registro publica uma de duas ações, nunca as duas — .registered quando o pedido nasce livre e .blocked quando nasce travado; o travado não avisa quem executa. Texto, audiência e canais dos cinco avisos não mudam. Contagens intactas: 64 slugs, 29 obrigatórios.
v1.1521/09/2026Validação de 18/09, achado 22 (decisão do Roger: o aviso de resíduo do Orçamento fica na v1). Slug novo nexio.budget-residue.aged: CostCenterOwners sem Watcher, E I p, janela de 7 dias, um aviso por dono e por nó, com a lista dos documentos e sem valor (RN-NOT-09). A idade é o BudgetPolicy.residueAlertAfterDays. nexio.budget.overrun-recorded ampliado: cobre também o Actual sob Block ou Uncovered e o positivo isento da virada, que o Orçamento mandava notificar sem slug. Contagens recontadas: 64 slugs (quarenta acrescentados às vinte e sete originais, menos três), vinte e nove obrigatórios, nove resolvedores.
v1.1420/09/2026Recuo da validação de 18/09 (achado 11). Os três slugs do portal — nexio.portal-invitation.sent, nexio.portal-access-link.reissued e nexio.portal-round.closing — deixam de apontar para SupplierFinanceContact, que é a cadeia do aviso bancário e não chega ao convidado: a audiência, o canal e o conteúdo passam a ser do Portal do Fornecedor, e os três ficam reservados pelo nome até o resolvedor do convidado existir. Sai daqui o detalhe do token no fragmento, que é regra do Portal. Contagens inalteradas: 63 slugs, nove resolvedores.
v1.1319/09/2026Decisão D4 do Fluxo de Aprovação (RN-AP-71): a descrição de nexio.approval-request.decision-refused passa a cobrir também o ajuste de documento aprovado recusado pelo desfecho. Nenhum slug novo.
v1.1213/09/2026Correção da validação R7. Contagem do cabeçalho (BX-01): a linha de resumo dizia 62 slugs contra os sessenta e três contados no catálogo — as três contagens vizinhas (1 agregado, 1 entidade de estado, 2 registros em código) foram reconferidas na página e batem. Ata B11: o cabeçalho e a § Trilha passam a carregar a ressalva que o resto do acervo carrega — o GrydAudit está em correção pelo time do Gryd.IO e o Nexio não o mapeia até a correção da plataforma; nenhuma tela de ato de controle entra em produção antes dela. RN-NOT-31: o e-mail exclusivo é do canal anterior do fornecedor, que é audiência externa — os oito avisos bancários internos seguem nos três canais. erasure-cancelled: o evento passa a ser nomeado na linha do slug, attachment-erasure-request.cancelled, igual ao Anexo.
v1.1112/09/2026Correção da validação R6. Aviso bancário: supplier-bank-account.change-approved passa a ir também ao canal anterior do fornecedor, por e-mail, com o fim da carência e link de contestação novo, e passa a ser obrigatório — a carência de 24 a 72 h corria sem o fornecedor legítimo saber que tinha começado (Ata B8). Audiência: a derivação de audienceMustResolve exclui audiência externa, que é o que a RN-NOT-29 já mandava tratar na origem; o campo, a RN-NOT-14 e a RN-NOT-28 dizem a mesma frase. Catálogo: 62 → 63 slugs — entra supplier-control-evidence.lost, o anexo que sustentava um controle já aplicado e caiu depois dele; obrigatórios 27 → 29. Eliminação a pedido do titular: erasure-blocked ganha o blockReason com ScopeIncomplete e o administrador do tenant nesse caso; erasure-purge-blocked ganha nome de evento próprio e o caso do expurgo não gravado com prazo vencido; erasure-cancelled avisa o titular em todo cancelamento. Semeadura: as conferências que a página já declarava passam a estar listadas — seis, não quatro. Contagens recontadas na página: sessenta e três slugs, vinte e nove obrigatórios, TenantAdmin em dezessete e PermissionHolders em nove. Forma (Ata B18): as dezesseis notas de auto-história saíram do corpo normativo.
v1.1012/09/2026Validação de 10/09 e as cinco rodadas de validação da onda 1. Catálogo: 27 → 62 slugs — trinta e cinco entraram com pedido registrado das trilhas (política publicada, configuração crítica do motor e do câmbio efetivada, papel de controle concedido e retirado, delegação criada por terceiro e a vencer, membro de grupo a vencer, passo parado, complementar da controladoria, resolução por reserva, desfecho recusado, instância cancelada, taxa sobrescrita, virada travada, empresa sem administrador, escopo cortado em cadeia e encerrado por remoção, seis do caminho de eliminação a pedido do titular, varredura falha e arquivo reprovado, infecção depois da liberação, nove do controle bancário e do canal de aviso, três do portal) e três saíram com a fila. Fora da v1: submissão diferida — o agregado DeferredSubmission, os campos, as três rotas, os cinco erros, os dois slugs e o aviso de liberação de varredura; §Scanning reescrita sem fila, e nenhum comando roda em nome de pessoa fora de requisição (RN-NOT-32). Fora da v1 também: Sms sai de allowedChannels — três canais, não quatro —, o aviso bancário sai só por e-mail e a redundância fora de banda é o callback, que não é canal desta spec (RN-NOT-31). Audiência: quatro resolvedores novos (5 → 9) — TenantAdmin, PermissionHolders(permission), CompanyScopeAdmins(companyId) e TenantContractContact, os três de plataforma declarados como requisito e nunca deduzidos (RN-NOT-27); a cadeia do SupplierFinanceContact passa a ser Finance → PortalAdmin → Commercial designado → contato designado do estabelecimento principal, só e-mail, sem condição de consentimento, e audiência externa vazia não cai no administrador (RN-NOT-29). Obrigatoriedade: 3 → 27 slugs, em quatro famílias (o aviso é o controle · o ato que não aconteceu · o controle não rodou · o prazo é de lei), e audienceMustResolve deixa de ser lista escrita à mão para ser regra conferida na semeadura (RN-NOT-28). Payload: aviso a destinatário externo não carrega identificador interno (RN-NOT-30). Convenção: uma grafia só — entidade e verbo em kebab-case; treze slugs em snake_case renomeados. Menores: dez sufixos de erro com _UNAVAILABLE → 503 vigente e código exato vencendo o sufixo; categorias Currency e Company (oito); read:notification-preferences na tabela de permissões; a trilha volta a uma ação; RequiredVariables do template é string anulável; o framework tem UserPreference — a lacuna é preferência por aviso e por canal, não preferência de conta.
v1.909/09/2026Três slugs novos (24 → 27): ativação que resolve vazia, instância reaberta, certidão de fornecedor a vencer. audienceParams na definição, com três papéis semeados — a audiência TenantRole não dizia qual papel, e dois avisos de governança entregavam a zero pessoas sem erro (RN-NOT-24). A guarda de payload deixa de ser lista de nomes proibidos e passa a ser classe declarada por variável, com texto de pessoa fora de qualquer aviso (RN-NOT-10, RN-NOT-25). mandatoryChannel → mandatoryChannels. Resolvedor Previous lê o contato vigente antes da alteração, qualquer que seja o tipo dela.
v1.1 – v1.803–09/09/2026Motor universal de aprovação e os slugs 20 a 24; alçada excedida, pergunta e resposta ao aprovador, resolução fora de escopo, exigência de aprovação não cumprida. Modo da fila de submissão diferida, com a volta a Waiting na recusa do dono.
v1.003/09/2026Primeira versão: as oito decisões, preferência como exceção por usuário × slug × canal, obrigatoriedade com canal e motivo, resolvedor por tipo de audiência no módulo dono do conceito, notificação como ponteiro e nunca relatório, slug igual à ação da trilha com o prefixo do produto, throttle na v1 e digest declarado.