No post anterior eu defendi que agentes de IA têm gaps herdados da base de treinamento, e que o harness (skills, CLAUDE.md, rules) é o mecanismo que entrega o conhecimento arquitetural específico de cada projeto. Mostrei os estudos, mostrei o volume de PRs, mostrei os projetos que falham.
Ficou faltando a parte que interessa a quem escreve código: o que muda na prática.
Então eu rodei o experimento. Peguei três tickets reais de uma plataforma de mensageria em produção.
.NET 9, event-driven architecture, RabbitMQ, sharding horizontal:
E pedi para o mesmo modelo resolver cada um duas vezes.
- Na primeira, sem acesso a nada do projeto: só o ticket e o conhecimento próprio do modelo.
- Na segunda, com as skills do repositório carregadas.
O resultado não foi o que eu esperava, e é por isso que vale escrever sobre ele.
Metodologia, para você poder repetir
Três tickets, escritos como um tech lead escreveria numa sprint:
- Endpoint — expor uma operação REST para solicitar hibernação de um canal, executada de forma assíncrona por um worker.
- Consumer — consumir o evento de criação de organização e disparar o comando que cria a API key padrão.
- Modelagem de status — modelar os estados de um canal com regras de capacidade consultáveis em código.
Cada ticket rodou em duas condições. Na condição sem harness, o agente recebeu instrução explícita de não ler nada do disco e responder apenas com conhecimento próprio. Na condição com harness, o agente recebeu ordem de ler as skills relevantes antes de escrever a primeira linha.
Mesmo modelo. Mesmo ticket, palavra por palavra. A única variável é o contexto arquitetural.
Caso 1: o problema não é o código ruim
O ticket do endpoint de hibernação produziu, sem harness, um pacote de 20 arquivos. Vale enumerar o que veio dentro, porque a lista é o argumento:
- uma entidade
ChannelHibernationRequestcom máquina de estados própria - a tabela correspondente, com índice único em
(TenantId, IdempotencyKey) - um segundo índice único filtrado, para impedir duas hibernações concorrentes do mesmo canal
IIntegrationEventPublishercom publisher confirms emandatory: trueRabbitMqIntegrationEventPublishersingleton, comSemaphoreSlimprotegendo o canal AMQPHibernationRequestRelay, umBackgroundServiceque reenvia solicitações cujo publish falhouResult<T>comErrorKind, mapeado paraProblemDetailsICurrentUserpara extrair claims- suporte a header
Idempotency-Key
Leia a lista de novo. Isso é bom. É outbox transacional feito à mão, com idempotência garantida no banco e não no código, com TimeProvider injetado para ser testável, com mandatory: true para denunciar routing key órfã em vez de perder mensagem em silêncio.
Se esse PR caísse no seu review, você aprovaria. Eu aprovaria.
E aí está o problema.
A plataforma já tem despacho assíncrono de comandos. Chama-se ICommandService, publica GenericCommand no exchange commands.realtime, e existe justamente para que ninguém precise escrever um publisher AMQP. A plataforma já tem tratamento de exceção centralizado, então Result<T> duplica um caminho que já existe. Já tem filtro de ownership via IOrganizationOwnedDTO no CrudController, então ICurrentUser reimplementa segurança que já está lá.
O agente construiu uma segunda plataforma dentro da plataforma. Conte as anexações: mensageria, persistência própria, semântica de erro, contexto de segurança, política de retry. Cinco preocupações que a arquitetura já absorve em outro lugar, todas puxadas para dentro do caminho do endpoint.
Com o harness carregado, o mesmo ticket produziu três arquivos novos e duas edições. A controller inteira ficou assim:
|
// POST: api/v1/Admin/Channel/{id}/Hibernation
[HttpPost("{id}/Hibernation")]
[Authorize(Roles = $"{Roles.System},{Roles.Organization}")]
public Task<IActionResult> RequestHibernationAsync(
Guid id,
[FromBody] HibernateChannelCommand command,
CancellationToken cancellationToken)
=> base.Accept(
channelService.CanRequestHibernationAsync,
channelService.RequestHibernationAsync,
command,
cmd => cmd.ChannelId = id,
cancellationToken);
|
Oito linhas. O resto é um command de domínio, um validator FluentValidation e um DTO de retorno 202 Accepted — todos seguindo o formato que as outras trinta operações da plataforma já seguem.
O agente com harness também citou, para cada decisão, a regra que a ditou: /controller-not-dispatcher para não injetar ICommandService na controller, a HARD RULE 4 para o naming Request*Async que comunica “solicitação aceita” em vez de “operação concluída”, /status-pattern para consultar channel.Status.CanHibernate em vez de comparar StatusId.
O que isso custa quando ninguém percebe
O código sem harness compila, roda e passa em code review. Não existe bug para apontar, não existe vulnerabilidade, não existe gargalo. Um revisor competente lê aquilo e aprova em quinze minutos.
Seis meses e quarenta PRs depois, a base tem dois mecanismos de despacho assíncrono. Metade das operações passa por ICommandService, metade por publishers artesanais. Quando alguém precisa adicionar tracing distribuído no despacho de comandos, precisa fazer em dois lugares — e vai descobrir o segundo lugar em produção.
Esse é o custo real do gap de treinamento: divergência, não defeito.
E é por isso que a skill que resolve isso não ensina a fazer certo. Ela ensina a fazer igual. As duas soluções: outbox artesanal e ICommandService são defensáveis em abstrato. Porém só uma delas é a que este projeto escolheu. O modelo não tem como saber qual, porque a escolha não é dedutível do ticket nem do estado da arte. Ela é histórica.
Em projeto de uma pessoa, divergência não custa nada. Numa base com quarenta desenvolvedores e agentes gerando PRs em paralelo, divergência é o custo dominante.
Caso 2: quando o agente reconstrói a sua biblioteca
O segundo ticket é mais direto: consumir um evento do RabbitMQ e disparar um comando a partir dele.
Sem harness, saíram nove arquivos, três filas novas e dois exchanges novos. O consumer implementou:
IRabbitMqConnectionscom duas conexões TCP separadas, para que flow control de publish não trave o dispatch de entregasCommandPublishercomSemaphoreSlim, porqueIChannelnão é thread-safe- topologia com fila de trabalho, fila de retry com TTL, fila
parkede dois exchanges fanout - contagem de tentativas lendo o header
x-death BackgroundServicecom loop de reconexão,BasicQosAsync,AsyncEventingBasicConsumer,BasicConsumeAsync- criação manual de
IServiceScopedentro do callback, para resolver o handler scoped
Tecnicamente sólido de novo. Filas quorum, ack manual, distinção entre erro permanente e transitório, CorrelationId propagado. Um sênior de mensageria assina embaixo.
A plataforma usa Oragon.RabbitMQ. Todo esse aparato já existe encapsulado. Com o harness, o handler inteiro é isto:
app.MapQueue("events.organization.created.flow.work", (
[FromBody] GenericEvent currentEvent) =>
{
ArgumentNullException.ThrowIfNull(currentEvent);
var command = new GenericCommand()
{
CommandName = "create-default-api-key",
Date = DateTime.UtcNow,
Metadata = currentEvent.Metadata,
};
return AmqpResults.Compose(
AmqpResults.Forward(
exchange: "commands.realtime",
routingKey: "create-default-api-key",
mandatory: false,
command),
AmqpResults.Ack());
})
.WithSafeRunnerConnection("rabbitmq_events")
.WithDispatchConcurrency(1)
.WithPrefetch(1);
Escopo de DI por mensagem, deserialização, reconexão, ack e nack: tudo isso é responsabilidade do framework. O que sobra no handler é a decisão de negócio: este fato vira esta intenção.
Repare num detalhe que só aparece na comparação lado a lado. A versão sem harness resolve dependência scoped com scope.ServiceProvider.GetRequiredService<OrganizationCreatedHandler>() dentro do callback. Isso é Service Locator, e a checklist de review do projeto bloqueia explicitamente em /no-factory. O modelo não fez isso por desleixo, fez porque, num BackgroundService singleton, essa é a saída idiomática. O padrão que a plataforma adota simplesmente elimina o problema em vez de administrá-lo.
Nove arquivos e cinco objetos de topologia contra um bloco e duas filas. Quatro anexações desta vez: ciclo de vida de conexão, topologia, política de retry e gerência de escopo de DI. A distância entre as duas versões é a distância entre usar a infraestrutura da empresa e reconstruí-la do zero, todo sprint, com uma variação ligeiramente diferente a cada vez.
A responsabilidade que não aparece no código
Os dois casos têm o mesmo diagnóstico, e ele merece nome.
Numa arquitetura madura, a responsabilidade de um artefato raramente se define por um comportamento. Ela se define pela ausência de uma preocupação que a arquitetura já absorveu em outro lugar.
O repositório assume todo o LINQ, para que os serviços não escrevam nenhum. O serviço de containers absorve a complexidade de lidar com containers, para que os serviços de negócio nunca precisem saber o que é um container. O provider implementa exatamente o seu passo e propaga a mensagem para a próxima perna da chain, para que nenhum provider resolva o problema inteiro.
Em todos os três, a responsabilidade real é uma proibição. O serviço está correto quando não escreve LINQ. O provider está correto quando ignora o passo seguinte. O interesse dominante de cada elemento é que ele deixe de fazer aquilo para o que não foi desenhado.
Agora olhe o corpus em que o modelo foi treinado. Tutorial, resposta de Stack Overflow, README, artigo técnico, documentação de framework. Todo esse material compartilha uma propriedade: precisa funcionar sozinho.
Um exemplo de controller num tutorial injeta o DbContext, publica no broker e trata a exceção ali mesmo, porque um exemplo que depende de doze arquivos não explicados não ensina nada.
O modelo aprendeu com artefatos autossuficientes e escreve artefatos autossuficientes. Sua arquitetura é feita de artefatos deliberadamente incompletos, que só fazem sentido porque os outros existem.
Um artefato que se basta, numa arquitetura desenhada para delegação, é por definição um artefato que anexou responsabilidades alheias.
Por que ler o repositório inteiro não resolve
A objeção óbvia é dar acesso ao codebase e deixar o agente induzir os padrões sozinho.
O que um artefato faz está escrito nele. O que ele foi proibido de fazer não deixa rastro. Ausência não produz evidência.
Um desenvolvedor recém-chegado que leia cinquenta controllers percebe que nenhuma delas injeta repositório. O que ele não consegue fazer, a partir da leitura, é separar três hipóteses:
- existe uma proibição,
- ninguém precisou até hoje,
- ou é permitido e não apareceu nessas cinquenta.
Na dúvida, ele levanta e pergunta para alguém. O agente não pergunta. Ele completa.
Conhecimento positivo é induzível do código. Conhecimento negativo precisa ser declarado por quem tomou a decisão. É por isso que RAG sobre o repositório, busca semântica e janela de contexto gigante resolvem uma metade do problema e não encostam na outra.
Fui medir a proporção no harness dessa plataforma. Entre as regras marcadas como BLOQUEIO, as que reprovam o PR e 64% são proibições. Nos checklists comuns, 25%. Quanto mais crítica a regra, maior a chance de ela existir para impedir alguma coisa.
As três regras mais importantes do projeto têm nome negativo: /controller-not-dispatcher, /event-not-action, /no-factory. A primeira HARD RULE do endpoint começa com “Controller NUNCA injeta nem usa” e lista sete tipos proibidos.
Isso explica a assimetria que atravessa os três experimentos. Em nenhum deles o agente sem harness deixou de fazer algo que deveria ter feito. Ele sempre fez a mais — cinco anexações no primeiro caso, quatro no segundo, uma no terceiro. O erro tem direção fixa, e a direção é a completude.
Um agente sem harness erra por excesso de zelo.
E excesso de zelo é o defeito mais difícil de reprovar num pull request.
Cada proibição é a cicatriz de um incidente
Há uma segunda camada nisso, e ela é a que fecha o argumento.
Boa parte das proibições de um harness maduro não nasceu de preferência estética. Nasceu de alguma coisa que já deu errado, custou dinheiro ou madrugada, e foi comprimida em uma regra de uma linha.
Pegue a que parece mais inofensiva de todas: usar Single() em vez de First().
// ❌ retorna o primeiro mesmo quando existem dois
var account = await context.Accounts
.Where(a => a.Id == accountId)
.FirstOrDefaultAsync();
// ✅ estoura quando existem zero ou dois
var account = await context.Accounts
.Where(a => a.Id == accountId)
.SingleOrDefaultAsync();
Lido como estilo, é irrelevante, a chave é única, os dois funcionam.
Lido como premissa, muda de natureza: a regra assume que nem o banco é confiável. Que uma duplicata pode existir por corrida, por migração malfeita, por reprocessamento de fila. First() escolhe uma das duas linhas em silêncio e segue. Single() para tudo.
A diferença entre os dois aparece meses depois, no dia em que alguém é tarifado errado. E aí ninguém consegue reconstituir qual das duas linhas foi escolhida, em qual requisição, por quanto tempo.
Um modelo sem contexto escolhe FirstOrDefaultAsync(). Não por descuido, mas porque é o que a documentação, os tutoriais e o corpus inteiro usam, e porque na esmagadora maioria dos casos está correto.
O exemplo que nenhum review pegaria
O caso mais claro dessa plataforma é a cardinalidade entre entidades Channel e Device.
O objeto de negócio termina em Channel. Device é uma abstração que amarra um plano de alocação a um dispositivo físico rodando em algum cluster, com recursos dependentes atrás dele: um schema no PostgreSQL, uma fila no RabbitMQ, um container ocupando memória numa VM remota (e possívelmente do outro lado do mundo).
Pela semântica de negócio, um canal tem um device ativo. A modelagem óbvia é 1:1. A modelagem real é 1:N.
O motivo aparece só no cenário de falha parcial. Recriar um device, upgrade, migração entre VMs, recuperação. Significa provisionar o novo antes de destruir o antigo. Com 1:1, é preciso apagar a linha para criar a próxima, e existe uma janela em que o schema, a fila e o container do device antigo continuam vivos no cluster sem nenhuma linha apontando para eles. Se o processo morre nessa janela, um deploy, um OOM, uma VM que cai, os recursos ficam órfãos e ninguém sabe mais que eles existem.
Com 1:N, os dois coexistem durante a transição. O antigo permanece rastreado até a limpeza confirmar. Se tudo cair no meio, a linha ainda está lá, e o processo de reconciliação sabe exatamente o que deletar.
O custo do erro não é um bug visível. É um schema órfão num Postgres remoto, uma fila que ninguém consome e um container consumindo memória numa VM que tem limite físico de recursos, multiplicado pelo número de máquinas, para sempre, sem nenhum sintoma além da conta de infraestrutura subindo e do teto de capacidade chegando antes do previsto.
Um modelo que olhasse esse schema veria 1:N e concluiria histórico, versionamento ou soft delete. Um modelo que modelasse do zero proporia 1:1, com um argumento sólido: é o que a semântica de negócio pede. Estaria certo em todos os cenários, exceto no único que a decisão existe para cobrir.
Repare que essa decisão não está no código de nenhum artefato. Está numa cardinalidade de relacionamento. Não há checklist de review que a alcance, não há teste que a proteja, e ela contraria o desenho que a leitura do domínio sugere.
O que nenhum modelo melhor vai adquirir
Essa é a diferença entre conhecer o padrão e conhecer o preço.
O modelo raciocina bem sobre o trade-off entre 1:1 e 1:N. Argumenta os dois lados. O que ele não tem é a informação de qual trade-off já foi pago, por quem, e quanto custou.
Essa informação nunca sai da empresa que pagou. Não está em nenhum corpus público, não vai estar no próximo, e nenhuma escala de treinamento produz acesso a um incidente que ninguém publicou. É o argumento que sobrevive à objeção mais comum: modelos melhores vão continuar sem as suas cicatrizes.
O mesmo raciocínio explica por que a infraestrutura é tratada como parte da aplicação: o bootstrapper aplica DDL e seed no startup, para que a incompatibilidade entre código e banco apareça no primeiro segundo de execução em vez de aparecer na primeira query em produção. É a mesma regra do Single() operando numa escala diferente: falhar cedo, alto e uma vez só.
Mas nem tudo são flores: O harness também apodrece
Aqui vem a parte que eu poderia ter deixado de fora do post, e que seria desonesto omitir.
No terceiro ticket “modelagem dos estados de um canal” eu pedi ao agente com harness que apontasse qualquer contradição que encontrasse entre as skills. Ele encontrou algumas. A principal:
create-entity, Step 2, manda criarpublic enum ChannelStatus { [IsDefault] Active = 1, Inactive = 2, Suspended = 3 }messagefy-conventions, na seção de entity patterns, repete o mesmo enum- dois arquivos de reference repetem de novo
code-review, na regra/status-pattern, marcada como BLOQUEIO se violado, proíbe: o status precisa ser entidade com tabela física, capabilities como colunasboole seed determinístico
Quatro documentos do harness ensinam o padrão que um quinto documento reprova.
O agente resolveu bem: seguiu a regra marcada como obrigatória, justificou a escolha pela hierarquia entre material normativo e material de scaffolding, e ainda recomendou abrir um ticket de débito para corrigir os quatro arquivos desatualizados. Mas um desenvolvedor que abrisse apenas create-entity reintroduziria o enum e levaria bloqueio no review e teria toda a razão de reclamar, porque a documentação do próprio projeto mandou fazer assim.
A conclusão incomoda e é a certa: harness é código. Envelhece, acumula débito, contradiz a si mesmo quando partes evoluem em velocidades diferentes, e precisa de review como qualquer outro artefato versionado. Quem trata skill como documentação-que-nunca-expira vai reencontrar exatamente o problema que teve com wiki corporativa.
Sem harness, o agente erra de formas variadas, cada PR erra diferente, e a variedade chama atenção. Com harness, ele erra de forma consistente. Uma regra errada no harness produz quarenta PRs errados do mesmo jeito, com justificativa articulada, citando a regra. Erro sistemático é mais barato de corrigir na origem e muito mais difícil de enxergar no review.
O que o harness tira de você
Um último dado que aparece nos transcripts e que eu não vou esconder.
O agente sem harness, no ticket do consumer, encerrou a resposta com esta observação:
“Se a criação da API key precisa ser garantida no mesmo instante da criação da Organization, o certo é outbox no serviço que cria a Organization, não um consumer de evento do jeito acima, evento perdido antes de chegar ao broker significa organização sem API key.”
É uma pergunta arquitetural legítima sobre o ticket. O agente com harness não fez essa pergunta. Ele executou.
No ticket de status, mesma coisa: a versão sem harness percebeu que dois estados tinham matriz de capacidades idêntica e sugeriu levar isso ao produto antes que virasse legado.
Harness converte o agente de arquiteto amador em executor disciplinado. É o que você quer em nove de cada dez tickets — e é exatamente o que você não quer no décimo, aquele em que o ticket está errado desde o enunciado.
Quem monta harness assumindo que ele elimina a necessidade de julgamento humano está trocando um problema por outro mais silencioso.
Onde isso deixa você
O gap de treinamento se manifesta como código competente, defensável, aprovável e estranho ao lugar onde foi colado. Um agente sem contexto arquitetural resolve o problema do ticket com o estado da arte que ele conhece, e resolve bem. O que ele não tem como descobrir sozinho é qual das cinco soluções corretas a sua empresa escolheu três anos atrás, e principalmente o que ela decidiu que ninguém mais faria.
A parte transferível por leitura você já tem: está no código, e qualquer ferramenta com busca semântica alcança. A outra metade sua arquitetura definiu por subtração: as responsabilidades que cada artefato precisa recusar para que o desenho continue de pé e por trás de boa parte delas existe um incidente que alguém pagou.
Essa metade nunca esteve escrita em lugar nenhum. O harness é onde ela passa a existir como texto, e escrever isso é o trabalho que ninguém pode delegar ao agente, porque o agente é justamente quem não estava lá quando aconteceu.
Três perguntas para levar para a sua base nesta semana:
Liste cinco coisas que um artefato do seu projeto precisa recusar fazer. Verifique quantas delas estão escritas em algum lugar que não seja uma conversa de corredor.
Se um desenvolvedor novo lesse cinquenta arquivos do mesmo tipo no seu repositório, quantas dessas cinco ele induziria sozinho? Essa é a fração que o agente também vai acertar. O resto é o seu backlog de harness.
E a que dói mais: quantas das suas decisões arquiteturais existem por causa de um incidente que nunca foi documentado? Enquanto o motivo continuar apenas na memória de quem estava de plantão naquela noite, ele não protege nem o desenvolvedor novo nem o agente protege só enquanto essa pessoa continuar na empresa.









0 comentários