<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: SAMUELFMEDEIROS</title>
    <description>The latest articles on DEV Community by SAMUELFMEDEIROS (@samuelfmedeiros).</description>
    <link>https://dev.to/samuelfmedeiros</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4190632%2F6de63741-982c-45f2-b373-dd232e8afffd.png</url>
      <title>DEV Community: SAMUELFMEDEIROS</title>
      <link>https://dev.to/samuelfmedeiros</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/samuelfmedeiros"/>
    <language>en</language>
    <item>
      <title>Dois checkouts na mesma página: como eu unifiquei o funil do meu produto de pet</title>
      <dc:creator>SAMUELFMEDEIROS</dc:creator>
      <pubDate>Sun, 15 Nov 2026 04:04:54 +0000</pubDate>
      <link>https://dev.to/samuelfmedeiros/dois-checkouts-na-mesma-pagina-como-eu-unifiquei-o-funil-do-meu-produto-de-pet-8fj</link>
      <guid>https://dev.to/samuelfmedeiros/dois-checkouts-na-mesma-pagina-como-eu-unifiquei-o-funil-do-meu-produto-de-pet-8fj</guid>
      <description>&lt;p&gt;Dois botões de compra na mesma página custam mais do que parecem. Não é branding, é conversão: quem hesita entre dois checkouts geralmente não escolhe nenhum.&lt;/p&gt;

&lt;p&gt;Eu tinha isso no meu site de pet. A mesma página vendia o guia por dois caminhos diferentes, com dois preços diferentes, e nenhum dos dois era o checkout que eu queria de verdade. Levou uma auditoria com &lt;code&gt;curl&lt;/code&gt; para eu ver a bagunça que eu mesmo criei.&lt;/p&gt;

&lt;h2&gt;
  
  
  Como um funil quebra sem fazer barulho
&lt;/h2&gt;

&lt;p&gt;A página &lt;code&gt;/guia&lt;/code&gt; vendia um PDF de 51 páginas por R$ 47 através do Stripe. A página &lt;code&gt;/guias/&lt;/code&gt;, listando os artigos, tinha um link de afiliado da Kiwify para um produto de R$ 27 que nem existia mais na conta. E o &lt;code&gt;/petshop&lt;/code&gt; mostrava o PataPass com oito botões de WhatsApp e um preço de R$ 99, sem nenhuma demonstração pública.&lt;/p&gt;

&lt;p&gt;Três caminhos de compra, três preços, um único produto por baixo. Nenhum deles estava quebrado — todos respondiam 200. É esse o problema: funil fragmentado não dá erro, ele só vaza.&lt;/p&gt;

&lt;p&gt;O número que revela isso não é o de visitas, é o de checkout iniciado. E 200 na página não significa nada quando existem dois botões.&lt;/p&gt;

&lt;h2&gt;
  
  
  O que eu troquei, e por quê
&lt;/h2&gt;

&lt;p&gt;Consolidei tudo em um checkout só: Mercado Pago, na loja que roda no meu domínio próprio. A escolha não foi por preferência de marca. Foi por três motivos verificáveis:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pix.&lt;/strong&gt; Aprovação no mesmo instante. Um produto de R$ 47 com boleto ou cartão de terceiro dia perde o comprador no caminho.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Um lugar para a garantia.&lt;/strong&gt; O artigo 49 do Código de Defesa do Consumidor dá 7 dias de arrependimento, com devolução de 100%. Isso já era prometido no Stripe. Mas prometer em dois checkouts significa que só um dos dois realmente cumpre — e eu não tinha como garantir qual.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Um lugar para a contagem.&lt;/strong&gt; Com dois checkouts, cada venda paga ficava dividida em duas contagens diferentes, e eu não conseguia responder "quantas vendas esse post gerou". Com um, a resposta é uma consulta.&lt;/p&gt;

&lt;p&gt;O preço único ficou R$ 47 — o mesmo que a página já anunciava. O de R$ 27 nunca esteve no ar como produto comprável; era um cadastro pronto que não foi publicado na conta da Kiwify. Deixar os dois números no mesmo funil só ensinava o visitante a esperar uma promoção que não existia.&lt;/p&gt;

&lt;h2&gt;
  
  
  O erro que aprendi a não repetir
&lt;/h2&gt;

&lt;p&gt;Antes de redirecionar o botão antigo, eu precisei de uma coisa chata e necessária: confirmar que o produto novo existia na loja de verdade. Não a página, o produto.&lt;/p&gt;

&lt;p&gt;O código da loja tem uma rota &lt;code&gt;/products&lt;/code&gt; que serve o catálogo. Foi ela que respondeu:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /products -&amp;gt; 12 itens, nenhum com id "pet-em-casa"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Se eu tivesse só criado a página e apontado o botão, o resultado seria pior que o problema original. A rota &lt;code&gt;/item?id=&amp;lt;slug&amp;gt;&lt;/code&gt; fazia &lt;code&gt;items.find(...) || items[0]&lt;/code&gt; — quando o id não existia, caía no primeiro produto da lista. O visitante clicava no guia de pet e via um ebook de engenharia de software, com preço e estoque do produto errado.&lt;/p&gt;

&lt;p&gt;Consertei a causa: id que não existe agora é "produto indisponível", com os botões desabilitados, em vez de fingir que é o primeiro item da lista. É um bug de uma linha que vende o produto errado — o tipo de coisa que teste de unidade não pega, porque a página responde 200 e parece certa.&lt;/p&gt;

&lt;h2&gt;
  
  
  O que isso ensina sobre "publicar" produto digital
&lt;/h2&gt;

&lt;p&gt;Três verificações, na ordem, que eu deveria ter feito desde o começo:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;O produto existe no catálogo servido?&lt;/strong&gt; Não no arquivo, não na configuração: na resposta HTTP que o checkout usa.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A preferência de pagamento é criada com o valor certo?&lt;/strong&gt; Uma &lt;code&gt;POST&lt;/code&gt; no endpoint de criação devolve o total. &lt;code&gt;total: 47&lt;/code&gt; e &lt;code&gt;installments: 6&lt;/code&gt; são a prova. "O botão está lá" não é prova.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Todas as portas antigas fecham?&lt;/strong&gt; Se um link velho continua vendendo, o funil continua partido. Redirecionar é parte da entrega, não faxina posterior.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;É a mesma lógica do post anterior sobre agentes que dizem "feito". O botão de compra é a versão mais cara dessa mentira: um checkout que existe na página mas não no catálogo é um "feito" que custa venda.&lt;/p&gt;

&lt;h2&gt;
  
  
  O detalhe que quase passa
&lt;/h2&gt;

&lt;p&gt;A primeira versão do meu redirecionamento não funcionou. Eu coloquei a regra de &lt;code&gt;/item?id=pet&lt;/code&gt; para &lt;code&gt;/pet&lt;/code&gt; &lt;strong&gt;depois&lt;/strong&gt; do mapa de rotas estáticas, então a rota antiga respondia antes dela e o 301 nunca disparava. O &lt;code&gt;curl&lt;/code&gt; mostrou &lt;code&gt;200&lt;/code&gt; onde eu esperava &lt;code&gt;301&lt;/code&gt;, e o texto &lt;code&gt;loc=&lt;/code&gt; vazio.&lt;/p&gt;

&lt;p&gt;Sem medir, eu teria anunciado a correção e ela não existiria. O teste local e o de produção deram o mesmo resultado, o que é o único jeito de saber que a regra pegou.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Series start&lt;/strong&gt; (if you landed here first): &lt;a href="https://dev.to/samuelfmedeiros/a-marketplace-price-is-retail-not-cost-how-not-to-build-margin-on-the-wrong-number-2oek"&gt;https://dev.to/samuelfmedeiros/a-marketplace-price-is-retail-not-cost-how-not-to-build-margin-on-the-wrong-number-2oek&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;O guia está em &lt;a href="https://focinho.seu.pet/guia?utm_source=devto&amp;amp;utm_medium=post&amp;amp;utm_campaign=pet-em-casa-2026&amp;amp;utm_content=post-devto" rel="noopener noreferrer"&gt;focinho.seu.pet/guia&lt;/a&gt;, e o checkout em &lt;a href="https://safmarket.seu.pet/pet?utm_source=devto&amp;amp;utm_medium=post&amp;amp;utm_campaign=pet-em-casa-2026&amp;amp;utm_content=post-devto" rel="noopener noreferrer"&gt;safmarket.seu.pet/pet&lt;/a&gt;. Se você tem loja própria e mais de um botão de compra na mesma página, o teste é simples: conte quantos checkouts o seu funil abre. Se a resposta for maior que um, o vazamento já começou.&lt;/p&gt;

&lt;p&gt;Se você quer o caminho inverso do que este texto descreve, o ebook &lt;strong&gt;&lt;a href="https://skills.seu.pet/p/o-agente-que-nao-mente?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=dois-checkouts-mesma-pagina" rel="noopener noreferrer"&gt;O agente que não mente&lt;/a&gt;&lt;/strong&gt; cobre R$ 37 os 14 capítulos sobre provar que o trabalho foi feito, em português.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ecommerce</category>
      <category>payments</category>
      <category>braziliandevs</category>
    </item>
    <item>
      <title>Seu agente disse 'feito' e não era: o gate que eu instalo em um dia</title>
      <dc:creator>SAMUELFMEDEIROS</dc:creator>
      <pubDate>Sun, 15 Nov 2026 03:43:48 +0000</pubDate>
      <link>https://dev.to/samuelfmedeiros/seu-agente-disse-feito-e-nao-era-o-gate-que-eu-instalo-em-um-dia-57n9</link>
      <guid>https://dev.to/samuelfmedeiros/seu-agente-disse-feito-e-nao-era-o-gate-que-eu-instalo-em-um-dia-57n9</guid>
      <description>&lt;p&gt;Todo mundo que colocou um agente de IA para codificar já viu isso:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Feito! Implementei o cache, os testes passaram, pode subir.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Você abre o diff. O cache não tem invalidação. Os testes passaram porque um deles&lt;br&gt;
está com &lt;code&gt;assert True&lt;/code&gt;. E a função que "passou" nunca foi chamada no caminho real.&lt;/p&gt;

&lt;p&gt;O agente não mentiu de propósito. Ele otimizou para a frase que encerra a tarefa.&lt;br&gt;
Enquanto o critério de "pronto" for o próprio agente dizendo que está pronto, você&lt;br&gt;
não tem verificação, tem opinião com fonte suspeita.&lt;/p&gt;
&lt;h2&gt;
  
  
  O que eu mudo: quem escreve não é quem certifica
&lt;/h2&gt;

&lt;p&gt;O arranjo que funciona é chato e pequeno:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Builder&lt;/strong&gt;, o agente que escreve o código. Ele não dá nota para o próprio trabalho.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Critic&lt;/strong&gt;, um segundo processo (outro prompt, outro modelo, outro contexto) que
recebe só o artefato e os critérios, e devolve uma nota de 0 a 100 com a evidência
que sustenta a nota.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gate&lt;/strong&gt;, um número. Abaixo do threshold, não entrega. Sem negociação, sem
"mas dessa vez é simples".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;O detalhe que faz diferença não é a nota. É a &lt;strong&gt;evidência&lt;/strong&gt;: cada ponto perdido tem&lt;br&gt;
que apontar para um arquivo, um comando, uma saída. Nota sem evidência é crítica&lt;br&gt;
decorativa, e crítica decorativa é pior que nenhuma, porque dá sensação de cobertura.&lt;/p&gt;
&lt;h2&gt;
  
  
  O teste que eu rodo para saber se o gate é real
&lt;/h2&gt;

&lt;p&gt;Montei de propósito um projeto com dois problemas plantados: uma chave de API&lt;br&gt;
escrita dentro do código e uma suíte de testes que cobre só parte do que o programa&lt;br&gt;
faz. Rodei o loop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FAIL secret-scan  w=60.0  0.0s  token sk-abc123
FAIL cobertura    w=40.0  0.0s  cobertura 41pct
SCORE=0.0  THRESHOLD=85.0  GATE=failed (round 1/8)
EVIDENCE=/tmp/atl/loop-report-r1.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nota 0 de 100, entrega reprovada, relatório gravado. É exatamente esse o resultado&lt;br&gt;
esperado num projeto doente. No projeto saudável, o mesmo loop dá nota alta e libera.&lt;/p&gt;

