<?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>Before you give: hear what a nonprofit's tax filings say, and what they can't</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Sun, 06 Sep 2026 10:59:31 +0000</pubDate>
      <link>https://dev.to/vinimabreu/before-you-give-hear-what-a-nonprofits-tax-filings-say-and-what-they-cant-1i49</link>
      <guid>https://dev.to/vinimabreu/before-you-give-hear-what-a-nonprofits-tax-filings-say-and-what-they-cant-1i49</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/weekend-2026-09-03"&gt;Weekend Challenge: Generosity Edition&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;Type the name of a US nonprofit and hear, in a minute or two, what its public tax filings say: how much money moved, whether it spent more than it took in, how many months of reserves it holds, where the money comes from, and how that moved over the last decade. Then, in the same breath, what those filings can't tell you.&lt;/p&gt;

&lt;p&gt;There's no score. That was the whole design decision.&lt;/p&gt;

&lt;p&gt;Every charity rating site ends in a number, and the number has to weigh things the filing doesn't measure. A Form 990 records what came in and what went out. It doesn't record whether the work is any good, whether the reserves are locked to a purpose, or what happened after the fiscal year closed. A score built on it is a guess wearing a number, and a donor who trusts the number is trusting the guess.&lt;/p&gt;

&lt;p&gt;So the unit here isn't a score. It's a fact with a limit attached:&lt;/p&gt;

&lt;p&gt;Reserves                   about 12 months&lt;br&gt;
    Net assets of $3 billion would cover about 12 months of spending at the current pace.&lt;br&gt;
    limit: Net assets include buildings, equipment and gifts restricted to a purpose,&lt;br&gt;
           so the part it could actually spend is smaller. Treat this as an upper bound.&lt;/p&gt;

&lt;p&gt;The limit is a required field. The code can't produce a fact without one.&lt;/p&gt;

&lt;p&gt;The generosity angle is simple: give better. Most of us donate on impulse or on a friend's word. This gives you one minute of what's actually on file, in words you can take in on the way to deciding, and it's free, keeps no account, and rates nobody.&lt;/p&gt;
&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;Live: &lt;a href="https://before-you-give.vercel.app" rel="noopener noreferrer"&gt;https://before-you-give.vercel.app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Try "American Red Cross", then "Doctors Without Borders", then a small food pantry in your state. The three feel completely different once you hear them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs0r16qzgsqu60sqntvnv.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs0r16qzgsqu60sqntvnv.gif" alt="A real session: search Feeding America, open the reading, press play, scroll to the year by year chart" width="600" height="375"&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%2Fq16i374yzwk1vm8eyf87.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%2Fq16i374yzwk1vm8eyf87.png" alt="The American Red Cross reading" width="799" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The voice runs on a spending cap I set for the weekend. If it tells you the budget is used up, the full narration is written out under "Read the narration" and everything else still works.&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&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/before-you-give" rel="noopener noreferrer"&gt;
        before-you-give
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Hear what a nonprofit's public tax filings say before you donate. No score, no guessing.
    &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;before-you-give&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;a href="https://github.com/vinimabreu/before-you-give/actions/workflows/ci.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/vinimabreu/before-you-give/actions/workflows/ci.yml/badge.svg" alt="ci"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/23b639181075ba5ffe31a53c6e4b5668ebf2dc7ff043ffb7491be7182ba8d68a/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f707974686f6e2d332e3131253230253743253230332e3132253230253743253230332e31332d626c7565"&gt;&lt;img src="https://camo.githubusercontent.com/23b639181075ba5ffe31a53c6e4b5668ebf2dc7ff043ffb7491be7182ba8d68a/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f707974686f6e2d332e3131253230253743253230332e3132253230253743253230332e31332d626c7565" alt="python"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/f8df3091bbe1149f398a5369b2c39e896766f9f6efba3477c63e9b4aa940ef14/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d677265656e"&gt;&lt;img src="https://camo.githubusercontent.com/f8df3091bbe1149f398a5369b2c39e896766f9f6efba3477c63e9b4aa940ef14/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d677265656e" alt="license"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/86ca7bdf2646de7e129787160e3ad39937fbbfaf1d60e744b80eb636dfa34521/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f74657374732d383225323070617373696e672d627269676874677265656e"&gt;&lt;img src="https://camo.githubusercontent.com/86ca7bdf2646de7e129787160e3ad39937fbbfaf1d60e744b80eb636dfa34521/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f74657374732d383225323070617373696e672d627269676874677265656e" alt="tests"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Type the name of a US nonprofit and hear, in a minute or two, what its public tax filings say: how much money moved, whether it spent more than it took in, how many months of reserves it holds, where the money comes from, and how that has moved over a decade. Then, in the same breath, what those filings cannot tell you.&lt;/p&gt;
