Especificação · Plataforma Gryd.IO

Eventos e Outbox

Este documento não é do Nexio. Responde à carta de 09/09/2026 do Nexio ao time do Gryd.IO e especifica o que a plataforma passa a oferecer: despacho de evento de domínio depois do commit, disponível a qualquer DbContext sem herança e sem override, e GrydMessaging, um módulo novo com o outbox transacional. As duas dependências têm dono declarado aqui: são da plataforma. Os dois pedidos menores entram junto — a coluna de ato no AuditLog e a precedência do código de erro sobre o sufixo, esta última já resolvida no código e sem contrato escrito.

depende de Gryd.Domain.Primitives · GrydJobs · GrydAudit consumidores previstos Nexio · GrydAuth · GrydFiles · GrydNotifications 1 núcleo no Core · 1 módulo novo · 2 entidades · 3 jobs · 5 ADRs · 1 spike v1.6 · 09/09/2026 · especificação revisada; estado real declarado nesta página

Contrato vigente · revisão consolidada

Responsabilidade e garantias

ResponsávelContrato
Gryd.IOMecanismo genérico: coleta por transação, despacho após commit, persistência/claim/lease do outbox, transporte, retries e inbox.
Contexto produtorEntidade concreta, tabela, configuração EF e migração de seu outbox, no mesmo banco e transação do fato. O módulo da plataforma não possui uma tabela central que separe mensagem e efeito.
Negócio NexioSignificado, destinatário, versão e chave do agregado; decide o que exige chamada atômica e o que precisa de entrega durável.
ConsumidorDedupe e efeito na mesma transação, validação de tenant/empresa, ordem por agregado quando necessária e compensação de efeitos externos.

Esta página especifica um contrato futuro. O Nexio consome Gryd 5.0.2; o checkout de Gryd.IO develop inspecionado em a4e8260 não contém o outbox aqui descrito. A estrutura do Nexio em 23eedd8 contém pontos de composição e testes de arquitetura; não implementa esse ciclo. Wolverine continua adiado.

Contrato vigente · revisão consolidada

Decisões do contrato

  1. Efeito obrigatório é chamada explícita na transação produtora.
  2. Evento em memória só sai após o commit da transação que o produziu.
  3. Reação que não pode desaparecer nasce como mensagem durável junto do fato.
  4. Plataforma fornece mecanismo; produtor possui tabela e migração; negócio possui significado.
  5. Entrega é ao menos uma vez, com idempotência atômica no consumidor.
  6. Ordem é por agregado, preservando a primeira mensagem ainda não concluída.
  7. Claims são curtos, com lease e recuperação; I/O externo fica fora da transação de claim.
  8. Implementação e migração precedem adoção; resultado de spike não comprova prontidão operacional.

Contrato vigente · revisão consolidada

Três classes de efeito

ClasseExemploMecanismo
AtômicoÚltimo voto, estado do pedido e razão; veredito de arquivo e referência de anexo.Chamada explícita, mesma conexão/transação; falha técnica reverte tudo.
Após commit, perda aceitávelTelemetria auxiliar, atualização dispensável de tela.Evento em memória da transação produtora. Perda por queda é aceita e declarada.
Após commit, durávelPedido ao ERP, convite, aviso de mudança bancária, pendência de controle, projeção obrigatória.Outbox na transação do fato; retries e dedupe. Não depender de handler em memória para criar a primeira mensagem.

Reaproveitamento

O que já existe no Core

Boa parte deste épico é composição, não construção — e uma parte é remoção. O framework já tem o vocabulário de evento, o motor de jobs com DLQ e a trilha de auditoria; o que falta é o fio que liga os três no momento certo.

PeçaOndeO que este épico faz com ela
DomainEvent · IDomainEventGryd.Domain/PrimitivesUsado como está. Já é INotification, já tem Id e OccurredOn — é a base do messageId estável e do occurredOn da mensagem
AggregateRoot · IAggregateRootGryd.DomainAddDomainEvent, DomainEvents e ClearDomainEvents ficam intactos. As 74 chamadas a AddDomainEvent na base não mudam de assinatura — muda quando elas produzem efeito
IUnitOfWork.ExecuteInTransactionAsyncGryd.InfrastructureJá resolve transação aninhada e execution strategy. É a fronteira que o DbTransactionInterceptor passa a observar; nenhuma mudança de contrato
TenantSaveChangesInterceptorGryd.Infrastructure/TenancyÉ o molde. Interceptor sealed, dependência por abstração, comportamento por options, registrado via AddInterceptors. Os novos entram ao lado dele, na mesma extensão
AuditSaveChangesInterceptorModules/AuditProva de que o padrão escala para fora do Core. Ganha a coluna Action no caminho de escrita
DomainEventDispatcherGryd.Infrastructure/ServicesRemovido. Está registrado no GrydAuthBuilder (linha 189) e não tem um único ponto de chamada. Publica dentro de DomainEventNotification, invólucro que o GrydAuthDbContext não usa — duas formas de publicar convivendo é uma armadilha, não uma opção
GrydJobsModules/JobsIRecurringJob + RecurringJobRegistrationService para os três jobs; IDeadLetterQueue e DeadLetterJob para o veneno; IRetryPolicyProvider (Polly) para o backoff. Nada de fila nova
IAuditStore · AuditEntryGryd.Observability/AuditGanha Action em AuditEntry, no builder e na entidade AuditLog
ErrorCodeSuffixesGryd.Application/ModelsUnavailable = "_UNAVAILABLE" já existe. O que entra é o registro de precedência, que tira do Core o if com o código do GrydAuth
TenantContextAccessorGryd.Infrastructure/TenancyAsyncLocal. É por onde o worker restaura o tenant da mensagem antes de publicar — o mesmo mecanismo que o ExpiredUploadSweepJob já manipula para varrer sem tenant
Molde do GrydFilesModules/FilesGrydFilesBuilder + AddGrydFiles(configuration, configure, configureDbContext) + ConfigurationGuard + ValidateOnStart: o GrydMessaging copia a forma inteira, incluindo a recusa a ter default implícito

O que não existe em lugar nenhum do framework hoje — e é a lista de entregas deste documento: coleta de evento fora do DbContext, despacho após o commit, isolamento de falha de handler, outbox (busca por outbox em todo o src/: zero ocorrências), guarda de idempotência no consumidor, coluna de ato na trilha e registro de precedência de código de erro.

Diagnóstico

O que o código faz hoje

O GrydAuthDbContext sobrescreve SaveChangesAsync e roda um laço: salva, publica os eventos dos agregados rastreados, verifica se algum handler deixou mudança pendente e, se deixou, salva de novo — até dez rodadas. Tudo isso dentro da transação que ele mesmo abriu, e só ele: o GrydFilesDbContext é DbContext puro e não despacha evento nenhum.

Evidência · branch feature/grydfiles-modulo-plataforma · 879dd954
AchadoOndeConsequência
Publicação dentro da transaçãoGrydAuthDbContext.SaveChangesWithEventLoopAsyncDispatchDomainEventsAsync roda entre o base.SaveChangesAsync e o transaction.CommitAsync
Laço de re-SaveChangesidem · maxIterations = 10O efeito do handler entra na transação do agregado — o oposto do contrato de reação assíncrona
Duas formas de publicarDbContext × DomainEventDispatcherO contexto faz _mediator.Publish(domainEvent); o dispatcher embrulha em DomainEventNotification. Um handler escrito contra DomainEventHandler<T> nunca dispara pelo caminho do contexto
Dispatcher sem chamadorGrydAuthBuilder:189Registrado em DI e nunca injetado em lugar nenhum. Código morto que parece contrato
Módulo sem despachoGrydFilesDbContextDbContext puro. Um agregado de arquivo que chamasse AddDomainEvent teria o evento descartado em silêncio
Sem outboxsrc/ inteiroZero ocorrências. Não há caminho garantido de saída para fora do processo
Sem coluna de atoGrydAudit.Core/Entities/AuditLog.csEntityType, EntityId, ChangeType, AdditionalData — não há Action

