<?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: Vinicius Pereira</title>
    <description>The latest articles on DEV Community by Vinicius Pereira (@vinimabreu).</description>
    <link>https://dev.to/vinimabreu</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%2F4010065%2Ff3d37966-fcdb-4c21-9df3-f47b258b99bd.jpeg</url>
      <title>DEV Community: Vinicius Pereira</title>
      <link>https://dev.to/vinimabreu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vinimabreu"/>
    <language>en</language>
    <item>
      <title>Python's casefold() merged two of my customers into one tenant</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Wed, 12 Aug 2026 18:17:44 +0000</pubDate>
      <link>https://dev.to/vinimabreu/pythons-casefold-merged-two-of-my-customers-into-one-tenant-1g75</link>
      <guid>https://dev.to/vinimabreu/pythons-casefold-merged-two-of-my-customers-into-one-tenant-1g75</guid>
      <description>&lt;p&gt;I spent a few days building a repo about multi-tenant retrieval. One knowledge base, many customers, and one promise: a customer can never retrieve another customer's documents. The filter runs before the candidate set is scored, the guard re-checks every chunk on the way out, every query lands in an audit log.&lt;/p&gt;

&lt;p&gt;Then I ran an adversarial pass whose only job was to break the promise rather than confirm it. It broke it in four lines of setup, and the hole was in the function I had written specifically to prevent identity confusion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line that was supposed to be boring
&lt;/h2&gt;

&lt;p&gt;Entitlement is compared on tenant identifiers, so identifiers get canonicalised once, at construction, and compared exactly afterwards:&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;normalise_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&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="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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;casefold&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reasoning felt obvious. &lt;code&gt;" ACME "&lt;/code&gt; and &lt;code&gt;acme&lt;/code&gt; are the same customer, somebody will paste one with a trailing space eventually, and a fence that says "access denied" because of a space is a support ticket. &lt;code&gt;casefold()&lt;/code&gt; rather than &lt;code&gt;lower()&lt;/code&gt; because casefold is the Unicode-correct one, the one you are told to use for caseless comparison.&lt;/p&gt;

&lt;p&gt;That is exactly why it is wrong here.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tenant that was two tenants
&lt;/h2&gt;

&lt;p&gt;The adversary created a customer called &lt;code&gt;Straße-Werke&lt;/code&gt; and gave a completely unrelated principal a grant on &lt;code&gt;strasse-werke&lt;/code&gt;. The unrelated principal read the documents.&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="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Straße-Werke&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;casefold&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;strasse-werke&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;casefold&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;casefold()&lt;/code&gt; is designed for caseless matching, and caseless matching is deliberately many-to-one. It is not a case conversion, it is a mapping onto a common form, and several of those mappings collapse characters that are not case variants of each other at all:&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="o"&gt;&amp;gt;&amp;gt;&amp;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="nf"&gt;casefold&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;# one character becomes two
&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ss&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;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="nf"&gt;casefold&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;     &lt;span class="c1"&gt;# KELVIN SIGN
&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;k&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;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="nf"&gt;casefold&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;     &lt;span class="c1"&gt;# LATIN SMALL LIGATURE FI
&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;fi&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;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="nf"&gt;casefold&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&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="nf"&gt;casefold&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# final sigma
&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one of those is a merge. Feed &lt;code&gt;casefold()&lt;/code&gt; two identifiers that an upstream registry considers distinct, and you get one entitlement key. Two customers become one customer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwrsln4q6r9f4b0ulj4hb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwrsln4q6r9f4b0ulj4hb.png" alt="Two distinct tenant identifiers entering casefold and leaving as a single entitlement key" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worse than a normal access bug
&lt;/h2&gt;

&lt;p&gt;Nothing failed. That is the part worth sitting with.&lt;/p&gt;

