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.
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.
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.
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.
Notificação é ponteiro, não relatório. O corpo carrega identificação, contexto não sensível e link — nunca valor sob permissão. A RN-O22 do Orçamento já dizia que sem read:budget-amount os valores não vão "nem na API, nem no CSV, nem na notificação"; aqui isso deixa de ser regra do orçamento e vira regra do produto. O catálogo declara as variáveis de cada slug, e nenhuma delas pode ser sensível: a checagem acontece na semeadura, não em tempo de execução.
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.
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.
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.
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.
| Assunto | Quem resolve | Detalhe |
|---|---|---|
| Entrega, retry e canal | GrydNotifications | Notification, 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ção | GrydNotifications | NotificationTemplate 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 agendamento | GrydNotifications · GrydJobs | INotificationQueue, 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 existem | Nexio | Catá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 recebe | Nexio | Resolvedores 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 receber | Nexio | NotificationPreference. 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 operador | Plataforma | Fila 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
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.
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.
Slug, Locale, Version, RequiredVariables e TenantId?. O Nexio semeia um template por slug e por locale suportado, e nunca cria a tabela.
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.
Produto
O catálogo de slugs
Vinte avisos, e nenhum deles foi inventado aqui: cada linha é um disparo já escrito em uma das nove specs publicadas, com o documento de origem na última coluna — o vigésimo entrou em 06/09/2026, quando o Fluxo escreveu o disparo de alçada estourada que a contra-análise apontou como faltante. O catálogo é a varredura, e a varredura é o que impede o vício clássico — o time inventar avisos que ninguém pediu e não escrever os que a regra de negócio exige.
Legenda de canais: E e-mail · I in-app · P push. Maiúscula é ligada por padrão, minúscula é disponível e desligada. obrig marca o que a preferência não desliga.
| Slug | Dispara quando | Audiência | Canais | Ritmo | Origem |
|---|---|---|---|---|---|
| nexio.approval-assignment.assigned | Uma tarefa entra na fila de um aprovador — de qualquer processo: desde 04/09/2026 cadastro e homologação de fornecedor, alteração bancária e curadoria de item também são instâncias do motor, e os avisos de tarefa, lembrete e decisão os cobrem | User | E I p | digest ✓ | Fluxo |
| nexio.approval-assignment.reminded | SLA a vencer ou vencido, com EscalationBehavior = Notify. Vai ao aprovador e a quem submeteu | User | E I p | digest ✓ · 24 h | Fluxo |
| nexio.approval-request.escalated | Escalate. A tarefa original continua viva, e o aviso diz isso. NotifyManager saiu do Fluxo em 08/09/2026 — dependia da hierarquia de pessoas, que é fase 2 | User | E I P | 24 h | Fluxo |
| nexio.approval-request.returned | Devolução para ajuste. Gera evento e avisa quem submeteu | User | E I P | imediato | Fluxo |
| nexio.approval-request.decided | Instância terminou em Approved ou Rejected. Variável outcome, um template só | User | E I P | imediato | Fluxo |
| nexio.approval-step.authority_exceeded | Um passo ativou nos Owners da raiz sem ninguém na cadeia com alçada para o valor — a aprovação vai acontecer no topo (decisão 7 do Fluxo) e a controladoria fica sabendo com a tarefa ainda aberta, a tempo de corrigir o vínculo ou reatribuir. Sem valor no corpo (RN-NOT-09): quantia e limite ficam do outro lado do link. Era o slug que faltava ao gate 9 da contra-análise. Novo em v1.2 | TenantRole | E I P | imediato | Fluxo |
| nexio.approval-step.resolved_out_of_scope | Um nível resolveu para quem não alcança a empresa do documento e o passo caiu no aprovador de reserva. É o mesmo tipo de fato do authority_exceeded — uma cadeia que não saiu como o desenho previa —, e o caminho normal é não acontecer: a RN-CC-30 recusa o vínculo fora de escopo na criação, e a RN-AP-40 recusa o alvo fora de escopo na publicação da política. Sobra o escopo revogado depois, com o vínculo dormente, e a controladoria descobre por aviso em vez de pela pergunta "por que este pedido foi parar no reserva?". Novo em v1.5 | TenantRole | E I P | imediato | Fluxo |
| nexio.approval-assignment.info_requested | Um aprovador perguntou algo ao requisitante (POST /approvals/{id}/assignments/{aid}/request-info) e o SLA do passo pausou. Vai ao requisitante: sem ele a pergunta existe e ninguém sabe — o documento fica parado esperando uma resposta que não foi pedida a ninguém, e o tempo conta contra o requisitante. É o par do info_answered, e faltava desde que a rota nasceu. Novo em v1.6 | User | E I P | imediato | Fluxo |
| nexio.approval-assignment.info_answered | O requisitante respondeu à pergunta de um aprovador (POST /approvals/{id}/answer-info). Vai a quem perguntou: sem ele o relógio do passo volta a correr em silêncio, e o SLA corre contra um aprovador que não sabe que a resposta chegou. Novo em v1.3 | User | E I p | imediato | Fluxo |
| nexio.budget.warning_signaled | Avaliação devolveu Warning — estoura dentro da tolerância | CostCenterOwners | E i | digest ✓ · 12 h | Orçamento |
| nexio.budget.approval_requirement_unmet | Um nó em RequireApproval estourou e a cadeia resolvida não tem nenhum passo vindo de política com critério de orçamento — a exigência foi declarada e não foi cumprida, quase sempre porque a política complementar foi aposentada ou expirou. Vai à controladoria com o documento ainda em aprovação, a tempo de reatribuir ou republicar a política. Não se confunde com o overrun_recorded: aquele diz "estourou" e vai aos donos do nó; este diz "o controle não rodou" e vai a quem responde por ele (RN-AP-42). Novo em v1.7 | TenantRole | E I P | imediato | Orçamento |
| nexio.budget.overrun_recorded | Política Warn deixou passar e marcou o documento como fora do orçamento | CostCenterOwners | E I p | imediato | Orçamento |
| nexio.exchange-rate.capture_failed | consecutiveFailures cruzou notifyOnFailureAfter — padrão 1, porque câmbio quebrado em silêncio é o modo de falha que aquela spec existe para evitar | User → TenantRole | E I P | 24 h por par | Moeda |
| nexio.item-compliance.expiring | Job diário, 30, 15 e 5 dias antes. Quando blocksPurchaseWhenExpired, o texto diz que vai bloquear | TenantRole | E i | digest ✓ · 24 h | Catálogo |
| nexio.item.rejected | Item recusado na curadoria — a instância ITEM terminou em Rejected. Carrega o motivo codificado e o comentário; avisa o criador. Convive com approval-request.decided: um avisa a decisão, o outro traz o conteúdo dela | User | E I p | imediato | Catálogo |
| nexio.item-import.completed | Importação assíncrona terminou. Link para o relatório — o exemplo mais limpo de ponteiro em vez de conteúdo | User | E I p | imediato | Catálogo |
| nexio.attachment.revoked | Anexo revogado em documento já submetido e ainda em aprovação | PendingApprovers | E I P | imediato | Anexo |
| nexio.attachment.infected | Veredito do antivírus deu Infected. Avisa quem anexou obrig | User | E I P | imediato | Anexo |
| nexio.attachment.scan_completed | Os anexos que travavam a submissão foram liberados. Vai a quem tentou submeter | User | e I P | imediato | Anexo |
| nexio.deferred-submission.completed | A fila submeteu o documento sozinha | User | E I P | imediato | esta spec |
| nexio.deferred-submission.abandoned | A fila desistiu — arquivo Infected ou Failed, permissão perdida, documento mudou. O que você pediu não aconteceu obrig | User | E I P | imediato | esta spec |
| nexio.supplier-bank-account.change_requested | Proposta de alteração bancária, avisando no canal antigo. Controle contra comprometimento de e-mail obrig | SupplierFinanceContact | E | imediato | Fornecedor |
| nexio.supplier-bank-account.change_approved | Proposta aprovada — a instância BANK_ACCOUNT_CHANGE terminou em Approved e o Fornecedor aplicou. A carência de 24 a 72 h antes do primeiro uso começa a contar, e o aviso diz quando termina. Convive com approval-request.decided, que avisa só a decisão | TenantRole | E I p | imediato | Fornecedor |
| nexio.supplier.compliance_changed | Job periódico encontrou sanção nova, certidão vencida ou situação cadastral alterada. Alerta, nunca bloqueio automático | TenantRole | E I p | digest ✓ · 24 h | Fornecedor |
Em lugar nenhum do catálogo — e é isso que ele prova. AssignmentRole = Watcher do Centro de Custo existe só para receber notificação e nunca aprova. Ele não é um slug, é um parâmetro do resolvedor: CostCenterOwners aceita includeWatchers, e os dois avisos de orçamento o ligam. Destinatário e decisor são eixos diferentes, e confundi-los é como se acaba mandando aviso só para quem tem alçada.
Nenhum slug de "documento criado", "campo alterado" ou "status mudou". Aviso de mudança de estado sem consequência para quem recebe é exatamente o que faz um usuário desligar a categoria inteira — e junto com ela o aviso que importava. A trilha do GrydAudit já responde "o que aconteceu com este documento"; notificação responde "você precisa fazer alguma coisa, ou alguém precisa saber agora".
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.
| Campo | Tipo | Regra | |
|---|---|---|---|
| slug | string | obrig | Chave. 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 |
| category | enum | obrig | Approval · Budget · Catalog · Supplier · Attachment · System. Só agrupa a tela. A preferência é por slug, não por categoria — a categoria existe para a lista de vinte e quatro itens ser legível, não para ser desligada em bloco |
| audienceType | enum | obrig | Um dos cinco de § Resolução de destinatário. Valor sem resolvedor registrado derruba a inicialização |
| allowedChannels | enum[] | obrig | Email · InApp · Push. Canal fora desta lista não aparece na tela e é recusado na API — não existe "ligar push" para um aviso que não faz sentido no celular |
| defaultChannels | enum[] | obrig | Subconjunto 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 |
| isMandatory | bool | obrig | Padrão false. Verdadeiro só nas duas famílias descritas em § Notificação que não se desliga. Três dos vinte e quatro slugs, e cada um com o motivo escrito |
| mandatoryChannel | enum? | cond | Obrigatório quando isMandatory; nulo caso contrário. Um canal só — o que carrega o controle. Os demais canais permitidos continuam desligáveis |
| mandatoryReason | string? | cond | Obrigató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 |
| variables | string[] | obrig | As variáveis que o disparo entrega ao template. Confrontadas com RequiredVariables do template semeado, na inicialização. Nenhuma pode ser sensível — ver § Payload e permissão |
| entityKey | string? | opc | Qual 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 |
| throttleWindow | interval | obrig | Zero significa imediato e sem memória. Diferente de zero, a mesma tupla usuário + slug + entidade não repete dentro da janela. Ver § Ritmo |
| digestEligible | bool | obrig | Declarado na v1, consumido na v2. Está aqui porque acrescentar o campo depois obrigaria a revisar vinte e quatro definições sob pressão de release — decidir agora custa uma coluna de tabela em documento |
| audit | string? | opc | A 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.
| Campo | Tipo | Regra | |
|---|---|---|---|
| tenantId | Guid | gerado | Do token, nunca aceito do cliente |
| userId | Guid | obrig | Sempre 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 |
| slug | string | obrig | Precisa existir no catálogo. Slug desconhecido é 422, não 404: o cliente mandou algo que este release não conhece |
| channel | enum | obrig | Precisa estar em allowedChannels da definição |
| enabled | bool | obrig | Só se grava quando difere de defaultChannels. Gravar um valor igual ao padrão apaga a linha em vez de criá-la — é o que faz a preferência acompanhar a mudança de padrão do produto |
Índice único por (tenantId, userId, slug, channel). A leitura é uma varredura de vinte e quatro definições contra as poucas linhas do usuário, resolvida em memória — não há join, e a tela carrega com uma consulta.
NotificationThrottle
| Campo | Tipo | Regra | |
|---|---|---|---|
| tenantId · userId · slug | Guid · Guid · string | obrig | Quem recebeu o quê |
| entityId | string | obrig | O valor de entityKey naquele disparo, ou a string vazia quando a definição não tem entityKey |
| lastSentAt | timestamptz | gerado | Upsert 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
| Campo | Tipo | Regra | |
|---|---|---|---|
| tenantId | Guid | gerado | Do token |
| ownerType · ownerId | enum · Guid | obrig | O documento a submeter. Mesmo enum e mesmo registro de descritores do Anexo — sem chave estrangeira, porque atravessa bounded context |
| requestedBy | Guid | obrig | Quem pediu, e quem recebe os dois avisos do fim. Não é necessariamente quem anexou o arquivo |
| state | enum | obrig | Waiting · Submitting · Completed · Abandoned · Cancelled. Submitting existe para a fila não tentar duas vezes o mesmo documento em duas instâncias da aplicação |
| abandonReason | enum? | cond | Obrigatório em Abandoned: FileInfected · FileScanFailed · PermissionLost · OwnerChanged · OwnerDiscarded · Expired. É o texto do aviso, e é o que a pessoa lê para saber o que fazer |
| expiresAt | timestamptz | gerado | 72 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?".
| Tipo | Parâmetros | Quem implementa | Devolve |
|---|---|---|---|
| User | userIds[] | o próprio módulo | As 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 |
| TenantRole | role | Auth · gryd | Todos 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 |
| CostCenterOwners | nodeId · rollup · includeWatchers | Centro de Custo | Os 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 |
| PendingApprovers | approvalRequestId | Fluxo de Aprovação | Os ApprovalAssignment em Pending ou Active da instância. Nunca os já decididos, nunca os obsoletos |
| SupplierFinanceContact | supplierId · channel | Fornecedor | Não devolve usuário — devolve endereço. channel = Previous lê o contato financeiro antes da alteração proposta, que é o ponto inteiro do controle bancário. Ver § Obrigatória |
O resolvedor mora no dono do conceito
O módulo de notificação declara a interface e o registro; quem implementa CostCenterOwners é o centro de custo, e quem implementa PendingApprovers é o fluxo. Sem isso, "notificação" vira o depósito onde regra de negócio de outros módulos se acumula — e o rollup do path passa a ter duas implementações que divergem no primeiro nó com vigência vencida.
Tipo sem resolvedor derruba a inicialização
Mesma disciplina do AttachmentOwnerRegistry: a validação acontece no startup, não em produção às três da manhã com a fila cheia. Acrescentar um tipo de audiência custa exatamente o que custa implementar o resolvedor dele.
Lista vazia não é erro
Nó de centro de custo sem Owner vigente, instância sem aprovador pendente, papel sem ninguém: o disparo registra recipients: 0 com o motivo e não lança exceção. Mas as duas audiências de controle — a bancária e a de câmbio — têm fallback declarado para o papel de administrador, porque para elas "ninguém" é uma falha de configuração, não um estado normal.
Deduplicação é do resolvedor combinado
Um disparo pode pedir mais de uma audiência — o lembrete de SLA vai ao aprovador e a quem submeteu. A união é feita por userId antes da preferência ser consultada, então a mesma pessoa nas duas listas recebe uma vez, e o motivo registrado traz as duas razões.
Decisão 4
Payload e permissão
Uma notificação sai do servidor para uma caixa de entrada que ninguém controla, para uma pessoa cuja permissão não foi checada no momento em que o texto foi montado. A tela sabe esconder o valor; o e-mail já saiu. O Orçamento escreveu a regra primeiro, para o caso dele. Aqui ela vira regra do produto.
A regra, e é uma só. O corpo de uma notificação carrega identificação — número do documento, nome do centro de custo, nome do fornecedor, data —, contexto não sensível — o que aconteceu, o que se espera de quem lê — e um link. Nunca carrega valor sob permissão: quantia, saldo, preço unitário, total do documento, limite de alçada. "O pedido PC-2026-0341 ficou fora do orçamento no CC Engenharia · abrir" é a notificação inteira. O número está do outro lado do link, onde a permissão é checada uma vez, no lugar em que ela já era checada.
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.
variables da definição é confrontado com uma lista de nomes proibidos e com o RequiredVariables do template semeado, na inicialização. Um slug que declara amount, total, balance, unitPrice ou approvalLimit não sobe. Assim a regra é verificada uma vez por deploy, e não vinte vezes por dia em tempo de execução — e ninguém precisa lembrar dela ao escrever o vigésimo disparo.
Consequência aceita: o e-mail é menos útil. Um gestor que recebe "estourou" e quer saber quanto precisa abrir o sistema. É o preço, foi escolhido de olhos abertos, e vale porque o erro contrário — o número no e-mail de quem não devia vê-lo — não tem correção depois de acontecer.
Decisão 2
Notificação que não se desliga
Três dos vinte e quatro slugs, em duas famílias, e cada família tem um motivo diferente.
| Slug | Canal obrigatório | Motivo exibido na tela |
|---|---|---|
| nexio.supplier-bank-account.change_requested | "Este aviso vai para o contato financeiro cadastrado antes da alteração. É o que detecta uma troca de conta feita por quem tomou a caixa de e-mail nova." | |
| nexio.attachment.infected | InApp | "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.abandoned | InApp | "A submissão que você pediu não aconteceu. Um aviso que a pessoa pode desligar transformaria isso num documento esquecido em rascunho." |
Família 1 — o aviso é o controle
O aviso bancário no canal antigo não acompanha um controle: ele é o controle. Desligável, o controle simplesmente não existe, e o cliente descobre isso depois do primeiro pagamento desviado. Vale a pena notar que este destinatário não é usuário do tenant, e por isso nem chega à preferência — ver abaixo.
Família 2 — o ato que não aconteceu
Anexo recusado e fila que desistiu contam a mesma história: a pessoa acredita que fez alguma coisa, e não fez. Silêncio aqui não é economia de ruído, é um documento parado em rascunho que ninguém procura. Por isso o canal obrigatório é InApp — o mais barato de todos, o que não invade e o que aparece quando a pessoa volta.
Obrigatória não é em todos os canais
mandatoryChannel é um canal só. Os demais canais permitidos continuam sendo escolha do usuário: quem quiser o aviso de arquivo infectado também por e-mail liga, e quem não quiser push desliga. Obrigar os três seria o caminho mais curto para a pessoa criar uma regra de caixa de entrada e nunca mais ver nenhum.
Quem não é usuário não tem preferência
O contato financeiro do fornecedor não tem conta, não tem tela e não tem linha em NotificationPreference. Para essa audiência o pipeline pula a consulta de preferência inteira — não "consulta e não acha", pula, porque perguntar a preferência de quem não pode ter uma é como se cria um caminho para o controle falhar silenciosamente quando alguém, um dia, criar a linha.
Decisões 6 e 7
Throttle, digest e locale
Throttle — o que a v1 constrói
Antes de entregar, o pipeline consulta NotificationThrottle pela tupla usuário + slug + entidade. Dentro da janela declarada, a entrega é descartada — não adiada, não enfileirada, não somada. É o comportamento certo para um lembrete: a informação da segunda cópia é a mesma da primeira, e ela ainda está lá.
O caso que o justifica é o lembrete de SLA. Um aprovador com quarenta tarefas vencendo recebe, sem throttle, quarenta e-mails; recebe, com janela de 24 horas por instância, um por instância por dia — que ainda é muito, e é exatamente por isso que digestEligible já está marcado neste slug.
Digest — o que a v1 declara e não constrói
digestEligible e entityKey em todas as vinte e quatro definições, NotificationThrottle registrando o que já saiu, e o INotificationScheduler do framework. O que falta da v2 é a janela por usuário, o template de resumo e a decisão de o que fazer com um aviso obrigatório dentro de um agrupado — e essa última é a razão principal de não fazer agora.
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 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: as duas coisas, com a segunda opcional a cada tentativa.
| Passo | O que o sistema faz | O que a pessoa vê |
|---|---|---|
| 1 · Recusa | Devolve 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 · Pedido | Cria DeferredSubmission em Waiting quando a pessoa escolhe enviar automaticamente. A opção "só avisar" não cria nada — é o slug nexio.attachment.scan_completed registrado para aquele usuário e documento | Uma 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 descritor | nexio.deferred-submission.completed, com o número do documento e o resultado |
| 4 · Veredito sujo | Infected ou Failed: a fila vai a Abandoned com o motivo. Nunca submete documento com anexo sem veredito limpo | nexio.deferred-submission.abandoned obrig, mais o nexio.attachment.infected para quem anexou, se for o caso |
| 5 · Silêncio | 72 horas sem veredito: Abandoned por Expired. O documento continua em rascunho, intacto | O 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ão | Semeada em | Observação |
|---|---|---|
| manage:notification-preference | todos os papéis operacionais | Editar 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-catalog | Administrador | A tela de catálogo — slug, audiência, canais, obrigatoriedade, variáveis e janela. Leitura pura: o catálogo é código |
| manage:deferred-submission | todos os papéis operacionais | Pedir 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
Dezenove linhas agrupadas pelas seis categorias, três colunas de canal com toggle. O que a categoria faz é agrupar, não desligar em bloco — não há interruptor de categoria, porque ele juntaria a sanção de fornecedor com o lembrete de cadastro.
Linha obrigatória mostra o canal travado com cadeado e o mandatoryReason ao lado, em texto normal — não em tooltip. Os demais canais dessa linha continuam clicáveis.
Nada de "salvar": cada toggle é um PUT. E o rodapé diz a frase que evita o chamado mais comum: "avisos novos entram ligados ou desligados conforme o padrão do produto; o que você mudou aqui continua valendo."
Somente leitura, com read:notification-catalog. Uma linha por slug com audiência, canais, janela, obrigatoriedade e as variáveis declaradas. Existe para responder, sem abrir código, as duas perguntas que o suporte recebe: "por que o fulano recebeu isso?" e "por que ninguém recebeu?".
A segunda pergunta é a que a coluna de audiência responde: nó sem Owner vigente entrega a zero pessoas, e isso é configuração do cliente, não defeito.
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.
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
| Rota | Permissão | Devolve | Erros |
|---|---|---|---|
| GET /notifications/preferences | read:notification-preference | As vinte e quatro definições já resolvidas contra as linhas do usuário: slug, categoria, rótulo, canais permitidos, estado atual de cada um, isMandatory e mandatoryReason. A tela não precisa saber o que é exceção e o que é padrão | — |
| PUT /notifications/preferences | manage:notification-preference | Lote 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ário | 400 · 422 |
| DELETE /notifications/preferences | manage:notification-preference | Apaga 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/catalog | read:notification-catalog | O catálogo inteiro como o código o declara, incluindo variables e audienceType. Não é paginado: são vinte e quatro itens e o número muda por release | — |
| POST /deferred-submissions | manage:deferred-submission | {ownerType, ownerId}. Cria ou devolve a fila viva daquele documento — idempotente. Recusa se o documento não tem anexo em Scanning, porque aí a submissão normal funciona | 400 · 404 · 409 · 422 |
| GET /deferred-submissions | manage:deferred-submission | A fila viva de um documento, ou as do próprio usuário. Estado, o que falta liberar e expiresAt | 400 · 404 |
| POST /deferred-submissions/{id}:cancel | manage:deferred-submission | Cancela. Só quem pediu, e só em Waiting — Submitting já está a caminho e cancelar ali seria uma corrida sem vencedor definido | 403 · 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ódigo | HTTP | Quando |
|---|---|---|
| NOTIFICATION_SLUG_UNKNOWN_UNPROCESSABLE | 422 | Slug 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_UNPROCESSABLE | 422 | Canal fora de allowedChannels daquele slug |
| NOTIFICATION_CHANNEL_MANDATORY_UNPROCESSABLE | 422 | Tentativa de desligar o mandatoryChannel. A resposta traz o mandatoryReason, para a tela não ter que carregar o texto duas vezes |
| NOTIFICATION_PREFERENCE_FORBIDDEN | 403 | userId diferente do requisitante. Não é 404: negar a existência aqui não protege nada e confunde o cliente |
| DEFERRED_SUBMISSION_NOT_FOUND | 404 | Não existe, já terminou, ou o documento está fora do escopo do usuário — os três respondem igual |
| DEFERRED_SUBMISSION_ALREADY_EXISTS | 409 | Só quando o pedido vem de outra pessoa. Do mesmo usuário é idempotente e devolve 200 com a fila existente |
| DEFERRED_SUBMISSION_NOT_APPLICABLE_CONFLICT | 409 | O 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_CONFLICT | 409 | Estado diferente de Waiting |
| DEFERRED_SUBMISSION_OWNER_TYPE_UNKNOWN | 400 | ownerType 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_CONFLICT | 409 | Repassado 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
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.
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.
nexio.<entidade>.<verbo-no-passado>, conferido por máquina na semeadura. A recíproca não vale: nem toda ação auditada tem aviso.
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ã.
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.
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.
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.
O contato financeiro do fornecedor não tem conta nem linha. O pipeline pula a consulta — não consulta e ignora o resultado.
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.
A lista de nomes proibidos é aplicada na semeadura. A regra 09 não depende de alguém lembrar dela ao escrever o vigésimo disparo.
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.
Mesma disciplina do ownerType sem descritor no Anexo: falha no startup, não em produção.
E antes da preferência. Papel certo em empresa errada não recebe, pela mesma regra que faz o documento responder 404.
Registra recipients: 0 com o motivo. As duas audiências de controle — bancária e câmbio — caem no papel de administrador, porque ali "ninguém" é configuração errada, não estado normal.
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.
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.
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.
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.
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.
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.
Índice único parcial. Pedido repetido do mesmo usuário devolve a fila existente; de outra pessoa, 409.
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.
NotificationDeliveryAttempt já guarda tentativa, canal, momento e falha. A trilha do Nexio registra só o que é decisão do usuário: mudança de preferência e pedido ou abandono de fila.
Aferição
Referência de mercado
Nenhuma das decisões acima é original, e é bom que não seja. O que o mercado mostra, quando se olha o que os sistemas grandes fazem de notificação em compras e em fluxo de trabalho:
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 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".
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.
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
- 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
TenantIdnoNotificationTemplate. - 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.
- Digest.
digestEligible,entityKeye 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 = Foreignpoderia 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.