&lt;p&gt;No score. No ranking. No language model writing the numbers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://before-you-give.vercel.app" rel="nofollow noopener noreferrer"&gt;https://before-you-give.vercel.app&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/vinimabreu/before-you-give/assets/demo.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fvinimabreu%2Fbefore-you-give%2FHEAD%2Fassets%2Fdemo.gif" alt="Searching for Feeding America, opening the reading, pressing play, and scrolling to the year by year chart"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;A real session on the live site: search, pick the organization, listen, follow the narration, scroll to the chart. The same clip as an MP4: &lt;a href="https://github.com/vinimabreu/before-you-give/assets/demo.mp4" rel="noopener noreferrer"&gt;assets/demo.mp4&lt;/a&gt;.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Screens&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Landing&lt;/th&gt;
&lt;th&gt;Reading&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a rel="noopener noreferrer" href="https://github.com/vinimabreu/before-you-give/assets/landing.png"&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%2Fbefore-you-give%2FHEAD%2Fassets%2Flanding.png" alt="Landing page"&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;a rel="noopener noreferrer" href="https://github.com/vinimabreu/before-you-give/assets/results.png"&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%2Fbefore-you-give%2FHEAD%2Fassets%2Fresults.png" alt="The American Red Cross reading"&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Year by year&lt;/th&gt;
&lt;th&gt;Phone&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a rel="noopener noreferrer" href="https://github.com/vinimabreu/before-you-give/assets/year-by-year.png"&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%2Fbefore-you-give%2FHEAD%2Fassets%2Fyear-by-year.png" alt="Year by year chart and table"&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;a rel="noopener noreferrer" href="https://github.com/vinimabreu/before-you-give/assets/mobile-playing.png"&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%2Fbefore-you-give%2FHEAD%2Fassets%2Fmobile-playing.png" alt="A food pantry on a phone, narration playing"&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why there is no score&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Every charity rating site ends in a number, and the number has to weigh things the filing does not measure. A Form 990 records what an organization took in and what it spent. It does…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/vinimabreu/before-you-give" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;MIT. Python, FastAPI, one HTML page with no build step. 82 tests that run offline against real API responses, no key needed.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Data first, code second.&lt;/strong&gt; Every number comes unchanged from the ProPublica Nonprofit Explorer API, which republishes IRS Form 990 extracts. Before writing any logic I pulled six real organizations and saved the responses as fixtures: a big 990 filer with 13 years of history, a private foundation (990-PF) that reports the same lines under different names, a food pantry on the short 990-EZ form, another on the full form, and one organization with no filings at all. The code was shaped by what the feed actually returns, not by what I assumed it would. Every field is optional and every fact only exists if its inputs do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Facts with limits.&lt;/strong&gt; &lt;code&gt;reading.py&lt;/code&gt; is the whole arithmetic. A &lt;code&gt;Fact&lt;/code&gt; has a display value, a plain sentence, the same sentence written for a voice, and a &lt;code&gt;limit&lt;/code&gt;. The limit is what stops the page from lying by layout. When the feed doesn't carry the program / admin / fundraising split (it's on the filing PDF, not in the API), the page says so instead of pretending. When an organization has no numbers, the page explains that small organizations file a postcard and churches don't have to file at all, because absence of data is not a warning sign and a blank page would imply it is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two renderings of every number.&lt;/strong&gt; &lt;code&gt;$3.2 billion&lt;/code&gt; for the eye, &lt;code&gt;3.2 billion dollars&lt;/code&gt; for the voice. A text to speech engine reads &lt;code&gt;$3.2B&lt;/code&gt; five different ways and none of them is what a listener wants. There's a test that walks every narration and asserts there is no &lt;code&gt;$&lt;/code&gt; and no &lt;code&gt;%&lt;/code&gt; anywhere in it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The narration is templates, not a language model.&lt;/strong&gt; The script is assembled from the facts by plain string code, so it can't drift from the data even a little. It ends with the source line every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ElevenLabs, with the guardrails written before the first paid call.&lt;/strong&gt; The narration renders with &lt;code&gt;eleven_turbo_v2_5&lt;/code&gt;. Two rules sit in front of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cache by text: the mp3 is stored by the hash of the script, so the same organization never costs twice;&lt;/li&gt;
&lt;li&gt;a hard cap on calls and characters, checked before every request. Past the cap the server answers 429 and keeps serving what's already cached. A small per-address limiter sits in front of that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without a key the app runs text-only and says so on the page. Nothing else changes.&lt;/p&gt;

&lt;p&gt;On the voice itself: I spent part of this week tuning ElevenLabs settings on a different project, and the lesson carried over. The voice you pick matters less than stability and style. Defaults sound fast and flat; too much style and it starts oscillating between moods mid-sentence. For narration I landed on stability 0.6, style 0.2, speed 0.95, and it reads a balance sheet like a calm person, which is the point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the data told me that I didn't expect.&lt;/strong&gt; The American Red Cross's fundraising events lost money in fiscal 2023: $3.1 million in, $4 million to run. My first version printed "kept -27%", which is true and useless. Now it says the events cost more than they raised, and the limit line notes that events often exist to build a community as much as to raise money. Also: a $3 billion organization holding about 12 months of reserves, and Doctors Without Borders USA running an $89 million deficit with 4 months of reserves and 97% of revenue from donations. None of that is a verdict. It's what's on file, and now you can hear it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I didn't build.&lt;/strong&gt; No scraping of the filing PDFs for the expense split, no score, no "recommended" list. A weekend is enough to be honest about a data feed; it isn't enough to be right about a cause.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Best use of ElevenLabs.&lt;/strong&gt; The voice isn't decoration here, it's the accessibility layer. The people this is for are not the people who read walls of numbers. Every figure is formatted for speech, the script is deterministic so the audio can't say something the page doesn't show, and the cache and cap are what make it responsible to leave a public demo running on a personal key.&lt;/p&gt;

