<?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: Felipe Bufelli</title>
    <description>The latest articles on DEV Community by Felipe Bufelli (@felipebufelli).</description>
    <link>https://dev.to/felipebufelli</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%2F4128538%2Feec3a8fe-411a-4354-b74d-94c9af1d3770.jpg</url>
      <title>DEV Community: Felipe Bufelli</title>
      <link>https://dev.to/felipebufelli</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/felipebufelli"/>
    <language>en</language>
    <item>
      <title>Integração com o iFood: o bug escondido por semanas</title>
      <dc:creator>Felipe Bufelli</dc:creator>
      <pubDate>Fri, 25 Sep 2026 20:53:03 +0000</pubDate>
      <link>https://dev.to/felipebufelli/integracao-com-o-ifood-o-bug-escondido-por-semanas-2ob7</link>
      <guid>https://dev.to/felipebufelli/integracao-com-o-ifood-o-bug-escondido-por-semanas-2ob7</guid>
      <description>&lt;h2&gt;
  
  
  O desafio
&lt;/h2&gt;

&lt;p&gt;Hoje eu quero falar da Temperô, um SaaS multi-tenant de gestão de restaurantes: pedidos, cozinha, caixa, comandas, várias unidades por restaurante. Stack em Node/TypeScript/Express, Postgres com pg puro (sem ORM, SQL cru, migrations numeradas).&lt;/p&gt;

&lt;p&gt;O objetivo era simples de enunciar,mas em termos de produção, bem difícil de sustentar: um pedido feito no marketplace iFood precisa entrar automaticamente no fluxo operacional do Temperô, cair na cozinha, seguir para produção e entrega, fechar o pagamento - e tudo isso com o status sincronizado nos dois sentidos. O restaurante não pode ter que operar dois sistemas em paralelo, um para o delivery do marketplace e outro para o resto.&lt;/p&gt;

&lt;p&gt;No artigo de hoje, eu conto como implementamos isso, os bugs que apareceram no caminho, e principalmente a investigação mais longa do projeto: o por que, às vezes, o evento de confirmação de um cancelamento simplesmente não chegava nunca.&lt;/p&gt;

&lt;h2&gt;
  
  
  As decisões de arquitetura
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Polling, não webhook.&lt;/strong&gt; A Order API do iFood expõe &lt;code&gt;GET /order/v1.0/events:polling&lt;/code&gt;, que devolve os eventos pendentes de um merchant, e &lt;code&gt;POST /order/v1.0/events/acknowledgment&lt;/code&gt;, que confirma o recebimento e tira o evento da fila. A cada 15 segundos, o Temperô sempre inicia a chamada, dentro de um &lt;code&gt;setInterval&lt;/code&gt; no próprio processo Node, sem serviço separado. Uma flag de controle simples evita sobreposição de execuções caso um tick demore mais que o intervalo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;polling&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;polling&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;polling&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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;await&lt;/span&gt; &lt;span class="nf"&gt;pollIfoodEvents&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;polling&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&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="mi"&gt;15&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nesse sentido, é importante dizer que não escolhemos polling por preferência técnica; ela era a única opção viável no momento: o iFood nunca chama o Temperô, o Temperô sempre chama o iFood.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;App "Centralizado" do iFood Developer.&lt;/strong&gt; O iFood oferece dois modelos de credencial: "Distribuído", uma credencial OAuth2 por restaurante/unidade, e "Centralizado", uma única credencial &lt;code&gt;client_credentials&lt;/code&gt; para toda a plataforma, filtrando o merchant específico via header &lt;code&gt;x-polling-merchants&lt;/code&gt; em cada request. Por aqui, escolhemos Centralizado, o que exigiu refatorar no meio do projeto um cache de token que originalmente era por unidade para um cache único em memória.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token OAuth2 com renovação proativa e reativa.&lt;/strong&gt; O token fica em memória, com expiração baseada no &lt;code&gt;expiresIn&lt;/code&gt; que o próprio iFood devolve. Em termos de renovação, fizemos tanto de forma proativa, com um buffer de 30 segundos antes de expirar, como de forma reativa, invalidando e tentando de novo uma vez em qualquer resposta 401.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pedido do iFood sempre nasce com &lt;code&gt;origin='ifood'&lt;/code&gt; e &lt;code&gt;dine_type='entrega'&lt;/code&gt; fixo.&lt;/strong&gt; O v1 da integração cobre só delivery. Pedidos de takeout ou dine-in que por algum motivo chegam via marketplace são recusados automaticamente, chamando &lt;code&gt;requestCancellation&lt;/code&gt; com o primeiro motivo disponível na lista de códigos.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sem mapeamento de cardápio.&lt;/strong&gt; O item do pedido iFood entra com &lt;code&gt;menu_item_id=null&lt;/code&gt;, usando nome e preço direto do payload recebido. Mapear cada item do cardápio do iFood para o cardápio interno do Temperô ficou fora do escopo do v1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pagamento sem operador humano.&lt;/strong&gt; Cada unidade tem um usuário "de sistema" sintético e um caixa virtual que abre e fecha sozinho, sem depender de alguém abrindo caixa manualmente pra receber pedido de marketplace.&lt;/p&gt;

&lt;h2&gt;
  
  
  O bug do code vs fullCode
&lt;/h2&gt;

&lt;p&gt;Ao longo da trajetória, é claro, surgiram alguns problemas.&lt;/p&gt;

