<?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: Softronic</title>
    <description>The latest articles on DEV Community by Softronic (@softronic).</description>
    <link>https://dev.to/softronic</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%2F4051571%2F72c980b6-479b-4566-8ac6-52d150c77c3b.png</url>
      <title>DEV Community: Softronic</title>
      <link>https://dev.to/softronic</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/softronic"/>
    <language>en</language>
    <item>
      <title>Corrective RAG for billing: the bug is not retrieval, it's the model narrating correct numbers wrong</title>
      <dc:creator>Softronic</dc:creator>
      <pubDate>Thu, 30 Jul 2026 16:49:38 +0000</pubDate>
      <link>https://dev.to/softronic/corrective-rag-for-billing-the-bug-is-not-retrieval-its-the-model-narrating-correct-numbers-wrong-4938</link>
      <guid>https://dev.to/softronic/corrective-rag-for-billing-the-bug-is-not-retrieval-its-the-model-narrating-correct-numbers-wrong-4938</guid>
      <description>&lt;p&gt;Most RAG demos are graded by an audience that cannot check the answer. Ask a docs bot something, get a fluent paragraph back, nobody in the room knows whether sentence three is invented. The demo lands.&lt;/p&gt;

&lt;p&gt;Billing is not like that. When a subscriber asks &lt;em&gt;"why is my bill higher this month"&lt;/em&gt; they are holding the invoice. They will check.&lt;/p&gt;

&lt;p&gt;We built a WhatsApp assistant for an ISP that answers plan and billing questions. This post is about the two things that turned out to matter, neither of which is "we used CRAG":&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A document corpus and a billing API are both called "retrieval" and they fail in &lt;strong&gt;completely different ways&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The failure that actually bites is not bad retrieval. It is the model taking &lt;strong&gt;correct&lt;/strong&gt; numbers and narrating them wrong.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code is from the real system. Comments translated from Spanish, otherwise untouched.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;inbound message
   ↓
state gate ──────────────► human agent active? bot writes nothing
   ↓
hybrid retrieval
   ├─ pgvector cosine ─┐
   └─ Postgres FTS ────┴─► RRF fusion ─► CRAG prune
   ↓
capability call (billing API) ─► circuit breaker
   ↓
no context AND no tool data? ──────────► escalate (before spending a token)
   ↓
LLM, structured output
   ↓
grounding verifier ────────► not grounded? ─► escalate
   ↓
humanizer ─────────────────► forbidden phrase? ─► escalate
   ↓
send
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every arrow pointing right is a refusal path. That is the design.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Fusing two rankings that aren't comparable
&lt;/h2&gt;

&lt;p&gt;Vector similarity and Postgres &lt;code&gt;ts_rank&lt;/code&gt; live on different scales. Adding them is meaningless. Reciprocal Rank Fusion only looks at &lt;em&gt;position&lt;/em&gt;, so it fuses them without pretending the scores are commensurable:&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;DEFAULT_RRF_K&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Smoothing constant. Damps the weight of the top positions so a single
ranking cannot dominate the fusion. 60 is the value from the original paper.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reciprocal_rank_fusion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;rankings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DEFAULT_RRF_K&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ranking&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rankings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ranking&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;id_&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scores&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;id_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;position&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;scores&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ties break on the id, deterministically. A result that reorders between identical runs makes tests flaky and debugging impossible.&lt;/p&gt;

&lt;p&gt;Why full-text at all, when you have embeddings? Because &lt;strong&gt;account numbers, tax IDs and national ID numbers have no semantic meaning&lt;/strong&gt;. An embedding cannot score digits. FTS is the only path by which those are ever retrieved.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. CRAG's refinement step, without the second model
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://arxiv.org/abs/2401.15884" rel="noopener noreferrer"&gt;CRAG&lt;/a&gt; (Yan et al., 2024) evaluates retrieval relevance and drops the noise &lt;em&gt;before&lt;/em&gt; generating. The paper uses a separate lightweight evaluator model.&lt;/p&gt;

&lt;p&gt;We skipped the evaluator. The retriever already computed cosine similarity and knows whether full-text hit — running a per-chunk model call every turn is the expensive part of CRAG and the least useful part when you already have scores.&lt;/p&gt;

