Contrato vigente · revisão consolidada
Responsabilidade e garantias
| Responsável | Contrato |
|---|---|
| Gryd.IO | Mecanismo genérico: coleta por transação, despacho após commit, persistência/claim/lease do outbox, transporte, retries e inbox. |
| Contexto produtor | Entidade 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 Nexio | Significado, destinatário, versão e chave do agregado; decide o que exige chamada atômica e o que precisa de entrega durável. |
| Consumidor | Dedupe 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
- Efeito obrigatório é chamada explícita na transação produtora.
- Evento em memória só sai após o commit da transação que o produziu.
- Reação que não pode desaparecer nasce como mensagem durável junto do fato.
- Plataforma fornece mecanismo; produtor possui tabela e migração; negócio possui significado.
- Entrega é ao menos uma vez, com idempotência atômica no consumidor.
- Ordem é por agregado, preservando a primeira mensagem ainda não concluída.
- Claims são curtos, com lease e recuperação; I/O externo fica fora da transação de claim.
- 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
| Classe | Exemplo | Mecanismo |
|---|---|---|
| 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ável | Telemetria 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ável | Pedido 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ça | Onde | O que este épico faz com ela |
|---|---|---|
DomainEvent · IDomainEvent | Gryd.Domain/Primitives | Usado como está. Já é INotification, já tem Id e OccurredOn — é a base do messageId estável e do occurredOn da mensagem |
AggregateRoot · IAggregateRoot | Gryd.Domain | AddDomainEvent, DomainEvents e ClearDomainEvents ficam intactos. As 74 chamadas a AddDomainEvent na base não mudam de assinatura — muda quando elas produzem efeito |
IUnitOfWork.ExecuteInTransactionAsync | Gryd.Infrastructure | Já resolve transação aninhada e execution strategy. É a fronteira que o DbTransactionInterceptor passa a observar; nenhuma mudança de contrato |
TenantSaveChangesInterceptor | Gryd.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 |
AuditSaveChangesInterceptor | Modules/Audit | Prova de que o padrão escala para fora do Core. Ganha a coluna Action no caminho de escrita |
DomainEventDispatcher | Gryd.Infrastructure/Services | Removido. 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 |
GrydJobs | Modules/Jobs | IRecurringJob + RecurringJobRegistrationService para os três jobs; IDeadLetterQueue e DeadLetterJob para o veneno; IRetryPolicyProvider (Polly) para o backoff. Nada de fila nova |
IAuditStore · AuditEntry | Gryd.Observability/Audit | Ganha Action em AuditEntry, no builder e na entidade AuditLog |
ErrorCodeSuffixes | Gryd.Application/Models | Unavailable = "_UNAVAILABLE" já existe. O que entra é o registro de precedência, que tira do Core o if com o código do GrydAuth |
TenantContextAccessor | Gryd.Infrastructure/Tenancy | AsyncLocal. É 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 GrydFiles | Modules/Files | GrydFilesBuilder + 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.
| Achado | Onde | Consequência |
|---|---|---|
| Publicação dentro da transação | GrydAuthDbContext.SaveChangesWithEventLoopAsync | DispatchDomainEventsAsync roda entre o base.SaveChangesAsync e o transaction.CommitAsync |
Laço de re-SaveChanges | idem · maxIterations = 10 | O efeito do handler entra na transação do agregado — o oposto do contrato de reação assíncrona |
| Duas formas de publicar | DbContext × DomainEventDispatcher | O contexto faz _mediator.Publish(domainEvent); o dispatcher embrulha em DomainEventNotification. Um handler escrito contra DomainEventHandler<T> nunca dispara pelo caminho do contexto |
| Dispatcher sem chamador | GrydAuthBuilder:189 | Registrado em DI e nunca injetado em lugar nenhum. Código morto que parece contrato |
| Módulo sem despacho | GrydFilesDbContext | DbContext puro. Um agregado de arquivo que chamasse AddDomainEvent teria o evento descartado em silêncio |
| Sem outbox | src/ inteiro | Zero ocorrências. Não há caminho garantido de saída para fora do processo |
| Sem coluna de ato | GrydAudit.Core/Entities/AuditLog.cs | EntityType, 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
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.
| Momento | Ação |
|---|---|
| Antes de salvar | Coletar 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ícita | Persistir 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ícita | Associar o lote à transação efetiva do save; publicar somente quando sua confirmação estiver comprovada. Testar separadamente o ciclo de interceptors do provedor. |
| Commit confirmado | Remover e despachar apenas o buffer dessa transação. Novos fatos produzidos pelo handler pertencem às próprias transações deles. |
| Rollback ou savepoint revertido | Descartar 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 incerto | Não afirmar sucesso ou repetir efeito cegamente: consultar o resultado pela chave da operação; retry usa a mesma identidade. |
| Handler ou fallback falha | Registrar 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
| Campo | Regra |
|---|---|
| messageId, tenantId, producerContext | Identidade estável; múltiplas mensagens de um evento derivam identidade por destino/tipo, sem colisão. |
| aggregateType, aggregateId, aggregateSequence | Chave 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, payloadHash | Nome 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, operationId | Escopo e rastreabilidade; actor informado é dado de auditoria, não identidade autenticada do worker. |
| status, attemptCount, nextAttemptAt | Pending, Dispatching, Dispatched, Failed ou DeadLettered. Backoff com jitter e teto; motivo persistido. |
| leaseOwner, leaseToken, leaseExpiresAt | Claim atômico; lease recuperável. Atualização de status exige o token atual, evitando worker antigo confirmar claim perdido. |
| createdAt, dispatchedAt, lastErrorCode | Monitoramento, 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ção | Condição |
|---|---|
| Pending / Failed → Dispatching | Somente cabeça elegível do agregado, nextAttemptAt vencido, claim sob trava curta. Incrementar tentativa e emitir leaseToken. |
| Dispatching → Dispatched | Transporte confirma; gravar com token de lease vigente. Queda antes dessa gravação permite duplicata. |
| Dispatching → Failed | Falha recuperável, com motivo e nova data. Não liberar mensagem posterior do mesmo agregado. |
| Dispatching expirado → Failed / novo claim | Recuperar após queda por leaseExpiresAt; tentativa anterior pode ter enviado. Usar dedupe e fencing; nunca presumir que nada ocorreu. |
| Failed → DeadLettered | Teto de tentativas ou falha permanente; trava agregado e alerta. |
| DeadLettered → Pending | Reprocessamento 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 ChangeType — Created, 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.
| Campo | Situação | Regra |
|---|---|---|
ChangeType | fica | Continua sendo a forma da mudança de linha, preenchido pelo interceptor. Não vira Action disfarçado |
Action | entra | varchar(80)?, indexado em (tenant_id, action, timestamp). Nulo quando a entrada nasce do interceptor e ninguém declarou ato |
AdditionalData | fica | Contexto 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.
| Item | Estado | Onde |
|---|---|---|
_UNAVAILABLE como décimo sufixo | existe | ErrorCodeSuffixes.Unavailable |
| Mapeamento para 503 | existe | GrydBaseController:241 |
USER_EMAIL_UNAVAILABLE → 409 | existe | GrydBaseController:218 — antes da varredura de sufixos |
| Precedência declarada | falta | É um if com literal, não uma regra publicada |
| Core sem vocabulário de módulo | falta | O Gryd.API conhece um código do GrydAuth |
Cabeçalho Retry-After | falta | 503 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ão | Escopo | Quem costuma ter |
|---|---|---|
| messaging:outbox:read | tenant | Operação e suporte. Não expõe payload em lista |
| messaging:outbox:retry | tenant | Operação. Reprocessar pode duplicar efeito no destino — por isso não é automático |
| messaging:outbox:discard | tenant | Administração. Descartar é decidir que o fato não vai ser integrado |
| messaging:outbox:read:any-tenant | plataforma | Administrador 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
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.
É registrada, medida e encaminhada ao IDomainEventFallback — DLQ do GrydJobs por padrão. Exceção de handler nunca sobe para o caso de uso.
O comportamento vem de interceptors registrados por AddGrydPlatformInterceptors. Um override de SaveChangesAsync que publique evento é defeito, e um teste de arquitetura o proíbe.
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.
É passo do caso de uso, dentro do ExecuteInTransactionAsync, explícito na leitura.
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.
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.
Sem invólucro. INotificationHandler<TEvent> é a única forma de escrever handler; DomainEventNotification e DomainEventHandler<T> deixam de existir.
Pelo mesmo DbContext, na tabela do schema dele. Não existe caminho que grave mensagem fora de uma transação de negócio.
Duplicata é comportamento previsto, não incidente. Idempotência é obrigação declarada do consumidor.
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.
Falha vira Failed com backoff; teto de tentativas vira DeadLettered, que trava a chave e alerta. Expurgo só alcança Dispatched.
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.
{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.
gryd_outbox_oldest_pending_age_seconds e o health check são entrega da v1, não melhoria posterior.
Com permissão própria, motivo obrigatório no descarte e linha na trilha com Action.
Action ao lado de ChangeType. Uma entrada pode ter as duas; nenhuma das duas substitui a outra.
{modulo}.{agregado}.{verbo-no-passado}, minúsculo, ≤ 80 caracteres, verificado por teste de convenção.
Precedência única, publicada e coberta por teste.
Exceções entram por MapErrorCode, com motivo escrito.
_UNAVAILABLE é dependência fora do ar503 com Retry-After. Nunca para recurso ocupado ou ausente.
Qualidade
SOLID, DRY e padrões
| Princípio | Onde aparece | O que seria a violação |
|---|---|---|
| SRP | O DbContext volta a ser persistência. Coleta, gravação de mensagem e publicação são três classes com um motivo de mudar cada | O GrydAuthDbContext de hoje: persiste, orquestra evento, controla transação e conta iterações |
| OCP | Novo destino é um IOutboxTransport; nova integração é um IIntegrationEventMapper; nova exceção de erro é um MapErrorCode. Nada disso toca o Core | O if (errorCode is "USER_EMAIL_UNAVAILABLE") dentro do Gryd.API |
| LSP | Qualquer DbContext serve. Não há base a herdar, então não há contrato de base a violar | Uma GrydDbContextBase cujo SaveChangesAsync sobrescrito por um produto desligasse o despacho em silêncio |
| ISP | IDomainEventQueue 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 |
| DIP | O domínio depende de DomainEvent e nada mais. O mapeador de integração mora na aplicação; o transporte, na infraestrutura | Agregado que chamasse _outbox.EnqueueAsync |
| DRY | Uma 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 inventar | Cada módulo com o seu laço de SaveChanges, a sua tabela de "já vi isto" e a sua chave Event no JSON |
| Padrão | Onde | Por quê |
|---|---|---|
| Interceptor | SaveChangesInterceptor, DbTransactionInterceptor | Comportamento transversal sem herança e sem tocar o caso de uso. É o padrão que o TenantSaveChangesInterceptor já estabeleceu no repositório |
| Transactional Outbox | OutboxMessage + interceptor + worker | A única forma conhecida de dar entrega garantida sem transação distribuída entre banco e destino |
| Unit of Work | ExecuteInTransactionAsync | Já existe e define a fronteira que o despacho observa. Não foi criado por este épico — foi usado |
| Mediator / Observer | MediatR, INotificationHandler | Reação em processo sem o produtor conhecer o consumidor |
| Strategy | IOutboxTransport, IDomainEventFallback | Mesma decisão que o IFileScanner do GrydFiles: a escolha de infraestrutura fica atrás de uma interface |
| Adapter | IIntegrationEventMapper | Traduz vocabulário de domínio em contrato de integração sem contaminar nenhum dos dois |
| Registry | IErrorCodeStatusMapper, mapeadores por tipo de evento | Extensão por registro em vez de switch no Core |
| Null Object | NullOutboxInterceptor, NullDomainEventFallback | Um produto sem mensageria e sem jobs continua rodando sem ramificação condicional. O NullAuditStore já é o precedente |
| Builder | GrydMessagingBuilder | Registro 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.
| Opção | Licença hoje | O que entrega | Veredito |
|---|---|---|---|
| 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 handler — BeginTransaction(_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
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.
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.
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.
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.
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.
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.
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
AggregateRootatual é raspável pela API de scraping sem mudar as 74 chamadas aAddDomainEvent. - O
GrydJobscontinua 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.
| Medida | Contagem | O que significa para a migração |
|---|---|---|
| using MediatR | 199 arquivos | A superfície total a tocar |
| IRequestHandler | 112 arquivos | Assinaturas a reescrever |
| IRequest<> | 101 arquivos | Contratos de comando e consulta |
| INotificationHandler | 12 arquivos | As reações — a parte que este épico toca |
| IPipelineBehavior | 18 arquivos · 13 classes | O risco. Cinco são do GrydAuth e decidem permissão e alcance entre tenants |
| AddMediatR | 8 pontos | Oito 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.
| Quando | O quê | Por quê |
|---|---|---|
| Agora · incondicional | F2 · corte seco — remover o laço, o dispatcher morto, o invólucro e a classe-base; classificar os handlers nas três categorias | Necessá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 · ponte | F1 · 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 dias | Spike "sucessor do MediatR" — roteiro completo na próxima seção | Custa dias e pode eliminar F3–F6 inteiras. A razão risco/retorno não tem discussão |
| Depois do spike | F3–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 data | Migração do mediator, se o spike passar | 199 arquivos e cinco behaviors de segurança não entram de carona em nenhum outro épico |
| Independente · agora | F8 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.
| AutoMapper | MediatR | |
|---|---|---|
| Modelo de licença | Idêntico — Lucky Penny, comercial a partir do corte, Community gratuita abaixo de US$ 5M de receita anual mediante registro | |
| Corte comercial | v15.0 | v13.0 |
| Versão no repositório | 16.1.1 — já além do corte | 12.4.1 — aquém do corte |
| A linha livre | 14.x, com GHSA-rvv3-g6hj-g44x aberta — DoS, severidade 7,5, corrigida só na linha comercial | 12.x, sem CVE conhecida, livre indefinidamente pelos termos originais |
| Enforcement em runtime | Nenhum nos dois. O que existe é obrigação contratual, não trava técnica | |
| Superfície no repositório | 7 arquivos de produção, 2 módulos — e IEntityMapper já é porta | 199 arquivos, 13 behaviors, 8 registros |
| Custo de saída | ~1 dia | um trimestre |
| Natureza do risco | Ferida aberta — paga hoje, e a saída óbvia leva a uma CVE | Relógio — sem custo e sem CVE hoje; o preço é estagnação |
| Decisão | remover agora ADR 0014 | planejar, 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.
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.
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.
| Projeto | Behavior | Papel |
|---|---|---|
| Gryd.Application | TenantScopeBehavior | crítico Resolve o escopo de tenant/grupo do request (ADR 0006) |
| TenantInjectionBehavior | Injeta o tenant corrente no request | |
| ValidationBehavior | FluentValidation antes do handler | |
| Gryd.Observability | LoggingBehavior | Log estruturado por request |
| PerformanceBehavior | Alerta de request lento | |
| TelemetryBehavior | Span de OpenTelemetry por request | |
| GrydAuth.Application | PermissionAuthorizationBehavior | crítico Decide permissão. Falha aqui é falha de segurança |
| CrossTenantDataFilterBehavior | crítico Decide alcance entre tenants (ADR 0007). Falha aqui é vazamento entre clientes | |
| TenantValidationBehavior | Valida o tenant do request | |
| TenantAuditBehavior | Trilha por request | |
| GrydJobs.Application | JobExecutionBehavior | Envelopa a execução de job |
| GrydReports.Application | ReportGenerationBehavior | Envelopa 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
- Branch
spike/wolverine-sucessor-mediatr, a partir defeature/grydfiles-modulo-plataforma. Não sai dela para lugar nenhum. - Escolher um caso de uso só, e que atravesse os três behaviors críticos — um comando do
GrydAuthque exija permissão e tenha alcance entre tenants. Migrar mais de um não responde melhor à pergunta; só custa mais. - Subir o Wolverine com persistência em PostgreSQL e
UseEntityFrameworkCoreTransactions(), ao lado do MediatR, sem removê-lo. Os dois convivem no spike. - Portar os três behaviors como middleware do Wolverine, preservando a ordem — que hoje é significativa e é a hipótese sob teste.
- Ligar o scraping de eventos do
AggregateRoot(PublishDomainEventsFromEntityFrameworkCore) sem alterar as 74 chamadas aAddDomainEvent. - 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.
- 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. - Escrever o relatório — uma página, formato abaixo — e parar.
Os seis critérios de saída
| # | Critério | Como medir | Passa se |
|---|---|---|---|
| 1 | Os behaviors críticos cabem no pipeline | Portar os três; a ordem entre eles é preservada e declarável | Nenhum precisa de estado estático novo, reflection nova ou ordem implícita para funcionar |
| 2 | A tenancy sobrevive | Suíte de integração de tenancy existente, sem alteração, contra o caminho novo | Verde sem editar teste. Editar um teste de tenancy para fazer o spike passar é reprovar o spike |
| 3 | O AggregateRoot é raspável | Scraping ligado, 74 chamadas intactas | Nenhuma mudança em AggregateRoot além de, no máximo, expor a coleção |
| 4 | O GrydJobs não é perdido | Verificar quem agenda: o Hangfire continua, ou o Wolverine assume | Ou o Hangfire convive, ou o Wolverine cobre dashboard, DLQ e política de retry por tipo — sem regressão de nenhuma das três |
| 5 | O impacto no consumidor é declarável | Escrever o que muda para quem consome o Gryd.IO | Cabe em uma página. Se não couber, a migração não é vendável a quem já usa o framework |
| 6 | O Wolverine acompanha o Hybrid | Configurar duas fontes — uma compartilhada e uma dedicada — com conexão resolvida em runtime pelo ITenantConnectionStringResolver, e transporte resolvido por tenant | As 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, noGrydReportsnem noGrydCrud. - 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
- Firmar contratos e propriedade de tabelas/migrações; inventariar efeitos existentes.
- Implementar e testar coleta por transação na plataforma, inclusive rollback e commit incerto.
- Implementar outbox do produtor, lease, cabeça por agregado, inbox e operação.
- Publicar pacote, consumir no Nexio e validar composição real dos quatro perfis.
- 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.
| ADR | Título | A decisão em uma frase |
|---|---|---|
| 0010 | Despacho de evento de domínio após o commit | A fronteira é o commit da unidade de trabalho, o mecanismo é interceptor e não classe-base, e falha de handler nunca desfaz o commit |
| 0011 | Outbox transacional é da plataforma | Tabela 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 |
| 0012 | Ato e mudança na trilha de auditoria | Action ao lado de ChangeType, com vocabulário convencionado e verificado |
| 0013 | Precedência de código de erro sobre sufixo | Código exato vence sufixo, sufixo vence 400, e o Core deixa de conhecer vocabulário de módulo |
| 0014 | Dependências sob licença comercial | AutoMapper 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
IEventBusque já existe noGryd.Infrastructure.Abstractionscontinua sem implementação, e este épico não a traz — o outbox entrega a umIOutboxTransport, 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 colunaActionentra 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.