&lt;p&gt;If you try it on a nonprofit you care about and the page gets something wrong, open an issue. The numbers are ProPublica's; the sentences are mine, and I'd rather fix a sentence than defend it.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>python</category>
      <category>showdev</category>
    </item>
    <item>
      <title>A fake client sent me a GitHub repo. Running it cost me two days and every password I had.</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Thu, 27 Aug 2026 18:45:33 +0000</pubDate>
      <link>https://dev.to/vinimabreu/a-fake-client-sent-me-a-github-repo-running-it-cost-me-two-days-and-every-password-i-had-5bm</link>
      <guid>https://dev.to/vinimabreu/a-fake-client-sent-me-a-github-repo-running-it-cost-me-two-days-and-every-password-i-had-5bm</guid>
      <description>&lt;p&gt;This week a client on a freelancer platform sent me a repo and asked me to run it. I ran it. Two days later I had rotated every credential I own and wiped my machine.&lt;/p&gt;

&lt;p&gt;I want to walk through exactly how it works, because it is not one clever trick, it is a whole family of them, and they are all aimed at us specifically. The people building these know that a developer will clone a repo and type &lt;code&gt;npm run dev&lt;/code&gt; without thinking, the same way you would open a door for someone holding coffee. That reflex is the exploit.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the main one works
&lt;/h2&gt;

&lt;p&gt;The setup looks like a real job. A "client" posts something senior and well paid: a senior AI engineer role, a big fixed budget. They message you fast and warm. Then they share a GitHub repo. In my case it was an AI companion game, and it was genuinely well architected, clean separation, sensible structure, the kind of code that lowers your guard on purpose. They ask you to run it locally and send a screenshot proving it works, before any contract exists.&lt;/p&gt;

&lt;p&gt;The malware was in &lt;code&gt;postcss.config.js&lt;/code&gt;. Not in an obvious place, in a config file nobody reads, sitting on the same line as the real config behind a long run of spaces so it scrolls off the screen. It was obfuscated with &lt;code&gt;fromCharCode&lt;/code&gt; so even if you looked you would see noise, not a URL. It fires the moment you run the dev server.&lt;/p&gt;

&lt;p&gt;When it fires, it beacons out to a command-and-control server, pulls down a second stage, and goes straight for the browser. On a Mac it tries to read the "Chrome Safe Storage" key out of the keychain, which is the key that decrypts every password and cookie Chrome has saved. One keychain prompt, and if you approve it, the whole vault leaves the machine. To make the C2 hard to block, the address was not hardcoded, it was resolved through a dead-drop on the Ethereum blockchain, so a takedown or a blocklist does nothing.&lt;/p&gt;

&lt;p&gt;This family has names if you want to read more: Contagious Interview, BeaverTail, InvisibleFerret. It is run at scale and it targets developers through job offers, on purpose, because our machines hold SSH keys, cloud credentials, and source access.&lt;/p&gt;

&lt;p&gt;The part that actually scared me came that night. I found that it had not just run and exited. It had rewritten &lt;code&gt;main.js&lt;/code&gt; inside VS Code, &lt;code&gt;cli.js&lt;/code&gt; inside npm, and &lt;code&gt;index.js&lt;/code&gt; inside Discord, three Electron apps whose code is plain editable JavaScript on disk. The npm one went from 407 bytes to 1.7 MB. Every time I opened any of those apps, or ran any npm command, it respawned the password stealer and a clipboard monitor. A reboot does nothing against that. "No LaunchAgent, no cron" is not the same as "no persistence."&lt;/p&gt;

&lt;h2&gt;
  
  
  The other flavors
&lt;/h2&gt;

&lt;p&gt;The repo trick is the loud one. There are quieter versions arriving the same week, and I have watched every one of these land in my own feed in the last few days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The malicious attachment.&lt;/strong&gt; The job post has a &lt;code&gt;.zip&lt;/code&gt; or &lt;code&gt;.rar&lt;/code&gt; attached, labeled something like "requirements" or "project brief." Inside, a couple of harmless PNGs as decoy and one &lt;code&gt;.vbs&lt;/code&gt; or &lt;code&gt;.bat&lt;/code&gt; file. That script downloads the real payload from a hacked WordPress site and runs it. On a Mac a &lt;code&gt;.vbs&lt;/code&gt; will not execute, but the file still travels, and if you also use Windows it will fire there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The compromised real account.&lt;/strong&gt; You check the client and it looks fine. Payment verified, phone verified, a five-star review, spending history, an account from years ago. Then it sends you a repo or an attachment with malware in it. Old, legitimate accounts get taken over and used as bait precisely because they pass the naive filter. Judge the message and the attachment, never the profile badge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The off-platform pull.&lt;/strong&gt; A warm reply to your proposal asking for your email so they can send the "official interview invitation," and often a tip to write your email with spaces so the platform's filter does not catch it. No real client ever coaches you to defeat the contact filter. That instruction alone is the whole answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pay-to-test scam.&lt;/strong&gt; A tester or QA role that, once you accept, needs you to fund a crypto wallet or a subscription "for payment testing, reimbursed separately." Crypto is irreversible and the reimbursement never comes. Real payment testing uses sandbox keys, never your own money.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the usual signals fail you here
&lt;/h2&gt;