&lt;p&gt;The problem it solves is concrete: &lt;code&gt;top_k&lt;/code&gt; asks for 6 chunks and RRF always returns 6, whether or not the sixth makes sense. &lt;strong&gt;Fusion orders, it does not filter.&lt;/strong&gt; That filler enters the prompt at full size, burns tokens every turn, and dilutes the good evidence.&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;DEFAULT_MARGIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;
&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;How far below the best chunk we still tolerate.

Deliberately a *relative* margin. A fixed threshold behaves badly at both ends:
on a well-answered query (best = 0.80) it lets a 0.40 through as &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;good&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; when
next to the best it&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s noise, and on a weak query (best = 0.42) it would prune
everything. The margin adapts: each chunk is compared to the best found on
*this* turn.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="n"&gt;DEFAULT_MIN_CHUNKS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;prune_irrelevant&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Chunk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_Scorable&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;
    &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Chunk&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DEFAULT_MARGIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;min_chunks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DEFAULT_MIN_CHUNKS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Chunk&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;min_chunks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vector_score&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;margin&lt;/span&gt;

    &lt;span class="n"&gt;kept&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;_is_relevant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;

    &lt;span class="c1"&gt;# Chunks arrive ordered best-to-worst, so backfilling from index 0
&lt;/span&gt;    &lt;span class="c1"&gt;# adds precisely the best of the discarded ones.
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;min_chunks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&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="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&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;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_is_relevant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_Scorable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;A full-text hit always survives; everything else competes on similarity.

    Full-text immunity is not a convenient exception: `plainto_tsquery` requires
    the terms to actually appear in the chunk, so the hit is itself proof of
    relevance. It is also the only path by which account numbers and tax IDs are
    retrieved — digits carry no semantic meaning, so they arrive here with a low
    cosine that would otherwise condemn them.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;matched_by_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vector_score&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the bias, because it is the opposite of the verifier below: &lt;strong&gt;when in doubt, keep.&lt;/strong&gt; Over-pruning would delete the one line that answered the question and escalate a case the bot could have solved — worse than wasting a few tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Tool results are first-class evidence
&lt;/h2&gt;

&lt;p&gt;The real balance is not in any document. It comes from the billing API, and it has to count as citable evidence or the verifier in step 4 would reject the &lt;em&gt;correct&lt;/em&gt; answer:&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="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slots&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ToolResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;capability&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;
    &lt;span class="n"&gt;evidence_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Text ready to pass to the LLM as evidence; None if there was no data.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;should_escalate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;`True` if the failure is infrastructure (billing API down) → escalate.
    A &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer not found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; does NOT escalate: it&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s data that simply isn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t there.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last field is a distinction worth stealing. "The API is down" and "this customer doesn't exist" both produce no data, and they are not the same event. One is an outage, the other is an answer.&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;except&lt;/span&gt; &lt;span class="n"&gt;CrmBusinessError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# The API works and said "doesn't exist": missing data, not a hard escalation.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ToolResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;capability&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;capability&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&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="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;should_escalate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;CRMError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Infrastructure: timeout, 5xx, open circuit → escalate.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ToolResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;capability&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;capability&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&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="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;should_escalate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the API really is down, retrying makes it worse — every inbound message burns 15s of timeout × 3 attempts, the worker queue backs up, and the API gets a stampede exactly while it is trying to recover. So there is a circuit breaker, and for the bot "API down" simply translates to &lt;em&gt;escalate fast&lt;/em&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;record_failure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;A failure counts; in HALF_OPEN it reopens immediately.

    Reopening on the first failure in HALF_OPEN is deliberate: if the probe
    request fails, the API is still bad and there is no point spending the
    rest of the threshold to confirm it.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_failures&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_state&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;CircuitState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HALF_OPEN&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_failures&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CircuitState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OPEN&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_opened_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The clock is injected so tests can verify reopening without actually sleeping. A test that does &lt;code&gt;sleep(60)&lt;/code&gt; gets deleted or marked slow, and the most delicate part of the system loses its coverage with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The part that actually matters