As três falhas da carta, respondidas

1 · Aviso de algo que não aconteceu. O handler que notifica roda dentro de uma transação que ainda pode falhar — o aprovador recebe "aprovado" de uma decisão que reverteu.

Resposta · Decisão 2: o gatilho passa a ser TransactionCommittedAsync. Nada é publicado antes de o banco confirmar. Rollback descarta a fila coletada e nenhum aviso sai.

2 · O efeito do handler entra na transação do agregado. O laço de re-SaveChanges transforma reação assíncrona em parte do ato.

Resposta · O corte seco: o laço é removido. O que precisava ser atômico vira passo explícito do caso de uso; o que era reação passa a rodar depois, e o que falhar vai para a DLQ em vez de derrubar o commit.

3 · Contenção. O motor de aprovação serializa toda decisão na raiz do ApprovalRequest por version/expectedVersion; handler lento dentro da transação amplia a janela de lock no ponto mais disputado do sistema.

Resposta · a janela passa a ser o tempo do UPDATE mais o commit. Envio de e-mail, chamada a serviço externo e escrita em índice de leitura saem inteiramente de dentro do lock. Um Quorum(2) com três decisores concorrentes continua serializado — como deve —, mas por microssegundos de banco, não por segundos de rede.

Contrato vigente · revisão consolidada

Fluxo produtor e consumidor

Transação produtoraFato + efeito obrigatório+ outbox do produtorApós commitClaim / lease / envioRetry ou confirmaçãoTransação consumidoraInbox + efeitoCommit e confirmaçãoPerda de confirmação pode repetir a entrega; a chave estável impede repetir o efeito.

O registro do outbox é mapeado pelo DbContext do produtor. O worker enumera fontes explicitamente registradas, com cursor e limite por fonte/tenant. Consumidor local que precise de durabilidade usa o mesmo mecanismo; estar no mesmo processo não torna um callback durável.

Contrato vigente · revisão consolidada

Coleta pertence à transação, não à requisição

Registro mínimo da coleta: (dbContextInstanceId, transactionId, savepointGeneration, eventId). Dois DbContexts numa requisição podem confirmar ou reverter separadamente: commit de A nunca publica eventos pendentes de B. Múltiplos SaveChanges na mesma transação acumulam somente eventos daquela transação.

MomentoAção
Antes de salvarColetar eventos sem I/O externo e mapear mensagens duráveis como entidades rastreadas pelo produtor. Registrar checkpoint para retry; limpar agregado somente com mecanismo de restauração em falha.
SaveChanges com transação explícitaPersistir efeitos e outbox, mas não publicar eventos em memória no SavedChanges. Esperar confirmação externa da transação.
SaveChanges com transação implícitaAssociar o lote à transação efetiva do save; publicar somente quando sua confirmação estiver comprovada. Testar separadamente o ciclo de interceptors do provedor.
Commit confirmadoRemover e despachar apenas o buffer dessa transação. Novos fatos produzidos pelo handler pertencem às próprias transações deles.
Rollback ou savepoint revertidoDescartar eventos/mensagens da parte revertida e restaurar coleta necessária ao retry. Commit da parte restante não publica a parte revertida.
Commit de resultado incertoNão afirmar sucesso ou repetir efeito cegamente: consultar o resultado pela chave da operação; retry usa a mesma identidade.
Handler ou fallback falhaRegistrar log e métrica sem lançar falha como se a operação já confirmada tivesse sido revertida. Se nem o fallback persistir, telemetria best effort pode se perder; efeito crítico já estava no outbox.

A API concreta dos interceptors será validada por testes de integração antes da publicação do pacote; esta tabela é o contrato, não código executável. ExecuteInTransactionAsync deve respeitar transação ambiente compartilhada e não confirmar um escopo interno prematuramente. Eventos não substituem coordenação atômica entre contextos.

Contrato vigente · revisão consolidada

Registro durável do produtor

CampoRegra
messageId, tenantId, producerContextIdentidade estável; múltiplas mensagens de um evento derivam identidade por destino/tipo, sem colisão.
aggregateType, aggregateId, aggregateSequenceChave inclui tenant e produtor. Sequência monotônica alocada sob serialização da raiz; identidade de banco isolada não prova ordem de commit.
messageType, schemaVersion, payload, payloadHashNome estável de negócio, payload versionado. Não serializar nomes de classes como contrato público. Dados mínimos; segredos protegidos e nunca nos logs.
companyId?, correlationId, causationId, operationIdEscopo e rastreabilidade; actor informado é dado de auditoria, não identidade autenticada do worker.
status, attemptCount, nextAttemptAtPending, Dispatching, Dispatched, Failed ou DeadLettered. Backoff com jitter e teto; motivo persistido.
leaseOwner, leaseToken, leaseExpiresAtClaim atômico; lease recuperável. Atualização de status exige o token atual, evitando worker antigo confirmar claim perdido.
createdAt, dispatchedAt, lastErrorCodeMonitoramento, reconciliação e retenção. Dispatched indica confirmação do transporte, não aplicação no ERP.

InboxMessage pertence ao consumidor e tem chave única (tenantId, consumerName, messageId), payloadHash e resultado. Insert de dedupe e efeito compartilham transação. Mesma chave com payload distinto é conflito. Efeito externo exige chave estável aceita pelo destino ou consulta/reconciliação; inbox isolado não cria exactly-once na rede.

Contrato vigente · revisão consolidada

Estados, lease e recuperação

TransiçãoCondição
Pending / Failed → DispatchingSomente cabeça elegível do agregado, nextAttemptAt vencido, claim sob trava curta. Incrementar tentativa e emitir leaseToken.
Dispatching → DispatchedTransporte confirma; gravar com token de lease vigente. Queda antes dessa gravação permite duplicata.
Dispatching → FailedFalha recuperável, com motivo e nova data. Não liberar mensagem posterior do mesmo agregado.
Dispatching expirado → Failed / novo claimRecuperar após queda por leaseExpiresAt; tentativa anterior pode ter enviado. Usar dedupe e fencing; nunca presumir que nada ocorreu.
Failed → DeadLetteredTeto de tentativas ou falha permanente; trava agregado e alerta.
DeadLettered → PendingReprocessamento autorizado e auditado, mantendo messageId. Descarte exige reconciliação explícita da sequência e dos efeitos, nunca exclusão silenciosa.

Contrato vigente · revisão consolidada

Primeira mensagem pendente por agregado

Uma candidata só é cabeça quando não existe mensagem de sequência menor do mesmo agregado com status diferente de Dispatched. O bloqueador inclui Failed em backoff, Dispatching com lease vivo e DeadLettered. Filtrar apenas mensagens prontas e depois ordenar não cumpre essa condição.