&lt;p&gt;Entre eles, estava o fato de que cada evento do polling do iFood vem com dois campos parecidos: &lt;code&gt;code&lt;/code&gt;, um código abreviado tipo &lt;code&gt;"PLC"&lt;/code&gt;, e &lt;code&gt;fullCode&lt;/code&gt;, o nome por extenso, tipo &lt;code&gt;"PLACED"&lt;/code&gt;. Durante semanas, o switch que processava os eventos comparava contra &lt;code&gt;code&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PLACED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleOrderPlaced&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CANCELLED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleOrderCancelled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;break&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;p&gt;O problema é que &lt;code&gt;event.code&lt;/code&gt; nunca era &lt;code&gt;"PLACED"&lt;/code&gt;, o valor real ali era &lt;code&gt;"PLC"&lt;/code&gt;. Todo evento verdadeiro caía silenciosamente no default, um no-op sem log de erro, sem exceção, sem nada que chamasse atenção. O sistema simplesmente parecia "não fazer nada" com eventos reais, enquanto testes manuais via curl pareciam funcionar, porque testavam outras partes do fluxo, não o processamento do evento em si.&lt;/p&gt;

&lt;p&gt;Só descobrimos isso analisando o JSON bruto de um evento capturado no log estruturado, comparando campo por campo com a documentação:&lt;br&gt;
&lt;/p&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"b3f1..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PLC"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fullCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PLACED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"orderId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a921..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"merchantId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"9f02..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"createdAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-03T14:22:10.000Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A correção foi fácil, com a troca de &lt;code&gt;event.code&lt;/code&gt; por &lt;code&gt;event.fullCode&lt;/code&gt; no switch, mas o aprendizado não foi, nem de perto, trivial: sempre compare contra o campo que a documentação define como canônico, não o que parece, pelo nome da propriedade, ser o identificador do evento. &lt;code&gt;code&lt;/code&gt; parecia óbvio demais pra estar errado, e foi exatamente por isso que passou despercebido por tanto tempo.&lt;/p&gt;

&lt;p&gt;Foi na mesma leva de correções que outro bug sutil apareceu, de filtro de data em UTC comparado contra horário local sem conversão explícita, fazendo pedidos de fim de noite sumirem da tela de "Pedidos do dia": o tipo de erro que só aparece quando alguém confere manualmente um caso de borda, não em teste automatizado com dados sintéticos.&lt;/p&gt;

&lt;h2&gt;
  
  
  O problema do cancelamento assíncrono
&lt;/h2&gt;

&lt;p&gt;Na esteira de problemas, tivemos que o &lt;code&gt;POST /requestCancellation&lt;/code&gt; do iFood devolve &lt;code&gt;202 Accepted&lt;/code&gt;. Isso não significa que o pedido foi cancelado, mas que "recebi seu pedido de cancelamento". A confirmação real chega depois, de forma assíncrona, pelo mesmo mecanismo de polling: um evento &lt;code&gt;CANCELLED&lt;/code&gt; se o cancelamento foi aceito, ou &lt;code&gt;CANCELLATION_REQUEST_FAILED&lt;/code&gt; se foi recusado.&lt;/p&gt;

&lt;p&gt;A primeira versão do sistema não respeitava essa assincronia. Ela marcava o pedido como cancelado imediatamente, de forma otimista, no mesmo instante da chamada HTTP. Isso divergia do estado real da plataforma toda vez que o iFood demorava ou nunca confirmava.&lt;/p&gt;

&lt;p&gt;A correção foi introduzir um estado intermediário, só para pedidos &lt;code&gt;origin='ifood'&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;ifood_orders&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;cancellation_pending&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;ifood_orders&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;cancellation_requested_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enquanto &lt;code&gt;cancellation_pending&lt;/code&gt; é verdadeiro, o pedido não muda de status de verdade. Na interface, a linha de ações do pedido é substituída por um único indicador, "Cancelamento em processamento", em vez de simplesmente desabilitar o botão de cancelar ao lado dos outros (o que deixava a tela ambígua sobre o que realmente estava acontecendo). O status só muda quando o evento de confirmação chega pelo polling.&lt;/p&gt;

&lt;h2&gt;
  
  
  A investigação mais longa: por que o evento de cancelamento às vezes não chegava
&lt;/h2&gt;

&lt;p&gt;Trocando em miúdos, essa foi a parte que mais consumiu tempo do projeto, e a que mais ensinou. Reuni algumas hipóteses que guiaram essa trajetória.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hipótese 1: instabilidade genérica do sandbox
&lt;/h3&gt;

&lt;p&gt;A primeira suspeita foi que o ambiente de testes do iFood processava eventos em lote, às vezes com atraso de horas. Tínhamos evidência real disso em outro contexto: um pedido que foi pra &lt;code&gt;DISPATCHED&lt;/code&gt; recebeu o evento &lt;code&gt;CONCLUDED&lt;/code&gt; só várias horas depois, com um payload de metadata assim:&lt;br&gt;
&lt;/p&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fullCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CONCLUDED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"metadata"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"triggerEvent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"previousStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DISPATCHED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CONCLUDED"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Isso reforçou a hipótese, mas não explicava por que, em alguns testes, o evento de cancelamento nunca chegava, nem depois de horas.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hipótese 2: o motivo do cancelamento importava
&lt;/h3&gt;

&lt;p&gt;Reparamos que testes usando o código de cancelamento &lt;code&gt;"503"&lt;/code&gt; (item indisponível) travavam sem confirmação, enquanto &lt;code&gt;"501"&lt;/code&gt; e &lt;code&gt;"512"&lt;/code&gt; (problema de sistema, loja vai abrir mais tarde) resolviam na hora. Isso virou a teoria de que códigos "contestáveis" pelo consumidor final exigiriam algum fluxo de negociação separado antes da confirmação.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hipótese 3: o Handshake Platform
&lt;/h3&gt;