&lt;/h2&gt;

&lt;p&gt;Everything above is retrieval hygiene. This is the piece I would push hardest on in a review of anyone else's billing assistant, because &lt;strong&gt;it survives every model upgrade&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The prompt &lt;em&gt;asks&lt;/em&gt; the model not to invent. This &lt;em&gt;verifies&lt;/em&gt; it:&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;class&lt;/span&gt; &lt;span class="nc"&gt;GroundingVerifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;used_chunk_ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;retrieved_chunk_ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;evidence_texts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;tool_result_texts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;GroundingResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two checks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every citation points at real evidence.&lt;/strong&gt; A model citing a chunk id that was never retrieved is inventing its own backing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No number appears without support.&lt;/strong&gt; Every numeric token in the answer — amounts, account numbers, phone numbers, dates — must appear in the retrieved evidence or in a tool result.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The bias is deliberate and the inverse of the pruner: &lt;strong&gt;when in doubt, reject.&lt;/strong&gt; A rejected answer escalates to a human, which is annoying but safe. A hallucination that ships is expensive and costs trust. That module is held to 100% test coverage.&lt;/p&gt;

&lt;h3&gt;
  
  
  The bug that makes this non-trivial
&lt;/h3&gt;

&lt;p&gt;Naive version: strip non-digits from the number, check if those digits appear in the evidence.&lt;/p&gt;

&lt;p&gt;That is wrong, and here is the counterexample that killed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Evidence (from the billing API):  45.00
Model says:                       4.500
Digits of both:                   "4500"  ✓ passes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two different amounts. Same digits. In Venezuelan notation &lt;code&gt;4.500&lt;/code&gt; reads as four thousand five hundred; &lt;code&gt;45.00&lt;/code&gt; is forty-five. Digit equality green-lights a hallucination that changes the magnitude of someone's bill by two orders of magnitude.&lt;/p&gt;

&lt;p&gt;So amounts are compared &lt;strong&gt;by canonical value&lt;/strong&gt;, not by digits:&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;_AMOUNT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^\d{1,3}(?:[.,\s]\d{3})*[.,]\d{1,2}$|^\d+[.,]\d{1,2}$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_amount_value&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Normalise a currency token to canonical `integer.decimals`.

    Returns None if the token is not amount-shaped. This is what stops `45.00`
    and `4.500` being considered equal.

    Treats the LAST `.`/`,` as the decimal separator and the rest as thousands,
    which is the Venezuelan convention (`1.234,56`) and also the English one
    (`1,234.56`).
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;_AMOUNT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&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;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cut&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rfind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rfind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;integer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\D&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;cut&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;decimals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cut&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;integer&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;decimals&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three rules, strictest first:&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;_number_supported&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="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;evidence_digits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;evidence_amounts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# 1. Literal, with digit boundaries. `12345678` is NOT accepted just because
&lt;/span&gt;    &lt;span class="c1"&gt;#    it appears inside a longer `123456789`.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;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="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(?&amp;lt;!\d)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;escape&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="o"&gt;+&lt;/span&gt; &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(?!\d)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. Amount value. `45,00` (Venezuelan decimal comma) matches `45.00` from
&lt;/span&gt;    &lt;span class="c1"&gt;#    the API, but `4.500` does NOT match `45.00` — different values.
&lt;/span&gt;    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_amount_value&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;value&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&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;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;evidence_amounts&lt;/span&gt;

    &lt;span class="c1"&gt;# 3. Digit equality — ONLY for identifiers (phones, accounts, tax IDs:
&lt;/span&gt;    &lt;span class="c1"&gt;#    7+ digits), where separators are cosmetic and don't change the value.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_digits_only&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="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;_MIN_IDENTIFIER_DIGITS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_digits_only&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="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;evidence_digits&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rule 3 is deliberately narrow. A short number that matched neither literally nor as an amount is rejected — &lt;code&gt;4.500&lt;/code&gt; must not pass just because it shares digits with &lt;code&gt;45.00&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The refusal path is the product
&lt;/h2&gt;