-- Predicado da cabeça; pseudocódigo SQL a validar no provedor
NOT EXISTS (
  SELECT 1 FROM producer_outbox earlier
  WHERE earlier.tenant_id = candidate.tenant_id
    AND earlier.aggregate_key = candidate.aggregate_key
    AND earlier.aggregate_sequence < candidate.aggregate_sequence
    AND earlier.status <> 'Dispatched'
)
-- Só depois: elegibilidade por estado, nextAttemptAt e lease.
-- Claim sob trava curta; commit; envio fora da transação.

SKIP LOCKED auxilia claims concorrentes, mas não garante ordem sozinho. A raiz serializa geração/commit de sequências; claim por agregado evita duas cabeças em paralelo. Lease renovável reduz sobreposição, mas queda de rede ainda pode repetir envio tardio. Consumidor que exige ordem guarda última sequência e dedupe no mesmo commit, retendo mensagens futuras até preencher lacunas; transporte preserva chave de ordenação quando disponível.

Justiça entre tenants é aplicada na seleção SQL/round-robin de cursores antes do LIMIT global. Cortar quotas em memória depois do LIMIT pode deixar um tenant pequeno sem atendimento. Começar sem particionamento fixo; medir backlog, latência e contenção antes de introduzir partições.

Contrato vigente · revisão consolidada

Contexto isolado no worker

A enumeração de fontes é operação técnica privilegiada, limitada às fontes registradas. Cada mensagem abre novo escopo, restaura tenant, empresa, correlação e credencial de serviço autorizada, executa, e limpa o contexto em finally. Nenhum AsyncLocal, sessão de banco ou cache pode vazar para a mensagem seguinte. Valor enviado no payload não concede acesso por si.

Workers usam UserCompanyRole ou concessão equivalente de serviço, com operações explícitas. Testar sequência A → B → falha → A, concorrência, tenant ausente, revogação e retorno de conexão ao pool. Consulta de estado não pode usar IgnoreQueryFilters como atalho irrestrito.

Contrato vigente · revisão consolidada

Migração do comportamento atual

O GrydAuth inspecionado publica eventos em override de SaveChanges; não se deve inferir que já oferece o contrato após commit. Antes de migrar, classificar todos os handlers em atômicos, duráveis e best effort; transformar efeitos obrigatórios em chamadas explícitas e criar outbox durável no produtor.

Publicar versão da plataforma com testes de transações explícitas, implícitas, múltiplos saves/contextos, savepoints e falhas. Migrar produtores e consumidores com compatibilidade de payload, definir corte de backlog e só então remover o despacho anterior. Não habilitar em produção dois caminhos que repitam o mesmo efeito. O Nexio só adota após consumir e validar a versão publicada.

Contrato vigente · revisão consolidada

Exemplo de coordenação no Nexio

Último voto: serializar ApprovalRequest e verificar expectedVersion; validar usuário e revisão; gravar voto; chamar o contrato do processo; reavaliar todos os limites orçamentários sob trava. Sucesso aplica estado e lançamentos. Insuficiência esperada devolve ReturnedForBudget, persiste Returned com motivo e aviso no outbox, sem novo compromisso. Exceção técnica reverte a operação completa.

Pedido efetivo: gravar versão efetiva, delta de obrigação e mensagem de integração na mesma transação. O worker envia exatamente a revisão persistida. ACK de transporte e status Received/Applied do ERP são fatos separados, reconciliáveis pela identidade estável da operação.

Pedido menor · 1

AuditLog · a coluna de ato

O AuditLog responde bem a "que campo mudou". Não responde a "quem autorizou". São perguntas diferentes e o ChangeTypeCreated, Updated, Deleted, Accessed — só sabe a primeira.

A evidência de que a coluna falta já está no repositório. O GrydFiles criou IFileAuditTrail para registrar quatro atos que o interceptor não enxerga, e cada um deles grava WithMetadata("Event", "download-url-issued") — o nome do ato escondido dentro de um JSON de contexto, sem índice e sem consulta. O Nexio chegaria ao mesmo lugar por conta própria, com outro nome de chave. Quando o mesmo contorno aparece pela segunda vez, o que falta não é disciplina: é a coluna.

CampoSituaçãoRegra
ChangeTypeficaContinua sendo a forma da mudança de linha, preenchido pelo interceptor. Não vira Action disfarçado
Actionentravarchar(80)?, indexado em (tenant_id, action, timestamp). Nulo quando a entrada nasce do interceptor e ninguém declarou ato
AdditionalDataficaContexto do ato — motivo, alvo, parâmetros. Deixa de carregar o nome do ato

Convenção do vocabulário

{modulo}.{agregado}.{verbo-no-passado}      // minúsculo, pontos, ≤ 80 caracteres

nexio.approval.granted
nexio.approval.revoked
nexio.bank-account.authorized
gryd-files.download-url.issued
gryd-messaging.outbox.retried
gryd-auth.user.impersonated

O verbo no passado não é estética: a trilha registra o que aconteceu, e grant descreveria uma intenção que ainda pode falhar. Um teste de convenção varre as constantes de cada módulo e quebra o build em nome fora do padrão — é o mesmo tipo de guarda que o GrydFiles usa contra chave desconhecida no appsettings.

Superfície

// Gryd.Observability.Audit
AuditEntry.Builder("ApprovalRequest", id)
    .WithChangeType(EntityChangeType.Updated)
    .WithAction(NexioAuditActions.ApprovalGranted)     // ← novo
    .WithUser(approverId)
    .WithMetadata("Quorum", "2/3")
    .Build();

// consulta
GET /api/v1/audit-logs?action=nexio.approval.granted&from=2026-09-01

Migração: coluna anulável, índice e um UPDATE que promove additional_data->>'Event' para action nas linhas já escritas pelo GrydFiles, prefixando o módulo. Depois disso o FileAuditTrail troca quatro WithMetadata("Event", …) por quatro WithAction(…) e a duplicação some — que é o teste real de que a coluna era mesmo a peça faltante.

Pedido menor · 2

Sufixo e precedência

Este pedido chegou como pendência e já está resolvido no código — o que não existe é o contrato escrito. A carta do Nexio diz que o mapeador reconhece nove sufixos; nesta branch reconhece dez.

Estado verificado · GrydBaseController.cs
ItemEstadoOnde
_UNAVAILABLE como décimo sufixoexisteErrorCodeSuffixes.Unavailable
Mapeamento para 503existeGrydBaseController:241
USER_EMAIL_UNAVAILABLE → 409existeGrydBaseController:218 — antes da varredura de sufixos
Precedência declaradafaltaÉ um if com literal, não uma regra publicada
Core sem vocabulário de módulofaltaO Gryd.API conhece um código do GrydAuth
Cabeçalho Retry-Afterfalta503 sem ele não diz ao cliente quando voltar

Para o Nexio, a resposta prática: CURRENCY_PROVIDER_UNAVAILABLE e FILE_SCAN_UNAVAILABLE já respondem 503 nesta branch, sem nenhuma mudança do lado de vocês. A precedência que vocês registraram do lado de lá está correta e agora é regra da plataforma.

A regra, publicada

Código exato vence sufixo. Sufixo vence o padrão. Nesta ordem, sem exceção e sem terceira via. Ausência de sufixo continua sendo 400.

RN-ER-01

O Core não conhece código de módulo. A exceção sai do if e vira registro: o módulo que precisa de uma declara na sua própria composição.

RN-ER-02

_UNAVAILABLE é dependência fora do ar — nunca recurso ocupado, nunca recurso ausente. Vem com Retry-After e é o único dos dez que não culpa quem chamou.

RN-ER-03 · a intenção já está escrita no <remarks> do próprio ErrorCodeSuffixes