&lt;p&gt;The filter ran correctly, against a key that was wrong. The candidate set was built correctly, from a key that was wrong. My guard, the deliberate second check that re-validates every chunk before it leaves, calls the same entitlement function, so it agreed. The audit log recorded a completely ordinary query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;QUERY principal=svc-partner allow=[strasse-werke/*/*] deny=[] returned=3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no exception, no anomaly, no unusual pattern. A reviewer reading that line sees a principal reading documents it is entitled to read, because by the time anything is logged, the two tenants are already the same tenant. Every layer of defence in depth inherits the same wrong key, so depth buys you nothing. The mistake happened before the first layer ran.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the same bug again, wearing different clothes
&lt;/h2&gt;

&lt;p&gt;Having fixed the fold, I still had &lt;code&gt;strip()&lt;/code&gt;. &lt;code&gt;str.strip()&lt;/code&gt; with no argument removes all Unicode whitespace, not just the ASCII kind:&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="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kronos&lt;/span&gt;&lt;span class="se"&gt;\u3000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kronos&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# IDEOGRAPHIC SPACE
&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same applies to &lt;code&gt;U+00A0&lt;/code&gt;, &lt;code&gt;U+2007&lt;/code&gt;, &lt;code&gt;U+1680&lt;/code&gt;, &lt;code&gt;U+205F&lt;/code&gt;, &lt;code&gt;U+0085&lt;/code&gt; and &lt;code&gt;U+2028&lt;/code&gt;. So a registry that enforces uniqueness on the raw string happily accepts &lt;code&gt;kronos&lt;/code&gt; and &lt;code&gt;kronos&lt;/code&gt; plus an ideographic space as two different customers, and my fence folds them into one.&lt;/p&gt;

&lt;p&gt;The detail that makes this a good trap: zero-width space and the other invisible characters that people usually test for, &lt;code&gt;U+200B&lt;/code&gt;, the soft hyphen, the byte order mark, are &lt;strong&gt;not&lt;/strong&gt; stripped. So a test suite that covers "invisible characters" in the obvious way passes while the actual merge path stays open.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fye53yey3ta18cbehsymr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fye53yey3ta18cbehsymr.png" alt="Seven Unicode whitespace code points that str.strip removes, next to four invisible characters it does not" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two fixes I tried and threw away
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;lower()&lt;/code&gt; instead of &lt;code&gt;casefold()&lt;/code&gt;.&lt;/strong&gt; It reads like the conservative choice. It does not help:&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="o"&gt;&amp;gt;&amp;gt;&amp;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="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;k&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Kelvin sign folds under &lt;code&gt;lower()&lt;/code&gt; too, because that mapping is simple case conversion, not full folding. Swapping the function narrows the hole without closing it, which is the worst outcome available: it feels fixed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reject non-ASCII identifiers at construction.&lt;/strong&gt; This closes it, and it is wrong for a different reason. It refuses legitimate identifiers from most of the world in order to fix a bug in my normalisation, and it hides the actual rule behind a character-set restriction that has nothing to do with the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Canonicalise as little as possible, and refuse anything that would need canonicalising to be safe:&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;ASCII_FOLD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;maketrans&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ascii_uppercase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ascii_lowercase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ASCII_WHITESPACE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="se"&gt;\t\n\r\v\f&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;normalise_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&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="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Trim ASCII whitespace, fold ASCII case, and nothing else.&lt;/span&gt;&lt;span class="sh"&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ASCII_WHITESPACE&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ASCII_FOLD&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;Straße-Werke&lt;/code&gt; and &lt;code&gt;strasse-werke&lt;/code&gt; now stay two tenants. A tenant named with non-ASCII characters keeps working, and keeps its identity.&lt;/p&gt;

&lt;p&gt;Then the second half, which matters more than the first: anything still carrying whitespace or a non-printable character after the trim is refused at construction, naming the offending code point in the error. Not folded onto its neighbour, not silently accepted. A tenant id with an ideographic space in the middle of it is a data problem upstream, and the honest thing a fence can do is say so out loud instead of guessing which neighbour it meant.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0uzpjz5kfpqfc58e4bsm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0uzpjz5kfpqfc58e4bsm.png" alt="An identifier carrying an invisible character refused at construction, with the code point named in the error" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The general shape of it
&lt;/h2&gt;

&lt;p&gt;The lesson generalises past Unicode, and it is the reason I am writing this down rather than quietly pushing the patch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When a normalised value becomes a security key, normalisation is part of the attack surface.&lt;/strong&gt; Any many-to-one transform applied to an identity is a merge operation, and a merge between two identities is a privilege grant. Case folding, accent stripping, whitespace collapsing, punctuation removal, homoglyph mapping, lowercasing an email local part: all of them are helpful in a search box and all of them are dangerous the moment the output is compared for authorisation.&lt;/p&gt;

&lt;p&gt;Two questions worth asking of any such function:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Can two inputs that the system elsewhere considers distinct produce the same output?&lt;/li&gt;
&lt;li&gt;If they can, which layer is supposed to notice? If the answer is "the layer that compares the outputs", there is no such layer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The reason I found this at all is that the adversarial pass was told to breach the fence, not to test it. A test written by the person who wrote the code asks "does it do what I meant". A test written to break it asks "what did I mean that was wrong". Those find different bugs, and the second kind is the kind that reaches production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the repo concentrates risk there on purpose
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9nw2i6yh1zzgoxhhguzz.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9nw2i6yh1zzgoxhhguzz.gif" alt="The same account asking the same question of the same corpus: three sections when the fence runs before the ranking, none at all when it runs after" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The repo's actual argument is older and simpler than the Unicode story. Most multi-tenant retrieval filters after the ranking, which means a leak needs one caller, one cache, one new endpoint to forget. Filtering before the candidate set is scored moves all of that risk into a single function.&lt;/p&gt;

&lt;p&gt;Which is precisely how I ended up here. Concentrating the risk is the right trade, and the bill it comes with is that the one function you concentrated it into is now the only thing worth attacking. That is why it carries 141 adversarial tests, and why four of them are about a character that is not a case variant of anything.&lt;/p&gt;

&lt;p&gt;The repo is at &lt;a href="https://github.com/vinimabreu/tenant-fence" rel="noopener noreferrer"&gt;github.com/vinimabreu/tenant-fence&lt;/a&gt;: the fence, the deliberately wrong version kept next to it so the suite can demonstrate the leak rather than describe it, and the tests that would have caught this on day one.&lt;/p&gt;

</description>
      <category>python</category>
      <category>security</category>
      <category>unicode</category>
      <category>ai</category>
    </item>
    <item>
      <title>My exactly-once refund paid twice, and 373 passing tests could never have caught it</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Tue, 04 Aug 2026 21:24:54 +0000</pubDate>
      <link>https://dev.to/vinimabreu/my-exactly-once-refund-paid-twice-and-373-passing-tests-could-never-have-caught-it-4g72</link>
      <guid>https://dev.to/vinimabreu/my-exactly-once-refund-paid-twice-and-373-passing-tests-could-never-have-caught-it-4g72</guid>
      <description>&lt;p&gt;I spent a week building a repo about making LangGraph agents trustworthy in production. Routing measured against labelled fixtures instead of vibes. A human gate before anything irreversible. Durable resume, so a process that dies mid-refund comes back and does not pay the customer twice.&lt;/p&gt;

&lt;p&gt;373 tests, all green. CI green on four Python versions. Then I ran an adversarial pass over it, whose only job was to break my claims rather than confirm them, and the first thing it broke was the one I had written the repo to prove.&lt;/p&gt;

&lt;h2&gt;
  
  
  The claim
&lt;/h2&gt;

&lt;p&gt;The centre of the whole thing is one sentence from my README:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;INSERT OR IGNORE&lt;/code&gt; then read. The claim and the check are one statement, so two attempts cannot both believe they are first.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Standard idempotency. Every effectful action gets a key, the key is inserted once, and whoever loses the race replays the stored result instead of running the handler again. My tests hammered it: eight concurrent processes on one key, one handler execution, seven replays. Crash injected at every boundary in the run, resumed from disk, effect count unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;The key was built like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;action_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ticket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ticket_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;call&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;args&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;# &amp;lt;- what the model typed
&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;call.args&lt;/code&gt; is raw model output. A language model asked for a $45.00 refund can write that amount in more than one way, and all of them are valid JSON that my schema happily accepts:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    4500  -&amp;gt;  action_id 1270c45d1612d5d1
  "4500"  -&amp;gt;  action_id 8f0f6363fea8adcf
  4500.0  -&amp;gt;  action_id b78a82d722dde95d

distinct idempotency keys for one identical $45.00 refund: 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Three keys. Three refunds. Same customer, same invoice, same amount.&lt;/p&gt;

&lt;p&gt;If a queue retries a ticket, or a customer submits twice, or the same conversation is driven again for any of the boring reasons production does that, and the model spells the number differently on the second pass, my exactly-once guarantee quietly becomes at-least-once. On money.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why my tests were structurally incapable of finding it
&lt;/h2&gt;

&lt;p&gt;This is the part I keep thinking about.&lt;/p&gt;

&lt;p&gt;The test suite runs offline against a deterministic stand-in: a keyword classifier that, given the same ticket, emits byte-identical JSON every single time. That is a deliberate design choice and mostly a good one. It makes the suite fast, free, and reproducible, with no API key and no network.&lt;/p&gt;

&lt;p&gt;It also means the second delivery of a ticket always produced exactly the same string as the first. The suite could never observe the failure, because the only component capable of producing the failure had been replaced with one that cannot.&lt;/p&gt;

&lt;p&gt;My tests were not weak. They were blind by construction, in a way that green output cannot show you. Every assertion I had written was true. The thing I never asserted was that two &lt;em&gt;equivalent&lt;/em&gt; calls collapse to one key, because with a deterministic generator, equivalent and identical are the same word.&lt;/p&gt;

&lt;p&gt;The fix is one line of intent: derive the key from the arguments &lt;strong&gt;after&lt;/strong&gt; the tool's own schema has parsed them, not from whatever the model typed.&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;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;validate_args&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;model_dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;action_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ticket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ticket_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;call&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;args&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;# parsed, not typed
&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Now &lt;code&gt;4500&lt;/code&gt;, &lt;code&gt;"4500"&lt;/code&gt;, &lt;code&gt;4500.0&lt;/code&gt;, &lt;code&gt;" 4500 "&lt;/code&gt; and &lt;code&gt;"+4500"&lt;/code&gt; all produce one key, while seven genuinely different refunds still produce seven. And there is a test that drives the same ticket twice with different spellings and asserts a single handler execution, so it stays fixed.&lt;/p&gt;
&lt;h2&gt;
  
  
  The second thing it broke: a metric that was measuring the wrong noun
&lt;/h2&gt;

&lt;p&gt;While it was in there, the same pass killed a number I had been quoting proudly.&lt;/p&gt;

&lt;p&gt;My report said &lt;code&gt;tool choice 21/22 correct (95.5%)&lt;/code&gt;. It compared the &lt;em&gt;name&lt;/em&gt; of the tool the model picked against the expected name. Nothing anywhere compared the arguments.&lt;/p&gt;

&lt;p&gt;So this ticket:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;We were charged $90.00 on INV-10032 but only $45.00 of that was valid. Refund the difference.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;with the model proposing &lt;code&gt;issue_refund(invoice_ref="INV-10032", amount_cents=9000)&lt;/code&gt; scored as &lt;strong&gt;perfectly correct&lt;/strong&gt;. Right tool, double the money. A version proposing $9,000 instead of $45 also scored perfectly correct.&lt;/p&gt;

&lt;p&gt;"Chose the right action" and "chose the right tool name" are not the same claim, and I had been publishing the second one under the first one's label.&lt;/p&gt;

&lt;p&gt;The fix added expected arguments to every fixture and a separate metric that counts them. Which is why my headline numbers got &lt;em&gt;worse&lt;/em&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;routing accuracy&lt;/td&gt;
&lt;td&gt;92.0%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;90.2%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tool metric&lt;/td&gt;
&lt;td&gt;21/22 name only&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;21/23 counting arguments&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;in-doubt stops&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;crash boundaries swept&lt;/td&gt;
&lt;td&gt;176&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;208&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nothing regressed. The measurements stopped flattering me. The in-doubt count tripled because the audit also found two windows inside the effect where a crash could land and my crash-point list did not know they existed, which meant a line in my report was true by accident rather than by design.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the repo does now
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0gcxg04rt6izn7uk8oxn.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0gcxg04rt6izn7uk8oxn.gif" alt="The full run in 21 seconds: routing scored, an irreversible refund held at the human gate, and the effect count staying at one across a crash" width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The whole thing in one pass. What follows is the same three acts, one at a time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg9wplyjtxiuhbv3llqal.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg9wplyjtxiuhbv3llqal.png" alt="Routing measured against 51 labelled fixtures, and a parser that turns untrusted output into a named escalation instead of an exception" width="800" height="1414"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Routing is scored per branch with a confusion matrix, and the parser never raises: output it cannot trust becomes an escalation carrying one of nine named reasons, recorded in state.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqn3a6ad3h5ymm9elvvtv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqn3a6ad3h5ymm9elvvtv.png" alt="An irreversible refund held at the human gate, with must-stop and must-not-stop both measured" width="800" height="789"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Irreversible tools stop for a human, and the interrupt is measured in &lt;strong&gt;both&lt;/strong&gt; directions. Missing a required stop is scored at threshold zero, because there is no acceptable rate for shipping a refund nobody approved. Stopping when you did not need to is scored separately with a real budget, because approval fatigue is how a gate stops meaning anything: interrupt people often enough for nothing and they start clicking approve without reading.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4zygxufylxj60feae8ae.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4zygxufylxj60feae8ae.png" alt="The same run killed mid-effect and resumed: the handler execution count stays at one" width="800" height="663"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And the part that took the longest to get honest. The process can be killed at any of 208 points in a run and resumed from disk. 112 of the reachable boundaries come back byte-identical with the effect count unchanged. The other 24 land in a window that genuinely cannot be closed, between claiming the effect and recording its result, and there the system stops and says it does not know rather than guessing. A human reconciles it with the ledger in front of them.&lt;/p&gt;

&lt;p&gt;Zero duplicates. Zero mismatches. A CI gate fails the build when any of those numbers moves.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/vinimabreu" rel="noopener noreferrer"&gt;
        vinimabreu
      &lt;/a&gt; / &lt;a href="https://github.com/vinimabreu/langgraph-production" rel="noopener noreferrer"&gt;
        langgraph-production
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A LangGraph support agent with the reliability layer around it: routing measured against labelled fixtures, a human gate before irreversible tools, durable resume with exactly-once effects, and a CI gate that fails when a number moves.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;langgraph-production&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;a href="https://github.com/vinimabreu/langgraph-production/actions/workflows/ci.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/vinimabreu/langgraph-production/actions/workflows/ci.yml/badge.svg" alt="CI"&gt;&lt;/a&gt;
&lt;a href="https://github.com/vinimabreu/langgraph-production/blob/main/pyproject.toml" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/120bed4ec9a0320556c08001e442cd21152627795aac866e8abb82a37c8cd5e7/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f707974686f6e2d332e3131253230253743253230332e3132253230253743253230332e3133253230253743253230332e31342d626c7565" alt="Python"&gt;&lt;/a&gt;
&lt;a href="https://github.com/vinimabreu/langgraph-production/blob/main/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/f8df3091bbe1149f398a5369b2c39e896766f9f6efba3477c63e9b4aa940ef14/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d677265656e" alt="License: MIT"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;A LangGraph agent with the reliability layer that decides whether it can ship.&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/vinimabreu/langgraph-production/assets/langgraph-production.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fvinimabreu%2Flanggraph-production%2FHEAD%2Fassets%2Flanggraph-production.gif" alt="The routing scorecard, the human gate before an irreversible refund, and a crash that leaves the effect count at one"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Building the graph is the easy half. The half that decides whether it goes live
is everything around it: what happens when the process dies between two nodes
whether the refund gets paid twice on the way back up, whether an approval
somebody gave on Tuesday can authorise a different action on Thursday, and
whether anyone can say out loud how often the router is right.&lt;/p&gt;
&lt;p&gt;This repo answers those four questions with code and with numbers, on a support
desk for a fictional product. All data is synthetic.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;The report this repo exists to produce&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Real output from &lt;code&gt;python -m langgraph_production.evaluation&lt;/code&gt; on this
repository, not an illustration:&lt;/p&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;
&lt;pre class="notranslate"&gt;&lt;code&gt;========================================================================
SUPPORT GRAPH EVALUATION
========================================================================
  fixtures      answer 13, escalate 15, tool 23  (total 51)
ROUTING
  accuracy      46/51 = 90.2%
  macro F1      0.907
  tool choice   22/23 correct (95.7%) on&lt;/code&gt;&lt;/pre&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/vinimabreu/langgraph-production" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  The takeaway I did not expect
&lt;/h2&gt;

&lt;p&gt;I went in expecting the audit to find sloppy corners. It found the opposite: my careful parts were fine, and the failure was hiding in the seam between two decisions that were each individually correct.&lt;/p&gt;

&lt;p&gt;Using a deterministic stand-in for tests: correct.&lt;br&gt;
Keying idempotency off the tool call: correct.&lt;/p&gt;

&lt;p&gt;Together they produce a system that is provably exactly-once against a generator that cannot vary, and at-least-once against the one you actually ship with.&lt;/p&gt;

&lt;p&gt;So the question I now ask about any test suite, including yours: &lt;strong&gt;what can this suite not observe, by construction?&lt;/strong&gt; Not what did I forget to assert. What has been designed out of the room. If you replace the non-deterministic component with a deterministic one for testing, every bug that only exists because of non-determinism is invisible to you, and it will stay invisible while the dashboard stays green.&lt;/p&gt;

&lt;p&gt;Green tests are evidence about the world you built for them. It is worth being explicit about how much of the real one you left out.&lt;/p&gt;

&lt;p&gt;Code, numbers, and the harness that produces them: &lt;a href="https://github.com/vinimabreu/langgraph-production" rel="noopener noreferrer"&gt;github.com/vinimabreu/langgraph-production&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Vinicius Pereira&lt;br&gt;
vinimabreu.dev · github.com/vinimabreu&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>testing</category>
      <category>langchain</category>
    </item>
    <item>
      <title>The ChocoDEV Signature Bar: one chocolate bar, zero images</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Sun, 02 Aug 2026 22:08:15 +0000</pubDate>
      <link>https://dev.to/vinimabreu/the-chocodev-signature-bar-one-chocolate-bar-zero-images-19l7</link>
      <guid>https://dev.to/vinimabreu/the-chocodev-signature-bar-one-chocolate-bar-zero-images-19l7</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/frontend-2026-07-29"&gt;Frontend Challenge: Comfort Food Edition&lt;/a&gt;, CSS Art.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Inspiration
&lt;/h2&gt;

&lt;p&gt;Yesterday I submitted a landing page for ChocoDEV, a chocolate brand that exists only in CSS. A brand needs a product shot, and this brand has a rule: no images, ever. So the product shot had to be drawn in the only medium the brand allows.&lt;/p&gt;

&lt;p&gt;It is one chocolate bar with a bite taken out of it, because a perfect untouched bar is a lie. Nobody photographs their comfort food before tasting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;Live: &lt;a href="https://vinimabreu.dev/chocodev/bar" rel="noopener noreferrer"&gt;vinimabreu.dev/chocodev/bar&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fallen chunk reacts if you click it. That is the entire JavaScript budget, three lines, and the piece is complete without them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9eyq4xsq2qu8ccmj44rx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9eyq4xsq2qu8ccmj44rx.png" alt="The ChocoDEV Signature Bar, a CSS chocolate bar with a bite taken out" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Journey
&lt;/h2&gt;

&lt;p&gt;Every chunk is a molded facet: four trapezoid faces built from angular gradients, so the light lands differently on each slope, the way a real mold presses chocolate. The letters are not printed on top, they are pressed in, one dark shadow above and one thin highlight below, and that two-shadow trick is the whole illusion of depth.&lt;/p&gt;

&lt;p&gt;The bite was the hard part. A clean clip-path zigzag looked like scissors had done it, so the exposed edge got fracture facets in lighter matte browns and a few crumbs, because chocolate never breaks politely.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu02t5g92ri2125u6y60x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu02t5g92ri2125u6y60x.png" alt="Close up of the bite edge, fracture facets and crumbs, with the fallen S chunk" width="800" height="686"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There is exactly one animation, a slow sheen that crosses the bar like it is turning under a studio light, and it sits behind a prefers-reduced-motion guard. When your system asks for stillness, the bar just stands there looking edible.&lt;/p&gt;

&lt;p&gt;Everything is gradients, box-shadow, border-radius and clip-path. Open devtools, it is chocolate all the way down.&lt;/p&gt;

</description>
      <category>frontendchallenge</category>
      <category>devchallenge</category>
      <category>css</category>
      <category>showdev</category>
    </item>
    <item>
      <title>ChocoDEV: a chocolate bar you can eat, in pure CSS</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Sun, 02 Aug 2026 21:22:19 +0000</pubDate>
      <link>https://dev.to/vinimabreu/chocodev-a-chocolate-bar-you-can-eat-in-pure-css-dga</link>
      <guid>https://dev.to/vinimabreu/chocodev-a-chocolate-bar-you-can-eat-in-pure-css-dga</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/frontend-2026-07-29"&gt;Frontend Challenge: Comfort Food Edition&lt;/a&gt;, Perfect Landing.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;ChocoDEV is a landing page for a chocolate brand that does not exist, and I am fine with that. Chocolate is my comfort food, and it turns out it is also South America's: the oldest traces of cacao were found in the upper Amazon, which as a Brazilian I consider a home win.&lt;/p&gt;

&lt;p&gt;The page has one rule: zero images. No PNGs, no SVGs, no background URLs. Every square of chocolate, every drip, every bevel is gradients, box-shadow and keyframes. The footer makes the claim and devtools can check it, which is my favorite kind of claim.&lt;/p&gt;

&lt;p&gt;The centerpiece is a chocolate bar you can actually eat. Each chunk is a button, each bite updates a counter, and when the bar is gone you get to bake a new one. There is also an exploded anatomy view of a single square, a scroll progress bar, and a testimonials section where four honest people confess things about chocolate that most of us only think.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;Live demo: &lt;a href="https://vinimabreu.dev/chocodev" rel="noopener noreferrer"&gt;vinimabreu.dev/chocodev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click a chunk of the bar. The counter is watching.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqpqc8a8fsn8wpc6wsust.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqpqc8a8fsn8wpc6wsust.png" alt="The ChocoDEV bar with three chunks eaten and a bake a new bar button" width="800" height="555"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Journey
&lt;/h2&gt;

&lt;p&gt;The zero images rule sounded like a gimmick and turned into the whole education. A believable chocolate square needs light coming from somewhere, and without images that means layered box-shadows: an inset highlight on top, an inset shadow at the bottom, a drop shadow below. Once the light direction was consistent, everything suddenly looked edible. Before that it looked like brown buttons.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffheiztkgq1ukg89kx5i4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffheiztkgq1ukg89kx5i4.png" alt="Exploded anatomy view of a chocolate square, five labeled layers" width="800" height="550"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The part I am most proud of is not visible. The page respects prefers-reduced-motion in three places: the CSS animations, the stat counters that jump straight to their final value instead of counting up, and the parallax that simply declines to run. Every interactive element is a real button with a name a screen reader can speak, the document has a language and a title, and the menu wraps instead of clipping on narrow screens. Accessibility is a judging criterion in this challenge, and I think it should be the first one: a landing page that only works for some visitors is a poster.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl9e8a3awgyhlonuo7x4z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl9e8a3awgyhlonuo7x4z.png" alt="Four flavor cards: Amazonia 70, Milk and Sea Salt 55, Caramel Crunch 64, Midnight Compile 100" width="800" height="538"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What I would do next: a dark and milk chocolate theme toggle, and sound on the snap. Probably a mistake. Most good ideas about chocolate are.&lt;/p&gt;

&lt;p&gt;Thanks for reading, and go eat something that makes you feel at home.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>frontendchallenge</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Everything that broke when I imported hand-written Make blueprints into a real workspace</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Tue, 28 Jul 2026 17:52:42 +0000</pubDate>
      <link>https://dev.to/vinimabreu/everything-that-broke-when-i-imported-hand-written-make-blueprints-into-a-real-workspace-2k7a</link>
      <guid>https://dev.to/vinimabreu/everything-that-broke-when-i-imported-hand-written-make-blueprints-into-a-real-workspace-2k7a</guid>
      <description>&lt;p&gt;The import dialog took my JSON without a single complaint. The first execution died on module 2 with &lt;code&gt;BundleValidationError: Validation failed for 7 parameter(s).&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That gap between "the import accepted it" and "the runtime will actually run it" is where I spent most of a day this week, and almost none of it is documented anywhere I could find. So here it is, in the order it broke.&lt;/p&gt;

&lt;p&gt;Context, briefly. I write Make (ex-Integromat) scenarios as blueprint JSON by hand instead of assembling them in the canvas, because I want them in git, diffable, reviewable. The scenarios route an order flow between an ERP and a 3PL. Every decision that can go wrong (is this order complete, which carrier code does a typo'd shipping method mean, is this event a duplicate) lives in a typed FastAPI service with 83 pytest tests, and every response carries the same envelope: &lt;code&gt;decision&lt;/code&gt; for the routers to branch on, &lt;code&gt;reason&lt;/code&gt; for a human, &lt;code&gt;evidence&lt;/code&gt; for the audit trail. The scenario routes; the service decides. To test the whole thing for real, I imported the blueprints into a live Make workspace and exposed the local service through a cloudflared tunnel.&lt;/p&gt;

&lt;p&gt;Then the workspace started grading my homework.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Import is a parser, not a validator
&lt;/h2&gt;

&lt;p&gt;The blueprints were clean JSON. Modules, routes, mappings, all structurally valid, and the import agreed. The first run did not: &lt;code&gt;Validation failed for 7 parameter(s)&lt;/code&gt;, naming seven fields I had never typed in my life, all missing from the HTTP module's mapper: &lt;code&gt;serializeUrl&lt;/code&gt;, &lt;code&gt;shareCookies&lt;/code&gt;, &lt;code&gt;rejectUnauthorized&lt;/code&gt;, &lt;code&gt;followRedirect&lt;/code&gt;, &lt;code&gt;useQuerystring&lt;/code&gt;, &lt;code&gt;gzip&lt;/code&gt;, &lt;code&gt;useMtls&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;These are the checkboxes the canvas quietly fills in when you drop an HTTP module onto a scenario. Write the module by hand and nobody fills them, and import does not care, because import only checks that the JSON parses into modules and routes. The module's own contract is enforced at execution time.&lt;/p&gt;

&lt;p&gt;Fine. I added the seven and ran again.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Validation failed for 1 parameter(s)&lt;/code&gt;: &lt;code&gt;followAllRedirects&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The first error listed seven missing parameters when there were eight. Runtime validation happens in layers, and each run only surfaces the layer it died in. Budget one execution per layer of complaints; the error list in front of you is not the whole bill.&lt;/p&gt;

&lt;p&gt;Here is the mapper that finally runs, straight from the blueprint:&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="nl"&gt;"mapper"&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;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://YOUR-SERVICE-HOST/orders/validate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"post"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"headers"&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Content-Type"&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;"application/json"&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;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;order_ref&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;{{1.order_ref}}&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;customer&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: {{13.json}}, &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;shipping_address&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: {{14.json}}, &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;shipping_method&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;{{1.shipping_method}}&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;lines&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: {{12.json}}}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"serializeUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"shareCookies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rejectUnauthorized"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"followRedirect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"followAllRedirects"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"useQuerystring"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gzip"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"useMtls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&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;Keep that block. It is the difference between a blueprint that imports and a blueprint that runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;{{1.body}}&lt;/code&gt; does not exist, and the failure mode is silence
&lt;/h2&gt;

&lt;p&gt;I had written the webhook mappings the way anyone with HTTP reflexes would: the webhook receives a POST, so the payload must live in &lt;code&gt;{{1.body}}&lt;/code&gt;. It does not. Make's custom webhook parses the incoming JSON and exposes the fields at the top level of the module output: &lt;code&gt;{{1.order_ref}}&lt;/code&gt;, &lt;code&gt;{{1.shipping_method}}&lt;/code&gt;. There is no &lt;code&gt;body&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And here is the part that costs hours: referencing a path that does not exist is not an error in Make. It renders as empty. Silently. My service received &lt;code&gt;b''&lt;/code&gt; first (the whole body reference resolved to nothing), and after a partial fix, the literal string &lt;code&gt;null&lt;/code&gt;. To the validator those looked like genuinely broken requests, so it did its job and reported them as such, which pointed me at the wrong suspect. I went through the service looking for a bug that was not there. The service was fine. The bug was an absence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ten lines that ended the guessing
&lt;/h2&gt;

&lt;p&gt;What broke the loop was not reading the scenario harder. It was logging what actually crossed the wire:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@app.middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;log_raw_body&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call_next&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&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="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;method&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; raw=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;call_next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Behind a tunnel, this is a complete diagnostic rig for a visual tool. Every mapping experiment in the canvas shows up seconds later as the exact bytes it produced. My terminal reads like a fever chart of the session: &lt;code&gt;raw=b''&lt;/code&gt;, then &lt;code&gt;raw=b'null'&lt;/code&gt;, then a perfect payload. Once I could see what Make sent instead of inferring it from downstream symptoms, every remaining bug fell in minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Collections do not serialize into text fields
&lt;/h2&gt;

&lt;p&gt;Next, the ops-alert branch. When validation fails, the scenario posts an alert carrying the list of gaps, and I mapped the gaps array straight into the raw JSON body of that request. Make rendered the collection as text, inside the quotes it happened to land in, and produced a body that was no longer JSON. The endpoint said 422, and it was right to.&lt;/p&gt;

&lt;p&gt;A collection mapped into a text field does not become JSON. It becomes a string shaped like whatever Make's text rendering of that collection is, which inside a hand-built JSON body is a syntax error.&lt;/p&gt;

&lt;p&gt;Two fixes, both in the final blueprints: run each collection through a JSON &amp;gt; Transform to JSON module and embed the result without surrounding quotes (&lt;code&gt;{{12.json}}&lt;/code&gt;, not &lt;code&gt;"{{12.json}}"&lt;/code&gt;), or keep alert bodies scalar-only (&lt;code&gt;gap_count&lt;/code&gt; instead of the gaps array). Look back at the mapper above and you can see both rules at work: webhook scalars quoted, collections embedded bare.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The default schedule is a 15-minute tick
&lt;/h2&gt;

&lt;p&gt;The scenario worked. Then it appeared to die. I sent test events and nothing happened. No error, no execution, no log line. Nothing.&lt;/p&gt;

&lt;p&gt;Make's default schedule runs a scenario every 15 minutes. A webhook trigger without "Immediately as data arrives" queues events silently until the next tick. To anyone watching, a perfectly healthy integration looks down for up to fourteen minutes at a stretch. One dropdown. Nothing broken. This is the incident titled "the integration stopped working" that resolves itself before anyone finishes triaging it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the wire showed when it all worked
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flaa10vfeh0s6l2pexujv.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flaa10vfeh0s6l2pexujv.gif" alt="Three webhook events decided and routed: a clean order created at the 3PL, a broken order alerted with seven named gaps, a typo'd shipping method held for a human with a ranked candidate" width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every decision, reason, gap and score above is taken verbatim from the live runs described in this post.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three routes, executed live through the imported scenario, values as returned:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A clean order came back &lt;code&gt;VALID&lt;/code&gt; with 15 checks passed, the shipping method mapped &lt;code&gt;MAPPED_EXACT&lt;/code&gt; to &lt;code&gt;SM-STD-01&lt;/code&gt;, and the order was created at the 3PL. The happy path nobody writes posts about.&lt;/li&gt;
&lt;li&gt;A broken order came back &lt;code&gt;INVALID&lt;/code&gt; with the reason &lt;code&gt;7 gap(s) found, 8 check(s) passed&lt;/code&gt; (the same 15 checks, now split), and routed to the ops alert with &lt;code&gt;gap_count&lt;/code&gt; 7. The router branched on &lt;code&gt;decision&lt;/code&gt;; the reason string went into the alert text unchanged.&lt;/li&gt;
&lt;li&gt;An order with the shipping method typed as "parcel" scored 0.60 on fuzzy match, below the 0.85 floor, so the service returned &lt;code&gt;UNMAPPED&lt;/code&gt; with one ranked candidate and the reason &lt;code&gt;returning 1 candidate(s) for human review instead of guessing&lt;/code&gt;. The scenario put the order on hold for a human.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last route is the whole argument. A visual flow will route a wrong guess with the same confidence as a right one. The refusal to guess has to come from somewhere with a test suite.&lt;/p&gt;

&lt;h2&gt;
  
  
  The outage I did not schedule
&lt;/h2&gt;

&lt;p&gt;Mid-session, cloudflared dropped the tunnel for real. Cloudflare error 1033, service unreachable. The &lt;code&gt;Break&lt;/code&gt; error handler on the HTTP module (current Make docs call the directive Retry; the blueprint directive is still &lt;code&gt;builtin:Break&lt;/code&gt;) parked the execution in Incomplete Executions with its mappings intact. Nothing lost, nothing half-applied, one click away from resolving once the service was reachable again. The exact failure mode this design exists to survive walked in unannounced, and the pattern held. I could not have scripted a better demo, and for once I didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not tell you
&lt;/h2&gt;

&lt;p&gt;The middleware shows what arrives, not what Make intended. A mapping bug that happens to produce valid-shaped JSON with wrong values sails through both the wire log and the schema check. The semantic checks still have to live in the service.&lt;/p&gt;

&lt;p&gt;None of this makes Make the villain. The event queue, the retry policy, the incomplete-executions safety net are better than what most hand-rolled webhook consumers ship. That is exactly why the routing belongs there, and the deciding does not.&lt;/p&gt;

&lt;p&gt;Hand-writing blueprints is a real trade. The canvas would have filled those eight HTTP parameters for me and I would never have met &lt;code&gt;BundleValidationError&lt;/code&gt;. I pay that tax to get scenarios in git with reviewable diffs. If you do not need the diff, click the modules.&lt;/p&gt;

&lt;p&gt;And every Transform to JSON module is one more operation on every run, which is the unit Make bills in. Scalar alert bodies are not just simpler. They are cheaper.&lt;/p&gt;

&lt;p&gt;The service, both importable blueprints with all eight parameters already in place, and these lessons written down live at &lt;a href="https://github.com/vinimabreu/make-failsafe" rel="noopener noreferrer"&gt;github.com/vinimabreu/make-failsafe&lt;/a&gt;, mostly so I never rediscover any of this at 2 a.m.&lt;/p&gt;

&lt;p&gt;The flow routes, the service decides, and the wire is the only place where both of them tell the truth.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>debugging</category>
      <category>python</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your LLM's confidence score is lying to you</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Mon, 20 Jul 2026 11:31:33 +0000</pubDate>
      <link>https://dev.to/vinimabreu/your-llms-confidence-score-is-lying-to-you-45le</link>
      <guid>https://dev.to/vinimabreu/your-llms-confidence-score-is-lying-to-you-45le</guid>
      <description>&lt;p&gt;The most expensive bug in an LLM system is not the output that is obviously broken. That one you catch. It is the fluent, well formed, confidently wrong answer that reads exactly like a correct one and walks straight into your system of record because nothing was standing between the model and production.&lt;/p&gt;

&lt;p&gt;"Reads correct" and "is correct" are two different properties, and the model only optimizes the first. So the answer that is almost right, the near miss, comes back just as polished as the answer that is exactly right. You cannot tell them apart by looking. Neither can the model.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbg6miuk4yjbad27fqrbx.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbg6miuk4yjbad27fqrbx.gif" alt="One LLM output at a time enters the gate, three external signals score it, and the result is routed to auto-accept, human review, or abstain." width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Similarity is not confidence
&lt;/h2&gt;

&lt;p&gt;When teams want to gate on that, they reach for a number. Usually one of two: the model's own reported confidence, or the similarity score of whatever chunk it retrieved. Both feel like confidence. Neither one is.&lt;/p&gt;

&lt;p&gt;Ask a model how sure it is and you get more generated text, sampled from the same distribution that just produced the wrong answer. It is a guess about a guess. And a similarity score measures proximity, not truth. The near miss scores high precisely because it sits close to the right answer. That is the whole trap: a similarity cutoff hands its highest marks to the single most dangerous output in the system, the confident near miss, and waves it through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compute it from what you can check
&lt;/h2&gt;

&lt;p&gt;The way out is to stop reading confidence off the model and start computing it from signals you can verify without trusting the model at all. Three of them carry most of the weight.&lt;/p&gt;

&lt;p&gt;Grounding asks whether the output is actually entailed by the evidence you gave it, not whether it reads well. An answer nobody can trace back to a source is not trustworthy, however clean it looks. A lexical-overlap baseline gets you moving, but the real seam is a &lt;code&gt;(claim, evidence) -&amp;gt; float&lt;/code&gt; function, so you can swap in an NLI model or a cross-encoder the moment overlap is too blunt. Entailment over proximity.&lt;/p&gt;

&lt;p&gt;Agreement resamples the generator on the same input and measures whether the answer survives. A model that flaps between three answers when you ask three times is telling you something a single clean draw hides. A minority draw is low confidence even when it parses perfectly. This is the one signal that costs you extra generations, so you price it in on purpose.&lt;/p&gt;

&lt;p&gt;Validation is the cheap one, and the one people skip. Does the output satisfy the schema you declared, every required field present and the right type? A structurally invalid output should never reach the auto-accept ceiling no matter how good the other signals look, so one missing field drags the score down instead of passing silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate, as code
&lt;/h2&gt;

&lt;p&gt;I wrote this up as a small reference implementation, &lt;a href="https://github.com/vinimabreu/confidence-gate" rel="noopener noreferrer"&gt;confidence-gate&lt;/a&gt;, so the pattern has something runnable behind it. The generator goes in as a plain callable, which keeps the whole thing deterministic and runs it with no API key. Wiring it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;confidence_gate&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;AgreementSignal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GroundingSignal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Policy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ReviewQueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ValidationSignal&lt;/span&gt;&lt;span class="p"&gt;,&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;Invoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;invoice_number&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;vendor&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;total&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;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&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="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Swap in a real model call. The gate does not care where the text
&lt;/span&gt;    &lt;span class="c1"&gt;# comes from, only whether it survives the signals.
&lt;/span&gt;    &lt;span class="k"&gt;return&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="s"&gt;invoice_number&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="s"&gt;INV-4021&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="s"&gt;vendor&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="s"&gt;Northwind Supplies&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="s"&gt;total&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="s"&gt;1840.00&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="s"&gt;date&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="s"&gt;2026-02-11&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="n"&gt;queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ReviewQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:memory:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;signals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;ValidationSignal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nc"&gt;GroundingSignal&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;AgreementSignal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;generator&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;generator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Policy&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inv-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;extract invoice fields&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="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invoice INV-4021 from Northwind Supplies. Date 2026-02-11. Total 1840.00 USD.&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;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aggregate_confidence&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the bundled demo over three documents, one clean, one ambiguous, one with no supporting evidence, and it prints the decision for each. Captured output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TASK            DECISION        CONF  SIGNALS
----------------------------------------------------------------
northwind-clean AUTO_ACCEPT     1.00  validation=1.00 grounding=1.00 agreement=1.00
globex-partial  HUMAN_REVIEW    0.50  validation=0.75 grounding=0.50 agreement=0.67
unknown-source  ABSTAIN         0.00  validation=1.00 grounding=0.00 agreement=1.00

review queue: 1 pending
  #1 globex-partial  conf=0.50  :: min aggregate 0.500 (accept&amp;gt;=0.75, abstain&amp;lt;=0.35) -&amp;gt; human_review
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The middle row is the whole argument. &lt;code&gt;globex-partial&lt;/code&gt; validates against the schema except for one missing field, only half of it is supported by the evidence, and the generator does not fully agree with itself on resampling. No single number screams "wrong", so the gate refuses to auto-accept and refuses to abstain. It routes the item to a person and keeps the receipt.&lt;/p&gt;

&lt;p&gt;The bottom row is the one that would have burned you. &lt;code&gt;unknown-source&lt;/code&gt; is structurally perfect, validation 1.00, and the model reproduces it on every resample, agreement 1.00. By any signal you could read off the model, it is maximally confident. Grounding is 0.00 because nothing in the evidence supports it. A confident, self-consistent answer with no external support, and the gate abstains.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tune on the cost of a false accept
&lt;/h2&gt;

&lt;p&gt;Look at how the aggregate falls out. It is the minimum of the signals, not the average. That is deliberate, and it comes from the cost structure rather than a taste for being strict. A cache miss or a re-run costs seconds. A false accept ships a confident wrong answer into the record with no model left in the loop to hedge it. Those two outcomes are not symmetric, so you do not tune the threshold to hit some acceptance rate. You tune it on what a single false accept costs you. A gate is only as strong as its weakest verified signal, so one failing signal is enough to deny auto-accept. Abstaining is cheap. Being confidently wrong is the bill you do not see coming.&lt;/p&gt;

&lt;p&gt;That leaves three outcomes instead of two. Auto-accept when the external signals agree. Human review, with the full audit trail, when they are mixed and a person should look. Abstain when there is no support at all, because refusing to answer beats guessing when you cannot check the guess. The model proposes, the code disposes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not do
&lt;/h2&gt;

&lt;p&gt;It does not make a wrong answer right. It refuses to ship one silently, which is a smaller and far more achievable goal. If your evidence is itself wrong, grounding will happily confirm a wrong answer against it, so the quality of your source text just became part of your trust boundary. The default entailment is lexical overlap, which is blunt: it catches "the answer cites nothing in the source" but not "the answer inverts the one number that mattered", so anything real gets a stronger function through that seam. Agreement costs you K generations per item, which is real latency and real money traded for a convergence signal. And none of this is a product with uptime promises. It is the shape of the design, made runnable.&lt;/p&gt;

&lt;p&gt;A confident wrong answer and a confident right one are the same string until something outside the model checks. Be that something.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>python</category>
      <category>ai</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Clear the Lineup: the passage my RAG retrieved and then threw away in silence</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Tue, 14 Jul 2026 23:24:30 +0000</pubDate>
      <link>https://dev.to/vinimabreu/clear-the-lineup-the-passage-my-rag-retrieved-and-then-threw-away-in-silence-1adb</link>
      <guid>https://dev.to/vinimabreu/clear-the-lineup-the-passage-my-rag-retrieved-and-then-threw-away-in-silence-1adb</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;rag-quality&lt;/code&gt; is a small, self-contained RAG pipeline: it retrieves passages from a corpus (sparse, dense, and hybrid modes), then generates a grounded answer that cites the passages it used and abstains when the context does not support one. It ships with an eval harness that scores retrieval quality.&lt;/p&gt;

&lt;p&gt;Recently I commented on a dev.to thread about non-deterministic retrieval, the ways a RAG can fetch the right passage and still answer wrong. In reply, the author added one more check to his list: verify the context you asked for is the context you actually got. That check named the exact hole I had in this repo, one stage past retrieval, in assembly, against a bug I had shipped and not noticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;

&lt;p&gt;The generation step assembles retrieved passages into a context block and sends it to the model. Here is what it did:&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;build_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&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;hits&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="n"&gt;Hit&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="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;blocks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&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;h&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="nb"&gt;id&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;h&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;text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MAX_CONTEXT_CHARS&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="s"&gt;Context passages:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;Question: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;Answer:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;MAX_CONTEXT_CHARS&lt;/code&gt; is 6000. It joins every retrieved passage, then hard-slices the string to 6000 characters. If the joined context runs longer, the tail is gone. The tail is the lowest-ranked hits, and the lowest-ranked hit is often the one that actually holds the answer: retrieval surfaced it, ranking put it last, the slice deleted it. No exception, no log, no return value that hints anything was cut. The caller gets a shorter prompt and believes it is complete.&lt;/p&gt;

&lt;p&gt;What makes this one sting is the asymmetry a few lines down in the same file:&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;if&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;stop_reason&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;[warning: answer truncated at the token limit]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The code already knows truncation is dangerous. It shouts when the model's &lt;strong&gt;output&lt;/strong&gt; is cut. It stays silent when the model's &lt;strong&gt;input&lt;/strong&gt; is cut, which is the more dangerous of the two, because a truncated input can quietly remove the one passage the answer depended on and the model will confidently answer from what is left.&lt;/p&gt;

&lt;p&gt;And the tests stayed green because nothing exercised the failure. The sample corpus is 17 short documents; its joined top-k context fits comfortably under 6000 characters, so the slice never removed anything in the bundled runs. The eval harness measures retrieval quality (hit@k, MRR, recall@k), whether the right passage was &lt;strong&gt;retrieved&lt;/strong&gt;, not whether it survived &lt;strong&gt;assembly&lt;/strong&gt; into the prompt. The bug lives in the gap between those two stages, which is precisely the gap no metric was watching.&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;The whole change, the fix, the tests, and the optional Sentry and Gemini tooling, is one pull request:&lt;/p&gt;


&lt;div class="ltag_github-liquid-tag"&gt;
  &lt;h1&gt;
    &lt;a href="https://github.com/vinimabreu/rag-quality/pull/1" rel="noopener noreferrer"&gt;
      &lt;img class="github-logo" alt="GitHub logo" src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg"&gt;
      &lt;span class="issue-title"&gt;
        Fix silent context truncation in build_prompt
      &lt;/span&gt;
      &lt;span class="issue-number"&gt;#1&lt;/span&gt;
    &lt;/a&gt;
  &lt;/h1&gt;
  &lt;div class="github-thread"&gt;
    &lt;div class="timeline-comment-header"&gt;
      &lt;a href="https://github.com/vinimabreu" rel="noopener noreferrer"&gt;
        &lt;img class="github-liquid-tag-img" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Favatars.githubusercontent.com%2Fu%2F262471465%3Fv%3D4" alt="vinimabreu avatar"&gt;
      &lt;/a&gt;
      &lt;div class="timeline-comment-header-text"&gt;
        &lt;strong&gt;
          &lt;a href="https://github.com/vinimabreu" rel="noopener noreferrer"&gt;vinimabreu&lt;/a&gt;
        &lt;/strong&gt; posted on &lt;a href="https://github.com/vinimabreu/rag-quality/pull/1" rel="noopener noreferrer"&gt;&lt;time&gt;Jul 14, 2026&lt;/time&gt;&lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
    &lt;div class="ltag-github-body"&gt;
      &lt;p&gt;&lt;code&gt;build_prompt&lt;/code&gt; joined every retrieved passage and hard-sliced the result to &lt;code&gt;MAX_CONTEXT_CHARS&lt;/code&gt;, dropping the tail with no exception, log, or return signal. The tail is the lowest-ranked hits, and the lowest-ranked hit can be the one that holds the answer. The same file already warns on output truncation but stayed silent on the input side.&lt;/p&gt;
&lt;p&gt;This packs whole passages in rank order up to the budget, never cuts a passage mid-text, and returns &lt;code&gt;dropped_ids&lt;/code&gt; so the caller announces what was left out, mirroring the existing output-truncation warning.&lt;/p&gt;
&lt;p&gt;Also adds:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;env-gated Sentry spans across the pipeline (no-op without &lt;code&gt;sentry-sdk&lt;/code&gt; and &lt;code&gt;SENTRY_DSN&lt;/code&gt;), so an over-budget request shows &lt;code&gt;total_context_chars&lt;/code&gt;, &lt;code&gt;dropped_ids&lt;/code&gt;, and a &lt;code&gt;context.truncated_chars&lt;/code&gt; measurement on the &lt;code&gt;rag.build_prompt&lt;/code&gt; span&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scripts/root_cause.py&lt;/code&gt;, an optional Gemini helper that localizes the loss from a trace plus the source&lt;/li&gt;
&lt;li&gt;five tests that run key-free and offline&lt;/li&gt;
&lt;/ul&gt;

    &lt;/div&gt;
    &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/vinimabreu/rag-quality/pull/1" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



&lt;h2&gt;
  
  
  My Improvements
&lt;/h2&gt;

&lt;p&gt;Stop slicing a string. Pack whole passages in rank order until the next one would blow the budget, never cut one mid-text, and return the ids you dropped so the caller can announce them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-def build_prompt(question: str, hits: list[Hit]) -&amp;gt; str:
-    blocks = [f"[{h.chunk.id}] {h.chunk.text}" for h in hits]
-    context = "\n\n".join(blocks)[: config.MAX_CONTEXT_CHARS]
-    return f"Context passages:\n{context}\n\nQuestion: {question}\n\nAnswer:"
&lt;/span&gt;&lt;span class="gi"&gt;+def build_prompt(question: str, hits: list[Hit]) -&amp;gt; tuple[str, list[str]]:
+    separator = "\n\n"
+    budget = config.MAX_CONTEXT_CHARS
+    blocks = [f"[{hit.chunk.id}] {hit.chunk.text}" for hit in hits]
+
+    kept, dropped_ids, used = [], [], 0
+    for hit, block in zip(hits, blocks):
+        extra = len(block) + (len(separator) if kept else 0)
+        if used + extra &amp;lt;= budget:
+            kept.append(block)
+            used += extra
+        else:
+            dropped_ids.append(hit.chunk.id)
+
+    if dropped_ids:
+        log.warning("context truncated: %d passage(s) dropped: %s",
+                    len(dropped_ids), ", ".join(dropped_ids))
+
+    context = separator.join(kept)
+    prompt = f"Context passages:\n{context}\n\nQuestion: {question}\n\nAnswer:"
+    return prompt, dropped_ids
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That diff is the essence of the change. The shipped version also opens the &lt;code&gt;rag.build_prompt&lt;/code&gt; span from the Sentry section around this same code, which is how those numbers reach the trace.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Generator.answer&lt;/code&gt; now unpacks the tuple and appends a note that mirrors the existing output warning, so the drop is visible in the answer itself:&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;if&lt;/span&gt; &lt;span class="n"&gt;dropped_ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="o"&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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dropped_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;[warning: &lt;/span&gt;&lt;span class="si"&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;dropped_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; passage(s) dropped from context: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The budget stays in characters here on purpose. The stronger version counts the real token budget of the target model; I noted that in the docstring rather than building it, because the point of this fix is the missing signal, not a better ruler.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proof.&lt;/strong&gt; Same input in both runs: four bulky decoy passages plus a short answer chunk that is retrieved but ranked last, so the joined context is 7343 characters against a 6000 budget. Real console output, captured with the repo's own interpreter, no API key.&lt;/p&gt;

&lt;p&gt;Before, the answer chunk is gone and nothing says so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MAX_CONTEXT_CHARS      = 6000
full joined context len= 7343
answer chunk id        = expenses::answer
answer text present in FULL joined context? True
answer text present in BUILT prompt?        False
answer sentence in built prompt?            False
------------------------------------------------------------
RESULT: answer chunk SILENTLY DROPPED. No exception, no warning, no log. Caller gets a prompt missing the answer.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After, packing skips the one passage that will not fit, keeps the small answer chunk behind it, and reports the id it dropped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;context truncated: 1 passage(s) dropped to fit 6000-char budget: expenses::3
MAX_CONTEXT_CHARS      = 6000
full joined context len= 7343
answer chunk id        = expenses::answer
dropped_ids reported   = ['expenses::3']
answer text present in BUILT prompt?        True
answer sentence in built prompt?            True
------------------------------------------------------------
  expenses::0        -&amp;gt; KEPT (verbatim)
  expenses::1        -&amp;gt; KEPT (verbatim)
  expenses::2        -&amp;gt; KEPT (verbatim)
  expenses::3        -&amp;gt; dropped whole
  expenses::answer   -&amp;gt; KEPT (verbatim)
------------------------------------------------------------
RESULT: answer PACKED IN, and the passage that did not fit (['expenses::3']) is reported to the caller. No silent loss.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four behavioural tests lock the contract: an over-budget answer chunk is either packed in or named in &lt;code&gt;dropped_ids&lt;/code&gt; and never silently gone, over-budget context reports its dropped ids, no passage is ever cut mid-text, and within-budget context drops nothing. A fifth pins the tuple return type so a caller cannot regress to the old string signature. All five run with the repo's own interpreter, no key and no network.&lt;/p&gt;

&lt;p&gt;The rule I kept: any place that decides what the model sees has to announce what it dropped. A retriever that finds the right passage and an assembler that quietly deletes it produce the same wrong answer, but only one of them leaves a trace. The output truncation in this file already knew that. The input side just needed to learn the same manners.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Use of Sentry
&lt;/h2&gt;

&lt;p&gt;The fix for "silent" is instrumentation. I added env-gated Sentry spans across the pipeline (off unless &lt;code&gt;SENTRY_DSN&lt;/code&gt; is set and &lt;code&gt;sentry-sdk&lt;/code&gt; is installed, so the repo never hard-depends on it). The span that matters here is &lt;code&gt;rag.build_prompt&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;total_context_chars&lt;/code&gt; and &lt;code&gt;max_context_chars&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;n_dropped&lt;/code&gt; and &lt;code&gt;dropped_ids&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;context.truncated_chars&lt;/code&gt; measurement&lt;/li&gt;
&lt;li&gt;a warning-level event whenever &lt;code&gt;n_dropped &amp;gt; 0&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now a wrong answer on a long-context request is not a mystery. The pipeline is instrumented end to end: rag.answer to rag.retrieve (hit_ids) to rag.build_prompt (dropped_ids) to rag.generate (stop_reason). The trace below exercises the assembly step directly, so it shows rag.answer to rag.build_prompt, where the truncation happens and the dropped ids surface.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqj4o8fse7po54jykttz7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqj4o8fse7po54jykttz7.png" alt=" " width="800" height="812"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdwrv3ekwn7kto1ejwp23.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdwrv3ekwn7kto1ejwp23.png" alt=" " width="800" height="816"&gt;&lt;/a&gt; On the over-budget request, the &lt;code&gt;rag.build_prompt&lt;/code&gt; span reads &lt;code&gt;total_context_chars&lt;/code&gt; 7343 against a &lt;code&gt;max_context_chars&lt;/code&gt; of 6000, a &lt;code&gt;context.truncated_chars&lt;/code&gt; measurement of 1343, and the &lt;code&gt;dropped_ids&lt;/code&gt;, sitting directly under the rag.answer transaction. The truncation that used to be invisible is now a value on a span.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Use of Google AI
&lt;/h2&gt;

&lt;p&gt;To make the localization repeatable I added &lt;code&gt;scripts/root_cause.py&lt;/code&gt;: it takes a Sentry trace plus the source files that ran, and asks Gemini to point at the line. The prompt is fixed and paste-ready:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You are debugging a RAG pipeline that returned a wrong answer. Here is a Sentry trace and the relevant source. The retrieval span shows the correct passage was retrieved, but the final answer is wrong. Localize the exact line where the retrieved context is lost, explain the mechanism, and note whether the loss is signalled to the caller. Then propose a minimal fix.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The script is env-gated on &lt;code&gt;GOOGLE_API_KEY&lt;/code&gt; or &lt;code&gt;GEMINI_API_KEY&lt;/code&gt; and fails with a clear message when no key is set, so it never runs blind.&lt;/p&gt;

&lt;p&gt;Run against the pre-fix source with the real trace, on &lt;code&gt;gemini-2.5-flash&lt;/code&gt;, it returned this (verbatim):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The Sentry trace clearly indicates that the &lt;code&gt;rag.retrieve&lt;/code&gt; span successfully identified &lt;code&gt;expenses::answer&lt;/code&gt; as a relevant passage. However, the subsequent &lt;code&gt;rag.build_prompt&lt;/code&gt; span shows a &lt;code&gt;total_context_chars&lt;/code&gt; of 7343 and a &lt;code&gt;max_context_chars&lt;/code&gt; of 6000, leading to &lt;code&gt;1343&lt;/code&gt; characters being truncated. The symptom confirms that &lt;code&gt;expenses::answer&lt;/code&gt; was "absent from the assembled prompt," which directly led to the LLM's abstention (&lt;code&gt;I could not find this in the provided documents.&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Exact Line Where Context is Lost.&lt;/strong&gt; The context is lost in &lt;code&gt;app/generate.py&lt;/code&gt;, specifically at this line within the &lt;code&gt;build_prompt&lt;/code&gt; function: &lt;code&gt;context = "\n\n".join(blocks)[: config.MAX_CONTEXT_CHARS]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Mechanism of Loss: naive string slicing after concatenation.&lt;/strong&gt; The function joins all blocks into a single long string, then applies a hard character limit by slicing that entire concatenated string. If the total length exceeds &lt;code&gt;config.MAX_CONTEXT_CHARS&lt;/code&gt;, the slice cuts characters from the end of the combined string. Passages later in the &lt;code&gt;hits&lt;/code&gt; list are more likely to be truncated, and even a highly relevant passage like &lt;code&gt;expenses::answer&lt;/code&gt; can be cut off if it falls past the character limit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Signalling of Loss.&lt;/strong&gt; "No exception, warning, or dropped-id was reported to the caller." The function simply returns the truncated string; there are no checks, &lt;code&gt;logging.warning&lt;/code&gt; calls, or exceptions that would inform the calling &lt;code&gt;Generator.answer&lt;/code&gt; method. Sentry captured the &lt;code&gt;context.truncated_chars&lt;/code&gt; measurement, but that is instrumentation-level reporting, not a signal within the application's business logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Minimal Fix.&lt;/strong&gt; Adjust &lt;code&gt;build_prompt&lt;/code&gt; to iteratively add passages, respecting &lt;code&gt;config.MAX_CONTEXT_CHARS&lt;/code&gt; without splitting individual passages, so higher-ranked complete passages are prioritized. [It then produced a pack-to-fit loop that stops adding blocks once the budget is reached.]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The model landed on the same line I did, read the mechanism straight off the trace measurements, and proposed the same pack-to-fit shape as the fix above. Sentry said which chunk vanished and by how much; Gemini said which line threw it away and why nothing noticed.&lt;/p&gt;

&lt;p&gt;Vinicius Pereira&lt;br&gt;
vinimabreu.dev · github.com/vinimabreu&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>sentry</category>
      <category>googleaichallenge</category>
    </item>
    <item>
      <title>The night I leaked my own payment key, and what caught it</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Tue, 14 Jul 2026 21:30:52 +0000</pubDate>
      <link>https://dev.to/vinimabreu/the-night-i-leaked-my-own-payment-key-and-the-circuit-breaker-that-caught-it-1o2c</link>
      <guid>https://dev.to/vinimabreu/the-night-i-leaked-my-own-payment-key-and-the-circuit-breaker-that-caught-it-1o2c</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I build a SaaS for small businesses. Restaurants, small shops, the kind of owner who needs to track sales, stock, cash flow and finances without wrestling a heavyweight ERP. For the first months I was heads-down on features, because that is what pays: the product has to solve a real problem the day you ship it.&lt;/p&gt;

&lt;p&gt;Then one night taught me something no feature ever could.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistake was entirely mine
&lt;/h2&gt;

&lt;p&gt;During a deploy, a configuration of my own doing exposed a payment API key in production. The exposure window was short, but the risk was not theoretical. Anyone who found that credential could try to use it. In production. Against real money.&lt;/p&gt;

&lt;p&gt;Here is the part I got right, and it was as much luck as foresight: weeks earlier I had built an emergency mechanism for exactly the kind of unlikely event I did not believe would ever happen to me.&lt;/p&gt;

&lt;p&gt;Every payment integration ran behind a monitor that watched for abnormal usage. A sudden jump in transaction volume. Calls from origins it did not recognize. Sharp changes in behavior. Anything outside the profile it expected. If any of those tripped, the integration did not wait for a human. It moved itself into a protection mode, blocked new operations, and paged me immediately.&lt;/p&gt;

&lt;p&gt;That is exactly what happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The circuit breaker did its job
&lt;/h2&gt;

&lt;p&gt;The monitor flagged the anomaly and locked the integration into protection mode before a single improper transaction went through. The alert woke me in the middle of the night. I revoked the compromised credential, generated a new key, rolled it across the whole infrastructure, and validated everything before the first customer opened the app in the morning.&lt;/p&gt;

&lt;p&gt;No customer was affected. No improper transaction went through. No financial data was exposed.&lt;/p&gt;

&lt;p&gt;And none of that made the mistake any smaller.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually learned
&lt;/h2&gt;

&lt;p&gt;The lesson was not "be more careful." Careful is not a strategy. I had been careful, and I still shipped the mistake at the worst possible layer.&lt;/p&gt;

&lt;p&gt;The real lesson was that security cannot rest on not making mistakes. It has to assume mistakes will happen, and it has to cap the blast radius when they do. The thing that saved me that night was not my discipline. It was a layer I had built specifically to survive my own discipline failing.&lt;/p&gt;

&lt;p&gt;So I rebuilt the security architecture around that idea:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Automated rotation of sensitive credentials, so a leaked key has a short life by default.&lt;/li&gt;
&lt;li&gt;Hard segregation between development and production, so the two can never bleed into each other.&lt;/li&gt;
&lt;li&gt;Detailed audit logging of critical events, so every sensitive action leaves a trail.&lt;/li&gt;
&lt;li&gt;Extra validation during deploy to stop secrets from being exposed in the first place.&lt;/li&gt;
&lt;li&gt;Continuous monitoring of every external integration.&lt;/li&gt;
&lt;li&gt;Real-time alerts for anything that looks abnormal.&lt;/li&gt;
&lt;li&gt;A mandatory security review before any release reaches production.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The question I ask before I ship anything now
&lt;/h2&gt;

&lt;p&gt;Every new feature I build now starts from one question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If I make a mistake tomorrow at 3 a.m., will the system protect my customers before they notice?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That night changed what engineering means to me. Building is no longer just shipping features. It is building systems that expect human failure and protect the people who trust them anyway.&lt;/p&gt;

&lt;p&gt;The bug that night was mine. The thing that smashed it was a layer I had built for a version of me that would eventually slip. Build that layer before you need it. You will need it.&lt;/p&gt;

</description>
      <category>bugsmash</category>
      <category>devchallenge</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My weekend challenge entry: an AI announcer for the beach-sport duels my futevôlei crew plays every day. Gemini writes the call, ElevenLabs voices it. Ring the bell yourself, the demo is live.</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Fri, 10 Jul 2026 12:10:16 +0000</pubDate>
      <link>https://dev.to/vinimabreu/my-weekend-challenge-entry-an-ai-announcer-for-the-beach-sport-duels-my-futevolei-crew-plays-every-4hhc</link>
      <guid>https://dev.to/vinimabreu/my-weekend-challenge-entry-an-ai-announcer-for-the-beach-sport-duels-my-futevolei-crew-plays-every-4hhc</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/vinimabreu/i-gave-my-futevolei-crew-an-ai-announcer-duelup-live-4h60" class="crayons-story__hidden-navigation-link"&gt;I gave my futevôlei crew an AI announcer (DuelUp Live)&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
      &lt;a href="https://dev.to/vinimabreu/i-gave-my-futevolei-crew-an-ai-announcer-duelup-live-4h60" class="crayons-article__context-note crayons-article__context-note__feed"&gt;&lt;p&gt;DEV Weekend Challenge: Passion Edition Submission&lt;/p&gt;

&lt;/a&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/vinimabreu" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4010065%2Ff3d37966-fcdb-4c21-9df3-f47b258b99bd.jpeg" alt="vinimabreu profile" class="crayons-avatar__image" width="375" height="640"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/vinimabreu" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Vinicius Pereira
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Vinicius Pereira
                
              
              &lt;div id="story-author-preview-content-4112345" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/vinimabreu" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4010065%2Ff3d37966-fcdb-4c21-9df3-f47b258b99bd.jpeg" class="crayons-avatar__image" alt="" width="375" height="640"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Vinicius Pereira&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/vinimabreu/i-gave-my-futevolei-crew-an-ai-announcer-duelup-live-4h60" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jul 10&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/vinimabreu/i-gave-my-futevolei-crew-an-ai-announcer-duelup-live-4h60" id="article-link-4112345"&gt;
          I gave my futevôlei crew an AI announcer (DuelUp Live)
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/devchallenge"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;devchallenge&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/weekendchallenge"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;weekendchallenge&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/googleaichallenge"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;googleaichallenge&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/vinimabreu/i-gave-my-futevolei-crew-an-ai-announcer-duelup-live-4h60" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/fire-f60e7a582391810302117f987b22a8ef04a2fe0df7e3258a5f49332df1cec71e.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;10&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/vinimabreu/i-gave-my-futevolei-crew-an-ai-announcer-duelup-live-4h60#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              3&lt;span class="hidden s:inline"&gt;&amp;nbsp;comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            4 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
      <category>ai</category>
      <category>llm</category>
      <category>showdev</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>I gave my futevôlei crew an AI announcer (DuelUp Live)</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Fri, 10 Jul 2026 09:52:42 +0000</pubDate>
      <link>https://dev.to/vinimabreu/i-gave-my-futevolei-crew-an-ai-announcer-duelup-live-4h60</link>
      <guid>https://dev.to/vinimabreu/i-gave-my-futevolei-crew-an-ai-announcer-duelup-live-4h60</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/weekend-2026-07-09"&gt;Weekend Challenge: Passion Edition&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Every Sunday my futevôlei crew meets on the sand in Niterói, Brazil. The rivalries are real: there are rematches people talk about all week, comebacks nobody is allowed to forget, and at least one duel per month that ends with someone buying açaí for everybody.&lt;/p&gt;

&lt;p&gt;I am so deep into this passion that I have been building a whole platform for it: &lt;strong&gt;DuelUp&lt;/strong&gt;, a competitive system for amateur beach sports, with ELO ratings, rank tiers from Sand to Legend, and virtual-coin stakes on real matches. It is not launched yet, but the crew already lives by its rules.&lt;/p&gt;

&lt;p&gt;This weekend I built the piece every real rivalry deserves and none of us had: &lt;strong&gt;an announcer&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DuelUp Live&lt;/strong&gt; is a fight bill for beach-sport duels. You pick the sport (footvolley, beach tennis, tennis or beach volleyball), write in the two sides with their nicknames and ranks, set the moment ("17-16, match point, blazing sun") and the bad blood ("rematch after last Sunday's comeback"). Then you ring the bell, and an electrifying AI sports announcer calls the match live, in English or in Portuguese with a Brazilian announcer voice, the kind we grew up hearing on the radio.&lt;/p&gt;

&lt;p&gt;Gemini writes the play-by-play. ElevenLabs gives it the voice. The rank tiers come straight from DuelUp's real ELO system, and the announcer uses them as drama: when Silver faces Gold, you get an underdog story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://live.duelup.app" rel="noopener noreferrer"&gt;live.duelup.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No signup, nothing to install. Click &lt;strong&gt;⚡ Example&lt;/strong&gt; to load a ready duel (each sport has three, they cycle), then &lt;strong&gt;🔔 RING THE BELL&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Two things worth knowing while you try it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every ring is a fresh call, written and voiced on the spot. Ring the bell twice on the same duel and you will never hear the same broadcast.&lt;/li&gt;
&lt;li&gt;It takes about 20 seconds, because it genuinely is an LLM writing a script and a TTS model performing it, live.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/LvkZolDs4RU"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwgkf82z7kw1sn5h1v86l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwgkf82z7kw1sn5h1v86l.png" alt="The fight bill" width="800" height="700"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqb1utf1nlc3wrr9jm9r5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqb1utf1nlc3wrr9jm9r5.png" alt="The announcer's call with the audio player" width="732" height="1308"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/vinimabreu/duelup-live" rel="noopener noreferrer"&gt;github.com/vinimabreu/duelup-live&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next.js 14, App Router, zero extra runtime dependencies. Two API routes, one for the writing, one for the voice, both plain REST calls with no SDKs.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The writing (Gemini).&lt;/strong&gt; The announcer is &lt;code&gt;gemini-flash-latest&lt;/code&gt; with a prompt that treats narration as radio, not prose: present tense, short explosive sentences, 90 to 120 words, written for the ear because a TTS voice will perform it. Two decisions mattered more than the prompt itself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Thinking disabled&lt;/strong&gt; (&lt;code&gt;thinkingBudget: 0&lt;/code&gt;). An announcer should not overthink. Without this, the model's reasoning tokens were eating the output budget and truncating calls mid-sentence. With it, calls come back complete and fast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A model cascade.&lt;/strong&gt; If &lt;code&gt;gemini-flash-latest&lt;/code&gt; returns a 503 under load, the route falls through to &lt;code&gt;gemini-flash-lite-latest&lt;/code&gt;, then &lt;code&gt;gemini-2.0-flash&lt;/code&gt;. The demo has already survived a real Google traffic spike this way. A demo that only works when the weather is nice is not a demo.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Temperature is 1.0, which is why no two calls are alike. The prompt is also sport-aware: tennis gets called on the court, everything else on the sand, and the rank tiers get woven in as favorite-versus-underdog tension.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The voice (ElevenLabs).&lt;/strong&gt; &lt;code&gt;eleven_multilingual_v2&lt;/code&gt; with stability set low, because announcers are not stable people. Portuguese calls go to Eduardo, a Brazilian voice that sounds like he has narrated a thousand matches. English calls go to Charlie, who brings the energy. The waveform player on the bill is wired to the real audio element: real progress, real duration, and the bars dance while he shouts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bill.&lt;/strong&gt; The design is a vintage fight poster: paper texture, ink borders, Anton for the headline type, a black promoter plate with the real DuelUp logo, "TALE OF THE TAPE" for the context fields, and the rank badges from the actual game next to each fighter. The whole UI switches between English and Portuguese, announcer included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best use of Google AI&lt;/strong&gt; and &lt;strong&gt;Best use of ElevenLabs.&lt;/strong&gt; They are not decorations here, they are the two halves of the product: Gemini is the announcer's brain, ElevenLabs is his lungs.&lt;/p&gt;




&lt;p&gt;One personal note. Futevôlei is not a theme I picked for a challenge. It is the sport I play every single day, and the crew in this post is my real crew. Getting to spend a weekend building for it, and then writing about it here, was the rare kind of work that does not feel like work.&lt;/p&gt;

&lt;p&gt;The platform came first, but the announcer went live before the platform did. That feels right. Passion projects do not follow roadmaps.&lt;/p&gt;




&lt;p&gt;Built by &lt;strong&gt;Vinicius Pereira&lt;/strong&gt; · &lt;a href="https://vinimabreu.dev" rel="noopener noreferrer"&gt;vinimabreu.dev&lt;/a&gt; · &lt;a href="https://github.com/vinimabreu" rel="noopener noreferrer"&gt;github.com/vinimabreu&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>ai</category>
      <category>googleaichallenge</category>
    </item>
    <item>
      <title>Lead Quorum: a multi-agent lead qualifier that refuses to guess (ADK + A2A)</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Wed, 08 Jul 2026 07:35:25 +0000</pubDate>
      <link>https://dev.to/vinimabreu/lead-quorum-a-multi-agent-lead-qualifier-that-refuses-to-guess-adk-a2a-5dom</link>
      <guid>https://dev.to/vinimabreu/lead-quorum-a-multi-agent-lead-qualifier-that-refuses-to-guess-adk-a2a-5dom</guid>
      <description>&lt;p&gt;&lt;em&gt;This post is my submission for &lt;a href="https://dev.to/deved/build-multi-agent-systems"&gt;DEV Education Track: Build Multi-Agent Systems with ADK&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Most lead scoring demos share a failure mode: you paste messy notes, a model returns a confident number, and nobody can answer why it is a 60 and not a 40. I built the opposite. Lead Quorum is a distributed multi-agent qualifier where the number is set by deterministic code, the explanation provably adds up to the score, and when two independent readers disagree about the input, the system abstains instead of guessing.&lt;/p&gt;

&lt;p&gt;Three production failures it kills by construction:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Opaque scores.&lt;/strong&gt; Every point is granted and explained on the same line of code, and a test parses the explanation and asserts the named points sum to the score. The reason cannot drift from the number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One model grading its own homework.&lt;/strong&gt; Two readers running two different Gemini models extract the same lead independently, as separate services. Agreement between different models is real signal, not a model agreeing with itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fake confidence on thin input.&lt;/strong&gt; "They mentioned possibly renewing" should not score like "they renewed in March."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What a run looks like. Clear notes, both models agree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CONFIRMED  60/100: +35 team of 30 seats &amp;gt;= 25; +15 reachable decision maker (vp);
           +10 renewed at least once
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Ambiguous notes, the models read "possibly renewing" differently:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EXCLUDED   readings disagree on which signals fire (prior_relationship: False vs True);
           abstaining instead of scoring a contradiction
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That EXCLUDED is the feature. The score would differ depending on which reading you believe, so the honest output is no score, plus exactly what disagreed. A defensible abstention beats a fake-precise number built on a contradiction.&lt;/p&gt;
&lt;h2&gt;
  
  
  Cloud Run Embed
&lt;/h2&gt;

&lt;p&gt;Open it, paste your own messy lead notes, and watch the audit trail: the score, the reason that reconciles to it, the two independent readings side by side, and the EXCLUDED abstention when they disagree about which rules fire. The two readers are separate Cloud Run services the orchestrator reaches over A2A.&lt;/p&gt;


&lt;div class="ltag__cloud-run"&gt;
  &lt;iframe height="600px" src="https://lq-app-598130840480.us-central1.run.app"&gt;
  &lt;/iframe&gt;
&lt;/div&gt;



&lt;h2&gt;
  
  
  Your Agents
&lt;/h2&gt;

&lt;p&gt;Five roles. Two are LLMs, three are deterministic code, and that split is the design.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Enrichment reader&lt;/strong&gt; (LlmAgent, gemini-flash-latest): extracts structured fields from raw notes. Temperature 0, pinned output schema, instructed to leave absent signals at their defaults instead of guessing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rederive reader&lt;/strong&gt; (LlmAgent, gemini-2.5-flash-lite): reads the same notes from scratch, independently, on a deliberately different model. Runs as its own microservice, exposed over the A2A protocol with ADK's &lt;code&gt;to_a2a()&lt;/code&gt; and consumed through its agent card with &lt;code&gt;RemoteA2aAgent&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scoring agent&lt;/strong&gt; (custom BaseAgent, no LLM): applies the rubric. Each rule grants its points and writes its reason in the same branch, and the agent refuses to emit a result whose reason does not reconcile to its score.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Corroboration agent&lt;/strong&gt; (custom BaseAgent, no LLM): compares the two readings in score-space. Same rules fire, values close: CONFIRMED. Same rules fire, readings drift: REVIEW, score stands, flagged. A rule flips between readings: EXCLUDED, no score, verdict names the flipped rule and both values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orchestrator&lt;/strong&gt; (SequentialAgent + ParallelAgent): the two readers run concurrently, so wall-clock is one LLM round-trip, then scoring and corroboration run as pure code. Exactly two LLM calls per lead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The readers deploy as independent Cloud Run services and the orchestrator reaches them over A2A, so the second opinion could be swapped for a different vendor or framework tomorrow without touching the pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Learnings
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Put the LLM only where judgment lives.&lt;/strong&gt; Multi-agent systems get expensive when every step is a model call. Two parallel calls per lead, everything downstream deterministic and unit-tested, made the system cheaper per unit of trust, not pricier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Score-space beats value-space for corroboration.&lt;/strong&gt; Two readings 500 dollars apart on the same side of a threshold produce the same score; that is drift, not contradiction. Comparing which rules fire, instead of raw field equality, lets abstention trigger only when the disagreement actually changes the answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconciliation has to be enforced, not intended.&lt;/strong&gt; "Keep the reason next to the points" is a discipline, and disciplines rot. A failing test does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A2A is what makes independence credible.&lt;/strong&gt; Behind an agent card, the second reader is a black box that could be any model, any framework, anywhere. That is the difference between a second opinion and an echo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ADK's workflow agents are underrated.&lt;/strong&gt; SequentialAgent and ParallelAgent gave me a deterministic, testable topology with no LLM routing where none was needed. The surprise of the build: RemoteA2aAgent has no output_key, so remote responses land in the event log, and a small capture adapter was the missing piece to keep the distributed pipeline identical to the local one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code: &lt;a href="https://github.com/vinimabreu/lead-quorum" rel="noopener noreferrer"&gt;github.com/vinimabreu/lead-quorum&lt;/a&gt;, MIT, 16 tests, including a script that runs the full distributed topology locally.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>buildmultiagents</category>
      <category>gemini</category>
      <category>adk</category>
    </item>
    <item>
      <title>Your LLM bill has two sides. Build the ledger that shows both.</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Tue, 07 Jul 2026 20:57:08 +0000</pubDate>
      <link>https://dev.to/vinimabreu/your-llm-bill-has-two-sides-build-the-ledger-that-shows-both-p54</link>
      <guid>https://dev.to/vinimabreu/your-llm-bill-has-two-sides-build-the-ledger-that-shows-both-p54</guid>
      <description>&lt;p&gt;Every RAG cost estimate starts the same way: input tokens equal top_k times chunk size, plus some overhead. Most of them stop there too. Then the invoice arrives, it is three times the estimate, and the team spends a sprint tuning chunk sizes while the actual money leaks somewhere that formula never touches.&lt;/p&gt;

&lt;p&gt;The bill for an LLM system is set by total input and output tokens, summed across every call a query triggers. Not per call. Not input only. Every call, both directions. If you only remember one sentence from this post, that is the one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why chunk math lies
&lt;/h2&gt;

&lt;p&gt;Four leaks, in the order I usually find them in real pipelines:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Calls you forgot exist.&lt;/strong&gt; A query condenser rewriting the user question. A silent SDK retry. A JSON repair call after a malformed response. An agent loop that took three turns instead of one. Each is a full-priced API call that no chunk formula predicts, and some frameworks make them without telling you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scaffolding re-sent on every call.&lt;/strong&gt; System prompt, tool schemas, formatting instructions, few-shot examples. This fixed overhead rides along on every single call, and in multi-call pipelines it often outweighs the retrieved chunks you are carefully tuning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The output side, billed at a premium.&lt;/strong&gt; Output tokens cost several times input on every major provider's list price, five times on current Claude models. A dashboard that tracks input tokens is watching the cheap half of the transaction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-call synthesis re-billing its own draft.&lt;/strong&gt; Refine-style chains regenerate the full running answer at every step. Only the final draft is delivered, but all N drafts are billed at output rates, and each discarded draft is re-sent as input to the next call. The answer component of your cost scales with N, and no chunk tuning touches it. Prompt caching does not rescue it either: the growing answer sits mid-prompt, which breaks the prefix match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop counting tokens. Start reading them.
&lt;/h2&gt;

&lt;p&gt;The fix is not a better estimate. It is refusing to estimate: every provider already returns exact usage on every response, so the whole job is logging what the API tells you and adding it up per query.&lt;/p&gt;

&lt;p&gt;Where you hook matters more than it looks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The SDK call site&lt;/strong&gt; is the reliable choke point. A thin wrapper around each &lt;code&gt;create()&lt;/code&gt; sees server-reported usage for every call you make, streaming or not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Framework callbacks are leakier.&lt;/strong&gt; Some read server-reported usage properly and propagate through nested calls (LangChain's usage metadata callback does). Others estimate with a local tokenizer instead of reading the wire, LlamaIndex's TokenCountingHandler being the documented example. And none of them see calls made outside the framework.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP-layer hooks have a streaming blind spot.&lt;/strong&gt; An httpx response hook fires when headers arrive, before the body. For non-streaming calls you can read the body inside the hook; for SSE streams the usage arrives at the end of a body the hook cannot safely consume. If you want wire-level capture for streams, you need a logging proxy or a transport wrapper that tees the stream.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For most codebases the call-site wrapper is one afternoon of work and covers everything that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ledger
&lt;/h2&gt;

&lt;p&gt;Columns that earn their keep, learned from what actually gets queried later:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;column&lt;/th&gt;
&lt;th&gt;why it exists&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;query_id&lt;/td&gt;
&lt;td&gt;ties every call in one user request together; propagate it with a contextvar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;call_index&lt;/td&gt;
&lt;td&gt;0, 1, 2... within the query; the column that exposes hidden calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;model&lt;/td&gt;
&lt;td&gt;mixed-model pipelines bill at mixed rates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;input_tokens, output_tokens&lt;/td&gt;
&lt;td&gt;the two sides of the bill, server-reported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cache_read, cache_write&lt;/td&gt;
&lt;td&gt;cached input bills at its own rates; on Anthropic these are two separate fields billed differently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stop_reason&lt;/td&gt;
&lt;td&gt;a truncated response often triggers a retry you will want to find&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;latency_ms&lt;/td&gt;
&lt;td&gt;free to record, and cost and latency investigations are usually the same investigation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stage&lt;/td&gt;
&lt;td&gt;"condense", "retrieve-answer", "repair"; the label that turns rows into a story&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A minimal implementation, SQLite plus a contextvar:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;contextvars&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ContextVar&lt;/span&gt;

&lt;span class="n"&gt;query_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ContextVar&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="nc"&gt;ContextVar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query_id&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tokens.db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;CREATE TABLE IF NOT EXISTS calls(
    query_id TEXT, call_index INT, model TEXT,
    input_tokens INT, output_tokens INT,
    cache_read INT, cache_write INT,
    stop_reason TEXT, latency_ms INT, stage TEXT)&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;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stop_reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;latency_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&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;qid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;query_id&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="ow"&gt;or&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;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT COUNT(*) FROM calls WHERE query_id=?&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;span class="n"&gt;qid&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO calls VALUES (?,?,?,?,?,?,?,?,?,?)&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;span class="n"&gt;qid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
         &lt;span class="n"&gt;usage&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_read&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;usage&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_write&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
         &lt;span class="n"&gt;stop_reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;latency_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrapping a non-streaming Anthropic call:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Anthropic&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;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;
    &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_read&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_read_input_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_write&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_creation_input_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&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="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;stop_reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stop_reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;latency_ms&lt;/span&gt;&lt;span class="o"&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;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set the contextvar once at the top of each user request (&lt;code&gt;query_id.set(str(uuid.uuid4()))&lt;/code&gt;), pass a &lt;code&gt;stage&lt;/code&gt; label at each call site, and the ledger fills itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The streaming gotchas
&lt;/h2&gt;

&lt;p&gt;Streaming is where naive capture quietly loses data, and the two big providers fail in different directions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenAI&lt;/strong&gt; only emits usage on a streamed call if you ask for it: pass &lt;code&gt;stream_options={"include_usage": True}&lt;/code&gt;. The usage then arrives on the final chunk, with every earlier chunk carrying &lt;code&gt;usage=None&lt;/code&gt;. Forget the flag and the ledger records nothing while the meter runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anthropic&lt;/strong&gt; splits usage across the stream: &lt;code&gt;message_start&lt;/code&gt; carries the input side, and the output count arrives via &lt;code&gt;message_delta&lt;/code&gt; near the end. If you use the SDK's streaming helper, the accumulated final message carries complete usage, so &lt;code&gt;stream.get_final_message().usage&lt;/code&gt; is the easy path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Field names do not agree across providers.&lt;/strong&gt; &lt;code&gt;prompt_tokens&lt;/code&gt; versus &lt;code&gt;input_tokens&lt;/code&gt;, cached tokens under &lt;code&gt;prompt_tokens_details.cached_tokens&lt;/code&gt; on one API and &lt;code&gt;cache_read_input_tokens&lt;/code&gt; on another. And some self-hosted OpenAI-compatible servers omit usage entirely. Normalize into your own schema at write time, not at query time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the first week shows you
&lt;/h2&gt;

&lt;p&gt;Having turned this on in production pipelines, the surprises arrive in a reliable order.&lt;/p&gt;

&lt;p&gt;First surprise: &lt;strong&gt;call count.&lt;/strong&gt; A rewrite step someone added in March, a retry that fires on every truncated response, an agent loop budgeted for one turn that averages 2.4. &lt;code&gt;GROUP BY query_id&lt;/code&gt; and the queries with eight rows instead of two are your bill.&lt;/p&gt;

&lt;p&gt;Second: &lt;strong&gt;the scaffolding.&lt;/strong&gt; The fixed prompt overhead, multiplied by the call count you just discovered, frequently outweighs the retrieved context. People tune the 2,000 variable tokens and ignore the 3,500 fixed ones riding on every call.&lt;/p&gt;

&lt;p&gt;Third, and only third: chunk size and top_k, the things everyone tunes first.&lt;/p&gt;

&lt;p&gt;The ledger also makes redundancy visible. If your corpus has near-duplicate documents, they show up as repeating, near-identical input deltas across queries: you are paying to send the model the same paragraphs again and again, and the fix (dedup before the context window) is a quality fix that happens to cut the bill.&lt;/p&gt;

&lt;p&gt;The whole analysis, honestly, is one query:&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;query_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&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;AS&lt;/span&gt; &lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;cost_proxy&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;calls&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;query_id&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;cost_proxy&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 5 is a list-price output-to-input ratio; swap in your provider's. The top 20 rows of that query are worth more than any cost-optimization blog post, including this one.&lt;/p&gt;

&lt;p&gt;The companion package is on GitHub and PyPI: &lt;a href="https://github.com/vinimabreu/token-ledger" rel="noopener noreferrer"&gt;token-ledger&lt;/a&gt; is exactly this ledger, packaged. &lt;code&gt;pip install llm-token-ledger&lt;/code&gt;, zero dependencies including the dashboard, one afternoon.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/sidswirl/how-many-tokens-does-your-rag-stack-actually-send-to-the-llm-4hn6"&gt;Sid Probstein made the argument from the architecture side this week&lt;/a&gt;, comparing what RAG stacks actually send across frameworks, and his closing line is the right summary of the whole subject: that number, not top_k, is your bill.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>tutorial</category>
      <category>python</category>
    </item>
  </channel>
</rss>