&lt;p&gt;The instinct is to treat escalations as the failure rate and drive them to zero. That optimises the wrong thing. Every escalation you remove by loosening the verifier becomes a confident answer about a bill, and some of those are wrong in a way the customer notices.&lt;/p&gt;

&lt;p&gt;Each reason carries a summary the human agent reads, so they don't re-read the whole thread:&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;_AGENT_SUMMARY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;EscalationReason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;EscalationReason&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NO_CONTEXT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The customer asked something that is not in the knowledge base.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;EscalationReason&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UNGROUNDED_OUTPUT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The bot was about to give an unsupported figure; it was stopped for safety.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;EscalationReason&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;USER_REQUEST&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The customer asked to speak to a person.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;EscalationReason&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TOOL_ERROR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Could not query the billing system (possible outage).&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;EscalationReason&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CAPABILITY_MISSING&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The customer wants data the bot cannot query yet.&lt;/span&gt;&lt;span class="sh"&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;p&gt;&lt;code&gt;USER_REQUEST&lt;/code&gt; fires unconditionally. There is always pressure to have the bot try once more before releasing the conversation — it improves your containment metric. It also means the subscriber who typed &lt;em&gt;"I want to talk to a human"&lt;/em&gt; gets a paragraph they didn't ask for. On a channel people use to talk to people, ignoring that request teaches them the bot is an obstacle, not a front door.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'd tell you before you build one
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Route before you retrieve.&lt;/strong&gt; "What does this line item mean" is a document question. "Why is my bill $14 higher" needs account state. Answering the second from the rate card is wrong for every subscriber whose delta came from somewhere else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Numbers from a system of record should reach the user as those numbers.&lt;/strong&gt; The model's job is the sentence around the figure, not the figure. Any delta, total or date comparison is a computation, and computations belong in testable code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instrument refusal rate as a dial, not a defect.&lt;/strong&gt; Tune it against complaints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The metric that matters is verified-wrong answers&lt;/strong&gt;, and it comes from support, not from your eval harness.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One honest note on scope: this is one system for one operator, described as architecture rather than as a benchmark. We are not going to show you a deflection-rate chart from a sample of one.&lt;/p&gt;

&lt;p&gt;Also worth stating plainly: the billing API integration is &lt;strong&gt;contract-first&lt;/strong&gt;. There is a documented HTTP contract and a simulator implementing it; the production system on the other side is still being built. Everything above — capability resolution, circuit breaker, tool-results-as-evidence — runs against that contract. We think that is the right order (the contract is the thing both sides agree on), but you should know it when you read the code.&lt;/p&gt;

&lt;p&gt;Longer version, including why WhatsApp specifically removes every UI affordance you'd normally use to show uncertainty: &lt;strong&gt;&lt;a href="https://softronic.dev/blog/crag-whatsapp-billing-assistant" rel="noopener noreferrer"&gt;Corrective RAG for Billing Questions on WhatsApp&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>python</category>
      <category>llm</category>
      <category>ai</category>
    </item>
    <item>
      <title>Bun vs Node.js in 2026: Should You Actually Migrate?</title>
      <dc:creator>Softronic</dc:creator>
      <pubDate>Tue, 28 Jul 2026 14:35:41 +0000</pubDate>
      <link>https://dev.to/softronic/bun-vs-nodejs-in-2026-should-you-actually-migrate-3e8</link>
      <guid>https://dev.to/softronic/bun-vs-nodejs-in-2026-should-you-actually-migrate-3e8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://softronic.dev/blog/bun-vs-node-2026-migration" rel="noopener noreferrer"&gt;softronic.dev&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://bun.sh/docs" rel="noopener noreferrer"&gt;Bun&lt;/a&gt; finished 2025 at the top of the JavaScript Rising Stars chart, ahead of every framework, library, and tool that year. By Q1 2026 it has hit production at a meaningful number of mid-sized engineering teams. The question on every CTO's desk: &lt;strong&gt;should we actually migrate, or is this another runtime that gets quietly archived next year?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We use Bun every day for tooling, scripts, and CLIs. We still run Node in production for most client servers. Here's the honest, opinionated case for and against — with the benchmarks that matter and the gotchas that bit us.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Bun's speed advantage is real and largest in install and test loops, which is where developer time actually goes.&lt;/li&gt;