// Gryd.API — o mapeador deixa de citar módulo
public interface IErrorCodeStatusMapper
{
    int Resolve(string errorCode);   // exato → sufixo → 400
}

// GrydAuth, na sua própria composição:
services.MapErrorCode("USER_EMAIL_UNAVAILABLE", StatusCodes.Status409Conflict,
    because: "anti-disclosure: não revelar se o e-mail existe");

// Nexio, se algum dia precisar:
services.MapErrorCode("BUDGET_PERIOD_CLOSED", StatusCodes.Status423Locked);

O parâmetro because não é decoração: ele aparece no relatório de mapeamentos que o startup imprime em desenvolvimento e na documentação de erros do Swagger. Uma exceção à convenção sem motivo escrito vira, em dois anos, uma linha que ninguém ousa remover.

Contrato vigente · revisão consolidada

Operação e recuperação

Medir idade da cabeça pendente, backlog por produtor/tenant, tentativas, leases expirados, dead letters, falhas de dedupe e tempo até aplicação no destino. Alertas não dependem exclusivamente do mesmo canal de notificação que estão monitorando. Health distingue disponibilidade da API de atraso da integração.

Operação exige inspeção segura do payload, retry com mesma identidade, correção versionada quando o conteúdo estiver errado, e reconciliação antes de descartar. Retenção do inbox cobre ao menos a janela máxima de retry/restauração do produtor; expurgo não pode permitir repetição de efeitos financeiros antigos.

Backup/restauração deve manter fato, outbox, inbox e referências de arquivo consistentes. Após restore, reconciliar operações já aplicadas fora do banco e invalidar sessões/permissões restauradas. Definir RPO/RTO, executar restauração ensaiada e medir resultados antes do primeiro cliente. Não há garantia operacional comprovada pelos testes de estrutura atuais.

Segurança

Permissões

PermissãoEscopoQuem costuma ter
messaging:outbox:readtenantOperação e suporte. Não expõe payload em lista
messaging:outbox:retrytenantOperação. Reprocessar pode duplicar efeito no destino — por isso não é automático
messaging:outbox:discardtenantAdministração. Descartar é decidir que o fato não vai ser integrado
messaging:outbox:read:any-tenantplataformaAdministrador de sistema, para diagnosticar fila cruzada

Nenhuma permissão nova para evento de domínio: reação em processo não tem superfície. E nenhuma permissão de escrita no outbox — a única forma de uma mensagem nascer é um efeito de negócio gravado numa transação. Um POST que criasse mensagem seria um caminho para forjar um fato que nunca aconteceu.

Contrato vigente · revisão consolidada

Configuração mínima

Configurar por fonte: intervalo de varredura, batchSize, quota por tenant, timeout de transporte, leaseDuration/renewal, backoff, maxAttempts e retenção de Dispatched/inbox. Validar lease contra timeout e margem de renovação. Não impor 32 partições ou lock que permaneça aberto durante chamada de rede.

Wake-up em memória é apenas otimização no mesmo processo. API e Worker separados dependem da varredura ou sinal durável; declarar e medir a latência máxima esperada. Limites de quota e alertas devem impedir que um tenant monopolize o worker.

Contrato

Regras de negócio

RN-EV-01Evento de domínio só é publicado depois do commit

Da unidade de trabalho que o produziu. Não há configuração que reverta isto, e o teste de tempo relativo é obrigatório na suíte do Core.

RN-EV-02Falha de handler não desfaz o commit

É registrada, medida e encaminhada ao IDomainEventFallback — DLQ do GrydJobs por padrão. Exceção de handler nunca sobe para o caso de uso.

RN-EV-03Nenhum DbContext implementa despacho

O comportamento vem de interceptors registrados por AddGrydPlatformInterceptors. Um override de SaveChangesAsync que publique evento é defeito, e um teste de arquitetura o proíbe.

RN-EV-04Rollback descarta apenas sua coleta

Buffers são por DbContext e transação, com checkpoints de savepoint. Rollback de A não apaga B; commit de A não publica B. Retry restaura os eventos da tentativa revertida sem duplicar mensagens.

RN-EV-05Efeito atômico não é handler

É passo do caso de uso, dentro do ExecuteInTransactionAsync, explícito na leitura.

RN-EV-06Coleta é idempotente por transação

eventId e checkpoint impedem recolher o mesmo evento em saves sucessivos. Mensagens de destinos distintos têm IDs próprios. Coleta não se confunde com buffer scoped de requisição.

RN-EV-07Nova transação, nova coleta

Handler que grava produz eventos da própria transação. Cada lote espera seu commit. Limitar ciclos e registrar falhas sem descartar mensagens duráveis nem publicar fatos de transação ainda aberta.

RN-EV-08Publica-se o próprio evento

Sem invólucro. INotificationHandler<TEvent> é a única forma de escrever handler; DomainEventNotification e DomainEventHandler<T> deixam de existir.

RN-OB-01A mensagem é gravada na transação do efeito

Pelo mesmo DbContext, na tabela do schema dele. Não existe caminho que grave mensagem fora de uma transação de negócio.

RN-OB-02Entrega ao menos uma vez

Duplicata é comportamento previsto, não incidente. Idempotência é obrigação declarada do consumidor.

RN-OB-03Ordem protege a cabeça do agregado

Nenhuma mensagem posterior ultrapassa uma anterior não Dispatched, inclusive backoff, lease ou dead letter. Sequência é serializada na raiz; consumidor deduplica e verifica sequência atomicamente. Partição é otimização futura, não a garantia única.

RN-OB-04Mensagem presa não some

Falha vira Failed com backoff; teto de tentativas vira DeadLettered, que trava a chave e alerta. Expurgo só alcança Dispatched.

RN-OB-05Worker tem escopo por mensagem

Fonte privilegiada não transfere privilégio ao consumo. Restaurar tenant/empresa e credencial de serviço, autorizar, executar e limpar contexto em toda saída, inclusive falha e cancelamento.

RN-OB-06Todo tipo é versionado

{produto}.{agregado}.{fato}.v{n}. Nunca o nome da classe .NET. Mudança incompatível de payload exige v{n+1} e convivência das duas versões.

RN-OB-07A idade do mais antigo é sinal de saúde

gryd_outbox_oldest_pending_age_seconds e o health check são entrega da v1, não melhoria posterior.

RN-OB-08Reprocessar e descartar são atos humanos

Com permissão própria, motivo obrigatório no descarte e linha na trilha com Action.

RN-AU-01Ato e mudança são colunas diferentes

Action ao lado de ChangeType. Uma entrada pode ter as duas; nenhuma das duas substitui a outra.

RN-AU-02O nome do ato segue a convenção

{modulo}.{agregado}.{verbo-no-passado}, minúsculo, ≤ 80 caracteres, verificado por teste de convenção.

RN-ER-01Código exato vence sufixo; sufixo vence o padrão 400

Precedência única, publicada e coberta por teste.

RN-ER-02O Core não conhece código de módulo

Exceções entram por MapErrorCode, com motivo escrito.

RN-ER-03_UNAVAILABLE é dependência fora do ar

503 com Retry-After. Nunca para recurso ocupado ou ausente.

Qualidade

SOLID, DRY e padrões