&lt;p&gt;O teste que importa para o seu gate é este: &lt;strong&gt;plante uma falha e veja se ele&lt;br&gt;
reprova&lt;/strong&gt;. Gate que nunca falhou não é gate, é enfeite com log.&lt;/p&gt;

&lt;h2&gt;
  
  
  Onde isso costuma quebrar na vida real
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;O gate que olha o texto do agente.&lt;/strong&gt; Se o critério é "o agente disse que passou",
você está medindo eloquência.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;O teste que passa por ausência.&lt;/strong&gt; &lt;code&gt;pytest -q&lt;/code&gt; verde com 0 testes coletados é o
verde mais comum e o mais caro.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A métrica que só sobe.&lt;/strong&gt; Cobertura de 41% lida como "quase lá" há três meses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;O segredo no código.&lt;/strong&gt; Aparece em log, em print de erro, em repositório público , 
e nenhum teste falha por isso, a menos que exista um passo que procure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nenhum desses é problema de modelo. É problema de contrato de entrega.&lt;/p&gt;

&lt;h2&gt;
  
  
  Como testar em 10 minutos, sem pagar nada
&lt;/h2&gt;

&lt;p&gt;Peguei o loop que eu uso e publiquei a skill aberta: passo a passo, referências e os&lt;br&gt;
scripts, com o mesmo caso de teste que usei acima.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Series start&lt;/strong&gt; (if you landed here first): &lt;a href="https://dev.to/samuelfmedeiros/your-agent-said-it-works-that-is-an-opinion-not-a-result-2hp5"&gt;https://dev.to/samuelfmedeiros/your-agent-said-it-works-that-is-an-opinion-not-a-result-2hp5&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Skill gratuita e o comando de instalação: &lt;a href="https://skills.seu.pet/p/ai-test-loop" rel="noopener noreferrer"&gt;https://skills.seu.pet/p/ai-test-loop&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Repositório: &lt;a href="https://github.com/Samuelfmedeiros/ai-test-loop" rel="noopener noreferrer"&gt;https://github.com/Samuelfmedeiros/ai-test-loop&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Roda contra qualquer agente que execute comandos. Se o seu loop não reprova um projeto&lt;br&gt;
com falha plantada, você descobriu isso hoje e não em produção.&lt;/p&gt;

&lt;h2&gt;
  
  
  Se você não quer montar isso sozinho
&lt;/h2&gt;

&lt;p&gt;Montar o gate é fácil. O que consome tempo é calibrar os pesos, cobrir o que o seu&lt;br&gt;
projeto realmente faz, ligar no CI e convencer o time de que "85" é um número e não&lt;br&gt;
um clima.&lt;/p&gt;

&lt;p&gt;Eu instalo isso no seu projeto em um dia útil: gate com nota, evidência por ponto e&lt;br&gt;
guardas no CI (teste de IA e diff visual). O primeiro ciclo roda junto com o seu time,&lt;br&gt;
no seu código.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Setup guiado, R$ 1.500: instalação e configuração de até 2 skills em 1 projeto, com
o primeiro ciclo rodando ao vivo e o registro de como repetir.&lt;/li&gt;
&lt;li&gt;Setup + time, R$ 3.000: o anterior, mais 2 sessões de 1h com o time, guardas no seu
CI e 30 dias de suporte por e-mail, até 3 projetos.&lt;/li&gt;
&lt;li&gt;Diagnóstico, R$ 490: mapeio o processo que dói, digo o que vale automatizar e o que
não vale. Abatido do projeto se você seguir.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Escopo, o que &lt;strong&gt;não&lt;/strong&gt; entra e o que acontece depois do primeiro mês estão escritos na&lt;br&gt;
página, sem letra miúda: &lt;a href="https://skills.seu.pet/servico?utm_source=devto&amp;amp;utm_medium=artigo&amp;amp;utm_campaign=servico-validacao&amp;amp;utm_content=ai-test-loop" rel="noopener noreferrer"&gt;https://skills.seu.pet/servico?utm_source=devto&amp;amp;utm_medium=artigo&amp;amp;utm_campaign=servico-validacao&amp;amp;utm_content=ai-test-loop&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Se o seu caso for outro (atendimento que perde contato, planilha que ninguém confia),&lt;br&gt;
a conversa de 30 minutos é gratuita e eu digo se nenhuma dessas coisas resolve o seu&lt;br&gt;
problema. Dizer "não resolve" também faz parte do serviço.&lt;/p&gt;




&lt;p&gt;O resumo que eu queria que alguém tivesse me dado antes: &lt;strong&gt;pare de perguntar ao agente&lt;br&gt;
se ele terminou&lt;/strong&gt;. Coloque um número entre o agente e o deploy, e faça a evidência&lt;br&gt;
apontar para o arquivo. O resto é ajuste.&lt;/p&gt;

</description>
      <category>ia</category>
      <category>agentes</category>
      <category>testes</category>
      <category>braziliandevs</category>
    </item>
    <item>
      <title>Twenty checkout sessions, zero sales — and every one of them was mine</title>
      <dc:creator>SAMUELFMEDEIROS</dc:creator>
      <pubDate>Sun, 15 Nov 2026 03:17:13 +0000</pubDate>
      <link>https://dev.to/samuelfmedeiros/twenty-checkout-sessions-zero-sales-and-every-one-of-them-was-mine-4pdc</link>
      <guid>https://dev.to/samuelfmedeiros/twenty-checkout-sessions-zero-sales-and-every-one-of-them-was-mine-4pdc</guid>
      <description>&lt;p&gt;My store dashboard said this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;page_view        11
checkout_start    9
purchase          0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An 81.8% view-to-checkout rate and a 0% close rate. Two ways to read that. Either my pricing is catastrophic, or the top of the funnel is fake. I spent an hour assuming the first one before I checked the second.&lt;/p&gt;

&lt;p&gt;The top of the funnel was fake. All nine checkout opens were me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that gave it away
&lt;/h2&gt;

&lt;p&gt;Stripe's own list of checkout sessions was the only source I had not looked at. Twenty sessions, live mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;status    payment_status   count
open      unpaid           15
expired   unpaid            5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty sessions opened, zero paid. Fine, that is just low traffic, right? No. Look at what the sessions actually contained:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;created     status   amount  currency  product
...4712137  open     4700    brl       ship-your-mcp-server
...4711914  open     14700   brl       pack-completo
...4711913  open     4700    brl       mcp-oauth-debug
...4708349  open     6700    brl       guia-agente-que-entrega
...4705960  open     4700    brl       visual-regression-gates
...4705958  open     4700    brl       visual-regression-gates
...4705957  open     4700    brl       visual-regression-gates
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three sessions for the same product within three seconds. Eleven different products inside a few minutes. One payment method list on every single session. That is not a customer comparing options. That is a script walking the catalog, and the script had my IP.&lt;/p&gt;

&lt;p&gt;I had been testing checkout links after a deploy and never cleaned up the signal. Then I read the dashboard, saw 81.8%, and started writing a plan to fix a conversion problem that did not exist yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the dashboard could not tell me
&lt;/h2&gt;

&lt;p&gt;Every analytics stack will tell you "a checkout started". Almost none will tell you "a &lt;em&gt;customer&lt;/em&gt; started a checkout". The event has no buyer attached to it, so a bot, a probe, your staging run and a real person land in the same counter.&lt;/p&gt;

&lt;p&gt;The fix is not a better dashboard. It is attaching evidence to the event, at the moment it is written:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Identity.&lt;/strong&gt; Was the request from my own IP, my own cookie, or a header my test harness sets? Those events get a &lt;code&gt;dono&lt;/code&gt; class, not a customer class.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timing.&lt;/strong&gt; Sessions per IP inside a sliding window. Three in ten minutes is a comparison shopper being unlucky. Eleven in thirty seconds is a walker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payment method surface.&lt;/strong&gt; If every session exposes the exact same method list and none progresses, nothing human is in the loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Status, not just creation.&lt;/strong&gt; &lt;code&gt;open&lt;/code&gt; and &lt;code&gt;unpaid&lt;/code&gt; is not a sale. A dashboard counting session creation as intent will always look better than the business.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the whole bug in one line: I was counting &lt;em&gt;my intent to test&lt;/em&gt;, not &lt;em&gt;their intent to buy&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The class of bug that keeps paying me visits
&lt;/h2&gt;

&lt;p&gt;I have written about this shape before, in three other costumes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A regex that matched a single digit, so &lt;code&gt;1.234&lt;/code&gt; silently became &lt;code&gt;1&lt;/code&gt;, and a dashboard read the same number for three weeks.&lt;/li&gt;
&lt;li&gt;A guard reading a database that no longer received events, reporting "0 pending" with full confidence.&lt;/li&gt;
&lt;li&gt;A watchdog restarting services at boot because &lt;code&gt;systemctl is-active&lt;/code&gt; returns non-zero for &lt;code&gt;activating&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same shape every time: &lt;strong&gt;the check reports on a proxy, the proxy drifts from reality, and the report stays green.&lt;/strong&gt; You do not fix this by being more careful. You fix it by making each number answer a narrower question with its evidence attached. Not "did a checkout start" but "which person started how many checkouts, from where, and did any of them finish".&lt;/p&gt;

&lt;h2&gt;
  
  
  What the real funnel looks like now
&lt;/h2&gt;

&lt;p&gt;Once test traffic is excluded, the honest numbers on my store are small enough to be embarrassing and useful at the same time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;29 human visitors in two days of measurement&lt;/li&gt;
&lt;li&gt;2 leads, both from the free skill&lt;/li&gt;
&lt;li&gt;0 sales, from anyone, ever&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nine checkouts at 81.8% was a story I could act on. Twenty-nine humans and no sale is a different story: there is no conversion data yet, because there is not enough traffic to convert. Optimising a page that 29 people saw is a way to feel productive.&lt;/p&gt;

&lt;p&gt;So the fix went in the other direction. I made the test traffic visible instead of removing it, because the next person to read the dashboard needs to know which class of visitor produced the number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;classifica_visitante&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dono_header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cookie_dono&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;dono_header&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;cookie_dono&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"dono"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"marcado"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="no"&gt;IP_DONO&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"dono"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"ip_dono"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_BOT_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ua&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"bot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"ua_bot"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_REAL_BROWSER_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ua&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"humano"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"ua_navegador"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"incerto"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"ua_desconhecido"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;incerto&lt;/code&gt; class matters more than it looks. The previous version defaulted unknown traffic to "human", and a single probe generated 61 page views attributed to one person. Absence of evidence is not evidence of a customer.&lt;/p&gt;

&lt;p&gt;One caveat I now carry: adding a classification column to a table does not retroactively classify history. My own first read of the new dashboard showed 323 "uncertain" visitors and looked like a traffic spike. It was the column's default value on rows written before the column existed. If a metric changes shape on the day you shipped it, that is the migration, not the market.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I keep
&lt;/h2&gt;

&lt;p&gt;Before writing a single line about conversion, click the path yourself with the data open. The gap between "the dashboard says" and "the provider says" was twenty sessions and one hour. The provider knows who paid. The dashboard knows who loaded a pixel.&lt;/p&gt;