&lt;p&gt;Pesquisando a documentação a fundo, encontramos endpoints reais de disputa: &lt;code&gt;POST /disputes/{disputeId}/accept&lt;/code&gt;, &lt;code&gt;/reject&lt;/code&gt;, &lt;code&gt;/alternatives/{alternativeId}&lt;/code&gt;. Existiam para cancelamentos contestáveis iniciados pelo consumidor, e nunca eram chamados pelo nosso sistema. Ufa, finalmente! Ela pareceu ser a explicação perfeita: estávamos deixando uma disputa aberta sem responder.&lt;/p&gt;

&lt;p&gt;Mas como nem tudo são flores, o teste que derrubou a hipótese 3. Minutos depois, repetimos o mesmo teste, com o mesmo &lt;code&gt;cancellationCode "503"&lt;/code&gt;. Dessa vez, o evento &lt;code&gt;CANCELLED&lt;/code&gt; chegou em cerca de 200 milissegundos, sem chamar nenhum endpoint de disputa. O payload trazia um metadado informativo, não um estado pendente de fato:&lt;br&gt;
&lt;/p&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fullCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CANCELLED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"metadata"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"CANCELLATION_DISPUTE"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"IS_CONTESTABLE"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CANCELLATION_IS_CONTESTABLE"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ou seja, o campo só indicava que aquele cancelamento poderia, em tese, ser contestado pelo consumidor. Não significava que estava, de fato, esperando handshake.&lt;/p&gt;

&lt;p&gt;A causa raiz foi encontrada por acaso. Tínhamos duas instâncias da aplicação rodando o mesmo loop de polling contra o mesmo &lt;code&gt;merchantId&lt;/code&gt;: o ambiente local de desenvolvimento e a produção no Railway, ambos usando a mesma credencial de teste.&lt;/p&gt;

&lt;p&gt;A fila de eventos do iFood é por merchant, não por consumidor da API. Quem faz o poll primeiro recebe o evento e o reconhece via acknowledgment, tirando-o da fila pra sempre. Se a instância que fez a chamada de cancelamento não fosse a mesma que vencesse a corrida pelo próximo poll, ela nunca mais veria a confirmação, enquanto a outra instância recebia e descartava um evento que não tinha efeito nenhum no banco dela.&lt;/p&gt;

&lt;p&gt;Naquele momento, tivemos um grande aprendizado, que entenderíamos só tempos depois: nunca rode dois consumidores de uma fila de webhook ou polling apontando pro mesmo recurso externo ao mesmo tempo, mesmo em ambientes nominalmente "separados", como dev local e produção, se eles compartilharem a mesma credencial e o mesmo merchant de teste.&lt;/p&gt;

&lt;p&gt;Para corrigir o problema, isolamos de vez os ambientes: o polling contra o merchant passou a rodar exclusivamente em produção, e o ambiente local parou de consumir a fila. Um único consumidor ativo por merchant deixou de ser uma regra implícita e virou uma invariante garantida pela própria topologia do sistema, não por disciplina de quem estava testando naquele dia.&lt;/p&gt;

&lt;h2&gt;
  
  
  Homologação: quando "funcionar certo" não é suficiente
&lt;/h2&gt;

&lt;p&gt;Fomos reprovados duas ou três vezes no processo formal de homologação do iFood, com uma mensagem genérica de "Erro ao recuperar logs da aplicação" no cenário de cancelamento. Em uma das tentativas, os logs estruturados provavam, com timestamp de cada etapa, que o fluxo inteiro (confirmação, requisição de cancelamento, &lt;code&gt;CANCELLATION_REQUESTED&lt;/code&gt;, &lt;code&gt;CANCELLED&lt;/code&gt;, acknowledgment) rodou em menos de dois minutos, sem erro nenhum do nosso lado.&lt;/p&gt;

&lt;p&gt;Às vezes o "seu sistema está errado" de um avaliador automático de terceiros é, na verdade, um sintoma de um problema no próprio avaliador. A única defesa nessa situação é ter logs estruturados e cronometrados o suficiente para provar isso com uma solicitação de revisão manual, em vez de ficar tentando adivinhar o que "consertar" num sistema que já estava funcionando.&lt;/p&gt;

&lt;h2&gt;
  
  
  O que sustentou toda essa investigação
&lt;/h2&gt;

&lt;p&gt;Nada disso teria sido reconstruível sem uma decisão tomada cedo: logar toda chamada HTTP ao iFood (path, método, unitId, merchantId, orderId, status, código de erro) e todo evento recebido, com o payload bruto completo, como uma linha JSON por evento, não só em caso de erro:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ifood.polling.event_received&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;merchantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fullCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fullCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&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;p&gt;Foi isso que permitiu reconstruir cada uma das hipóteses da investigação, analisando só os logs do Railway, sem acesso direto ao painel do iFood. Numa integração assíncrona com um sistema externo que você não controla, o log estruturado deixa de ser um detalhe de observabilidade e vira a única fonte de verdade disponível quando algo não bate.&lt;/p&gt;

&lt;h2&gt;
  
  
  O que você precisa saber quando for integrar com marketplaces por polling
&lt;/h2&gt;

&lt;p&gt;Compare sempre contra o campo canônico documentado, não o que parece ser o identificador pelo nome. Trate qualquer resposta &lt;code&gt;202&lt;/code&gt; ou similar como confirmação de recebimento, nunca como confirmação de efeito. Modele o estado intermediário explicitamente no banco em vez de assumir otimisticamente que uma ação já aconteceu. E, acima de tudo, garanta que só existe um consumidor ativo por fila externa compartilhada, porque duas instâncias "separadas" brigando pela mesma fila produzem um bug que parece instabilidade de terceiro, mas é “apenas” um problema inteiramente seu.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Integração com o iFood: o problema na homologação do developer</title>
      <dc:creator>Felipe Bufelli</dc:creator>
      <pubDate>Fri, 25 Sep 2026 20:48:48 +0000</pubDate>
      <link>https://dev.to/felipebufelli/integracao-com-o-ifood-o-bug-que-ficou-escondido-por-semanas-e-a-investigacao-que-terminou-numa-30hf</link>
      <guid>https://dev.to/felipebufelli/integracao-com-o-ifood-o-bug-que-ficou-escondido-por-semanas-e-a-investigacao-que-terminou-numa-30hf</guid>
      <description>&lt;h2&gt;
  
  
  O desafio