Princípio → decisão concreta
PrincípioOnde apareceO que seria a violação
SRPO DbContext volta a ser persistência. Coleta, gravação de mensagem e publicação são três classes com um motivo de mudar cadaO GrydAuthDbContext de hoje: persiste, orquestra evento, controla transação e conta iterações
OCPNovo destino é um IOutboxTransport; nova integração é um IIntegrationEventMapper; nova exceção de erro é um MapErrorCode. Nada disso toca o CoreO if (errorCode is "USER_EMAIL_UNAVAILABLE") dentro do Gryd.API
LSPQualquer DbContext serve. Não há base a herdar, então não há contrato de base a violarUma GrydDbContextBase cujo SaveChangesAsync sobrescrito por um produto desligasse o despacho em silêncio
ISPIDomainEventQueue tem quatro membros; IDomainEventPublisher, um; IOutboxTransport, um. O consumidor de cada um usa tudo o que vêIDomainEventDispatcher com três sobrecargas, das quais nenhuma tem chamador
DIPO domínio depende de DomainEvent e nada mais. O mapeador de integração mora na aplicação; o transporte, na infraestruturaAgregado que chamasse _outbox.EnqueueAsync
DRYUma extensão declara a ordem dos interceptors; um IInboxGuard resolve idempotência para todos; uma coluna Action apaga o contorno do GrydFiles e o que o Nexio ia inventarCada módulo com o seu laço de SaveChanges, a sua tabela de "já vi isto" e a sua chave Event no JSON
Padrões usados, e por quê aqui
PadrãoOndePor quê
InterceptorSaveChangesInterceptor, DbTransactionInterceptorComportamento transversal sem herança e sem tocar o caso de uso. É o padrão que o TenantSaveChangesInterceptor já estabeleceu no repositório
Transactional OutboxOutboxMessage + interceptor + workerA única forma conhecida de dar entrega garantida sem transação distribuída entre banco e destino
Unit of WorkExecuteInTransactionAsyncJá existe e define a fronteira que o despacho observa. Não foi criado por este épico — foi usado
Mediator / ObserverMediatR, INotificationHandlerReação em processo sem o produtor conhecer o consumidor
StrategyIOutboxTransport, IDomainEventFallbackMesma decisão que o IFileScanner do GrydFiles: a escolha de infraestrutura fica atrás de uma interface
AdapterIIntegrationEventMapperTraduz vocabulário de domínio em contrato de integração sem contaminar nenhum dos dois
RegistryIErrorCodeStatusMapper, mapeadores por tipo de eventoExtensão por registro em vez de switch no Core
Null ObjectNullOutboxInterceptor, NullDomainEventFallbackUm produto sem mensageria e sem jobs continua rodando sem ramificação condicional. O NullAuditStore já é o precedente
BuilderGrydMessagingBuilderRegistro legível e com validação de composição, no molde do GrydFilesBuilder
— evitado —Template Method (classe-base de DbContext)Seria a resposta óbvia à pergunta "qual DbContext é responsável" e seria a errada: acopla todo produto a uma hierarquia e deixa o mecanismo à mercê de um override

Contrato vigente · revisão consolidada

Provas exigidas antes da adoção

  • Tempo relativo de commit e evento em transação explícita/implícita; duas transações/DbContexts na mesma requisição; múltiplos saves; rollback de savepoint; cancelamento; commit incerto.
  • Atomicidade de efeito e outbox, e de inbox e efeito; mesmo ID com conteúdo diferente; queda após envio e antes do ACK persistido.
  • Mensagem anterior em backoff, Dispatching ou DeadLettered impede a posterior; queda recupera lease; worker antigo não confirma lease substituído.
  • Concorrência e equidade entre tenants; contexto limpo após falha; revogação de credencial; restore com dedupe ainda válido.
  • Último voto sem verba preservado em Returned; nenhuma transição Approved ou entrada de consumo adicional.

São requisitos de validação da implementação futura. Os testes atuais do Nexio não executam estes cenários de banco ou mensageria.

Antes de construir

Alternativas de mercado avaliadas

Construir só é decisão se as alternativas foram olhadas. O outbox transacional é um padrão maduro com implementações prontas em .NET, e esta seção existe para que a escolha de escrever fique registrada como escolha, não como omissão. O despacho pós-commit é outro caso: ninguém vende essa peça isolada — todas as bibliotecas a entregam embutida num message bus.

Levantamento · setembro de 2026
OpçãoLicença hojeO que entregaVeredito
MassTransit v9 comercial Massient · ~US$ 4k/ano PME, US$ 12k/ano enterprise Bus completo: outbox, sagas, scheduling, transportes Não. A v8 gratuita recebe só correção de segurança e sai de suporte neste ano — adotá-la hoje é adotar algo já em fim de vida. E num framework a licença não é nossa: é de cada produto que consumir o Gryd.IO
Wolverine open source o pago é a observabilidade (CritterWatch) As duas camadas. Outbox EF Core nativo, filas locais duráveis sem broker, e substituiria o MediatR Único candidato sério. Ver o quadro abaixo — merece spike, não descarte
NServiceBus comercial por endpoint, sempre foi Plataforma completa, com ferramental de operação maduro Não. Custo e peso desproporcionais ao problema, e o mesmo repasse de licença aos consumidores
DotNetCore.CAP MIT ativo, v10 Outbox nativo, inbox de idempotência, dashboard Não. Exige broker e a API é invasiva no caso de uso: dbContext.Database.BeginTransaction(_capBus) — o oposto de "o produto não escreve nada"
Brighter open source Outbox EF Core, inbox, sweeper Não. Obriga DepositPostAsync + ClearOutboxAsync explícitos em cada handler, e também assume transporte
Rebus livre Bus enxuto; outbox mais recente Não. Outbox menos maduro que os demais e, ainda assim, é um bus inteiro para um problema de uma tabela

O padrão que decide

Ninguém vende o outbox à la carte. Ele vem dentro de um bus, e junto vêm transporte, serialização, pipeline de consumer, sagas e uma segunda árvore de configuração e DI. Adotar para ganhar uma tabela e um worker é comprar a casa pela maçaneta.

Todas assumem broker. A entrega do Nexio é ao ERP. Se ela for HTTP, adotar CAP ou MassTransit significa subir RabbitMQ para falar com um sistema que fala HTTP — pagar a infraestrutura inteira de mensageria para não usá-la. A exceção é o Wolverine, cujas filas locais duráveis dispensam broker.

Duas delas invadem o caso de uso. CAP e Brighter exigem uma chamada explícita dentro do handlerBeginTransaction(_capBus), DepositPostAsync. É exatamente a ergonomia que a § Como um produto usa existe para evitar, e é irreversível: uma vez que 200 handlers citam a biblioteca, ela não sai mais.

As três coisas que nenhuma delas faz

1 · Tenancy

Restaurar TenantContextAccessor por mensagem, respeitar o filtro global e o OptionalTenantVisibilityMode. Nenhuma conhece esse modelo, e essa integração é uma fração relevante do que este documento especifica — ou seja, adotar não elimina o trabalho, só o move para dentro de uma extensão de terceiro.

2 · Framework, não aplicação

O Gryd.IO é consumido por outros produtos. Uma dependência adotada aqui — e a licença dela — é empurrada para todo produto que usar o framework. Numa aplicação você decide por você; num framework você decide por terceiros que não participaram da conversa.

3 · Composição com o que já existe

GrydJobs, GrydAudit, ExecuteInTransactionAsync, Result, os dez sufixos de erro. Um bus traz o segundo universo de retry, DLQ, configuração e dashboard, sobreposto ao primeiro — e duas respostas para "o que está preso" é o defeito que este épico existe para eliminar.

O que se adota de mercado desde o primeiro dia