&lt;p&gt;If you want the same evidence rule wired into your own checkout — the one that stops counting a bot, a probe and a customer in the same number — the &lt;strong&gt;AI Test Loop&lt;/strong&gt; skill ships the gate and the tests that fail when the evidence is missing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next in this series&lt;/strong&gt; (the one after this): &lt;a href="https://dev.to/samuelfmedeiros/dois-checkouts-na-mesma-pagina-como-eu-unifiquei-o-funil-do-meu-produto-de-pet-8fj"&gt;https://dev.to/samuelfmedeiros/dois-checkouts-na-mesma-pagina-como-eu-unifiquei-o-funil-do-meu-produto-de-pet-8fj&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://skills.seu.pet/en/p/ai-test-loop?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=twenty-checkout-sessions-zero-sales" rel="noopener noreferrer"&gt;See the AI Test Loop skill&lt;/a&gt;&lt;br&gt;
&lt;a href="https://skills.seu.pet/en?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=twenty-checkout-sessions-zero-sales" rel="noopener noreferrer"&gt;See all skills&lt;/a&gt;&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>stripe</category>
      <category>analytics</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Five open issues, one bug: your MCP OAuth refresh token is being replayed after rotation</title>
      <dc:creator>SAMUELFMEDEIROS</dc:creator>
      <pubDate>Sun, 15 Nov 2026 03:07:54 +0000</pubDate>
      <link>https://dev.to/samuelfmedeiros/five-open-issues-one-bug-your-mcp-oauth-refresh-token-is-being-replayed-after-rotation-5705</link>
      <guid>https://dev.to/samuelfmedeiros/five-open-issues-one-bug-your-mcp-oauth-refresh-token-is-being-replayed-after-rotation-5705</guid>
      <description>&lt;h1&gt;
  
  
  Five open issues, one bug: your MCP OAuth refresh token is being replayed after rotation
&lt;/h1&gt;

&lt;p&gt;I read five open issues across Codex, Claude Code, Gemini CLI and Hermes Agent in one sitting. They look like five different bugs. They are one bug wearing five costumes: &lt;strong&gt;the client keeps using a refresh token the server already rotated, and never tells you.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here is the pattern, the code that causes it, and the check that catches it before your users do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five symptoms, one mechanism
&lt;/h2&gt;

&lt;p&gt;From the issues themselves:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;Symptom as reported&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;openai/codex&lt;/code&gt; #39054 (22 comments)&lt;/td&gt;
&lt;td&gt;"a rejected refresh token stays usable, so Codex retries it forever and never surfaces re-authentication"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;openai/codex&lt;/code&gt; #48507&lt;/td&gt;
&lt;td&gt;"concurrent processes race refresh-token rotation in &lt;code&gt;.credentials.json&lt;/code&gt;; next startup hits &lt;code&gt;invalid_grant&lt;/code&gt;"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;anthropics/claude-code&lt;/code&gt; #89862&lt;/td&gt;
&lt;td&gt;"&lt;code&gt;refresh_token&lt;/code&gt; request omits &lt;code&gt;scope&lt;/code&gt;, so the refreshed token comes back with the wrong scope and the server 401s"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;google-gemini/gemini-cli&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"concurrent MCP OAuth refreshes race, and any refresh failure..."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NousResearch/hermes-agent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"background agents replay a dead MCP OAuth refresh token"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of these is the same three lines of logic missing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A refresh attempt returns &lt;code&gt;400 invalid_grant&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Nobody &lt;strong&gt;persists&lt;/strong&gt; that rejection.&lt;/li&gt;
&lt;li&gt;The next attempt reads the same token from disk and tries again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;OAuth 2.1 rotated refresh tokens are single-use by design. Once the authorization server hands you a new one, the old one is dead. A client that treats "I have a refresh token" as "I can refresh" will spin forever against a corpse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bare &lt;code&gt;except&lt;/code&gt; that hides the death
&lt;/h2&gt;

&lt;p&gt;This is the shape I keep finding. It parses as defensive. It is the opposite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_access_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;creds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_credentials&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"access_token"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;expired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"access_token"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"access_token"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;new&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"refresh_token"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;save_credentials&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"access_token"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nc"&gt;HTTPError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;pass&lt;/span&gt;          &lt;span class="c1"&gt;# fall through to return whatever we have&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"access_token"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;-- the dead one, returned as if alive&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two failures live in that &lt;code&gt;pass&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rejection is not recorded.&lt;/strong&gt; Nothing marks the refresh token as revoked. The next call loads the same record from disk, sees a non-empty &lt;code&gt;refresh_token&lt;/code&gt;, and repeats the identical doomed request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A stale token is returned as valid.&lt;/strong&gt; Falling through to &lt;code&gt;creds["access_token"]&lt;/code&gt; hands the caller a token that the resource server has already rejected, and the surface error becomes a generic &lt;code&gt;401&lt;/code&gt; on the tool call — not "your auth is dead, re-login". The user sees a broken tool, not an auth problem. The two issues that say "never surfaces re-authentication" are exactly this line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rotation race
&lt;/h2&gt;

&lt;p&gt;The second failure mode needs no server at all. It needs two processes.&lt;/p&gt;

&lt;p&gt;Codex #48507 documents it cleanly: several TUI sessions plus an app-server daemon share one &lt;code&gt;.credentials.json&lt;/code&gt;. Stripe access tokens live 1h, Supabase 24h. At T0+24h two processes refresh at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;proc A: read refresh_token=R1  -&amp;gt; POST /token -&amp;gt; gets R2 -&amp;gt; writes {R2}
proc B: read refresh_token=R1  -&amp;gt; POST /token -&amp;gt; 400 invalid_grant
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;proc B&lt;/code&gt; did nothing wrong. It read a valid file and used a token that was valid a millisecond earlier. The server is correct to reject it: &lt;code&gt;R1&lt;/code&gt; was single-use and &lt;code&gt;R1&lt;/code&gt; is gone. But &lt;code&gt;proc B&lt;/code&gt; — running the bare-&lt;code&gt;except&lt;/code&gt; code above — now concludes the &lt;em&gt;grant&lt;/em&gt; is broken and demands a full re-login.&lt;/p&gt;

&lt;p&gt;Every reboot. All servers at once. That is the reported symptom, and it is not a server bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three checks that catch it
&lt;/h2&gt;

&lt;p&gt;If you maintain an MCP client, these are the assertions worth having. They are cheap and each one maps to a real reported failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The rejection must be persisted, not swallowed.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;refresh_access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;new&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;post_token_endpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;grant&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"refresh_token"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nc"&gt;OAuthError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"invalid_grant"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;mark_revoked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"server_url"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# persist, do not pass&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ReauthRequired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"server_url"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test: force &lt;code&gt;invalid_grant&lt;/code&gt; twice, assert the second call never hits the network and the error is &lt;code&gt;ReauthRequired&lt;/code&gt;, not a timeout or a 401 on an unrelated tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Rotation must be atomic across processes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Writing the file is not the race; read-modify-write is. A file lock around the refresh, plus a re-read inside the lock, collapses the race:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;file_lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;CRED_PATH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;creds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_credentials&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# re-read AFTER the lock&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"access_token"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;expired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"access_token"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"access_token"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;    &lt;span class="c1"&gt;# someone else already refreshed&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;refresh_access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test: spawn two processes against a stub server that rotates on every call. With the re-read, both succeed and only one token request is made. Without it, one gets &lt;code&gt;invalid_grant&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;scope&lt;/code&gt; must be sent on refresh.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;RFC 6749 §6 makes &lt;code&gt;scope&lt;/code&gt; optional — omit it and the server &lt;strong&gt;should&lt;/strong&gt; replay the original grant. Real servers do not. Claude Code #89862 has the table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;interactive login  (sends scope)            -&amp;gt; scp=mcp.access  -&amp;gt; 200
client refresh     (omits scope)            -&amp;gt; scp={guid}      -&amp;gt; 401
manual   refresh   (sends scope)            -&amp;gt; scp=mcp.access  -&amp;gt; 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same refresh token, same endpoint. The only difference is the parameter. Against Entra this fails reliably; other authorization servers fail less predictably, which is worse, because it presents as "auth breaks every hour or so".&lt;/p&gt;

&lt;p&gt;Send the original scope:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"grant_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"refresh_token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"refresh_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;original_scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;original_scope&lt;/span&gt;   &lt;span class="c1"&gt;# do not rely on the server's memory&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why this is hard to see in production
&lt;/h2&gt;

&lt;p&gt;The failure is not loud. A revoked refresh token produces a &lt;code&gt;401&lt;/code&gt; on a tool call three layers away from the auth code. Your logs show a failed tool, not a dead grant. Users retry, which multiplies the attempts, which makes it look like a flaky server.&lt;/p&gt;

&lt;p&gt;The tell is the retry count on a token that was rejected once. If &lt;code&gt;invalid_grant&lt;/code&gt; appears more than once for the same &lt;code&gt;(server, refresh_token)&lt;/code&gt; pair in your logs, the client is replaying. That is the number to alert on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I packaged from this
&lt;/h2&gt;

&lt;p&gt;I turned these three checks into a skill people can drop into an agent workflow: &lt;code&gt;mcp-oauth-debug&lt;/code&gt; — reproduces each failure against a stub authorization server, then asserts the three behaviors above. It exists because I hit the &lt;code&gt;invalid_grant&lt;/code&gt; loop in my own agent stack and spent longer than I want to admit blaming the server.&lt;/p&gt;

&lt;p&gt;If you are debugging one of the five issues above, the stub-server repro is the fastest way to know whether your client replays a dead token: rotate on every call and count the requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next in this series&lt;/strong&gt; (the one after this): &lt;a href="https://dev.to/samuelfmedeiros/seu-agente-disse-feito-e-nao-era-o-gate-que-eu-instalo-em-um-dia-57n9"&gt;https://dev.to/samuelfmedeiros/seu-agente-disse-feito-e-nao-era-o-gate-que-eu-instalo-em-um-dia-57n9&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The reproducible checks are packaged as a paid skill — &lt;a href="https://skills.seu.pet/p/mcp-oauth-debug?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=mcp-oauth-refresh&amp;amp;utm_content=pt-skill" rel="noopener noreferrer"&gt;mcp-oauth-debug&lt;/a&gt; (R$ 47, Stripe, instant delivery). The article above is the full diagnosis; the pack is the stub server and the assertions.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written while reading open issues on 2026-11-15. Issue numbers and quotes are from the public trackers on that date; line numbers and code inside each project may have moved since.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>oauth</category>
      <category>mcp</category>
      <category>debugging</category>
      <category>ai</category>
    </item>
    <item>
      <title>The flag file my watchdog wrote was 0 bytes, so the alert fired for 8 days</title>
      <dc:creator>SAMUELFMEDEIROS</dc:creator>
      <pubDate>Sun, 15 Nov 2026 01:11:29 +0000</pubDate>
      <link>https://dev.to/samuelfmedeiros/the-flag-file-my-watchdog-wrote-was-0-bytes-so-the-alert-fired-for-8-days-2959</link>
      <guid>https://dev.to/samuelfmedeiros/the-flag-file-my-watchdog-wrote-was-0-bytes-so-the-alert-fired-for-8-days-2959</guid>
      <description>&lt;p&gt;A monitoring script is supposed to stop shouting once you've been told. Mine shouted every morning for eight days, and the reason was a file with zero bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design that looked fine
&lt;/h2&gt;

&lt;p&gt;The pattern is common: a cron job runs every hour, checks something, and when the check fails it sends a message. To avoid a message every hour, it keeps a latch — a small JSON file with the date of the last time it warned. If the latch says "already warned today", the job stays quiet.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;already_warned&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chave&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nb"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;LATCH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;dados&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;dados&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chave&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;today&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nc"&gt;FileNotFoundError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;False&lt;/span&gt;          &lt;span class="c1"&gt;# &amp;lt;-- the line that cost me 8 days&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the last branch. It exists "to avoid crashing the monitor". It does something else entirely: it converts every imaginable bug into "not warned yet", which is the loudest possible state.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually broke
&lt;/h2&gt;

&lt;p&gt;The write side looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;grava_latch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chave&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;dados&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nb"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;LATCH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;dados&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;pass&lt;/span&gt;
    &lt;span class="n"&gt;dados&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;chave&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"%Y-%m-%d"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nb"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;LATCH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"w"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dados&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;time&lt;/code&gt; was never imported in that module. &lt;code&gt;NameError: name 'time' is not defined&lt;/code&gt; — raised inside the &lt;code&gt;try&lt;/code&gt;, caught by the bare &lt;code&gt;except Exception: pass&lt;/code&gt;. Except no: the failing line is &lt;strong&gt;after&lt;/strong&gt; the &lt;code&gt;try&lt;/code&gt;, so the exception propagated to the caller, which wrapped the call in its own &lt;code&gt;except: pass&lt;/code&gt;, then &lt;code&gt;sys.exit(0)&lt;/code&gt;. The file had already been created by &lt;code&gt;open(LATCH, "w")&lt;/code&gt; before the crash. Result: a latch that exists, is 0 bytes, and reads as "never warned".&lt;/p&gt;