&lt;/h2&gt;

&lt;p&gt;Hoje eu quero falar da Temperô, um SaaS multi-tenant de gestão de restaurantes: pedidos, cozinha, caixa, comandas, várias unidades por restaurante. Stack em Node/TypeScript/Express, Postgres com pg puro (sem ORM, SQL cru, migrations numeradas).&lt;/p&gt;

&lt;p&gt;O objetivo era simples de enunciar,mas em termos de produção, bem difícil de sustentar: um pedido feito no marketplace iFood precisa entrar automaticamente no fluxo operacional do Temperô, cair na cozinha, seguir para produção e entrega, fechar o pagamento - e tudo isso com o status sincronizado nos dois sentidos. O restaurante não pode ter que operar dois sistemas em paralelo, um para o delivery do marketplace e outro para o resto.&lt;/p&gt;

&lt;p&gt;No artigo de hoje, eu conto como implementamos isso, os bugs que apareceram no caminho, e principalmente a investigação mais longa do projeto: o por que, às vezes, o evento de confirmação de um cancelamento simplesmente não chegava nunca.&lt;/p&gt;

&lt;h2&gt;
  
  
  As decisões de arquitetura
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Polling, não webhook.&lt;/strong&gt; A Order API do iFood expõe &lt;code&gt;GET /order/v1.0/events:polling&lt;/code&gt;, que devolve os eventos pendentes de um merchant, e &lt;code&gt;POST /order/v1.0/events/acknowledgment&lt;/code&gt;, que confirma o recebimento e tira o evento da fila. A cada 15 segundos, o Temperô sempre inicia a chamada, dentro de um &lt;code&gt;setInterval&lt;/code&gt; no próprio processo Node, sem serviço separado. Uma flag de controle simples evita sobreposição de execuções caso um tick demore mais que o intervalo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;polling&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;polling&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;polling&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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;await&lt;/span&gt; &lt;span class="nf"&gt;pollIfoodEvents&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;polling&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&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="mi"&gt;15&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nesse sentido, é importante dizer que não escolhemos polling por preferência técnica; ela era a única opção viável no momento: o iFood nunca chama o Temperô, o Temperô sempre chama o iFood.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;App "Centralizado" do iFood Developer.&lt;/strong&gt; O iFood oferece dois modelos de credencial: "Distribuído", uma credencial OAuth2 por restaurante/unidade, e "Centralizado", uma única credencial &lt;code&gt;client_credentials&lt;/code&gt; para toda a plataforma, filtrando o merchant específico via header &lt;code&gt;x-polling-merchants&lt;/code&gt; em cada request. Por aqui, escolhemos Centralizado, o que exigiu refatorar no meio do projeto um cache de token que originalmente era por unidade para um cache único em memória.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token OAuth2 com renovação proativa e reativa.&lt;/strong&gt; O token fica em memória, com expiração baseada no &lt;code&gt;expiresIn&lt;/code&gt; que o próprio iFood devolve. Em termos de renovação, fizemos tanto de forma proativa, com um buffer de 30 segundos antes de expirar, como de forma reativa, invalidando e tentando de novo uma vez em qualquer resposta 401.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pedido do iFood sempre nasce com &lt;code&gt;origin='ifood'&lt;/code&gt; e &lt;code&gt;dine_type='entrega'&lt;/code&gt; fixo.&lt;/strong&gt; O v1 da integração cobre só delivery. Pedidos de takeout ou dine-in que por algum motivo chegam via marketplace são recusados automaticamente, chamando &lt;code&gt;requestCancellation&lt;/code&gt; com o primeiro motivo disponível na lista de códigos.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sem mapeamento de cardápio.&lt;/strong&gt; O item do pedido iFood entra com &lt;code&gt;menu_item_id=null&lt;/code&gt;, usando nome e preço direto do payload recebido. Mapear cada item do cardápio do iFood para o cardápio interno do Temperô ficou fora do escopo do v1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pagamento sem operador humano.&lt;/strong&gt; Cada unidade tem um usuário "de sistema" sintético e um caixa virtual que abre e fecha sozinho, sem depender de alguém abrindo caixa manualmente pra receber pedido de marketplace.&lt;/p&gt;

&lt;h2&gt;
  
  
  O bug do code vs fullCode
&lt;/h2&gt;

&lt;p&gt;Ao longo da trajetória, é claro, surgiram alguns problemas.&lt;/p&gt;

&lt;p&gt;Entre eles, estava o fato de que cada evento do polling do iFood vem com dois campos parecidos: &lt;code&gt;code&lt;/code&gt;, um código abreviado tipo &lt;code&gt;"PLC"&lt;/code&gt;, e &lt;code&gt;fullCode&lt;/code&gt;, o nome por extenso, tipo &lt;code&gt;"PLACED"&lt;/code&gt;. Durante semanas, o switch que processava os eventos comparava contra &lt;code&gt;code&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PLACED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleOrderPlaced&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CANCELLED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleOrderCancelled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;break&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;p&gt;O problema é que &lt;code&gt;event.code&lt;/code&gt; nunca era &lt;code&gt;"PLACED"&lt;/code&gt;, o valor real ali era &lt;code&gt;"PLC"&lt;/code&gt;. Todo evento verdadeiro caía silenciosamente no default, um no-op sem log de erro, sem exceção, sem nada que chamasse atenção. O sistema simplesmente parecia "não fazer nada" com eventos reais, enquanto testes manuais via curl pareciam funcionar, porque testavam outras partes do fluxo, não o processamento do evento em si.&lt;/p&gt;