A resposta a "não reinventar a roda" não é adotar um bus: é não escrever as quatro peças que já são padrão. Nenhuma delas acopla arquitetura — são formato, matemática e convenção.

Polly · já está no repositório

Não escrever a matemática do backoff. O Polly 8 calcula atraso exponencial com jitter (DelayBackoffType.Exponential + UseJitter). Usa-se o Polly para calcular o próximo instante, ainda que quem persista seja o next_attempt_at — são duas escalas de tempo e só a primeira é problema resolvido.

CloudEvents · CNCF

Não inventar o envelope. id, source, type, time, subject e datacontenttype mapeiam um a um nos campos da OutboxMessage; tenantid e correlationid entram como extensões. O ganho é concreto: qualquer consumidor futuro — outro produto, um broker, algo fora do .NET — lê sem tradutor, e a versão no type deixa de ser convenção nossa para ser convenção de todo mundo.

OpenTelemetry · semantic conventions

Não inventar nome de métrica. O repositório já tem OTel. As convenções de messaging.* e o W3C Trace Context existem justamente para que a mensagem costure o trace do request ao consumo. O quadro da § Observabilidade deve seguir a convenção, não a nossa preferência.

Testcontainers · já está no repositório

Não simular o banco. Testcontainers.PostgreSql já é dependência. Os testes de concorrência que a § Estratégia de teste exige — SKIP LOCKED com quatro workers, lock órfão, ordem dentro da chave — só provam alguma coisa contra PostgreSQL real, e a infraestrutura para isso já está paga.

Wolverine · o candidato que merece spike

É a única opção que resolve as duas camadas deste documento sem exigir broker: o outbox EF Core é nativo, as filas locais podem ser duráveis em PostgreSQL, e os handlers rodam a partir da fila — ou seja, depois do commit. De quebra, resolveria o congelamento do MediatR descrito abaixo. Descartá-lo por reflexo seria desonesto.

O que ele custa, e por que a resposta não é óbvia. O Wolverine assume a transação e o pipeline de handler: UseEntityFrameworkCoreTransactions() e o scraping de eventos do agregado só funcionam com ele no comando. Isso significa substituir ValidationBehavior, TenantScopeBehavior, TenantInjectionBehavior e JobExecutionBehavior, e mexer na assinatura dos handlers dos sete módulos. Numa aplicação, seria um refactor grande. Num framework que é a espinha de outros produtos, é uma troca de espinha — e o custo cai em quem consome.

Critério de saída do spike · 2 a 3 dias, em branch

  • Os quatro behaviors do MediatR cabem no pipeline do Wolverine sem gambiarra — em especial o TenantScopeBehavior, que hoje depende da ordem do pipeline.
  • O TenantContextAccessor é restaurado na execução da fila durável, e uma mensagem de um tenant não enxerga dado de outro.
  • O AggregateRoot atual é raspável pela API de scraping sem mudar as 74 chamadas a AddDomainEvent.
  • O GrydJobs continua dono do agendamento, ou o Wolverine o substitui sem perder dashboard, DLQ e política de retry por tipo.
  • O impacto no consumidor do framework é declarável em uma página.

Falhando qualquer um dos seis, constrói-se a versão fina deste documento e adotam-se as quatro peças de mercado acima. Passando os seis, o Wolverine é a melhor opção disponível e este documento vira a especificação de como configurá-lo — o contrato da § Entrega não muda em nenhum dos dois caminhos, que é exatamente o que IOutboxTransport e IIntegrationEventMapper compram.

Veredito e sequência

A revisão da árvore referenciada identifica doze implementações de pipeline, distribuídas em cinco projetos; quatro pertencem ao GrydAuth. MissingDatabaseBehavior é um enum de política de risco e não entra na contagem. A migração exige preservar ordem, autorização, tenancy e observabilidade; seu prazo ainda precisa ser estimado, não deduzido da quantidade de classes.

Superfície do MediatR · conferida em 09/09/2026
MedidaContagemO que significa para a migração
using MediatR199 arquivosA superfície total a tocar
IRequestHandler112 arquivosAssinaturas a reescrever
IRequest<>101 arquivosContratos de comando e consulta
INotificationHandler12 arquivosAs reações — a parte que este épico toca
IPipelineBehavior18 arquivos · 13 classesO risco. Cinco são do GrydAuth e decidem permissão e alcance entre tenants
AddMediatR8 pontosOito composições independentes a reconciliar num modelo só

A assimetria que decide

Construir fino e adotar o Wolverine depois custa jogar fora ~1.500 linhas — o contrato de IOutboxTransport e IIntegrationEventMapper sobrevive intacto. Adotar o Wolverine e ele não encaixar custa ter reescrito o pipeline de seis projetos, incluindo PermissionAuthorizationBehavior e CrossTenantDataFilterBehavior. Entre duas decisões de custo desigual, escolhe-se a reversível.

A Camada 1 não tem alternativa em nenhum cenário — exceto adotar o Wolverine inteiro. E é ela, não o outbox, que destrava a RN-AP-38 e o OwnerDiscarded. O caminho crítico da Requisition não depende da decisão de mercado.

F1 é a ponte planejada. O núcleo pós-commit continua necessário enquanto a adoção do Wolverine estiver adiada. O tamanho e o prazo de implementação precisam ser estimados e validados; o spike isolado não substitui essa entrega.

O que espera o spike é o outbox, não o núcleo. F3–F6 são exatamente o que o Wolverine entregaria de graça. Construí-las antes de saber a resposta é o único desperdício realmente possível nesta sequência.

A sequência recomendada
QuandoO quêPor quê
Agora · incondicionalF2 · corte seco — remover o laço, o dispatcher morto, o invólucro e a classe-base; classificar os handlers nas três categoriasNecessário em todos os cenários. E a classificação dos handlers é justamente o inventário que uma migração para o Wolverine exigiria — nada aqui é desperdiçado
Agora · ponteF1 · núcleo pós-commit (~400 linhas de interceptor)Destrava a Requisition esta semana, e continua valendo mesmo que o Wolverine vença, porque a migração dele leva um trimestre
Em paralelo · 2 a 3 diasSpike "sucessor do MediatR"roteiro completo na próxima seçãoCusta dias e pode eliminar F3–F6 inteiras. A razão risco/retorno não tem discussão
Depois do spikeF3–F6 · GrydMessaging, ou a decisão de esperar o WolverineÉ a única parte cuja construção o spike pode tornar desnecessária
Épico próprio · com dataMigração do mediator, se o spike passar199 arquivos e cinco behaviors de segurança não entram de carona em nenhum outro épico
Independente · agoraF8 e a ADR 0014 (AutoMapper)Não dependem de nada disto e não deveriam esperar

A aposta, dita com todas as letras. A expectativa é que o Wolverine não passe no critério do TenantScopeBehavior somado ao CrossTenantDataFilterBehavior: a tenancy do Gryd.IO vive em AsyncLocal, em query filter global e na ordem do pipeline, e é justamente o tipo de acoplamento que não sobrevive a uma troca de mediator. Se a aposta se confirmar, constrói-se a versão fina deste documento — com CloudEvents, Polly, OTel e Testcontainers — e a decisão fica registrada como escolha informada. O spike não existe para mudar o resultado esperado: existe para que o resultado esperado deixe de ser palpite.

Risco separado · dependências que mudaram de licença

O levantamento trombou num achado que não é deste épico, mas é do repositório. As duas dependências têm o mesmo dono e o mesmo modelo de licença — e mesmo assim recebem decisões opostas, porque o que decide não é a licença: é urgência × custo de saída.