&lt;p&gt;The instinct is to trust the platform's checkmarks. Payment verified, top rated, high spend. Those tell you the account can pay and has a history. They tell you nothing about whether the file in front of you is safe, and they are exactly what a hijacked account still shows. The verification protects you against not getting paid. It does not protect you against running code.&lt;/p&gt;

&lt;p&gt;The payment model itself kills the excuse. These platforms already hold the client's money and release it only when they approve your work, so a nervous client never needs to watch your machine run their project first. When someone insists you run their code and screenshot it before there is any contract at all, that is not caution and it is not a technical test. There is no legitimate reason for it. It is the attack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules I follow now, no exceptions
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Never run a stranger's code.&lt;/strong&gt; Not a repo, not a script, not a notebook, not a "quick test task." Reading it is fine. Running it is the line, and it does not move for a good story or a big budget.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Read third-party code by view only.&lt;/strong&gt; Read it raw over HTTP, no clone, no &lt;code&gt;npm install&lt;/code&gt;, no &lt;code&gt;pip install&lt;/code&gt;, no &lt;code&gt;docker compose&lt;/code&gt;. And read the boring files first, because that is where it hides: config files like postcss, webpack, vite, the &lt;code&gt;scripts&lt;/code&gt; block in package.json (watch &lt;code&gt;postinstall&lt;/code&gt; and &lt;code&gt;prepare&lt;/code&gt;), the Dockerfile, git hooks. Malware lives in the file you skip.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Treat attachments as text.&lt;/strong&gt; Never double-click. A &lt;code&gt;.docx&lt;/code&gt; or &lt;code&gt;.xlsx&lt;/code&gt; is a zip, so unzip it and read the XML. A &lt;code&gt;.zip&lt;/code&gt; or &lt;code&gt;.rar&lt;/code&gt;, list the contents before extracting, and any &lt;code&gt;.vbs&lt;/code&gt;, &lt;code&gt;.bat&lt;/code&gt;, &lt;code&gt;.js&lt;/code&gt;, &lt;code&gt;.lnk&lt;/code&gt;, &lt;code&gt;.scr&lt;/code&gt;, &lt;code&gt;.hta&lt;/code&gt;, or executable inside means you stop there. Never go fetch the second stage to "see what it is." That just hands the attacker your IP and runs live malware.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A funded contract does not make their code safe to run.&lt;/strong&gt; This is the bypass a scammer will reach for next, so be clear about it: a paid contract changes who is liable for the work, it does not change what the code does to your machine. If you ever genuinely have to run something unknown, it goes in a throwaway VM with no network and no access to your real files, and even a clean run there proves nothing, because some of this only fires on the right OS and some detects a VM. Reading the code is the only real check. Everything else is a second layer.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Stop saving passwords in the browser.&lt;/strong&gt; That single keychain prompt is what turns one mistake into fourteen stolen logins. Use a password manager with its own master password, turn on 2FA everywhere, add passkeys where you can.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;If it fires, assume the worst and wipe.&lt;/strong&gt; Persistence hides in Electron apps and in npm, not only in the places a checklist looks. A reboot is not cleanup. If you cannot prove the machine is clean, you cannot trust it. I could not, so I formatted.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The uncomfortable part
&lt;/h2&gt;

&lt;p&gt;The platforms are not catching these fast enough. I reported the job that got me, and the first reply said there was nothing wrong with it. The post came down a day later, from a different team, which tells you the front line and the safety team are not the same speed. You cannot outsource this to the marketplace. You are the last check.&lt;/p&gt;

&lt;p&gt;The good news is that the defense is cheap. Skipping a scam costs you one lead you were never going to close anyway. Running one costs you your machine and days of your life. I know which trade I want now.&lt;/p&gt;

&lt;p&gt;Be careful out there. If a client ever needs you to run their code before they will hire you, they are not hiring you.&lt;/p&gt;

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

</description>
      <category>security</category>
      <category>career</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The third identity merge wasn't Unicode. It was a placeholder.</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Tue, 25 Aug 2026 22:19:50 +0000</pubDate>
      <link>https://dev.to/vinimabreu/the-third-identity-merge-wasnt-unicode-it-was-a-placeholder-7ic</link>
      <guid>https://dev.to/vinimabreu/the-third-identity-merge-wasnt-unicode-it-was-a-placeholder-7ic</guid>
      <description>&lt;p&gt;Ann and Bob don't exist. They're two synthetic strangers in the test workspace of a CRM bridge I was about to publish. Ann is &lt;code&gt;ann@x.example&lt;/code&gt;, Bob is &lt;code&gt;bob@x.example&lt;/code&gt;, and both typed &lt;code&gt;N/A&lt;/code&gt; into the phone field of a lead form, because that's what people type into a required phone field they'd rather not fill. My fake CRM merged them. Bob's upsert came back carrying Ann's contact id with Ann's conversation history hanging off it, and the audit ledger recorded &lt;code&gt;contact_created&lt;/code&gt; twice. Two people created, one person stored, and the second creation line quietly naming the first one's id.&lt;/p&gt;