&lt;p&gt;Só descobrimos isso analisando o JSON bruto de um evento capturado no log estruturado, comparando campo por campo com a documentação:&lt;br&gt;
&lt;/p&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"b3f1..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PLC"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fullCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PLACED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"orderId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a921..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"merchantId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"9f02..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"createdAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-03T14:22:10.000Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A correção foi fácil, com a troca de &lt;code&gt;event.code&lt;/code&gt; por &lt;code&gt;event.fullCode&lt;/code&gt; no switch, mas o aprendizado não foi, nem de perto, trivial: sempre compare contra o campo que a documentação define como canônico, não o que parece, pelo nome da propriedade, ser o identificador do evento. &lt;code&gt;code&lt;/code&gt; parecia óbvio demais pra estar errado, e foi exatamente por isso que passou despercebido por tanto tempo.&lt;/p&gt;

&lt;p&gt;Foi na mesma leva de correções que outro bug sutil apareceu, de filtro de data em UTC comparado contra horário local sem conversão explícita, fazendo pedidos de fim de noite sumirem da tela de "Pedidos do dia": o tipo de erro que só aparece quando alguém confere manualmente um caso de borda, não em teste automatizado com dados sintéticos.&lt;/p&gt;

&lt;h2&gt;
  
  
  O problema do cancelamento assíncrono
&lt;/h2&gt;

&lt;p&gt;Na esteira de problemas, tivemos que o &lt;code&gt;POST /requestCancellation&lt;/code&gt; do iFood devolve &lt;code&gt;202 Accepted&lt;/code&gt;. Isso não significa que o pedido foi cancelado, mas que "recebi seu pedido de cancelamento". A confirmação real chega depois, de forma assíncrona, pelo mesmo mecanismo de polling: um evento &lt;code&gt;CANCELLED&lt;/code&gt; se o cancelamento foi aceito, ou &lt;code&gt;CANCELLATION_REQUEST_FAILED&lt;/code&gt; se foi recusado.&lt;/p&gt;

&lt;p&gt;A primeira versão do sistema não respeitava essa assincronia. Ela marcava o pedido como cancelado imediatamente, de forma otimista, no mesmo instante da chamada HTTP. Isso divergia do estado real da plataforma toda vez que o iFood demorava ou nunca confirmava.&lt;/p&gt;

&lt;p&gt;A correção foi introduzir um estado intermediário, só para pedidos &lt;code&gt;origin='ifood'&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;ifood_orders&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;cancellation_pending&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;ifood_orders&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;cancellation_requested_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enquanto &lt;code&gt;cancellation_pending&lt;/code&gt; é verdadeiro, o pedido não muda de status de verdade. Na interface, a linha de ações do pedido é substituída por um único indicador, "Cancelamento em processamento", em vez de simplesmente desabilitar o botão de cancelar ao lado dos outros (o que deixava a tela ambígua sobre o que realmente estava acontecendo). O status só muda quando o evento de confirmação chega pelo polling.&lt;/p&gt;

&lt;h2&gt;
  
  
  A investigação mais longa: por que o evento de cancelamento às vezes não chegava
&lt;/h2&gt;

&lt;p&gt;Trocando em miúdos, essa foi a parte que mais consumiu tempo do projeto, e a que mais ensinou. Reuni algumas hipóteses que guiaram essa trajetória.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hipótese 1: instabilidade genérica do sandbox
&lt;/h3&gt;

&lt;p&gt;A primeira suspeita foi que o ambiente de testes do iFood processava eventos em lote, às vezes com atraso de horas. Tínhamos evidência real disso em outro contexto: um pedido que foi pra &lt;code&gt;DISPATCHED&lt;/code&gt; recebeu o evento &lt;code&gt;CONCLUDED&lt;/code&gt; só várias horas depois, com um payload de metadata assim:&lt;br&gt;
&lt;/p&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fullCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CONCLUDED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"metadata"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"triggerEvent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"previousStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DISPATCHED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CONCLUDED"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Isso reforçou a hipótese, mas não explicava por que, em alguns testes, o evento de cancelamento nunca chegava, nem depois de horas.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hipótese 2: o motivo do cancelamento importava
&lt;/h3&gt;

&lt;p&gt;Reparamos que testes usando o código de cancelamento &lt;code&gt;"503"&lt;/code&gt; (item indisponível) travavam sem confirmação, enquanto &lt;code&gt;"501"&lt;/code&gt; e &lt;code&gt;"512"&lt;/code&gt; (problema de sistema, loja vai abrir mais tarde) resolviam na hora. Isso virou a teoria de que códigos "contestáveis" pelo consumidor final exigiriam algum fluxo de negociação separado antes da confirmação.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hipótese 3: o Handshake Platform
&lt;/h3&gt;

&lt;p&gt;Pesquisando a documentação a fundo, encontramos endpoints reais de disputa: &lt;code&gt;POST /disputes/{disputeId}/accept&lt;/code&gt;, &lt;code&gt;/reject&lt;/code&gt;, &lt;code&gt;/alternatives/{alternativeId}&lt;/code&gt;. Existiam para cancelamentos contestáveis iniciados pelo consumidor, e nunca eram chamados pelo nosso sistema. Ufa, finalmente! Ela pareceu ser a explicação perfeita: estávamos deixando uma disputa aberta sem responder.&lt;/p&gt;