Mesmo modelo, decisões opostas · conferido em 09/09/2026
 AutoMapperMediatR
Modelo de licençaIdêntico — Lucky Penny, comercial a partir do corte, Community gratuita abaixo de US$ 5M de receita anual mediante registro
Corte comercialv15.0v13.0
Versão no repositório16.1.1já além do corte12.4.1aquém do corte
A linha livre14.x, com GHSA-rvv3-g6hj-g44x aberta — DoS, severidade 7,5, corrigida só na linha comercial12.x, sem CVE conhecida, livre indefinidamente pelos termos originais
Enforcement em runtimeNenhum nos dois. O que existe é obrigação contratual, não trava técnica
Superfície no repositório7 arquivos de produção, 2 módulos — e IEntityMapper já é porta199 arquivos, 13 behaviors, 8 registros
Custo de saída~1 diaum trimestre
Natureza do riscoFerida aberta — paga hoje, e a saída óbvia leva a uma CVERelógio — sem custo e sem CVE hoje; o preço é estagnação
Decisãoremover agora ADR 0014planejar, não agora depende do spike

O AutoMapper quase não é usado. São sete arquivos de produção, em dois módulos. E o GrydCrud já tem a porta: IEntityMapper em GrydCrud.Core.Abstractions, com AutoMapperEntityMapper como uma implementação, num projeto separado só para isso. O resto do framework já mapeia à mão — JobMappingExtensions, AuditLogMappingExtensions. O AutoMapper é a exceção do repositório, não a regra, e a arquitetura já previu a troca antes de ela ser necessária.

O plano do AutoMapper · Mapperly

Descer de versão não é opção: leva à CVE aberta, que o gate de SCA do ADR 0001 acusaria no primeiro build. Registrar a Community License é paliativo — o critério é da organização que usa, e num framework distribuído a terceiros isso é dívida repassada a quem não escolheu a dependência. Sobra remover, e o substituto tem nome.

Substituto · Riok.Mapperly

Apache 2.0, ativo, source generator. Zero reflexão em tempo de execução, zero custo de startup, e o mapeamento vira código gerado que aparece no debugger. Não é só mais barato de licença: erro de mapeamento passa a ser erro de compilação, em vez de AutoMapperConfigurationException na primeira requisição em produção.

O único ponto de desenho

IEntityMapper é genérico em <TSource, TDestination>, e um source generator precisa dos pares conhecidos em tempo de compilação. Resolve-se com um registro de pares preenchido no startup — que é exatamente o que AutoMapperServiceCollectionExtensions já faz hoje com profiles. A porta não muda, e nenhum dos quatro Default*Operation do GrydCrud é tocado.

Escopo da ADR 0014, para não contaminar este épico: GrydCrud.Mapperly ao lado do GrydCrud.AutoMapper existente; GrydReports troca um profile e cinco handlers; remoção do pacote do Directory.Packages.props; e o teste que já existe em GrydCrud.AutoMapper.Tests passa a rodar contra a implementação nova. O MediatR não entra nessa ADR — é outra escala e outra decisão.

Fontes do levantamento, todas conferidas em 09/09/2026: anúncio das edições comerciais de AutoMapper e MediatR (Jimmy Bogard) e a FAQ de licenciamento da Lucky Penny; a página de licença do MassTransit v9 na Massient; a página de produtos da JasperFx; o repositório do DotNetCore.CAP e a documentação de outbox EF Core do Brighter; o aviso GHSA-rvv3-g6hj-g44x e a análise do impacto na última versão livre do AutoMapper; a documentação de durabilidade, filas locais e eventos de domínio do Wolverine.

Trabalho destacado

Roteiro do spike · Wolverine

Esta seção é autocontida, e é de propósito. Quem for executar o spike não precisa ler o resto do documento nem ter participado da conversa que o originou: tudo o que é preciso saber — a pergunta, o terreno já levantado, os arquivos, os critérios e o que não fazer — está aqui. O que estiver fora daqui é contexto, não requisito.

A pergunta de saída

O Wolverine consegue substituir o MediatR no Gryd.IO sem quebrar a tenancy e sem exigir gambiarra em nenhum dos três behaviors críticos?

Resposta esperada ao fim do dia 3: sim ou não, com evidência. "Talvez, com mais tempo" é não.

Por que a pergunta importa: o MediatR 12.x é livre indefinidamente mas está congelado — sem updates nem suporte —, e o Wolverine é o sucessor natural. Se ele couber, traz junto o outbox transacional com filas locais duráveis sem broker, e as features F3 a F6 deste épico deixam de precisar existir. Se não couber, o Gryd.IO constrói a versão fina especificada aqui e a decisão fica registrada como escolha, não como omissão.

O terreno · evidência já levantada

Conferido em feature/grydfiles-modulo-plataforma · 879dd954 · 09/09/2026. Não é preciso redescobrir nada disto.

Os doze pipelines — a ordem é parte do contrato
ProjetoBehaviorPapel
Gryd.ApplicationTenantScopeBehaviorcrítico Resolve o escopo de tenant/grupo do request (ADR 0006)
TenantInjectionBehaviorInjeta o tenant corrente no request
ValidationBehaviorFluentValidation antes do handler
Gryd.ObservabilityLoggingBehaviorLog estruturado por request
PerformanceBehaviorAlerta de request lento
TelemetryBehaviorSpan de OpenTelemetry por request
GrydAuth.ApplicationPermissionAuthorizationBehaviorcrítico Decide permissão. Falha aqui é falha de segurança
CrossTenantDataFilterBehaviorcrítico Decide alcance entre tenants (ADR 0007). Falha aqui é vazamento entre clientes
TenantValidationBehaviorValida o tenant do request
TenantAuditBehaviorTrilha por request
GrydJobs.ApplicationJobExecutionBehaviorEnvelopa a execução de job
GrydReports.ApplicationReportGenerationBehaviorEnvelopa a geração de relatório

Os três marcados como críticos, mais o critério 6, são o spike inteiro. Se eles couberem, os outros nove são mecânicos e não precisam ser portados para responder à pergunta. Se um deles não couber, a resposta é não e os outros nove não importam.

Arquivos para ler primeiro

src/Core/Gryd.Application/Behaviors/TenantScopeBehavior.cs
src/Modules/Auth/GrydAuth.Application/Common/Behaviors/PermissionAuthorizationBehavior.cs
src/Modules/Auth/GrydAuth.Application/Common/Behaviors/CrossTenantDataFilterBehavior.cs
src/Core/Gryd.Infrastructure/Tenancy/TenantContextAccessor.cs      # o AsyncLocal
src/Core/Gryd.Infrastructure/Tenancy/TenantQueryFilterExtensions.cs # o query filter global
src/Core/Gryd.Domain/Primitives/AggregateRoot.cs                    # o alvo do scraping
src/Modules/Auth/GrydAuth.Infrastructure/Data/GrydAuthDbContext.cs  # o laço a substituir
src/Core/Gryd.API/Extensions/ServiceCollectionExtensions.cs         # 1 dos 8 AddMediatR