&lt;li&gt;The gotchas are concentrated in native modules and less-common Node APIs — pilot before committing a production service.&lt;/li&gt;
&lt;li&gt;The 5% of cases where Bun isn't a drop-in will cost more than the 95% where it is, if you don't plan for them.&lt;/li&gt;
&lt;li&gt;A hybrid is legitimate: Bun for tooling and tests, Node for the runtime, until your dependency graph is clean.
## Why Bun keeps winning&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Bun's pitch is simple: faster, all-in-one, drop-in for most Node code. Each part of that has substance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Faster.&lt;/strong&gt; Bun is built on the JavaScriptCore engine (the same one in Safari) rather than V8, plus a Zig-based runtime designed from scratch for speed. The differences are not subtle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;All-in-one.&lt;/strong&gt; Bun is the runtime, the package manager, the bundler, the test runner, and a TypeScript transpiler in one binary. You can delete &lt;code&gt;npm&lt;/code&gt;, &lt;code&gt;tsx&lt;/code&gt;, &lt;code&gt;jest&lt;/code&gt;/&lt;code&gt;vitest&lt;/code&gt;, &lt;code&gt;webpack&lt;/code&gt;/&lt;code&gt;esbuild&lt;/code&gt;, and a fair amount of &lt;code&gt;package.json&lt;/code&gt; config when you adopt it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Drop-in (mostly).&lt;/strong&gt; Bun implements the Node API surface — &lt;code&gt;fs&lt;/code&gt;, &lt;code&gt;path&lt;/code&gt;, &lt;code&gt;http&lt;/code&gt;, &lt;code&gt;child_process&lt;/code&gt;, npm package resolution, the works. For greenfield code it usually just runs.&lt;/p&gt;

&lt;p&gt;The combination is genuinely productive for a category of work we'll come back to.&lt;/p&gt;

&lt;p&gt;Worth pairing with &lt;a href="https://softronic.dev/blog/typescript-2026-professional-default" rel="noopener noreferrer"&gt;the case for TypeScript as the default&lt;/a&gt;, since the two decisions usually land in the same sprint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real benchmarks from our own work
&lt;/h2&gt;