&lt;p&gt;Mas como nem tudo são flores, o teste que derrubou a hipótese 3. Minutos depois, repetimos o mesmo teste, com o mesmo &lt;code&gt;cancellationCode "503"&lt;/code&gt;. Dessa vez, o evento &lt;code&gt;CANCELLED&lt;/code&gt; chegou em cerca de 200 milissegundos, sem chamar nenhum endpoint de disputa. O payload trazia um metadado informativo, não um estado pendente de fato:&lt;br&gt;
&lt;/p&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fullCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CANCELLED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"metadata"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"CANCELLATION_DISPUTE"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"IS_CONTESTABLE"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CANCELLATION_IS_CONTESTABLE"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ou seja, o campo só indicava que aquele cancelamento poderia, em tese, ser contestado pelo consumidor. Não significava que estava, de fato, esperando handshake.&lt;/p&gt;

&lt;p&gt;A causa raiz foi encontrada por acaso. Tínhamos duas instâncias da aplicação rodando o mesmo loop de polling contra o mesmo &lt;code&gt;merchantId&lt;/code&gt;: o ambiente local de desenvolvimento e a produção no Railway, ambos usando a mesma credencial de teste.&lt;/p&gt;

&lt;p&gt;A fila de eventos do iFood é por merchant, não por consumidor da API. Quem faz o poll primeiro recebe o evento e o reconhece via acknowledgment, tirando-o da fila pra sempre. Se a instância que fez a chamada de cancelamento não fosse a mesma que vencesse a corrida pelo próximo poll, ela nunca mais veria a confirmação, enquanto a outra instância recebia e descartava um evento que não tinha efeito nenhum no banco dela.&lt;/p&gt;

&lt;p&gt;Naquele momento, tivemos um grande aprendizado, que entenderíamos só tempos depois: nunca rode dois consumidores de uma fila de webhook ou polling apontando pro mesmo recurso externo ao mesmo tempo, mesmo em ambientes nominalmente "separados", como dev local e produção, se eles compartilharem a mesma credencial e o mesmo merchant de teste.&lt;/p&gt;

&lt;p&gt;Para corrigir o problema, isolamos de vez os ambientes: o polling contra o merchant passou a rodar exclusivamente em produção, e o ambiente local parou de consumir a fila. Um único consumidor ativo por merchant deixou de ser uma regra implícita e virou uma invariante garantida pela própria topologia do sistema, não por disciplina de quem estava testando naquele dia.&lt;/p&gt;

&lt;h2&gt;
  
  
  Homologação: quando "funcionar certo" não é suficiente
&lt;/h2&gt;

&lt;p&gt;Fomos reprovados duas ou três vezes no processo formal de homologação do iFood, com uma mensagem genérica de "Erro ao recuperar logs da aplicação" no cenário de cancelamento. Em uma das tentativas, os logs estruturados provavam, com timestamp de cada etapa, que o fluxo inteiro (confirmação, requisição de cancelamento, &lt;code&gt;CANCELLATION_REQUESTED&lt;/code&gt;, &lt;code&gt;CANCELLED&lt;/code&gt;, acknowledgment) rodou em menos de dois minutos, sem erro nenhum do nosso lado.&lt;/p&gt;

&lt;p&gt;Às vezes o "seu sistema está errado" de um avaliador automático de terceiros é, na verdade, um sintoma de um problema no próprio avaliador. A única defesa nessa situação é ter logs estruturados e cronometrados o suficiente para provar isso com uma solicitação de revisão manual, em vez de ficar tentando adivinhar o que "consertar" num sistema que já estava funcionando.&lt;/p&gt;

&lt;h2&gt;
  
  
  O que sustentou toda essa investigação
&lt;/h2&gt;

&lt;p&gt;Nada disso teria sido reconstruível sem uma decisão tomada cedo: logar toda chamada HTTP ao iFood (path, método, unitId, merchantId, orderId, status, código de erro) e todo evento recebido, com o payload bruto completo, como uma linha JSON por evento, não só em caso de erro:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ifood.polling.event_received&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;merchantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fullCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fullCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&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;p&gt;Foi isso que permitiu reconstruir cada uma das hipóteses da investigação, analisando só os logs do Railway, sem acesso direto ao painel do iFood. Numa integração assíncrona com um sistema externo que você não controla, o log estruturado deixa de ser um detalhe de observabilidade e vira a única fonte de verdade disponível quando algo não bate.&lt;/p&gt;

&lt;h2&gt;
  
  
  O que você precisa saber quando for integrar com marketplaces por polling
&lt;/h2&gt;

&lt;p&gt;Compare sempre contra o campo canônico documentado, não o que parece ser o identificador pelo nome. Trate qualquer resposta &lt;code&gt;202&lt;/code&gt; ou similar como confirmação de recebimento, nunca como confirmação de efeito. Modele o estado intermediário explicitamente no banco em vez de assumir otimisticamente que uma ação já aconteceu. E, acima de tudo, garanta que só existe um consumidor ativo por fila externa compartilhada, porque duas instâncias "separadas" brigando pela mesma fila produzem um bug que parece instabilidade de terceiro, mas é “apenas” um problema inteiramente seu.&lt;/p&gt;

</description>
      <category>node</category>
      <category>typescript</category>
      <category>postgres</category>
      <category>api</category>
    </item>
    <item>
      <title>Como estruturei uma esteira de desenvolvimento acelerada por IA</title>
      <dc:creator>Felipe Bufelli</dc:creator>
      <pubDate>Wed, 16 Sep 2026 18:27:31 +0000</pubDate>
      <link>https://dev.to/felipebufelli/como-estruturei-uma-esteira-de-desenvolvimento-acelerada-por-ia-1nfn</link>
      <guid>https://dev.to/felipebufelli/como-estruturei-uma-esteira-de-desenvolvimento-acelerada-por-ia-1nfn</guid>
      <description>&lt;p&gt;Spec-Driven Development + GitHub Actions + Claude Code&lt;/p&gt;