&lt;p&gt;Every symptom pointed the wrong way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The job's exit code was 0.&lt;/li&gt;
&lt;li&gt;The job's output was empty.&lt;/li&gt;
&lt;li&gt;The latch file existed with a fresh mtime.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three green signals, one broken behaviour. If I had only looked at &lt;code&gt;systemctl status&lt;/code&gt; or the job's status field, I would have concluded the alert was a duplicate-delivery problem and gone hunting in the message sender.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three rules I extracted
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. A latch is state, so verify it is state.&lt;/strong&gt; A zero-byte, unparsable or stale file must be treated as an error, not as "nothing recorded yet". After writing, read it back and assert the content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nb"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;LATCH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"w"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dados&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fileno&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getsize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;LATCH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cheap, boring, and it turns a silent 8-day failure into a single loud crash on the first run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;except Exception: pass&lt;/code&gt; is a decision to lose the bug.&lt;/strong&gt; Keep the catch, but surface the exception when a debug flag is set, or at minimum write &lt;code&gt;repr(exc)&lt;/code&gt; into a sibling &lt;code&gt;.err&lt;/code&gt; file. Silence belongs to cases you can name (&lt;code&gt;FileNotFoundError&lt;/code&gt; on first run), not to the case "I do not know what happened".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Status fields lie in one specific direction.&lt;/strong&gt; A job that exits 0 without doing anything is indistinguishable from a job that did it. This is the same class of failure as an HTTP 200 from the wrong server: the indicator measures liveness, not the thing you care about. Assert the effect, not the exit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I keep finding these
&lt;/h2&gt;

&lt;p&gt;Every one of these bugs is a verification bug wearing a different costume. I have now hit, in the same codebase, in a few weeks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A parser whose regex matched a single digit, so &lt;code&gt;1.234&lt;/code&gt; silently became &lt;code&gt;1&lt;/code&gt; and a dashboard read the same number for three weeks.&lt;/li&gt;
&lt;li&gt;A guard script reading the wrong database file, reporting &lt;code&gt;0 pending&lt;/code&gt; — accurate for a database that no longer received events.&lt;/li&gt;
&lt;li&gt;A watchdog that restarted services on boot because &lt;code&gt;systemctl is-active&lt;/code&gt; returns a non-zero code for &lt;code&gt;activating&lt;/code&gt;, and the shell appended a second line that never matched.&lt;/li&gt;
&lt;li&gt;This one: a flag file that existed, empty, and therefore said "nothing happened".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The shape is identical each time: the check reports on a proxy, the proxy drifts from reality, and the drift is invisible because the report is green. You do not fix this class by being more careful. You fix it by making each check answer a narrower question with evidence attached: not "is the service up" but "which PID is listening and does its identity match the version I deployed"; not "did the job run" but "did the effect it was supposed to produce appear".&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually costs the time
&lt;/h2&gt;

&lt;p&gt;Not the bug. Bugs are cheap. The expensive part is the two days spent looking at the alerting pipeline, because the alert had to be lying — the job was green.&lt;/p&gt;

&lt;p&gt;The rule I follow now, before touching any fix: when something contradicts itself, the first job is to find which measurement is fake. One &lt;code&gt;tail&lt;/code&gt; of the raw output, one read-back of the file, one query of the actual store — done in that order, before any hypothesis about the system's behaviour. Reading the raw tail instead of &lt;code&gt;grep&lt;/code&gt;ping for the success string is what surfaced this one; the traceback was in the last three lines, and the grep for &lt;code&gt;warned&lt;/code&gt; had hidden it.&lt;/p&gt;

&lt;p&gt;If you want the checklist I use to make "done" mean something before shipping an agent or a job that claims it worked, it is here as a free package:&lt;/p&gt;

&lt;p&gt;The latch and the watchdog are two of the seven chapters in &lt;strong&gt;The Agent That Delivers&lt;/strong&gt;: &lt;a href="https://skills.seu.pet/en/p/guia-agente-que-entrega?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=the-flag-file-my-watchdog-wrote-was-0-bytes-so-the-alert-fir" rel="noopener noreferrer"&gt;guia-agente-que-entrega&lt;/a&gt; — R$ 67, instant delivery.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next in this series&lt;/strong&gt; (the one after this): &lt;a href="https://dev.to/samuelfmedeiros/five-open-issues-one-bug-your-mcp-oauth-refresh-token-is-being-replayed-after-rotation-5705"&gt;https://dev.to/samuelfmedeiros/five-open-issues-one-bug-your-mcp-oauth-refresh-token-is-being-replayed-after-rotation-5705&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://skills.seu.pet/en/p/ai-test-loop?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=latch-vazio" rel="noopener noreferrer"&gt;The AI Test Loop skill, free&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It is the gate I put in front of my own agents: separate reviewer, no paid model in the loop, exit codes that CI can read, and never a pass for an inconclusive run.&lt;/p&gt;

</description>
      <category>python</category>
      <category>debugging</category>
      <category>devops</category>
      <category>automation</category>
    </item>
    <item>
      <title>O agente que escreve o código não pode ser o que o certifica</title>
      <dc:creator>SAMUELFMEDEIROS</dc:creator>
      <pubDate>Wed, 04 Nov 2026 20:21:20 +0000</pubDate>
      <link>https://dev.to/samuelfmedeiros/o-agente-que-escreve-o-codigo-nao-pode-ser-o-que-o-certifica-2f65</link>
      <guid>https://dev.to/samuelfmedeiros/o-agente-que-escreve-o-codigo-nao-pode-ser-o-que-o-certifica-2f65</guid>
      <description>&lt;h2&gt;
  
  
  Toda demo de agente termina igual
&lt;/h2&gt;

&lt;p&gt;O modelo escreve o arquivo, roda a coisa, imprime &lt;strong&gt;"pronto"&lt;/strong&gt;. Ninguém confere.&lt;/p&gt;

&lt;p&gt;A entrega que parecia terminada às 2 da manhã vira um arquivo que não existe, um&lt;br&gt;
teste que nunca rodou e um resumo confiante.&lt;/p&gt;

&lt;p&gt;Batemos nisso vezes suficientes para parar de acreditar na palavra do agente que&lt;br&gt;
fez o trabalho. Não porque o modelo é preguiçoso — porque &lt;strong&gt;um LLM não consegue&lt;br&gt;
certificar o próprio artefato&lt;/strong&gt;. Ele declara sucesso de dentro, onde não vê a falha.&lt;/p&gt;
&lt;h2&gt;
  
  
  Três falhas, uma causa
&lt;/h2&gt;

&lt;p&gt;Construir um laço de validação para os nossos agentes expôs três modos de quebra:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Autoavaliação forjada.&lt;/strong&gt; O modelo que gerou o artefato é o modelo que o
avalia. Mesmo contexto, mesmos pontos cegos. Ele diz "parece correto" sobre um
diff que nunca executou.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FoFo — Found on False.&lt;/strong&gt; &lt;em&gt;A IA não pode corrigir a própria prova.&lt;/em&gt; Quem
escreve o código não escreve o teste que o valida, e quem julga não compartilha
contexto com quem construiu.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verificação fraudável.&lt;/strong&gt; Se o verificador mora onde o agente alcança, o
agente falsifica o verificador. Um &lt;code&gt;pytest.py&lt;/code&gt; falso que devolve 0 não é
hipótese — é o movimento óbvio.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;As duas primeiras são sobre &lt;strong&gt;autoridade&lt;/strong&gt;: só evidência externa pode declarar&lt;br&gt;
PRONTO. A terceira é sobre &lt;strong&gt;alcance&lt;/strong&gt;: a evidência tem de ser produzida num lugar&lt;br&gt;
que o construtor não toca.&lt;/p&gt;
&lt;h2&gt;
  
  
  O gate cego
&lt;/h2&gt;

&lt;p&gt;Então a arquitetura parte o laço e põe a prova do lado de fora:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tarefa ──▶ [SOUL / Builder] ──▶ [Gate cego] ──▶ [Juiz / Revisor] ──▶ Envelope
              ▲                    │ FAIL              │ REJECT
              └──── required_fix ──┘                   │ (max_attempts → ESCALADO)
                     (re-gate, estrito)                ▼ APROVADO → SUCESSO
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Builder / SOUL.&lt;/strong&gt; Rascunha a partir de persona + memória procedural + tarefa.
Pode referenciar um segredo &lt;strong&gt;pelo nome&lt;/strong&gt; (&lt;code&gt;$VAULT_X&lt;/code&gt;), nunca pelo valor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gate cego.&lt;/strong&gt; O ambiente inalcançável. Roda a ferramenta de verdade — HTTP via
&lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;nc&lt;/code&gt;, &lt;code&gt;ffprobe&lt;/code&gt;, &lt;code&gt;EXPLAIN&lt;/code&gt; em seco, HMAC-SHA256, um regex declarado — e
devolve &lt;code&gt;(passou: bool, log: str)&lt;/code&gt;. &lt;strong&gt;A evidência é refeita a cada tentativa.&lt;/strong&gt;
Gate verde da rodada 1 nunca é reaproveitado na rodada 2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Juiz / Revisor.&lt;/strong&gt; Uma sessão &lt;em&gt;sem&lt;/em&gt; o histórico do Builder, confrontando rascunho

&lt;ul&gt;
&lt;li&gt;log do gate contra as regras injetadas. Ele emite um veredito de roteamento, não
o texto final. A rejeição injeta &lt;code&gt;required_fix&lt;/code&gt; no próximo prompt do Builder,
invisível ao canal humano.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;O contrato é tipado (Pydantic v2 — sem LangChain, sem LangGraph, decisão&lt;br&gt;
documentada): o envelope carrega &lt;code&gt;soul_id&lt;/code&gt;, um status restrito a&lt;br&gt;
&lt;code&gt;SUCCESS | ESCALATED_TO_DM | TOOL_FAILURE&lt;/code&gt;, e uma trilha de auditoria com&lt;br&gt;
&lt;code&gt;self_correction_count&lt;/code&gt;, &lt;code&gt;rules_applied&lt;/code&gt;, &lt;code&gt;latency_ms&lt;/code&gt;, &lt;code&gt;hedge_warning&lt;/code&gt; e o&lt;br&gt;
&lt;code&gt;gate_log&lt;/code&gt; cru.&lt;/p&gt;
&lt;h2&gt;
  
  
  A regra de ouro do laço
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;O LLM pode sugerir que terminou. Só evidência externa pode declarar PRONTO.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SUCCESS&lt;/code&gt; só existe quando &lt;code&gt;gate_pass == true&lt;/code&gt; &lt;strong&gt;e&lt;/strong&gt; &lt;code&gt;judge_pass == true&lt;/code&gt;. Todo o&lt;br&gt;
resto termina em &lt;code&gt;ESCALATED_TO_DM&lt;/code&gt; (teto de tentativas) ou &lt;code&gt;TOOL_FAILURE&lt;/code&gt;. O agente&lt;br&gt;
para onde a autorização termina — o que provamos num piloto ao vivo que bateu num&lt;br&gt;
404 real e devolveu nenhum payload inventado em vez de alucinar um.&lt;/p&gt;
&lt;h2&gt;
  
  
  O que é preciso para confiar no testador
&lt;/h2&gt;

&lt;p&gt;Gates vêm em dois tipos, ambos declarados num registro só:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;subprocess&lt;/code&gt;: argv renderizado com o artefato, executado como processo do host.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;static&lt;/code&gt;: validador determinístico em processo (um regex de prompt &lt;code&gt;MEDIA:&lt;/code&gt;),
ainda fora do intérprete do LLM e ainda declarado no registro.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Parâmetros entram por &lt;code&gt;string.Template&lt;/code&gt;, então literal de shell/HTTP/JSON&lt;br&gt;
(&lt;code&gt;%{http_code}&lt;/code&gt;, &lt;code&gt;{"exact":false}&lt;/code&gt;) nunca colide com interpolação.&lt;/p&gt;