&lt;p&gt;We re-ran the benchmarks on our own internal services in March 2026, on identical hardware (Apple M2 Pro, 16 GB) and identical workloads. These aren't synthetic microbenchmarks. These are real tasks we run dozens of times per day.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Node 22 LTS&lt;/th&gt;
&lt;th&gt;Bun 1.2&lt;/th&gt;
&lt;th&gt;Bun speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;package.json&lt;/code&gt; install (medium project)&lt;/td&gt;
&lt;td&gt;18.4 s&lt;/td&gt;
&lt;td&gt;2.1 s&lt;/td&gt;
&lt;td&gt;8.8x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test suite (&lt;a href="https://vitest.dev/guide/" rel="noopener noreferrer"&gt;Vitest&lt;/a&gt; vs &lt;code&gt;bun test&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;9.2 s&lt;/td&gt;
&lt;td&gt;1.7 s&lt;/td&gt;
&lt;td&gt;5.4x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript CLI script startup&lt;/td&gt;
&lt;td&gt;380 ms&lt;/td&gt;
&lt;td&gt;35 ms&lt;/td&gt;
&lt;td&gt;10.8x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP server, 50K req hello-world&lt;/td&gt;
&lt;td&gt;12.4 s&lt;/td&gt;
&lt;td&gt;4.9 s&lt;/td&gt;
&lt;td&gt;2.5x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP server, 50K req with DB + JSON parse&lt;/td&gt;
&lt;td&gt;28.1 s&lt;/td&gt;
&lt;td&gt;22.7 s&lt;/td&gt;
&lt;td&gt;1.2x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build (esbuild on Node vs &lt;code&gt;bun build&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;4.6 s&lt;/td&gt;
&lt;td&gt;1.4 s&lt;/td&gt;
&lt;td&gt;3.3x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two patterns to notice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bun crushes Node on cold starts, install times, and test runs.&lt;/strong&gt; This is where the all-in-one tooling and fast startup compound.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For long-running servers doing I/O against a database, the gap narrows to 20-25%.&lt;/strong&gt; Most production workloads are bottlenecked on Postgres or Redis, not the JavaScript runtime. Bun still wins, but not enough to justify a migration on its own.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The takeaway: if your engineering productivity is bottlenecked on tooling speed (install, test, build, dev server), Bun has a strong case. If your production cost is bottlenecked on database latency, Bun's win is smaller than the chart suggests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production gotchas that bit us
&lt;/h2&gt;

&lt;p&gt;We've shipped Bun to production for three client engagements in the last 9 months. Here's what surprised us, in roughly the order of pain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Native modules.&lt;/strong&gt; Bun supports N-API and most native modules, but coverage is not 100%. We hit one issue with a legacy MongoDB driver and one with a niche image-processing library. The fix in both cases was upgrading to a maintained alternative, but that's a migration cost you need to budget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;npm script semantics.&lt;/strong&gt; Most &lt;code&gt;package.json&lt;/code&gt; scripts run, but some assume &lt;code&gt;node_modules/.bin&lt;/code&gt; lookup order or specific environment variable propagation that differs slightly under Bun. The fixes are easy. Finding them in a CI failure at 11 PM is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitoring and APM.&lt;/strong&gt; Datadog, New Relic, and Sentry all officially support Bun now, but the auto-instrumentation has lagged. Some hooks (especially around async context propagation) require manual setup. Plan for a half-day per service to wire it up properly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cluster mode and process management.&lt;/strong&gt; Node's &lt;code&gt;cluster&lt;/code&gt; module is a common production pattern. Bun has its own primitives for this and they work, but the migration is not zero-effort. If you're behind PM2 or a Kubernetes deployment that handles process management, this is a non-issue. If you have homegrown cluster code, plan for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long-tail npm package compatibility.&lt;/strong&gt; Anecdotally we hit a compat issue roughly once per 50 packages. Almost always a niche package, almost always already replaced upstream. But if your codebase has a &lt;code&gt;node_modules&lt;/code&gt; with 800 packages, statistically you will hit a few.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory characteristics differ.&lt;/strong&gt; Bun and Node have different garbage collection profiles. We saw one workload where memory usage was 30% lower under Bun, and one where it was 15% higher. Profile before assuming.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision matrix
&lt;/h2&gt;

&lt;p&gt;After running Bun in production for the last 9 months and Node for the last 12 years, here's how we decide.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Default choice&lt;/th&gt;
&lt;th&gt;Reasoning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;New CLI tool or developer script&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Bun&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Startup speed, single binary, zero config&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Internal tooling, dev scripts, codemods&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Bun&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New Cloudflare Workers / edge service&lt;/td&gt;
&lt;td&gt;Workers runtime&lt;/td&gt;
&lt;td&gt;Edge runtime, not Node or Bun&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New monolith API server&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Node&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Maturity, ecosystem, APM, hiring pool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Greenfield microservice (low risk)&lt;/td&gt;
&lt;td&gt;Either&lt;/td&gt;
&lt;td&gt;Pick what your team knows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Existing Node service, working fine&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Stay on Node&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No migration value unless cold-start matters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Existing Node service, slow CI&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Bun for CI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Run Bun in CI/test, Node in prod = best of both&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latency-sensitive serverless function&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Bun&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cold start is the win&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heavy database I/O service&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Node&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;DB is the bottleneck; Node's maturity wins&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test runner / build tooling&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Bun&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Massive speedup, low risk&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern: &lt;strong&gt;Bun wins decisively for short-lived processes and tooling. Node still wins for long-lived production servers where ecosystem maturity beats raw speed.&lt;/strong&gt; A lot of teams can get most of Bun's value by running it in CI and dev environments while leaving production on Node.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration tactics that actually work
&lt;/h2&gt;

&lt;p&gt;If you decide to migrate, here's the order that's worked for us and our clients.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start with CI.&lt;/strong&gt; Switch &lt;code&gt;npm install&lt;/code&gt; and your test runner to Bun. Zero production risk, immediate developer-experience win. This step alone saves most teams 10-30 minutes per CI run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move local dev next.&lt;/strong&gt; Use Bun as the local dev server. Catch any compatibility issues against your codebase without any production exposure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migrate scripts and codemods.&lt;/strong&gt; Anything in &lt;code&gt;scripts/&lt;/code&gt; that runs on a developer's machine. Bun's startup speed compounds across hundreds of script invocations a day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pilot one production service.&lt;/strong&gt; Pick something non-critical and re-runnable. Run it on Bun in parallel with the Node version, compare metrics for two weeks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roll out gradually, service by service.&lt;/strong&gt; Don't do a big-bang migration. Each service gets its own go/no-go.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A team that does step 1 alone gets 60% of the benefit with 5% of the risk. That's where most of our clients land.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we actually run at Softronic
&lt;/h2&gt;

&lt;p&gt;For internal tooling, scripts, our own design system build, and most client-facing dev environments: Bun.&lt;/p&gt;

&lt;p&gt;For client production servers: usually Node 22 LTS. The exception is latency-sensitive serverless work, where we'll run Bun on Fly.io machines or Cloudflare Containers.&lt;/p&gt;

&lt;p&gt;For Cloudflare Workers (which is where a lot of our 2026 work lives): the Workers runtime, which is neither Bun nor Node and has its own constraints.&lt;/p&gt;

&lt;p&gt;This is a deliberately boring answer. We watch the Bun roadmap closely. The window where it makes sense as the default production runtime is narrowing every quarter, and there's a version — probably Bun 2.0 — where we'll start defaulting to it for greenfield production services too.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few things we'd push back on in the hype cycle
&lt;/h2&gt;

&lt;p&gt;We're broadly bullish on Bun. We also notice the same pattern with every fast-rising runtime: the chart leaders attract a wave of "you're crazy not to migrate" content that glosses over real costs. Worth pushing back on a few claims that have been making the rounds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Bun is a drop-in replacement for Node."&lt;/strong&gt; Mostly yes, sometimes no. The 5% of cases where it isn't will cost you more than the 95% where it is, if you don't plan for them. Pilot before you commit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"You can replace your whole toolchain with Bun."&lt;/strong&gt; True for greenfield work and small teams. For larger teams, the cost of standardizing the toolchain across 30 engineers, CI pipelines, internal templates, and editor configs is real. We've seen teams half-migrate and end up worse off than if they'd picked a side and stuck with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Bun's HTTP server is 4x faster than Node's, period."&lt;/strong&gt; It's faster on hello-world benchmarks. In real workloads bottlenecked on a database, the difference shrinks substantially. Benchmark your workload, not the synthetic test that comes with the runtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Bun supports all of npm."&lt;/strong&gt; It supports most of npm. The packages where it doesn't are concentrated in the long tail and in older or niche native modules. For modern, popular packages — Express, Fastify, Hono, Prisma, Drizzle, Zod, all the framework ecosystems — it's effectively complete.&lt;/p&gt;

&lt;p&gt;The runtime wars in JavaScript over the last decade have rewarded patience. io.js merged back into Node. &lt;a href="https://docs.deno.com/runtime/" rel="noopener noreferrer"&gt;Deno&lt;/a&gt; is still around but didn't take over the way the early hype suggested. Bun has a real shot at being different, and even if it isn't, the parts of it we're using today (the test runner, the package manager, the CLI runtime) have already paid for themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you'd want help
&lt;/h2&gt;

&lt;p&gt;If your team is weighing a Bun migration or trying to figure out where in your stack it actually pays off, we can save you the discovery work. We've done the painful version on real codebases.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://softronic.dev/services#custom" rel="noopener noreferrer"&gt;Our custom software services&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://softronic.dev/hire-latam-engineers" rel="noopener noreferrer"&gt;Hire LatAm Node and Bun engineers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://softronic.dev/contact" rel="noopener noreferrer"&gt;Book a 30-minute call&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick the runtime that fits the workload, not the chart. Bun is real, useful, and worth adopting today — just not always for the part of the stack you assumed.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>bunjs</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