&lt;h2&gt;
  
  
  O problema que eu tinha em mãos
&lt;/h2&gt;

&lt;p&gt;Toda esteira de desenvolvimento passa pelos mesmos gargalos manuais. Alguém escreve uma spec, alguém revisa, alguém implementa, alguém abre uma Pull Request (PR), alguém revisa de novo. Cada troca de mão é uma pausa, um contexto perdido, um "deixa eu ver isso amanhã".&lt;br&gt;
No entanto, na squad em que atuo, decidimos atacar isso de um jeito diferente. Transformamos boa parte desse fluxo em uma esteira automatizada, em que um agente de IA (Claude Code) executa as etapas repetíveis do processo, e um humano só entra nos pontos que realmente exigem análise.&lt;br&gt;
O resultado é uma pipeline rodando dentro do GitHub, dirigida inteiramente por labels em Issues, seguindo o modelo de Spec-Driven Development (Spec Kit).&lt;br&gt;
E eu pensei: porque não falar sobre essa transformação? Assim, esse artigo é o "como" por trás disso, incluindo os erros que travaram o piloto e o que ainda estamos ajustando.&lt;/p&gt;

&lt;h2&gt;
  
  
  O conceito central
&lt;/h2&gt;

&lt;p&gt;A regra de ouro do design é simples de enunciar e poderosa na prática: A label sdd:* do Issue é a fonte da verdade. O board do GitHub Project é apenas a visão.&lt;br&gt;
Isso resolve um problema clássico de automação. Quando o estado "real" mora em um lugar editável (o board) por qualquer um, a qualquer momento, e a automação tenta inferir o que fazer a partir dele, criam-se condições de corrida e estados inconsistentes. Ao colocar a label como única fonte de verdade, o board vira só uma janela: bonita, útil, mas descartável.&lt;br&gt;
O mapeamento fundamental é 1 card = 1 spec = 1 branch:&lt;br&gt;
&lt;strong&gt;Card&lt;/strong&gt;: um Issue aberto por um template específico ("Spec card").&lt;br&gt;
&lt;strong&gt;Spec&lt;/strong&gt;: a pasta specs/NNN-slug/ gerada pelo agente.&lt;br&gt;
&lt;strong&gt;Branch&lt;/strong&gt;: NNN-slug, nascida de staging, com PR de volta pra staging.&lt;br&gt;
Mover o card de coluna é, na prática, trocar a label. Cada troca dispara os workflows via evento issues: labeled.&lt;/p&gt;

&lt;h2&gt;
  
  
  A máquina de estados
&lt;/h2&gt;

&lt;p&gt;O ponto mais importante do design é que nem toda etapa é automática. Cada label carrega um type que decide o comportamento:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Significado&lt;/th&gt;
&lt;th&gt;O agente executa?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;gate&lt;/td&gt;
&lt;td&gt;Etapas de validação humana&lt;/td&gt;
&lt;td&gt;Não, só espera&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;auto&lt;/td&gt;
&lt;td&gt;Etapa do agente&lt;/td&gt;
&lt;td&gt;Sim, roda, commita, avança&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;external&lt;/td&gt;
&lt;td&gt;Delegado a outro workflow (deploy)&lt;/td&gt;
&lt;td&gt;Não&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;terminal&lt;/td&gt;
&lt;td&gt;Fim (done)&lt;/td&gt;
&lt;td&gt;Não&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;failure&lt;/td&gt;
&lt;td&gt;Bloqueado&lt;/td&gt;
&lt;td&gt;Não&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Na prática, o fluxo intercala etapas automáticas com etapas de validação humana. O agente gera a spec, mas um humano valida antes do plano ser gerado. O agente gera o plano e as tasks, mas um humano aprova antes da implementação rodar. O agente implementa, mas um humano revisa o PR antes do deploy.&lt;br&gt;
Essa alternância responde direto a uma pergunta que qualquer engenheiro sênior faz antes de aprovar esse tipo de sistema: e se o agente decidir algo errado? Bem, a resposta é que ele nunca decide sozinho o suficiente para causar dano sem alguém ter validado o passo anterior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anatomia de uma execução automática
&lt;/h2&gt;

&lt;p&gt;Quando uma label do tipo auto é aplicada, o workflow principal roda uma sequência bem definida:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Resolve o estágio, lendo a configuração da label e exportando os atributos necessários.&lt;/li&gt;
&lt;li&gt;Garante que a branch do card existe, criando-a a partir de staging de forma idempotente.&lt;/li&gt;
&lt;li&gt;Faz checkout da branch.&lt;/li&gt;
&lt;li&gt;Monta o prompt para o agente, concatenando instruções base com a instrução específica da etapa.&lt;/li&gt;
&lt;li&gt;Executa o agente (Claude Code) via GitHub Action.&lt;/li&gt;
&lt;li&gt;Se houve mudança de arquivos, commita e faz push.&lt;/li&gt;
&lt;li&gt;Se a etapa abre PR, abre como rascunho, por convenção de título.&lt;/li&gt;
&lt;li&gt;Comenta um resumo no card.&lt;/li&gt;
&lt;li&gt;Avança a label automaticamente, disparando a próxima etapa da cascata.&lt;/li&gt;
&lt;li&gt;Em caso de falha, bloqueia o card e comenta o link da execução com erro.
E um detalhe fez diferença na prática: algumas etapas declaram que precisam gerar artefatos, arquivos novos ou modificados. Se a etapa roda e nada muda no repositório, o job falha de propósito, porque um agente pode ter um falso positivo sem ter escrito nada - o que entendemos como uma falha silenciosa. &lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Os erros que travaram o piloto
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Workflows disparados por eventos de Issue só funcionam a partir do branch default
&lt;/h3&gt;