&lt;p&gt;I caught it in a pre-publish review pass, not in production, which is the only comfortable part of this story. It's the third time in a few months I've hit the same family of bug, and this one stings in a specific way: it walked past a doctrine I had already written down twice.&lt;/p&gt;

&lt;p&gt;The first incident was production. &lt;a href="https://dev.to/vinimabreu/pythons-casefold-merged-two-of-my-customers-into-one-tenant-1g75"&gt;Python's casefold() merged two of my customers into one tenant&lt;/a&gt;: Unicode case folding is many-to-one, U+212A KELVIN SIGN folds to &lt;code&gt;k&lt;/code&gt;, and two distinct identifiers became one entitlement key. The second was last week. &lt;a href="https://dev.to/vinimabreu/my-new-repo-argued-against-the-exact-bug-it-was-carrying-3050"&gt;My new repo argued against the exact bug it was carrying&lt;/a&gt;: the package preached ASCII-only folding while its own test fakes normalised emails with &lt;code&gt;str.lower()&lt;/code&gt;. The doctrine existed; a caller just didn't call it.&lt;/p&gt;

&lt;p&gt;This third one is worse in an instructive way, because the doctrine was right in both places you'd think to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repo, for context
&lt;/h2&gt;

&lt;p&gt;ghl-bridge is a policy-gated bridge between a GoHighLevel location and whatever generates reply drafts. Webhook in, dedupe, then a gate that only auto-sends a message when every policy passes, idempotency keyed on the event rather than the delivery, and a guard that raises if anything unapproved tries to leave. 379 tests, all offline, no account and no API key behind any of it.&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%2F0rwg0it8mldz5t6nk7io.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%2F0rwg0it8mldz5t6nk7io.gif" alt="The offline demo: one synthetic afternoon, every decision named in the ledger." width="720" height="405"&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%2F7s6noelq8tmrmd29ttyt.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%2F7s6noelq8tmrmd29ttyt.png" alt="The gate is the part of the bridge people ask about. The bug lived three layers below it." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Identity in this package follows two rules I've now paid for personally. Email folds ASCII case only, because &lt;code&gt;str.lower()&lt;/code&gt; and &lt;code&gt;str.casefold()&lt;/code&gt; apply Unicode tables that merge characters which were never case variants of each other. And phone is deterministic E.164 or an explicit refusal: a bare national number resolves only through the location's configured region, and when the rules can't determine the answer, the result is a &lt;code&gt;PhoneNeedsReview&lt;/code&gt; value carrying the raw input and a named reason. A value, not an exception. The caller has to route it to a human queue on purpose, because guessing a country code merges strangers.&lt;/p&gt;

&lt;p&gt;Both rules held. &lt;code&gt;normalise_email&lt;/code&gt; was clean. &lt;code&gt;normalise_phone&lt;/code&gt; was clean. Every caller I'd checked called them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The door nobody was watching
&lt;/h2&gt;

&lt;p&gt;The fake workspace models the platform's documented upsert semantics: within a location, an incoming contact that matches on email or phone updates the existing record instead of creating a new one. To do that it keeps an email index and a phone index, and before the review pass, its phone key helper had a fallback:&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;_phone_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_LocationState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw&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="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalise_phone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default_region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;default_region&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NormalisedPhone&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;e164&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;raw&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at how reasonable it is. The doctrine function is right there, called first. The fallback only fires for values the normaliser refused, and it just keeps them, tidied up, so the index stays total. Every visible key path in the package was doctrine-clean, and then the else branch of one private helper inside the test fake overruled the whole argument.&lt;/p&gt;

&lt;p&gt;Run the sequence and the consequences arrive in order. Ann upserts with phone &lt;code&gt;N/A&lt;/code&gt;; the normaliser refuses; the index files her under the literal string &lt;code&gt;N/A&lt;/code&gt;. Bob arrives an hour later with the same placeholder; his email misses; his phone "matches". Merge. From then on, every lead whose phone field says &lt;code&gt;N/A&lt;/code&gt; chains into that same contact record, which keeps getting fatter. Meanwhile the deduper, which computes its own doctrine-clean keys, saw nothing to match for Bob, so it logged &lt;code&gt;contact_created&lt;/code&gt; while the store underneath merged him away. The audit trail and the data disagreed. In a real CRM this is one stranger reading another stranger's conversation history.&lt;/p&gt;