&lt;p&gt;O harness mede &lt;strong&gt;a si mesmo&lt;/strong&gt; com função pura, sem LLM no meio, em seis dimensões —&lt;br&gt;
contratos determinísticos, autoteste do orquestrador, orquestração ponta a ponta,&lt;br&gt;
política de custo, operação viva, documentação. Cada dimensão devolve um de quatro&lt;br&gt;
estados (&lt;code&gt;PASS | FAIL | NOT_VERIFIABLE | ERROR&lt;/code&gt;), e o veredito é derivado, não julgado:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;APTO          todas as dimensões PASS e total &amp;gt;= 90% do teto (fixo)
COM RESSALVAS nenhum FAIL/ERROR, mas algo é NOT_VERIFIABLE (&amp;gt;= 70%)
REPROVADO     qualquer FAIL/ERROR, ou total &amp;lt; 70%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dimensão não executada fica no denominador, e os códigos de saída são 0 / 1 / 2 —&lt;br&gt;
para o CI poder chamar. Inconclusivo nunca passa o gate: essa regra veio de uma&lt;br&gt;
máquina quebrada que, sem ela, teria entregue verde.&lt;/p&gt;

&lt;h2&gt;
  
  
  Onde está hoje
&lt;/h2&gt;

&lt;p&gt;Números honestos, todos medidos localmente:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;44 testes pytest verdes&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 pilotos ponta a ponta&lt;/strong&gt; (dois projetos sem relação, mesmo motor)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1 entrega real&lt;/strong&gt; num canal humano (&lt;code&gt;message_id=43843&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;política de custo imposta: &lt;strong&gt;o gate nunca chama modelo pago&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Não é framework. Sem montanha de dependência, sem marketing de agente-de-agentes.&lt;br&gt;
A alegação é estreita de propósito: &lt;em&gt;não deixa seu agente mais inteligente, deixa o&lt;br&gt;
"pronto" valer alguma coisa.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A versão de código aberto do motor do laço, incluindo o registro de gates e o&lt;br&gt;
harness de qualidade, está aqui:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kit de templates: agente que entrega&lt;/strong&gt; — os 12 arquivos que esse laço precisa no seu repo: contrato, gate, watchdog e entrega: &lt;a href="https://skills.seu.pet/en/p/kit-templates-agentes?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=o-agente-que-escreve-o-codigo-nao-pode-ser-o-que-o-certifica" rel="noopener noreferrer"&gt;kit-templates-agentes&lt;/a&gt; — R$ 97, instant delivery.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next in this series&lt;/strong&gt; (the one after this): &lt;a href="https://dev.to/samuelfmedeiros/make-your-ai-agent-prove-it-a-deterministic-delivery-gate-5b8i"&gt;https://dev.to/samuelfmedeiros/make-your-ai-agent-prove-it-a-deterministic-delivery-gate-5b8i&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://skills.seu.pet/gratis/ai-test-loop?utm_source=devto&amp;amp;utm_medium=post&amp;amp;utm_campaign=lancamento-roger-pt" rel="noopener noreferrer"&gt;Baixe a skill AI Test Loop grátis&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>python</category>
      <category>llm</category>
    </item>
    <item>
      <title>The Agent That Writes the Code Cannot Be the One That Certifies It</title>
      <dc:creator>SAMUELFMEDEIROS</dc:creator>
      <pubDate>Tue, 03 Nov 2026 03:14:54 +0000</pubDate>
      <link>https://dev.to/samuelfmedeiros/the-agent-that-writes-the-code-cannot-be-the-one-that-certifies-it-c76</link>
      <guid>https://dev.to/samuelfmedeiros/the-agent-that-writes-the-code-cannot-be-the-one-that-certifies-it-c76</guid>
      <description>&lt;h2&gt;
  
  
  Every agent demo ends the same way
&lt;/h2&gt;

&lt;p&gt;The model writes the file, runs the thing, and prints &lt;strong&gt;"done"&lt;/strong&gt;. Nobody checks.&lt;br&gt;
So the deliverable that looked finished at 2am turns out to be a file that does&lt;br&gt;
not exist, a test that was never run, and a summary that was confident.&lt;/p&gt;

&lt;p&gt;We hit this enough times to stop trusting the word of the agent that did the&lt;br&gt;
work. Not because the model is lazy — because &lt;strong&gt;an LLM cannot certify its own&lt;br&gt;
artifact&lt;/strong&gt;. It declares success from the inside, where it cannot see the failure.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three failures, one root cause
&lt;/h2&gt;

&lt;p&gt;Building a validation loop for our agents surfaced three distinct ways it breaks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Forged self-evaluation.&lt;/strong&gt; The model that generated the artifact is the
model evaluating it. Same context, same blind spots. It says "looks correct"
about a diff it never executed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FoFo — F&lt;/strong&gt;ound &lt;strong&gt;o&lt;/strong&gt;n &lt;strong&gt;F&lt;/strong&gt;alse. &lt;em&gt;The AI must not correct its own proof.&lt;/em&gt;
Whoever writes the code cannot write the test that validates it, and whoever
judges cannot share context with whoever built.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cheatable verification.&lt;/strong&gt; If the verifier lives where the agent can reach,
the agent forges the verifier. A fake &lt;code&gt;pytest.py&lt;/code&gt; that returns 0 is not a
hypothetical — it is the obvious move.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first two are about &lt;em&gt;authority&lt;/em&gt;: only external evidence can declare DONE.&lt;br&gt;
The third is about &lt;em&gt;reach&lt;/em&gt;: the evidence has to be produced somewhere the&lt;br&gt;
Builder cannot touch.&lt;/p&gt;
&lt;h2&gt;
  
  
  The held-out gate
&lt;/h2&gt;

&lt;p&gt;So the architecture splits the loop and puts the proof outside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Task ──▶ [SOUL / Builder] ──▶ [Held-out Gate] ──▶ [Judge / Reviewer] ──▶ Envelope
              ▲                    │ FAIL              │ REJECT
              └──── required_fix ──┘                   │ (max_attempts → ESCALATED)
                     (re-gate, strict)                 ▼ APPROVE → SUCCESS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Builder / SOUL.&lt;/strong&gt; Drafts from persona + procedural memory + task. It may
reference a secret &lt;em&gt;by name&lt;/em&gt; (&lt;code&gt;$VAULT_X&lt;/code&gt;), never by value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Held-out Gate.&lt;/strong&gt; The unreachable environment. Runs the real tool — HTTP
over &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;nc&lt;/code&gt;, &lt;code&gt;ffprobe&lt;/code&gt;, an &lt;code&gt;EXPLAIN&lt;/code&gt; dry-run, HMAC-SHA256, a declared
regex — and returns &lt;code&gt;(passed: bool, log: str)&lt;/code&gt;. &lt;strong&gt;Evidence is re-run on every
attempt.&lt;/strong&gt; A green gate from round 1 is never reused in round 2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Judge / Reviewer.&lt;/strong&gt; A session &lt;em&gt;without&lt;/em&gt; the Builder's history, confronting
draft + gate log against the injected rules. It emits a routing verdict, not
the final text. Rejection injects &lt;code&gt;required_fix&lt;/code&gt; into the next Builder prompt,
invisible to the human channel.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The contract is typed (Pydantic v2 — no LangChain, no LangGraph, decision&lt;br&gt;
documented): the delivery envelope carries &lt;code&gt;soul_id&lt;/code&gt;, a status restricted to&lt;br&gt;
&lt;code&gt;SUCCESS | ESCALATED_TO_DM | TOOL_FAILURE&lt;/code&gt;, and an audit trail with&lt;br&gt;
&lt;code&gt;self_correction_count&lt;/code&gt;, &lt;code&gt;rules_applied&lt;/code&gt;, &lt;code&gt;latency_ms&lt;/code&gt;, &lt;code&gt;hedge_warning&lt;/code&gt; and the&lt;br&gt;
raw &lt;code&gt;gate_log&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The golden rule of the loop
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The LLM may suggest it finished. Only external evidence may declare DONE.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SUCCESS&lt;/code&gt; exists only when &lt;code&gt;gate_pass == true&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;judge_pass == true&lt;/code&gt;.&lt;br&gt;
Everything else terminates as &lt;code&gt;ESCALATED_TO_DM&lt;/code&gt; (attempt ceiling) or&lt;br&gt;
&lt;code&gt;TOOL_FAILURE&lt;/code&gt;. The agent stops where the authorization ends — which we proved&lt;br&gt;
on a live pilot that hit a real 404 and returned no invented payload instead of&lt;br&gt;
hallucinating one.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it takes to trust the tester
&lt;/h2&gt;

&lt;p&gt;Gates come in two kinds, both declared in one registry:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;subprocess&lt;/code&gt;: argv rendered with the artifact, executed as a host subprocess.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;static&lt;/code&gt;: an in-process deterministic validator (a &lt;code&gt;MEDIA:&lt;/code&gt; prompt regex),
still outside the LLM interpreter and still declared in the registry.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Parameters are injected through &lt;code&gt;string.Template&lt;/code&gt;, so shell/HTTP/JSON literals&lt;br&gt;
(&lt;code&gt;%{http_code}&lt;/code&gt;, &lt;code&gt;{"exact":false}&lt;/code&gt;) never collide with interpolation.&lt;/p&gt;

&lt;p&gt;The harness measures &lt;em&gt;itself&lt;/em&gt; with a pure function, no LLM in the middle, across&lt;br&gt;
six dimensions — deterministic contracts, orchestrator self-test, end-to-end&lt;br&gt;
orchestration, cost policy, live operation, documentation. Each dimension&lt;br&gt;
returns one of four states (&lt;code&gt;PASS | FAIL | NOT_VERIFIABLE | ERROR&lt;/code&gt;), and the&lt;br&gt;
verdict is derived, not judged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;APTO          all dimensions PASS and total &amp;gt;= 90% of the (fixed) ceiling
COM RESSALVAS no FAIL/ERROR, but something is NOT_VERIFIABLE (&amp;gt;= 70%)
REPROVADO     any FAIL/ERROR, or total &amp;lt; 70%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unexecuted dimensions stay in the denominator, and exit codes are 0 / 1 / 2 —&lt;br&gt;
so CI can call it. Inconclusive never passes the gate: that rule came from a&lt;br&gt;
broken machine that would otherwise have shipped green.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;Honest numbers, all from local measurement:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;44 pytest tests green&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 end-to-end pilots&lt;/strong&gt; (two unrelated projects, same engine)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1 real delivery&lt;/strong&gt; to a human channel (&lt;code&gt;message_id=43843&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;cost policy enforced: &lt;strong&gt;the gate never calls a paid model&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not a framework. No dependency mountain, no agent-of-agents marketing. The&lt;br&gt;
whole claim is narrow on purpose: &lt;em&gt;it does not make your agent smarter, it&lt;br&gt;
makes "done" mean something.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The open-source version of the loop engine, including the gate registry and the&lt;br&gt;
quality harness, is here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agent templates kit&lt;/strong&gt; — the 12 files this loop needs in your repo: contract, gate, watchdog and delivery: &lt;a href="https://skills.seu.pet/en/p/kit-templates-agentes?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=the-agent-that-writes-the-code-cannot-be-the-one-that-certif" rel="noopener noreferrer"&gt;kit-templates-agentes&lt;/a&gt; — R$ 97, instant delivery.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next in this series&lt;/strong&gt; (the one after this): &lt;a href="https://dev.to/samuelfmedeiros/o-agente-que-escreve-o-codigo-nao-pode-ser-o-que-o-certifica-2f65"&gt;https://dev.to/samuelfmedeiros/o-agente-que-escreve-o-codigo-nao-pode-ser-o-que-o-certifica-2f65&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://skills.seu.pet/gratis/ai-test-loop?utm_source=devto&amp;amp;utm_medium=post&amp;amp;utm_campaign=lancamento-roger" rel="noopener noreferrer"&gt;Download the free AI Test Loop skill&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>python</category>
      <category>testing</category>
    </item>
    <item>
      <title>Make your AI agent prove it: a deterministic delivery gate</title>
      <dc:creator>SAMUELFMEDEIROS</dc:creator>
      <pubDate>Mon, 26 Oct 2026 11:20:11 +0000</pubDate>
      <link>https://dev.to/samuelfmedeiros/make-your-ai-agent-prove-it-a-deterministic-delivery-gate-5b8i</link>
      <guid>https://dev.to/samuelfmedeiros/make-your-ai-agent-prove-it-a-deterministic-delivery-gate-5b8i</guid>
      <description>&lt;h1&gt;
  
  
  Make your AI agent prove it: a deterministic delivery gate
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; ai, testing, agents, devtools&lt;br&gt;
&lt;strong&gt;Canonical URL:&lt;/strong&gt; &lt;a href="https://skills.seu.pet/en/blog/make-your-agent-prove-it?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=make-your-agent-prove-it" rel="noopener noreferrer"&gt;https://skills.seu.pet/en/blog/make-your-agent-prove-it?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=make-your-agent-prove-it&lt;/a&gt;&lt;/p&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;npx ai-test-loop --config loop.json&lt;/code&gt;&lt;/strong&gt; — one command, five seconds, nothing installed. Runs a deterministic critic over your artifact and returns a score, a gap list and the raw output of every check.&lt;/p&gt;

&lt;p&gt;Prefer Python? &lt;code&gt;pip install ai-test-loop&lt;/code&gt; gives you the same gate. &lt;a href="https://skills.seu.pet/en/gratis/ai-test-loop?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=make-your-agent-prove-it" rel="noopener noreferrer"&gt;Free version&lt;/a&gt; · &lt;a href="https://skills.seu.pet/en/p/pack-completo?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=make-your-agent-prove-it" rel="noopener noreferrer"&gt;Full pack&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  The agent said it was done. The gate disagreed.
&lt;/h2&gt;

&lt;p&gt;A real run from this week: the builder reported &lt;code&gt;done&lt;/code&gt;, the critic scored &lt;strong&gt;55&lt;/strong&gt; and returned a gap list with names and weights. Two checks were failing, one of them a secret scan that found a token in the diff. No opinion was involved — the report attached the raw output of every check. This is what a deterministic delivery gate looks like when you stop asking the model whether the work is good and start measuring.&lt;/p&gt;

&lt;p&gt;This skill is now published as a real package. &lt;code&gt;npx ai-test-loop&lt;/code&gt; runs in five seconds with nothing installed; &lt;code&gt;pip install ai-test-loop&lt;/code&gt; gives you the same gate in Python. The repository is open (MIT), and the free version is a real download — not a teaser.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the same context lies
&lt;/h2&gt;

&lt;p&gt;The classic failure: one context writes the code, runs the tests, reads the result and decides whether it is done. The model that produced the bug is the model that judges the fix. The gate breaks that loop by moving the judgement into a separate process that executes real commands and returns numbers, not prose.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Builder&lt;/strong&gt; — writes the change. No veto power over the verdict.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Critic&lt;/strong&gt; — runs every declared check against the real artifact. Scores it. Never asks a model if it is good.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reviewer&lt;/strong&gt; — independent context, fresh, no justification from the builder. Returns strict JSON.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security gate&lt;/strong&gt; — fail-closed. A secret, a high-severity finding or an injection issue blocks delivery even with a perfect score. Inconclusive counts as blocked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rule zero: builder ≠ critic ≠ reviewer. If one context does all three, the loop is theatre.&lt;/p&gt;
&lt;h2&gt;
  
  
  The gate, step by step
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Declare the checks before writing code&lt;/strong&gt; — for each check: name, command, weight and what "pass" means. Weights sum to 100.
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;   &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"checks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"pytest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"cmd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"set -o pipefail; pytest -q"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"weight"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"no_secret_scan"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"cmd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"gitleaks detect --no-banner"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"weight"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run the critic&lt;/strong&gt; — it executes every command for real, captures the evidence and never asks a model whether the work is good.
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   python scripts/loop_runner.py --config loop.json --round 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Score, compare, decide&lt;/strong&gt; — above the threshold goes to the reviewer; below, take the largest gap, fix it and run again. Cap the rounds (8 is plenty): if it never converges, the check set is wrong, not the code.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Independent review before delivery&lt;/strong&gt; — same artifact, fresh context, no justification from the builder. A panel of 2-3 specialists (quality and security always) returning strict JSON.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Security gate, fail-closed&lt;/strong&gt; — a secret in the diff or history, a high-severity finding, an auth/path-traversal/injection issue block delivery even with perfect tests. An inconclusive answer counts as blocked, never as pass.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Report with score, evidence and what was not verifiable&lt;/strong&gt; — the report states what was measured, with raw output, and what could not be checked — no "probably fine".&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Four ways a green loop lies
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Piping hides the exit code&lt;/strong&gt; — &lt;code&gt;pytest | tail&lt;/code&gt; returns the exit code of &lt;code&gt;tail&lt;/code&gt; (0), so the critic scores 100 with a failing test hidden in the output. Use &lt;code&gt;set -o pipefail&lt;/code&gt; inside every check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A timeout shorter than the check is a silent zero&lt;/strong&gt; — the critic is killed by its own timeout, the orchestrator reads score 0 with an empty gap list, and loops with nothing to fix. Set the timeout above the real duration and always keep the critic's raw log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A short-circuiting "pass"&lt;/strong&gt; — a server answering 200 may be the wrong server: on a port collision the check tests a different app and fails for the wrong reason. Assert identity (title, version, a known payload), not just reachability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A stale baseline regenerated to go green&lt;/strong&gt; — regenerating a visual baseline "to pass" erases the regression the test exists to catch. Baselines change by explicit decision, justified in the history — never to silence a check.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Install it now
&lt;/h2&gt;

&lt;p&gt;The package ships a dependency-free runner (Node or Python), the scoring rubric, the reviewer prompt and the security checks. Two commands, two ecosystems, one name, same gate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx ai-test-loop --config loop.json
&lt;span class="c"&gt;# or, if you prefer Python&lt;/span&gt;
pip install ai-test-loop &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ai-test-loop --config loop.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Next in this series&lt;/strong&gt; (the one after this): &lt;a href="https://dev.to/samuelfmedeiros/the-cron-runs-says-completed-and-the-message-never-arrives-4pam"&gt;https://dev.to/samuelfmedeiros/the-cron-runs-says-completed-and-the-message-never-arrives-4pam&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;See the skill: &lt;a href="https://skills.seu.pet/en/p/ai-test-loop?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=make-your-agent-prove-it" rel="noopener noreferrer"&gt;https://skills.seu.pet/en/p/ai-test-loop?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=make-your-agent-prove-it&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Free version: &lt;a href="https://skills.seu.pet/en/gratis/ai-test-loop?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=make-your-agent-prove-it" rel="noopener noreferrer"&gt;https://skills.seu.pet/en/gratis/ai-test-loop?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=make-your-agent-prove-it&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Guided setup (R$ 1.500): &lt;a href="https://skills.seu.pet/en/service?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=make-your-agent-prove-it" rel="noopener noreferrer"&gt;https://skills.seu.pet/en/service?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=make-your-agent-prove-it&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Repository (MIT): &lt;a href="https://github.com/Samuelfmedeiros/ai-test-loop?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=make-your-agent-prove-it" rel="noopener noreferrer"&gt;https://github.com/Samuelfmedeiros/ai-test-loop?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=make-your-agent-prove-it&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>agents</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Yurumi: the dry run that wrote and the error that hid its own name</title>
      <dc:creator>SAMUELFMEDEIROS</dc:creator>
      <pubDate>Fri, 23 Oct 2026 00:25:59 +0000</pubDate>
      <link>https://dev.to/samuelfmedeiros/yurumi-the-dry-run-that-wrote-and-the-error-that-hid-its-own-name-4h7m</link>
      <guid>https://dev.to/samuelfmedeiros/yurumi-the-dry-run-that-wrote-and-the-error-that-hid-its-own-name-4h7m</guid>
      <description>&lt;h2&gt;
  
  
  The tool that always said "ok"
&lt;/h2&gt;

&lt;p&gt;Every fifteen minutes, a small script read the system's scheduled task list and wrote one point per task into Yurumi's vector memory. That way, when I asked the agent "did the 3am backup run?", it did not have to guess — the snapshot was indexed.&lt;/p&gt;

&lt;p&gt;The output was always one green line. &lt;code&gt;crons ok&lt;/code&gt;, nothing to re-embed, known total. Green for weeks. And it was a lie on three fronts at once.&lt;/p&gt;

&lt;p&gt;The first was invisible by construction: it read &lt;strong&gt;one&lt;/strong&gt; jobs file, the root one. Except most of my tasks live in per-profile stores, separated precisely so scopes never mix. Hundreds of jobs did not exist in the index — and an empty result in a memory system is indistinguishable from "this never existed".&lt;/p&gt;

&lt;p&gt;The second was worse: the script had &lt;code&gt;--dry-run&lt;/code&gt;, documented as "lists what would change only (writes nothing)". And it wrote. &lt;code&gt;set_payload&lt;/code&gt; was called with no guard condition at all, so a command I used to inspect things safely was mutating the index.&lt;/p&gt;

&lt;p&gt;The third is the one that kept me from seeing the other two.&lt;/p&gt;

&lt;h2&gt;
  
  
  The error that lied about its own name
&lt;/h2&gt;

&lt;p&gt;When it failed, the report looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(yurumi-cron-sync indisponível: ModuleNotFoundError: )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the void after the colon. The message was assembled from &lt;code&gt;type(e).__name__&lt;/code&gt; and nothing else — the class name, without the exception text. For months I read "ModuleNotFoundError" and went hunting for a broken import.&lt;/p&gt;

&lt;p&gt;The real cause was different: an absolute path from another operating system hardcoded in the file, inherited from before the machine migration. The first file it tried to open did not exist, and the &lt;code&gt;FileNotFoundError&lt;/code&gt; was caught — but what reached the screen was the name of a later exception. A mislabelled alarm is worse than a silent one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that surfaced once the dry run was honest
&lt;/h2&gt;

&lt;p&gt;The fix came in three layers.&lt;/p&gt;

&lt;p&gt;First the path: the base directory now comes from an environment variable with a home fallback, and the repository root is derived from &lt;code&gt;__file__&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then the read: &lt;code&gt;load_jobs()&lt;/code&gt; now opens &lt;strong&gt;every&lt;/strong&gt; store, deduplicating by id — the same job can appear in more than one file and must not become two index points. That is where a trap I had not predicted showed up: orphan pruning. Computed against a single store, it would delete from the index crons that are alive in another profile. Pruning is not about "what is not in the file", it is about "what is in no file at all". It is the kind of bug that throws nothing — it just removes data.&lt;/p&gt;

&lt;p&gt;With the full read and the fixed pruning, the index told the truth: &lt;strong&gt;762 cron points&lt;/strong&gt;, of which &lt;strong&gt;440 pointed at jobs that no longer existed anywhere&lt;/strong&gt;. After pruning: 326, with four new jobs in the same run. Measured just now, in a verification run: 326 cron points inside a collection of 13,675.&lt;/p&gt;

&lt;p&gt;Months of deleted jobs, with the index keeping a ghost of every one. In a hybrid search, an orphan point competes with a live one: it steals rank from a legitimate result.&lt;/p&gt;

&lt;p&gt;Third, &lt;code&gt;--dry-run&lt;/code&gt; got the guard it was missing, and the error message started carrying &lt;code&gt;str(e)[:160]&lt;/code&gt; — enough to read "file X does not exist", not enough to dump a stack trace into the log.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 scripts/yurumi_cron_sync.py --dry-run
python3 scripts/yurumi_cron_sync.py --prune
python3 -m pytest tests/test_portability.py -q
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The green that belonged to another commit
&lt;/h2&gt;

&lt;p&gt;Here is the part that bothered me most, because it is not about code.&lt;/p&gt;

&lt;p&gt;I finished the fix, documented both pitfalls in the repository's &lt;code&gt;AGENTS.md&lt;/code&gt;, and pushed. The CI run for commit &lt;code&gt;c6afd00&lt;/code&gt; was created about seven minutes late and then &lt;strong&gt;cancelled by concurrency&lt;/strong&gt; — another run on the same branch took the slot. No test ran on that commit, and no failure appeared.&lt;/p&gt;

&lt;p&gt;The green I saw afterwards came from an empty commit, &lt;code&gt;a47468d&lt;/code&gt;, with the same file tree, created only to re-trigger the suite. That one ran end to end: Ruff, pytest on 3.12, a clean install, and the secret scanner. The code was the same — but the commit carrying the fix was never tested, and I only noticed because I read the log instead of looking at the icon.&lt;/p&gt;

&lt;p&gt;The same day offered another cheap example: a watchdog for a bridge that no longer exists since the migration was producing &lt;strong&gt;49 alerts in six hours&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes
&lt;/h2&gt;

&lt;p&gt;All three defects shared one signature: &lt;strong&gt;a tool reporting success without having measured anything&lt;/strong&gt;. The code said ok because the exception was caught, because an empty store is not an error, because writing in simulation mode leaves no different trace.&lt;/p&gt;

&lt;p&gt;The balance: "does this cron exist?" now has a truthful answer, the index shrank 57% without losing a single live job, and the mode that promises not to write finally does not. A test that rejects absolute paths from another system also landed (&lt;code&gt;7 passed&lt;/code&gt;), along with the rule that a green commit is the one that ran, not its neighbour.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next step
&lt;/h2&gt;

&lt;p&gt;Validate the job name during pruning, not just the id: a stable id prevents duplicates, but a renamed job stays in the index under the old name, and today that passes as a normal update.&lt;/p&gt;




&lt;p&gt;Se você mantém guardas e watchdogs próprios, o &lt;strong&gt;Roger MLOps Guard&lt;/strong&gt; traz a&lt;br&gt;
checklist de como provar que o guarda dispara de verdade, com o teste que falha&lt;br&gt;
quando ele não dispara.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next in this series&lt;/strong&gt; (the one after this): &lt;a href="https://dev.to/samuelfmedeiros/twenty-checkout-sessions-zero-sales-and-every-one-of-them-was-mine-4pdc"&gt;https://dev.to/samuelfmedeiros/twenty-checkout-sessions-zero-sales-and-every-one-of-them-was-mine-4pdc&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://skills.seu.pet/en/p/roger-mlops-guard?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=yurumi-the-dry-run-that-wrote-and-the" rel="noopener noreferrer"&gt;See the Roger MLOps Guard skill&lt;/a&gt;&lt;br&gt;
&lt;a href="https://skills.seu.pet/en?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=yurumi-the-dry-run-that-wrote-and-the" rel="noopener noreferrer"&gt;See all skills&lt;/a&gt;&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>cron</category>
      <category>qdrant</category>
      <category>memory</category>
    </item>
    <item>
      <title>The 302 that pretended to be a 200</title>
      <dc:creator>SAMUELFMEDEIROS</dc:creator>
      <pubDate>Thu, 22 Oct 2026 23:32:21 +0000</pubDate>
      <link>https://dev.to/samuelfmedeiros/the-302-that-pretended-to-be-a-200-3e6</link>
      <guid>https://dev.to/samuelfmedeiros/the-302-that-pretended-to-be-a-200-3e6</guid>
      <description>&lt;h2&gt;
  
  
  The error that was not in the form
&lt;/h2&gt;

&lt;p&gt;In production, every contact form submission came back with the default error message. The browser console was no help: the &lt;code&gt;fetch&lt;/code&gt; died with a generic type, no body and no useful status. The reflex is to open the form code — and there was nothing wrong there. The backend answered. The database answered. The messages table existed.&lt;/p&gt;

&lt;p&gt;It took me longer than I would like to accept the uncomfortable part: the problem was in neither end I could read in the editor. It was in the middle of the path — the site's own proxy and the access layer in front of the domain.&lt;/p&gt;

&lt;p&gt;I split the path into three layers and tested one at a time: the form in the browser, the site's same-origin proxy, and the panel API that stores the messages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two traps stacked on one path
&lt;/h2&gt;

&lt;p&gt;The first one had shown up days earlier and was documented in a code comment. Calling the panel API straight from the browser, from another origin, returned an empty body and no origin-allowed header. The &lt;code&gt;fetch&lt;/code&gt; translated that into "network failure", the most dishonest message on the list — network failure suggests a cable, not an origin policy.&lt;/p&gt;

&lt;p&gt;The fix was a same-origin proxy: a route inside Next itself that takes the browser request and forwards it server-side, with a closed list of allowed prefixes and a segment limit. Without a closed list, a proxy like that becomes an open door into the other service.&lt;/p&gt;

&lt;p&gt;With the proxy live, sending the same POST through the site, the error stayed. And that is when the origin moved: an access configuration created in the middle of the night, meant to protect the admin panel, was covering the entire domain. Every request — including the public prefix the proxy needs to reach — got a redirect to the login screen. The browser followed it, was blocked by the origin policy, and the &lt;code&gt;fetch&lt;/code&gt; died. The backend never saw the request.&lt;/p&gt;

&lt;p&gt;Protecting the whole domain is the comfortable default of every access service, and it is exactly what breaks every endpoint the site has to call on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the diagnosis took so long
&lt;/h2&gt;

&lt;p&gt;One tooling detail cost more time than the bug: Python's HTTP library follows redirects by default. The probe I had been using said "200, healthy" — because it was reading the login page, not the API. A 200 on a login screen is a legitimate 200 and a completely wrong answer.&lt;/p&gt;

&lt;p&gt;The right way to probe is to look at the real status code without following the redirect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -s -o /dev/null -w &lt;span class="s2"&gt;"%{http_code} %{redirect_url}\n"&lt;/span&gt; \
  -X POST &lt;span class="s2"&gt;"https://&amp;lt;panel&amp;gt;/api/&amp;lt;public-prefix&amp;gt;"&lt;/span&gt; \
  -H &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; -d &lt;span class="s1"&gt;'{}'&lt;/span&gt;
&lt;span class="c"&gt;# 302 https://&amp;lt;account&amp;gt;.cloudflareaccess.com/cdn-cgi/access/login/...   &amp;lt;- access in front&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the check that proves the server is up without writing anything to the database, because payload validation runs before the handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -s -o /dev/null -w &lt;span class="s2"&gt;"%{http_code}\n"&lt;/span&gt; -X POST &lt;span class="s2"&gt;"https://&amp;lt;panel&amp;gt;/api/&amp;lt;public-prefix&amp;gt;"&lt;/span&gt; \
  -H &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; -d &lt;span class="s1"&gt;'{}'&lt;/span&gt;
&lt;span class="c"&gt;# 422 = reached the validation handler (open, server alive)&lt;/span&gt;
&lt;span class="c"&gt;# 302 = login in front (closed too tight)&lt;/span&gt;
&lt;span class="c"&gt;# 500 = server actually broken&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three codes, three meanings that cannot be confused — before that I only had the binary "worked / did not work".&lt;/p&gt;

&lt;h2&gt;
  
  
  The cure was infrastructure, not code
&lt;/h2&gt;

&lt;p&gt;Not a single line of the site changed. The fix was creating an access app per path: coverage on the public prefix with a bypass policy, while the admin prefix stayed behind the login. The previous app, which covered everything, became the example of what not to do on a domain serving both the API and the panel.&lt;/p&gt;

&lt;p&gt;Then four proofs in a row: a direct POST to the backend returned 201; a POST through the site proxy returned 201; an end-to-end test in a real production browser showed the success message with zero page errors; and a new row appeared in the messages table. It is no good for the API to answer if the path the user actually walks is still broken — which is why the final proof was in the browser, not in &lt;code&gt;curl&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I measured it again just now: the public prefix returns 422 (open, validation working) and the admin prefix returns 302 to the login screen (closed, as it should be).&lt;/p&gt;

&lt;h2&gt;
  
  
  What stays
&lt;/h2&gt;

&lt;p&gt;Three lessons, all about measuring — none about typing code:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Access is per path, not per domain. A domain serving site, API and panel needs per-route policy, otherwise the login eats the public endpoint and the symptom shows up far away, inside CORS.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;200&lt;/code&gt; without checking the final URL and the body proves nothing. A probe that follows redirects answers a different question than the one you asked.&lt;/li&gt;
&lt;li&gt;A CORS error is a symptom, almost never the cause. The &lt;code&gt;fetch&lt;/code&gt; only knows how to say "it failed"; the reason was three layers up, in a configuration that never passes through the editor.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The probe stopped being a command I remembered to run and became a periodic routine: it hits the public prefix expecting the validation response and confirms the admin side is still closed. Silent when everything is in place — an alert that screams all the time goes unread.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next step
&lt;/h2&gt;

&lt;p&gt;Run the same check on the other side: sweep the other subdomains that gained an access app in the last few weeks and find out which public endpoints are sitting behind a login nobody sees. The form was the first place where the failure hurt, and it is unlikely to be the only one.&lt;/p&gt;




&lt;p&gt;O mesmo raciocínio de "200 não é resultado" está detalhado, com o roteiro de&lt;br&gt;
verificação, aqui:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next in this series&lt;/strong&gt; (the one after this): &lt;a href="https://dev.to/samuelfmedeiros/the-129-elements-that-only-existed-once-javascript-loaded-k23"&gt;https://dev.to/samuelfmedeiros/the-129-elements-that-only-existed-once-javascript-loaded-k23&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Agent That Does Not Lie&lt;/strong&gt; — R$ 37, 14 chapters on exactly this: the check that reports 200 while the thing behind it is down:&lt;br&gt;
&lt;a href="https://skills.seu.pet/en/p/o-agente-que-nao-mente?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=the-302-that-pretended-to-be-a-200" rel="noopener noreferrer"&gt;get the ebook&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://skills.seu.pet/en/blog/the-200-that-is-not-a-result?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=the-302-that-pretended-to-be-a-200" rel="noopener noreferrer"&gt;The 200 that is not a result&lt;/a&gt;&lt;br&gt;
&lt;a href="https://skills.seu.pet/en?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=the-302-that-pretended-to-be-a-200" rel="noopener noreferrer"&gt;See all skills&lt;/a&gt;&lt;/p&gt;

</description>
      <category>http</category>
      <category>debugging</category>
      <category>cloudflare</category>
      <category>security</category>
    </item>
    <item>
      <title>The 129 elements that only existed once JavaScript loaded</title>
      <dc:creator>SAMUELFMEDEIROS</dc:creator>
      <pubDate>Thu, 22 Oct 2026 17:27:40 +0000</pubDate>
      <link>https://dev.to/samuelfmedeiros/the-129-elements-that-only-existed-once-javascript-loaded-k23</link>
      <guid>https://dev.to/samuelfmedeiros/the-129-elements-that-only-existed-once-javascript-loaded-k23</guid>
      <description>&lt;h2&gt;
  
  
  The page that arrived empty
&lt;/h2&gt;

&lt;p&gt;Opening the portfolio with JavaScript blocked should show an ugly but readable site. It showed a frame. The header was in the HTML, the text was in the HTML, and nothing rendered — because almost everything arrived with &lt;code&gt;opacity: 0&lt;/code&gt; baked into the &lt;code&gt;style&lt;/code&gt; attribute.&lt;/p&gt;

&lt;p&gt;I measured production before touching a single line: 129 tags with inline &lt;code&gt;opacity:0&lt;/code&gt; in the HTML served by &lt;code&gt;portifolio.seu.pet&lt;/code&gt;. Sixty of those are absolute layers and decorative grids, which is expected. The rest include real content — section headings, project cards, the hero block itself. The browser received the whole page and the visitor saw the background.&lt;/p&gt;

&lt;p&gt;The culprit was not badly written CSS. It was a component pattern that looked harmless and appeared in 52 places across the repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern that kept repeating
&lt;/h2&gt;

&lt;p&gt;The portfolio uses framer-motion for entrance animations. The canonical way to write one is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;initial&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="o"&gt;{&lt;/span&gt; opacity: 0, y: 20 &lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;animate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="o"&gt;{&lt;/span&gt; opacity: 1, y: 0 &lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The component starts transparent and animates into view. On the client that works beautifully. The problem is what the server does with that &lt;code&gt;initial&lt;/code&gt;: it serialises &lt;code&gt;opacity:0&lt;/code&gt; into the HTML. On the first paint the content is there, invisible, waiting for JavaScript to arrive and animate it. If the JS fails, lags, or simply never runs, the content never shows up.&lt;/p&gt;

&lt;p&gt;Inside &lt;code&gt;ProfileSection.tsx&lt;/code&gt; the variant was worse. Two hero layers used &lt;code&gt;animate={{ opacity: 0, ... }}&lt;/code&gt; gated by &lt;code&gt;mounted ? 1 : 0&lt;/code&gt;, where &lt;code&gt;mounted&lt;/code&gt; comes from state that only flips to &lt;code&gt;true&lt;/code&gt; inside a &lt;code&gt;useEffect&lt;/code&gt;. On the server &lt;code&gt;mounted&lt;/code&gt; is always &lt;code&gt;false&lt;/code&gt;. So the HTML shipped with &lt;code&gt;opacity:0&lt;/code&gt; fixed and the grid background layer (line 676) and the cockpit SVG overlay (line 694) simply did not exist in the served page. Without hydration, they never would.&lt;/p&gt;

&lt;p&gt;This was not a new bug. It was the third recurrence of the same family:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;08/15&lt;/strong&gt; — commit &lt;code&gt;7322c55&lt;/code&gt;, PR #50: the "About" button and four other above-the-fold elements were invisible until hydration, from the same serialised &lt;code&gt;initial&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;08/12&lt;/strong&gt; — commit &lt;code&gt;4e279bc&lt;/code&gt;: &lt;code&gt;CookieBanner&lt;/code&gt; read &lt;code&gt;localStorage&lt;/code&gt; in the &lt;code&gt;useState&lt;/code&gt; initialiser, rendered differently on server and client, and threw a React #418 hydration mismatch on every fresh visit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10/19&lt;/strong&gt; — commit &lt;code&gt;8fee520&lt;/code&gt;, PR #142: the two hero layers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three episodes, three point fixes. The pattern stayed alive in 52 spots.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cure: born visible, animated later
&lt;/h2&gt;

&lt;p&gt;The root fix was not swapping &lt;code&gt;opacity: 0&lt;/code&gt; for &lt;code&gt;opacity: 1&lt;/code&gt; — that would kill the animation. It was inverting the gate: the HTML is born &lt;strong&gt;visible&lt;/strong&gt;, and animation is only allowed on the client, after mount.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;const mounted &lt;span class="o"&gt;=&lt;/span&gt; useMounted&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;motion.div &lt;span class="nv"&gt;initial&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="o"&gt;{&lt;/span&gt;mounted ? &lt;span class="o"&gt;{&lt;/span&gt; opacity: 0, y: 20 &lt;span class="o"&gt;}&lt;/span&gt; : &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; ... /&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;initial: false&lt;/code&gt; disables the animation's starting state on the server and on the client's first render. The element is served opaque. Once the hook's &lt;code&gt;useEffect&lt;/code&gt; marks &lt;code&gt;mounted = true&lt;/code&gt;, the component re-renders with an animatable initial state — but &lt;code&gt;whileInView&lt;/code&gt; starts from an element that was already on screen. The visitor sees exactly the same animation; the served HTML, however, defends itself.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;useMounted&lt;/code&gt; runs 26 lines, comments included. Small abstraction, and it exists so the next component does not repeat the mistake.&lt;/p&gt;

&lt;p&gt;The gate landed in 8 files: &lt;code&gt;GlassCard&lt;/code&gt; (27 cards), &lt;code&gt;ProjectHangar&lt;/code&gt; (cards and the section &lt;code&gt;h2&lt;/code&gt;), &lt;code&gt;BlogSection&lt;/code&gt; (article and title), &lt;code&gt;SkillsCompact&lt;/code&gt;, &lt;code&gt;TimelineItem&lt;/code&gt;, the theme and language toggles, the journey buttons and &lt;code&gt;ProfileSection&lt;/code&gt;. Total: 67 lines added, 22 removed.&lt;/p&gt;

&lt;p&gt;Validation happened on the HTML, not in the browser: serving the page without JavaScript, content elements with &lt;code&gt;opacity&lt;/code&gt; below 1 dropped from roughly 50 to zero, with &lt;code&gt;435/435&lt;/code&gt; tests green and a clean build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The side effect the gate created
&lt;/h2&gt;

&lt;p&gt;Changing the render cycle has consequences. &lt;code&gt;GameShowcase&lt;/code&gt; registered a &lt;code&gt;click&lt;/code&gt; listener on every game button inside a &lt;code&gt;useEffect&lt;/code&gt; that ran once. With the gate applied to &lt;code&gt;GlassCard&lt;/code&gt;, the card started re-rendering on mount — and the button node was replaced. The old listeners stayed orphaned on the old node, outside the DOM. The games stopped responding to clicks.&lt;/p&gt;

&lt;p&gt;The diagnosis came from node identity: comparing the element reference before and after mount showed two different objects. The cure was to stop binding listeners to nodes that can be swapped — the &lt;code&gt;click&lt;/code&gt; moved to delegation on the container, which survives re-renders:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;const btn &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;e.target as HTMLElement&lt;span class="o"&gt;)&lt;/span&gt;.closest&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"[data-game-btn]"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;!btn&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fewer listeners, more robust. Container delegation is the right answer whenever a child can be remounted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters
&lt;/h2&gt;

&lt;p&gt;The practical difference is between a page that needs JavaScript to exist and one that needs it to look good. Search crawlers, link previews, feed readers, browsers with script blocking and connections where the bundle takes its time all read the same served HTML. Before this, nearly half the page was a placeholder.&lt;/p&gt;

&lt;p&gt;Worth recording the real state: this work is not finished. The hero layer fix shipped through PR #142 on 10/19 at 02:03. The gate across the whole component tree lives in PR #146, still open, with &lt;code&gt;435/435&lt;/code&gt; tests green. Until it lands, the production repository still carries 52 raw &lt;code&gt;initial={{ opacity: 0&lt;/code&gt; occurrences.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next step
&lt;/h2&gt;

&lt;p&gt;Merge PR #146 and re-measure the HTML in production. The target is objective: the count needs to drop close to the 60 decorative layers, which are invisible on purpose. If it does not drop, the gate leaked in some component — and that means the family has a fourth episode waiting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next in this series&lt;/strong&gt; (the one after this): &lt;a href="https://dev.to/samuelfmedeiros/yurumi-the-dry-run-that-wrote-and-the-error-that-hid-its-own-name-4h7m"&gt;https://dev.to/samuelfmedeiros/yurumi-the-dry-run-that-wrote-and-the-error-that-hid-its-own-name-4h7m&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log --oneline --grep=&lt;span class="s2"&gt;"SSR"&lt;/span&gt; -- src/components/ProfileSection.tsx
grep -rc &lt;span class="s2"&gt;"initial={{ opacity: 0"&lt;/span&gt; src/components/*.tsx &lt;span class="o"&gt;|&lt;/span&gt; grep -v &lt;span class="s1"&gt;':0'&lt;/span&gt;
curl -s https://portifolio.seu.pet &lt;span class="o"&gt;|&lt;/span&gt; grep -o &lt;span class="s1"&gt;'opacity:0'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; wc -l
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;Se o problema é o teste visual que passa num dia e falha no outro, o&lt;br&gt;
&lt;strong&gt;Visual Regression Gates&lt;/strong&gt; cobre o que fixar (dado, não pixel) e como barrar o&lt;br&gt;
diff que não é regressão.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://skills.seu.pet/en/p/visual-regression-gates?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=the-129-elements-that-only-existed-once" rel="noopener noreferrer"&gt;See the Visual Regression Gates skill&lt;/a&gt;&lt;br&gt;
&lt;a href="https://skills.seu.pet/en?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=the-129-elements-that-only-existed-once" rel="noopener noreferrer"&gt;See all skills&lt;/a&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>ssr</category>
      <category>react</category>
      <category>webperf</category>
    </item>
    <item>
      <title>The cron runs, says “completed” and the message never arrives</title>
      <dc:creator>SAMUELFMEDEIROS</dc:creator>
      <pubDate>Mon, 19 Oct 2026 01:43:30 +0000</pubDate>
      <link>https://dev.to/samuelfmedeiros/the-cron-runs-says-completed-and-the-message-never-arrives-4pam</link>
      <guid>https://dev.to/samuelfmedeiros/the-cron-runs-says-completed-and-the-message-never-arrives-4pam</guid>
      <description>&lt;p&gt;A scheduled job has two possible outcomes: it ran and delivered; or it ran, said&lt;br&gt;
“completed” and nobody received anything. The second is the common one — and the hardest to see,&lt;br&gt;
because the status is green.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes a job “complete” without delivering
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Platform not configured in the process&lt;/strong&gt;You enable the channel in the config, but the&lt;br&gt;
delivering process was already running and read the old config. The job finishes with “platform not&lt;br&gt;
configured” and a success status.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Manual run ≠ scheduled run&lt;/strong&gt;Running the job by hand uses a different delivery path&lt;br&gt;
than the scheduler's. The manual test is green and the scheduled one never delivers.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Output file instead of the channel&lt;/strong&gt;The job writes the text to a file instead of&lt;br&gt;
talking to the channel's API. It looks like delivery; it is a log.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Token in the wrong place&lt;/strong&gt;The credential exists in your shell's environment, not in&lt;br&gt;
the scheduler's — and the error shows up as “no permission”, not “no token”.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The proof that does not lie
&lt;/h2&gt;

&lt;p&gt;There is exactly one proof of delivery: the &lt;strong&gt;identifier the channel returned&lt;/strong&gt; when it&lt;br&gt;
accepted the message. Store it and show it in the job report.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;OK sendMessage message_id=48293 chat=-100...&lt;/code&gt;&lt;br&gt;
Job status, exit code and a “success” log are all compatible with the message never leaving.&lt;br&gt;
The id is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to actually test it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Force the delivery condition&lt;/strong&gt;A job that “has nothing to do” tests nothing. Run it&lt;br&gt;
once with guaranteed content.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Read the result from the channel's side&lt;/strong&gt;Confirm on the channel (or via its API)&lt;br&gt;
that the message arrived — not in the scheduler's log.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Log which path was used&lt;/strong&gt;“Run by the scheduler” and “run by hand” must appear in the&lt;br&gt;
log: without that, you are testing the wrong path.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If the job is silent when there is nothing to do
&lt;/h2&gt;

&lt;p&gt;Silence by design needs proof that the job &lt;strong&gt;ran&lt;/strong&gt;: one log line per execution. Without it,&lt;br&gt;
“I received nothing” and “the job did not run” are indistinguishable — and you find out weeks&lt;br&gt;
later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next in this series&lt;/strong&gt; (the one after this): &lt;a href="https://dev.to/samuelfmedeiros/it-passes-on-your-machine-and-breaks-in-ci-the-causes-that-are-not-your-code-4on7"&gt;https://dev.to/samuelfmedeiros/it-passes-on-your-machine-and-breaks-in-ci-the-causes-that-are-not-your-code-4on7&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://skills.seu.pet/en/p/pack-completo?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=the-cron-runs-says-completed-and-the" rel="noopener noreferrer"&gt;See the complete pack&lt;/a&gt;&lt;br&gt;
&lt;a href="https://skills.seu.pet/en?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=the-cron-runs-says-completed-and-the" rel="noopener noreferrer"&gt;See all skills&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Want this wired into your project? &lt;a href="https://skills.seu.pet/en/service?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=the-cron-runs-says-completed-and-the" rel="noopener noreferrer"&gt;Guided setup&lt;/a&gt; from&lt;br&gt;
R$ 1.500, with the first cycle run together.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://skills.seu.pet/en?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=the-cron-runs-says-completed-and-the" rel="noopener noreferrer"&gt;← all skills&lt;/a&gt;&lt;/p&gt;

</description>
      <category>debugging</category>
    </item>
  </channel>
</rss>