&lt;p&gt;Se você edita o workflow numa branch de feature, ele não dispara. Todo ajuste nos workflows da esteira precisa chegar na branch principal para valer. Essa foi a causa número um de "por que isso não rodou".&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Falso-sucesso silencioso
&lt;/h3&gt;

&lt;p&gt;Sem configurar o modo de permissão correto na execução do agente, ele roda com permissões restritas: o comando não escreve nenhum arquivo, mas a execução retorna sucesso mesmo assim. CI verde, board avançando, e nada de fato foi feito. A defesa foi o mecanismo de exigir artefatos: se a etapa deveria gerar arquivos e não gerou, o job falha explicitamente em vez de deixar passar.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A autenticação do git quebra depois da execução do agente
&lt;/h3&gt;

&lt;p&gt;A ação que executa o agente reconfigura a autenticação do git no runner, o que faz o git push seguinte falhar com erro de autenticação. Por aqui, a correção consistiu em fazer o push usando uma URL autenticada explicitamente, em vez de depender da configuração padrão do runner.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. O token padrão do GitHub Actions não dispara outros workflows
&lt;/h3&gt;

&lt;p&gt;Essa é uma proteção anti-loop intencional do GitHub. Se você troca uma label usando o token padrão, isso não dispara os workflows que escutam esse evento. Na prática, o board para de sincronizar e a cascata de etapas é simplesmente interrompida, sem erro nenhum aparente. Nesse caso, a solução foi usar um token de acesso pessoal dedicado para as operações que precisam dessa cascata.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. O token padrão também não pode abrir Pull Requests
&lt;/h3&gt;

&lt;p&gt;Restrição direta do GitHub Actions em repositórios: por padrão, ele não tem permissão para criar ou aprovar PRs. De novo, resolvido com o token dedicado.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Comandos de GraphQL pra listar ou ver PRs exigem escopo de organização que o token não tinha
&lt;/h3&gt;

&lt;p&gt;A solução foi trocar esses comandos específicos por chamadas REST equivalentes, que exigem um escopo mais simples.&lt;br&gt;
Nenhum desses é complexo isoladamente. Juntos, formam o tipo de fricção que trava um piloto promissor na primeira semana se você não souber que eles existem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Segurança: tratando o conteúdo do Issue como dado não confiável
&lt;/h2&gt;

&lt;p&gt;O corpo e o título do Issue são editáveis por qualquer pessoa com acesso ao repositório, e esse conteúdo entra no prompt do agente. Isso é, por definição, uma superfície de prompt injection.&lt;br&gt;
Assim, as mitigações aplicadas foram:&lt;br&gt;
Repositório privado, com controle de quem pode aplicar labels.&lt;br&gt;
Instrução explícita ao agente para tratar o conteúdo do card como dado, nunca como comando: sem perguntas interativas, sem trocar de branch, sem ações fora do escopo da etapa.&lt;br&gt;
Um delimitador aleatório, gerado a cada execução, para evitar injeção de comandos via variáveis de ambiente do workflow.&lt;br&gt;
Isso não elimina o risco por completo. Qualquer label pode, em tese, disparar um job com permissão de escrita. Para o estágio atual, de uso interno, esse risco residual foi aceito conscientemente. E com isso, chegamos ao próximo passo de hardening, que é uma lista de permissão mais granular de quem pode acionar cada etapa.&lt;/p&gt;

&lt;h2&gt;
  
  
  Onde estamos hoje
&lt;/h2&gt;

&lt;p&gt;A esteira está rodando há um mês e meio, processando de 4 a 6 specs por semana de forma contínua. E aqui é importante dizer que não é prova de conceito, é uso real e recorrente.&lt;br&gt;
Ao escrever esse artigo, tem um aprendizado que vale a pena compartilhar: no começo, as specs estavam sendo escritas com escopo grande demais. O efeito prático disso é que a implementação gerada pelo agente frequentemente não cobria 100% do que tinha sido especificado - o escopo era grande demais para uma única execução guiada capturar por completo.&lt;br&gt;
Nesse contexto, para corrigir, não mexemos no motor da esteira, mas, sim,no início do processo, com specs menores e mais enxutas, delimitando melhor o escopo. Granularidade da unidade de trabalho importa tanto para um agente quanto importa para um time humano dividir uma sprint - algo que ainda estamos refinando.&lt;/p&gt;

&lt;h2&gt;
  
  
  Se eu fosse começar de novo
&lt;/h2&gt;

&lt;p&gt;Bom, e aí você me pergunta: e se fosse necessário fazer tudo novamente? Primeiro, eu desenharia a validação de granularidade das specs desde o primeiro dia, em vez de descobrir na prática que specs grandes geram implementações incompletas. E manteria exatamente como está a decisão de intercalar as etapas de validação humana com etapas automáticas: foi essa escolha que tornou possível confiar na esteira sem abrir mão de controle nos pontos que realmente importam.&lt;br&gt;
Se você está pensando em estruturar algo parecido, o conselho mais prático que eu tenho é: comece assumindo que o agente vai retornar sucesso em situações que não são sucesso nenhum, e construa suas próprias redes de segurança para isso. Se há algum segredo, é não confiar só no código de saída da execução.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>git</category>
      <category>claude</category>
    </item>
  </channel>
</rss>