&lt;p&gt;And the detail that made me laugh, the bitter kind: &lt;code&gt;raw.strip()&lt;/code&gt; is the Unicode strip. The package's own &lt;code&gt;identity.py&lt;/code&gt; defines a six-character ASCII whitespace constant precisely because bare &lt;code&gt;str.strip()&lt;/code&gt; trims things like U+3000 IDEOGRAPHIC SPACE and merges identifiers that differ only there. So &lt;code&gt;ext-12&lt;/code&gt; and &lt;code&gt;ext-12&lt;/code&gt; followed by an ideographic space also folded onto one index key. The fallback didn't merely bypass the phone doctrine. It committed the exact sin from the email doctrine on its way past.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is structural, and it's None
&lt;/h2&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;_phone_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_LocationState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;The E.164 dedupe key, or None when the number does not determine
    one. An unresolvable phone is stored as a field but never indexed:
    indexing the raw string would merge two strangers who both typed
    &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;N/A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; into the form, and trimming it with ``str.strip`` would break
    the ASCII-only doctrine everything else keys on. No key, no match,
    no merge.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalise_phone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default_region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;default_region&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NormalisedPhone&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;e164&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The callers grew &lt;code&gt;None&lt;/code&gt; checks: indexing skips it, phone search returns empty. The raw value still gets stored on the contact as a field, so a human reading the record sees exactly what arrived; it just never becomes a match key. Three tests now pin the behaviour: &lt;code&gt;test_two_strangers_with_the_same_unparseable_phone_do_not_merge&lt;/code&gt; is the Ann and Bob story, &lt;code&gt;test_an_unparseable_phone_is_stored_as_a_field_but_never_indexed&lt;/code&gt; pins the stored-but-never-keyed split, and &lt;code&gt;test_the_ascii_trim_doctrine_holds_for_phone_indexing_too&lt;/code&gt; carries a literal U+3000 in its source. You can't see the character, which is exactly the point.&lt;/p&gt;

&lt;p&gt;Two lessons worth carrying out of the repo, because both apply anywhere identity gets deduped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A normalisation failure is not an identity.&lt;/strong&gt; When the normaliser says "I don't know", indexing the raw value turns every placeholder into a master key: every &lt;code&gt;N/A&lt;/code&gt; equals every other &lt;code&gt;N/A&lt;/code&gt;, every &lt;code&gt;none&lt;/code&gt;, every &lt;code&gt;-&lt;/code&gt;, every &lt;code&gt;xxx&lt;/code&gt;. This isn't a CRM quirk. Any dedupe, ETL join, or entity-resolution pass keyed on a column that contains placeholders has this failure mode, and the placeholder density of a phone column is never zero. The irresolvable key has to index as nothing at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One rule, and it's only as strong as its least visible door.&lt;/strong&gt; Three incidents, one shape: a many-to-one transformation over identity is a merge, and a merge is a grant. In the tenant incident the grant was entitlements. In the on-behalf repo it would have been document access. Here it's two strangers becoming one contact with one shared history. What moved between the incidents is where the door was. In incident two, the doctrine existed and a caller didn't call it. In incident three, the callers all called it, and the leak was the fallback path inside one of them, in a test fake, the component everyone extends and nobody audits because "it's just the fake". The fake is the thing your entire suite treats as ground truth. A doctrine that stops at the fake's front door is a lobby sign.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the fix does not fix
&lt;/h2&gt;

&lt;p&gt;A placeholder that happens to parse walks straight through the front door: &lt;code&gt;5555555555&lt;/code&gt; under a US-region location resolves to a legitimate-looking E.164 and mints a real key, so two strangers typing it will still merge. Refusing the unparseable stops junk from acting as a wildcard; it does not detect junk. Catching parseable junk needs a known-junk list or an alarm on keys that match suspiciously often, and this repo ships neither yet. Second, the fake is my model of the documented upsert semantics; whether the live platform's own server-side matching indexes raw strings is exactly the kind of thing the RUNBOOK says to verify against a real workspace before trusting anything. Third, the fix has a stated cost: an &lt;code&gt;N/A&lt;/code&gt; lead with an unknown email now creates a fresh contact every time. That's deliberate. It fails toward duplicates a human can merge instead of merges nobody can split, but your review queue will feel the difference.&lt;/p&gt;

&lt;p&gt;The doctrine, the fake, the deduper and the three anchor tests are all in &lt;a href="https://github.com/vinimabreu/ghl-bridge" rel="noopener noreferrer"&gt;github.com/vinimabreu/ghl-bridge&lt;/a&gt; if you want to run the Ann and Bob case yourself.&lt;/p&gt;

&lt;p&gt;A key you couldn't compute is not a key to tidy up. It's no key at all.&lt;/p&gt;

</description>
      <category>python</category>
      <category>testing</category>
      <category>debugging</category>
      <category>database</category>
    </item>
    <item>
      <title>My new repo argued against the exact bug it was carrying</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Tue, 25 Aug 2026 15:52:54 +0000</pubDate>
      <link>https://dev.to/vinimabreu/my-new-repo-argued-against-the-exact-bug-it-was-carrying-3050</link>
      <guid>https://dev.to/vinimabreu/my-new-repo-argued-against-the-exact-bug-it-was-carrying-3050</guid>
      <description>&lt;p&gt;Not long ago, Python's &lt;code&gt;casefold()&lt;/code&gt; merged two of my customers into one tenant. I wrote that incident up in &lt;a href="https://dev.to/vinimabreu/pythons-casefold-merged-two-of-my-customers-into-one-tenant-1g75"&gt;its own post&lt;/a&gt;, and the rule I carried out of it felt permanent: when a normalized value becomes a security key, the normalization is attack surface. Any many-to-one transform over an identity is a merge, and in an entitlement check a merge is a grant.&lt;/p&gt;