Passo a passo

  1. Branch spike/wolverine-sucessor-mediatr, a partir de feature/grydfiles-modulo-plataforma. Não sai dela para lugar nenhum.
  2. Escolher um caso de uso só, e que atravesse os três behaviors críticos — um comando do GrydAuth que exija permissão e tenha alcance entre tenants. Migrar mais de um não responde melhor à pergunta; só custa mais.
  3. Subir o Wolverine com persistência em PostgreSQL e UseEntityFrameworkCoreTransactions(), ao lado do MediatR, sem removê-lo. Os dois convivem no spike.
  4. Portar os três behaviors como middleware do Wolverine, preservando a ordem — que hoje é significativa e é a hipótese sob teste.
  5. Ligar o scraping de eventos do AggregateRoot (PublishDomainEventsFromEntityFrameworkCore) sem alterar as 74 chamadas a AddDomainEvent.
  6. Rodar os testes de integração de tenancy que já existem contra o caminho novo, com Testcontainers e PostgreSQL real. Não escrever testes novos: os que existem são o critério, e é exatamente por isso que eles servem.
  7. Provar o pós-commit: um handler registra o instante em que rodou, o caso de uso registra o instante do commit, e a asserção é handler > commit.
  8. Escrever o relatório — uma página, formato abaixo — e parar.

Os seis critérios de saída

#CritérioComo medirPassa se
1Os behaviors críticos cabem no pipelinePortar os três; a ordem entre eles é preservada e declarávelNenhum precisa de estado estático novo, reflection nova ou ordem implícita para funcionar
2A tenancy sobreviveSuíte de integração de tenancy existente, sem alteração, contra o caminho novoVerde sem editar teste. Editar um teste de tenancy para fazer o spike passar é reprovar o spike
3O AggregateRoot é raspávelScraping ligado, 74 chamadas intactasNenhuma mudança em AggregateRoot além de, no máximo, expor a coleção
4O GrydJobs não é perdidoVerificar quem agenda: o Hangfire continua, ou o Wolverine assumeOu o Hangfire convive, ou o Wolverine cobre dashboard, DLQ e política de retry por tipo — sem regressão de nenhuma das três
5O impacto no consumidor é declarávelEscrever o que muda para quem consome o Gryd.IOCabe em uma página. Se não couber, a migração não é vendável a quem já usa o framework
6O Wolverine acompanha o HybridConfigurar duas fontes — uma compartilhada e uma dedicada — com conexão resolvida em runtime pelo ITenantConnectionStringResolver, e transporte resolvido por tenantAs duas fontes funcionam sem implementação própria de message store e sem fork da persistência dele. Este é o critério com maior chance de reprovar: as filas duráveis do Wolverine assumem um store que ele possui, e banco por tenant resolvido em runtime é onde ele briga com o framework

O que o spike não faz

  • Não migra os 199 arquivos. Migra um caso de uso.
  • Não remove o MediatR, nem toca nos oito pontos de AddMediatR.
  • Não implementa o outbox do Nexio, nem o GrydMessaging.
  • Não toca no GrydFiles, no GrydReports nem no GrydCrud.
  • Não resolve o AutoMapper — isso é a ADR 0014 e corre em paralelo, sem dependência.
  • Não faz merge. A entrega é conhecimento, não código.

O relatório · uma página, seis respostas

1. VEREDITO           sim | não        (uma palavra, na primeira linha)
2. CRITÉRIOS          os seis, com passou/falhou e a evidência de cada um
3. ONDE DOEU          o que exigiu contorno, e qual contorno
4. TENANCY            o que aconteceu com AsyncLocal, query filter e ordem
5. CUSTO ESTIMADO     semanas para os 199 arquivos, com o método da estimativa
6. RECOMENDAÇÃO       adotar agora | adotar depois, com gatilho | não adotar

Onde parar. Se ao fim do terceiro dia os três behaviors críticos não estiverem verdes, o spike falhou — e falhar aqui é o resultado barato. Três dias respondendo "não" valem mais do que seis semanas descobrindo a mesma coisa dentro de F3–F6. O erro a evitar não é reprovar o Wolverine: é esticar o spike até ele passar.

Contexto opcional, para quem quiser: a § Alternativas de mercado tem o levantamento completo e o motivo de o Wolverine ser o único candidato; a § Contratos do núcleo tem o que o Gryd.IO construiria caso a resposta seja não. Nenhuma das duas é pré-requisito para executar este roteiro.

Contrato vigente · revisão consolidada

Sequência de entrega

  1. Firmar contratos e propriedade de tabelas/migrações; inventariar efeitos existentes.
  2. Implementar e testar coleta por transação na plataforma, inclusive rollback e commit incerto.
  3. Implementar outbox do produtor, lease, cabeça por agregado, inbox e operação.
  4. Publicar pacote, consumir no Nexio e validar composição real dos quatro perfis.
  5. Executar cenários de falha e restauração com PostgreSQL e transporte reais antes do primeiro cliente.

Wolverine permanece experimento histórico; sua adoção exige nova decisão e não é pré-requisito deste caminho.

Decisão registrada

ADRs a abrir

O repositório mantém ADRs numeradas em docs/adr/, hoje até a 0009. Cinco decisões deste documento merecem registro próprio, porque são decisões de plataforma que produtos futuros vão herdar sem ter participado da conversa.

ADRTítuloA decisão em uma frase
0010Despacho de evento de domínio após o commitA fronteira é o commit da unidade de trabalho, o mecanismo é interceptor e não classe-base, e falha de handler nunca desfaz o commit
0011Outbox transacional é da plataformaTabela no schema de quem escreve, entrega ao menos uma vez, ordem por agregado, idempotência exigida do consumidor — com as alternativas de mercado avaliadas e o motivo de cada rejeição
0012Ato e mudança na trilha de auditoriaAction ao lado de ChangeType, com vocabulário convencionado e verificado
0013Precedência de código de erro sobre sufixoCódigo exato vence sufixo, sufixo vence 400, e o Core deixa de conhecer vocabulário de módulo
0014Dependências sob licença comercialAutoMapper sai (Mapperly no lugar) — já está na faixa paga e a linha livre tem CVE aberta. O MediatR não entra nesta ADR: é outra escala, e depende do spike. Independe deste épico e não deveria esperar por ele

Limite

Fora de escopo

  • Broker de mensagens. RabbitMQ, Kafka, Service Bus. O IEventBus que já existe no Gryd.Infrastructure.Abstractions continua sem implementação, e este épico não a traz — o outbox entrega a um IOutboxTransport, e um transporte que fale com broker é trabalho de quem tiver um.
  • Entrega exatamente uma vez. Não é oferecida e não será. Ver § Entrega.
  • Event sourcing. O outbox não é store de eventos: a mensagem entregue é expurgada e o estado continua vivendo nas tabelas do agregado.
  • Saga e coreografia de processo. Compensação, timeout de processo longo e máquina de estados distribuída são outro problema, e resolvê-los sem antes ter entrega garantida seria construir o segundo andar.
  • Inbox como dedupe global. O IInboxGuard é por consumidor, não por sistema. Dois consumidores da mesma mensagem processam os dois, e é isso que se espera.
  • Reordenar mensagem presa. Não há "pular esta e continuar". Destravar uma chave é descartar explicitamente, com motivo e trilha.
  • Retenção do AuditLog. Fora deste épico; a coluna Action entra sem mexer na política de guarda da trilha.

Se a resposta do time do Gryd.IO for diferente desta. A carta do Nexio termina dizendo que, se a plataforma não assumir, nada quebra: o Nexio reabre a decisão e assume, com o custo registrado. Este documento assume — as duas dependências são da plataforma. O que fica registrado do outro lado é a contrapartida: a Requisition não começa antes de F1 a F5 existirem, e o contrato da § Entrega vale a partir da aceitação deste documento, não da entrega do código.