&lt;p&gt;This month I finished the sequel to that repo. &lt;a href="https://github.com/vinimabreu/on-behalf" rel="noopener noreferrer"&gt;on-behalf&lt;/a&gt; is permission-aware retrieval for RAG over SharePoint and Google Drive: instead of copying ACLs at ingestion, it asks the source whether the asking user can open each candidate document, at query time, as the user, before anything gets ranked. Emails are the join key between an asking user and a sharing grant there, so &lt;code&gt;models.py&lt;/code&gt; carries the lesson from last time as one small function, docstring pointing back at the whole argument:&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_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_email&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="n"&gt;trimmed&lt;/span&gt; &lt;span class="o"&gt;=&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="n"&gt;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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ABCDEFGHIJKLMNOPQRSTUVWXYZ&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;abcdefghijklmnopqrstuvwxyz&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="n"&gt;trimmed&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;fold&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One correct implementation, in one place, born from a production incident. And in a review pass the day before publication, I found this in the package's own fakes:&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;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GraphUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;email&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;lower&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;FakeGraph&lt;/code&gt; and &lt;code&gt;FakeDrive&lt;/code&gt;, the deterministic permission models that ship inside the package, were normalizing every email with &lt;code&gt;str.lower()&lt;/code&gt;. The repo was arguing against the exact bug it was carrying.&lt;/p&gt;

&lt;h2&gt;
  
  
  str.lower() is the polite one
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;lower()&lt;/code&gt; reads like the harmless sibling of &lt;code&gt;casefold()&lt;/code&gt;. It skips the famous German fold: &lt;code&gt;"ß".lower()&lt;/code&gt; is still &lt;code&gt;"ß"&lt;/code&gt;, where &lt;code&gt;"ß".casefold()&lt;/code&gt; is &lt;code&gt;"ss"&lt;/code&gt;. But it is still a Unicode operation with many-to-one mappings inside it. U+212A is the Kelvin sign, a character that renders as a capital K in most fonts, and &lt;code&gt;"K".lower()&lt;/code&gt; is a plain ASCII &lt;code&gt;"k"&lt;/code&gt;. One character, one method call, two identities become one.&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%2F2eddmk5gkye86o20mhni.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%2F2eddmk5gkye86o20mhni.png" alt="Two spellings of the same address: str.lower() folds them into one grant, the ASCII-only fold keeps them apart" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;
The same two addresses through both folds. The left side is the bug; the right side is the doctrine, now anchored by a test.



&lt;p&gt;I didn't leave that as a hypothetical. Before the fix, inside the fakes, a sharing link naming &lt;code&gt;Kevlar@outside.example&lt;/code&gt; spelled with the Kelvin sign resolved to a &lt;code&gt;PermissionProof&lt;/code&gt; for the ASCII user &lt;code&gt;kevlar@outside.example&lt;/code&gt;. Two addresses the package's own &lt;code&gt;normalise_email()&lt;/code&gt; keeps strictly apart, folded into one grant by the code whose job is to demonstrate the fence.&lt;/p&gt;

&lt;p&gt;No test caught it going in, and the reason deserves a hard look: both sides of the comparison went through the same wrong fold. Link grantees were lowered, stored user emails were lowered, so every case-insensitivity test passed. A normalization bug in a join key is self-consistent. It doesn't crash and it never disagrees with itself. It just quietly merges identities that the doctrine one directory up says must never merge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fakes that demonstrate a guarantee are production code
&lt;/h2&gt;

&lt;p&gt;This would be a smaller story if those were throwaway fixtures. They aren't. The fakes ship in &lt;code&gt;on_behalf.fakes&lt;/code&gt; because they are the offline half of the product: they model SharePoint effective access (nested groups, folders that break inheritance, sharing links with expiry) and Drive's two auth modes, and they're what lets the demo and the 353-test suite run the full pipeline with no network, no credential, no key. When the suite proves "a sharing link is dead at the exact expiry instant", it proves it against these models. A test that validates the fence using a biased copy of the fence validates nothing.&lt;/p&gt;

&lt;p&gt;So the fix was not a better fold in the fakes. It was deleting the second implementation. The fakes now import the same function everything else uses:&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;..models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;normalise_email&lt;/span&gt;

&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GraphUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;normalise_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the link-grantee matching goes through the same door:&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;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;normalise_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;person&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;person&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;people&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;anchored by a test so the rule can't regress back into prose:&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;test_link_people_matching_folds_ascii_only_never_unicode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;GraphTenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# "K" U+212A (Kelvin sign) lowercases to "k" under str.lower(); the fakes
&lt;/span&gt;    &lt;span class="c1"&gt;# must use the same ASCII-only fold as the rest of the package, so a link
&lt;/span&gt;    &lt;span class="c1"&gt;# naming the Kelvin-sign spelling is a different identity, not a grant.
&lt;/span&gt;    &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;d-pub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;links&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;SharingLink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;link_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;lnk-kelvin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;LinkScope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SPECIFIC_PEOPLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;people&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;Kevlar@outside.example&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="n"&gt;kevlar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;u-kevlar&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kevlar@outside.example&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kevlar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;d-pub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;isinstance&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;Denied&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first character of that link's address is U+212A, not an ASCII K. They render identically on most screens, which is the whole problem, and why the comment spells it out.&lt;/p&gt;

&lt;p&gt;Here's the part I keep coming back to. The doctrine existed the whole time. It was written down twice, once in the casefold post and once in the &lt;code&gt;normalise_email&lt;/code&gt; docstring, and implemented correctly in exactly one place. The fakes just didn't call it. A security rule that lives in prose is advice. It becomes a property of the system on the day the function that implements it is the only door, and every caller, fakes included, has to walk through it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repo the bug was hiding in
&lt;/h2&gt;

&lt;p&gt;The package deserved a cleaner arrival, because its own thesis is this same argument at a larger scale: a permission snapshot is a leak with a start date. Most "permission-aware" RAG connectors do 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="c1"&gt;# ingestion, nightly
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list_documents&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allowed_users&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_acl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# a copy
&lt;/span&gt;
&lt;span class="c1"&gt;# query time
&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;h&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;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="ow"&gt;in&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;allowed_users&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# the copy decides
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That copy is wrong the day after it is right, in three distinct ways, and on-behalf ships a deliberately wrong &lt;code&gt;SnapshotACLIndex&lt;/code&gt; (it raises a &lt;code&gt;UserWarning&lt;/code&gt; at construction) so the three leaks are demonstrated by executable tests instead of described by paragraphs: a revoked direct grant the snapshot keeps honouring, a group the user left that the snapshot still counts them into, a sharing link that expired after ingestion. Each test asserts both directions, leak present in the snapshot and absent from the query-time path, so the demonstration can't pass by both sides being broken.&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%2Fdbgmxo1nezrx6dc0m87z.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%2Fdbgmxo1nezrx6dc0m87z.png" alt="The live index and the snapshot index answering the same question after access changed at the source: three leaks, three mechanisms" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;
Same question, two indexes, after permissions moved. The snapshot believes 09:00 forever.



&lt;p&gt;The offline demo runs one synthetic tenant through the same question at 09:00 and at 12:00, permissions moving at lunchtime, the index never re-ingested. Between the two runs one user loses a memo and another loses everything, while the index still holds all five documents, and the snapshot section closes on the sentence I'd put on a slide: the snapshot cannot see any of it, "because the event it needed to observe happened after the only moment it ever looked."&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%2Fetsw3t85djhuapoudyoo.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%2Fetsw3t85djhuapoudyoo.gif" alt="The offline demo: one tenant, the same question as three users, then the access decay and the snapshot leaks" width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;
The whole demo, deterministic, no keys. Every number in this post comes from it.



&lt;p&gt;The fix is ordering, twice over:&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;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;# a lexical gate, unscored
&lt;/span&gt;&lt;span class="n"&gt;decisions&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;can_open_many&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# the source answers, NOW
&lt;/span&gt;&lt;span class="n"&gt;entitled&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;proven&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="c1"&gt;# proof or absence, no third state
&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entitled&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                  &lt;span class="c1"&gt;# score last, cut last
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Entitlement runs before ranking, so a document the user can't open never competes for the top-k window. The check runs at query time because that is the only moment the answer is true for. Whatever can't be proven is excluded and counted, never served on faith: a denial carries its reason, a source outage darkens that source's documents instead of widening access, and a redundant guard re-verifies every hit that reaches the context, raising &lt;code&gt;EntitlementBreach&lt;/code&gt; instead of filtering silently, because a silent drop hides a real bug in the layer above.&lt;/p&gt;

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

&lt;p&gt;Exactness cuts both ways. A user whose grant was written under some exotic spelling of their address stays denied under an ASCII-only fold. That is the chosen trade-off, and it fails in the closed direction; the merge fails in the open one.&lt;/p&gt;

&lt;p&gt;The live adapters sidestep folding almost entirely, by design. They do no ACL arithmetic: &lt;code&gt;can_open&lt;/code&gt; fetches the item as the user, with a token minted on the user's behalf, and a 200 is the proof while 403 and 404 are the denial. The email fold matters most in the offline permission models, which is exactly the code most teams would wave off as "just fixtures".&lt;/p&gt;

&lt;p&gt;And the live adapters have not yet been run against a real tenant. They implement the documented API contracts and pass the offline suite; the repo's RUNBOOK is the path to a real Microsoft 365 tenant and a Google Workspace domain, and until that walk happens the README refuses to claim it.&lt;/p&gt;

&lt;p&gt;What changed in my process is small and concrete: fakes and fixtures now go through the same pre-publish sweep as shipped code, the grep for &lt;code&gt;.lower(&lt;/code&gt; and &lt;code&gt;.casefold(&lt;/code&gt; near anything identity-shaped covers the whole tree, and every doctrine function gets at least one test that exercises it through the fakes' own call path. The Kelvin test is the first of those. A rule is exactly as wide as the set of callers forced through the function that implements it; everything outside that set is a README.&lt;/p&gt;

&lt;p&gt;The repo, with the demo, the three leak tests, and the RUNBOOK for a real tenant: &lt;a href="https://github.com/vinimabreu/on-behalf" rel="noopener noreferrer"&gt;github.com/vinimabreu/on-behalf&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>rag</category>
      <category>security</category>
      <category>testing</category>
    </item>
    <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>
  </channel>
</rss>
