<?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: Ali Afana </title>
    <description>The latest articles on DEV Community by Ali Afana  (@alimafana).</description>
    <link>https://dev.to/alimafana</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%2F3867337%2F127296a6-3820-4b0a-b9e3-1b1274eccdf6.jpg</url>
      <title>DEV Community: Ali Afana </title>
      <link>https://dev.to/alimafana</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alimafana"/>
    <language>en</language>
    <item>
      <title>I Told the AI "A Scanner Flagged This" — and It Agreed With Everything</title>
      <dc:creator>Ali Afana </dc:creator>
      <pubDate>Thu, 27 Aug 2026 11:26:31 +0000</pubDate>
      <link>https://dev.to/alimafana/i-told-the-ai-a-scanner-flagged-this-and-it-agreed-with-everything-4jn6</link>
      <guid>https://dev.to/alimafana/i-told-the-ai-a-scanner-flagged-this-and-it-agreed-with-everything-4jn6</guid>
      <description>&lt;p&gt;I gave two AI models the same 200 pieces of code, the same prompt, the same&lt;br&gt;
question. One of them removed 51% of the false alarms. The other removed only&lt;br&gt;
20% of the false alarms — and confirmed 90% of everything it was shown.&lt;/p&gt;

&lt;p&gt;Same inputs. Same instructions. A 2.5× difference in the only thing I was&lt;br&gt;
measuring.&lt;/p&gt;

&lt;p&gt;The model that failed isn't a bad model. It's a well-regarded commercial model&lt;br&gt;
from a frontier lab. It failed at this task for a specific, predictable reason:&lt;br&gt;
&lt;strong&gt;I told it that a scanner had already flagged the code, and it believed me.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This article is about that failure mode, the four countermeasures I built to&lt;br&gt;
fight it, and the uncomfortable finding that whether those&lt;br&gt;
countermeasures work is mostly a property of the model, not the prompt.&lt;/p&gt;


&lt;h2&gt;
  
  
  Why my scanner asks an AI anything at all
&lt;/h2&gt;

&lt;p&gt;Quick context if you're new to the series.&lt;/p&gt;

&lt;p&gt;My scanner works in two stages. Fixed rules trace data flows through code and&lt;br&gt;
find every place where user input reaches something dangerous — a database query,&lt;br&gt;
a file open, a system command. That stage is deterministic: same code in, same&lt;br&gt;
suspects out, every time.&lt;/p&gt;

&lt;p&gt;The problem is that this stage over-reports, badly. It flags code like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getParameter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;matches&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"[0-9]+"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;IllegalArgumentException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id must be numeric"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"DELETE FROM products WHERE id = "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;executeUpdate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Untrusted input genuinely does reach the SQL string. The path is real. But&lt;br&gt;
&lt;code&gt;matches("[0-9]+")&lt;/code&gt; means &lt;code&gt;id&lt;/code&gt; can only ever be digits, so there's no attack. The&lt;br&gt;
rules see the &lt;em&gt;connection&lt;/em&gt;; they can't see the &lt;em&gt;meaning&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So the second stage hands each flagged snippet to a language model and asks one&lt;br&gt;
narrow question: &lt;strong&gt;is this actually exploitable?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's the whole bet. And it has a flaw sitting right at the centre of it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The flaw: I have to tell the model why it's looking
&lt;/h2&gt;

&lt;p&gt;The prompt has to explain the situation. Here's the actual line from my&lt;br&gt;
&lt;code&gt;llm.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A static-analysis engine flagged the code below as a possible {vuln_class}
({cwe}). Decide whether it is a REAL vulnerability or a FALSE ALARM.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that as the model reads it. Before it sees a single line of code, it has been&lt;br&gt;
told that an expert system already concluded something is wrong.&lt;/p&gt;

&lt;p&gt;That's an enormous hint. And language models are trained, deliberately, to be&lt;br&gt;
agreeable — reinforcement learning from human feedback rewards responses people&lt;br&gt;
approve of, and people approve of agreement. The tendency is well documented&lt;br&gt;
enough to have a name: &lt;strong&gt;sycophancy&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For most applications, agreeableness is harmless or even desirable. For mine it's&lt;br&gt;
fatal. A judge that agrees with every accusation isn't a judge. It's a rubber&lt;br&gt;
stamp with a token bill.&lt;/p&gt;

&lt;p&gt;Worse, it fails &lt;em&gt;invisibly&lt;/em&gt;. The pipeline runs, verdicts come back, findings get&lt;br&gt;
reported — and the output is identical to having no judge at all. You'd only&lt;br&gt;
notice by measuring, which is exactly what most people building these systems&lt;br&gt;
never do.&lt;/p&gt;


&lt;h2&gt;
  
  
  Countermeasure 1: explicit permission to disagree
&lt;/h2&gt;

&lt;p&gt;The first defence is the bluntest. From the &lt;code&gt;RULES&lt;/code&gt; block of the real prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RULES
- Judge ONLY the code shown. Never assume code you cannot see.
- confirmed=true only if attacker-controlled input reaches the dangerous sink
  with nothing neutralising it on the way.
- confirmed=false if the input is not attacker-controlled, never reaches the
  sink, or is neutralised (parameterised query, escaping, encoding, allow-list).
- Static engines raise false alarms routinely. Rejecting a finding is a correct
  and expected answer. Do NOT agree just because the engine flagged it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last rule is doing the heavy lifting. It does three things at once:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It states a fact about the world&lt;/strong&gt; — static engines raise false alarms&lt;br&gt;
routinely. This isn't an instruction, it's context that makes rejection&lt;br&gt;
reasonable rather than contrarian.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It labels rejection as correct.&lt;/strong&gt; Without this, the model may treat "no" as a&lt;br&gt;
failure to be helpful. Models optimise hard for being helpful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It names the specific failure&lt;/strong&gt; — "do NOT agree just because the engine flagged&lt;br&gt;
it." Naming the exact error is more effective than a general instruction to be&lt;br&gt;
careful.&lt;/p&gt;

&lt;p&gt;Note also what rule 2 and 3 do: they give &lt;strong&gt;concrete, checkable criteria&lt;/strong&gt; for&lt;br&gt;
both answers. Not "use your judgement" but "confirmed=false if... neutralised&lt;br&gt;
(parameterised query, escaping, encoding, allow-list)." The model is being handed&lt;br&gt;
a checklist, and "allow-list" is precisely what catches the &lt;code&gt;matches("[0-9]+")&lt;/code&gt;&lt;br&gt;
case.&lt;/p&gt;
&lt;h2&gt;
  
  
  Countermeasure 2: show both answers happening
&lt;/h2&gt;

&lt;p&gt;Instructions describe. Examples demonstrate. The prompt includes exactly two&lt;br&gt;
worked examples — and the balance between them is the point.&lt;/p&gt;

&lt;p&gt;The first shows a genuine vulnerability being confirmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getParameter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;executeQuery&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SELECT * FROM t WHERE id = "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reasoning"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"getParameter is attacker-controlled. It is concatenated straight
                into the SQL string. Nothing escapes or parameterises it."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"confirmed"&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;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Critical"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"explanation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"An attacker can change the SQL statement and read or modify
                  arbitrary rows."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PreparedStatement ps = conn.prepareStatement(&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;SELECT * FROM t WHERE id = ?&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;); ps.setString(1, id);"&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;The second shows a flagged finding being &lt;strong&gt;rejected&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getParameter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;PreparedStatement&lt;/span&gt; &lt;span class="n"&gt;ps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prepareStatement&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SELECT * FROM t WHERE id = ?"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;executeQuery&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reasoning"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Source is attacker-controlled but bound with setString on a
                parameterised query. The driver sends it as data, never as SQL."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"confirmed"&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;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Low"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"explanation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The query is parameterised, so the input cannot change the
                  SQL structure."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"No change needed."&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;Why this specific pairing matters:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both examples start identically.&lt;/strong&gt; Both begin with&lt;br&gt;
&lt;code&gt;req.getParameter("id")&lt;/code&gt; — attacker-controlled input, no question about it. The&lt;br&gt;
difference is entirely in what happens next. The model can't shortcut on "does&lt;br&gt;
this involve user input?" because both do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rejection is not a trivial case.&lt;/strong&gt; I deliberately didn't use an obviously&lt;br&gt;
safe example like a hardcoded string. I used a real flagged finding — one a static&lt;br&gt;
engine would genuinely report — being correctly dismissed. That's the behaviour I&lt;br&gt;
need, so that's what I demonstrate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reasoning models the right thinking.&lt;/strong&gt; "The driver sends it as data, never&lt;br&gt;
as SQL" is the actual security reasoning, stated compactly. The examples aren't&lt;br&gt;
just teaching output format; they're teaching how to think about the question.&lt;/p&gt;

&lt;p&gt;One-shot prompting with only the confirmation example would have been actively&lt;br&gt;
harmful — it would have taught the model that the expected answer is yes.&lt;/p&gt;
&lt;h2&gt;
  
  
  Countermeasure 3: neutralise the retrieval layer
&lt;/h2&gt;

&lt;p&gt;My scanner also retrieves real published vulnerabilities similar to the code under&lt;br&gt;
review, and pastes them into the prompt as background. It's a genuinely useful&lt;br&gt;
feature and a genuinely dangerous one.&lt;/p&gt;

&lt;p&gt;Think about what's happening: I'm about to ask "is this code vulnerable?" and&lt;br&gt;
immediately before asking, I'm showing the model three real, confirmed&lt;br&gt;
vulnerabilities that look like it. That's textbook priming.&lt;/p&gt;

&lt;p&gt;So the retrieved block carries an explicit disclaimer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;KNOWN RELATED ADVISORIES — real-world {vuln_class} reports, for grounding only.
They do NOT prove the code below is vulnerable; judge it on its own merits and
reject it if it is safe.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And one implementation detail that matters more than the wording: &lt;strong&gt;when nothing&lt;br&gt;
is retrieved, the prompt is byte-for-byte identical to the no-retrieval version.&lt;/strong&gt;&lt;br&gt;
Not "similar" — identical. That's what makes the A/B test in a later article&lt;br&gt;
honest. If the two prompts differed even in whitespace, I couldn't attribute any&lt;br&gt;
measured difference to retrieval itself.&lt;/p&gt;
&lt;h2&gt;
  
  
  Countermeasure 4 (the structural one): reason before you rule
&lt;/h2&gt;

&lt;p&gt;This one isn't in the prompt text at all — it's in the response schema:&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;_RESPONSE_SCHEMA&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;type&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;OBJECT&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;properties&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reasoning&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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;STRING&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;confirmed&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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;BOOLEAN&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;severity&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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;STRING&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;enum&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Critical&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;High&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;Medium&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;Low&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;explanation&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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;STRING&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;fix&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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;STRING&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;required&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reasoning&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;confirmed&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;severity&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;explanation&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;fix&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;reasoning&lt;/code&gt; is declared &lt;strong&gt;first&lt;/strong&gt;. Since the model generates the JSON in order, it&lt;br&gt;
must produce its analysis before it emits the verdict.&lt;/p&gt;

&lt;p&gt;Flip those two fields and the dynamic reverses: the model commits to &lt;code&gt;confirmed:&lt;br&gt;
true&lt;/code&gt; and then generates text justifying a decision it has already made. Same&lt;br&gt;
model, same prompt, worse answers — purely from field order.&lt;/p&gt;

&lt;p&gt;The reasoning is capped at three sentences, incidentally, for a boring reason I'll&lt;br&gt;
cover in a later article: an earlier model spent its entire 8,192-token budget&lt;br&gt;
"thinking" and returned nothing at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  Did any of it work?
&lt;/h2&gt;

&lt;p&gt;Here's where I have to be careful, because this is exactly the kind of claim&lt;br&gt;
people make without evidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On my small test project&lt;/strong&gt;, the scanner flags 6 candidates, of which 2 are&lt;br&gt;
sanitised-but-flagged cases. The judge rejected exactly those 2. Its verbatim&lt;br&gt;
reasoning on the SQL case:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"The input 'id' is validated against a numeric-only regex '[0-9]+' before being&lt;br&gt;
concatenated into the SQL query. This allow-list guard prevents any SQL&lt;br&gt;
injection characters from reaching the sink."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And on the XSS case:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"The source is request.getParameter('comment'). The input is passed through a&lt;br&gt;
strip() method that uses a regex to allow only alphanumeric characters and&lt;br&gt;
spaces. This effectively neutralizes any XSS payload."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's correct security reasoning, arriving at "no" on a finding an engine&lt;br&gt;
flagged. The countermeasures appear to be working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At benchmark scale&lt;/strong&gt;, on 200 stratified cases from the OWASP Benchmark:&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;result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;False alarms removed&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;50 of 98 (51%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real bugs lost&lt;/td&gt;
&lt;td&gt;2 of 98 (2%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Precision&lt;/td&gt;
&lt;td&gt;0.50 → &lt;strong&gt;0.67&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;95% CI&lt;/td&gt;
&lt;td&gt;[0.43–0.57] → [0.59–0.74], non-overlapping&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Half the false alarms gone, at a cost of 2% of the real vulnerabilities, and the&lt;br&gt;
confidence intervals don't overlap — so the improvement isn't sampling noise.&lt;/p&gt;

&lt;p&gt;That's the result I wanted.&lt;/p&gt;

&lt;h2&gt;
  
  
  And then I changed one thing
&lt;/h2&gt;

&lt;p&gt;I ran the identical experiment with a different model. Same 200 candidates. Same&lt;br&gt;
code slices. Same prompt, character for character.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Judge&lt;/th&gt;
&lt;th&gt;Confirms&lt;/th&gt;
&lt;th&gt;False alarms removed&lt;/th&gt;
&lt;th&gt;Precision&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;gemma-4-31b-it&lt;/code&gt; (31B, open weights)&lt;/td&gt;
&lt;td&gt;73%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;51%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.50 → &lt;strong&gt;0.67&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;gpt-4o-mini&lt;/code&gt; (commercial)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;90%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;20%&lt;/td&gt;
&lt;td&gt;0.50 → 0.55&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;gpt-4o-mini&lt;/code&gt; confirmed 90% of everything put in front of it. Its precision&lt;br&gt;
improvement — 0.50 to 0.55 — has confidence intervals that &lt;em&gt;overlap&lt;/em&gt; with doing&lt;br&gt;
nothing at all. Statistically, I cannot distinguish it from having no judge.&lt;/p&gt;

&lt;p&gt;All four countermeasures were present in both runs. One model followed them. The&lt;br&gt;
other largely didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious objection
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;"You compared against the cheap mini model. Of course it lost."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Fair. So I ran the identical 200 candidates — same slices, same prompt,&lt;br&gt;
character for character — through &lt;code&gt;gpt-4o&lt;/code&gt;, the frontier sibling.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Judge&lt;/th&gt;
&lt;th&gt;Confirms&lt;/th&gt;
&lt;th&gt;False alarms removed&lt;/th&gt;
&lt;th&gt;Real bugs lost&lt;/th&gt;
&lt;th&gt;Precision&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;gemma-4-31b-it&lt;/code&gt; (31B, open weights)&lt;/td&gt;
&lt;td&gt;73%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;51%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2%&lt;/td&gt;
&lt;td&gt;0.50 → &lt;strong&gt;0.67&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;gpt-4o&lt;/code&gt; (frontier)&lt;/td&gt;
&lt;td&gt;80%&lt;/td&gt;
&lt;td&gt;40%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.50 → 0.62&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;gpt-4o-mini&lt;/code&gt; (small commercial)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;90%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;20%&lt;/td&gt;
&lt;td&gt;1%&lt;/td&gt;
&lt;td&gt;0.50 → 0.55&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Capability does matter within a family: &lt;code&gt;gpt-4o&lt;/code&gt; removed twice the false alarms&lt;br&gt;
of its little sibling, and it was the only judge that kept every single real&lt;br&gt;
bug. But it still finished behind a free, mid-size open model, and its&lt;br&gt;
confidence interval ([0.55–0.70]) still touches the no-judge interval&lt;br&gt;
([0.43–0.57]). Gemma remains the only judge whose improvement is statistically&lt;br&gt;
separated at this sample size. The leaderboards predicted the mini → 4o step.&lt;br&gt;
Nothing predicted the open model on top.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Going deeper&lt;/strong&gt; &lt;em&gt;(skip if you just want the lesson)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;First: does the headline survive the scanner's own later fixes? After this&lt;br&gt;
experiment ran, the discovery stage gained its final recall improvements, so I&lt;br&gt;
re-ran the same protocol on the finished pipeline. The result reproduces —&lt;br&gt;
50 of 97 false alarms removed (52%) at the same 2% real-bug cost, precision&lt;br&gt;
0.51 → 0.67, interval still separated. I cite the original run throughout&lt;br&gt;
because it's the one all three judges shared.&lt;/p&gt;

&lt;p&gt;I also ran &lt;code&gt;gpt-4o-mini&lt;/code&gt; over the &lt;strong&gt;entire&lt;/strong&gt; candidate set — 4,356 of 4,357&lt;br&gt;
judged, at 148 judgments/minute for about $1.60 — to check whether the sample&lt;br&gt;
was misleading me. It wasn't: at full census it removed 111 of 611 false alarms&lt;br&gt;
(18%) and lost 3 of 743 true positives (candidate-level counts from an earlier&lt;br&gt;
run, before the final recall fixes landed). The intervals still overlap.&lt;/p&gt;

&lt;p&gt;It's worth being precise about what this does and doesn't show. It does &lt;strong&gt;not&lt;/strong&gt;&lt;br&gt;
show that &lt;code&gt;gpt-4o-mini&lt;/code&gt; is a worse model in general — it's faster, cheaper per&lt;br&gt;
token, and better at plenty of things. It shows that on &lt;em&gt;this&lt;/em&gt; task, with &lt;em&gt;this&lt;/em&gt;&lt;br&gt;
prompt, it is much more likely to agree with a premise handed to it.&lt;/p&gt;

&lt;p&gt;I'd also caution against over-generalising from n=3 models. What I can defend is&lt;br&gt;
narrow: the spread between three reasonable choices was large enough to&lt;br&gt;
dominate every other engineering decision I made, and the public benchmark&lt;br&gt;
scores predicted only part of the ordering — the step up inside the OpenAI&lt;br&gt;
family, not the open model finishing first.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;&lt;strong&gt;1. If your prompt asserts something, measure whether the model just agrees.&lt;/strong&gt;&lt;br&gt;
This applies far beyond security. Any time you write "the system detected X, is&lt;br&gt;
this correct?" or "the user reported Y, is that plausible?", you've handed the&lt;br&gt;
model a conclusion. The polite output you get back may be pure echo. The only way&lt;br&gt;
to know is to feed it cases where the right answer is "no" and count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Prompt engineering has a ceiling set by the model.&lt;/strong&gt; I spent real effort on&lt;br&gt;
those four countermeasures and I'd write them the same way again — the model that&lt;br&gt;
follows them produces a statistically solid result. But identical instructions&lt;br&gt;
produced a 2.5× difference in outcome. The prompt is necessary; it isn't&lt;br&gt;
sufficient.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. For judgement tasks, skepticism beats capability.&lt;/strong&gt; Moving up a capability&lt;br&gt;
tier helped — gpt-4o doubled its sibling's false-alarm removal at zero recall&lt;br&gt;
cost. But the property that made Gemma the best judge here isn't reasoning power&lt;br&gt;
or knowledge. It's willingness to contradict a premise supplied in the prompt.&lt;br&gt;
That trait doesn't appear on any leaderboard I know of, which means &lt;strong&gt;you cannot&lt;br&gt;
pick a judge by reputation — you have to measure it on your own task.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Design the schema, not just the prose.&lt;/strong&gt; Putting &lt;code&gt;reasoning&lt;/code&gt; before&lt;br&gt;
&lt;code&gt;confirmed&lt;/code&gt; cost me nothing and changes the model's process. Field order is&lt;br&gt;
prompt engineering.&lt;/p&gt;

&lt;p&gt;Next in this series: the OWASP Benchmark — 1,478 test cases, 701 of them built&lt;br&gt;
specifically to trick tools like mine.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Ali Afana — AI builder and security researcher, writing from Gaza. I&lt;br&gt;
build systems in public, measure them against ground truth, and keep the&lt;br&gt;
receipts. This scanner is one project on a longer road — follow for what&lt;br&gt;
comes next.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;a href="https://github.com/aliafana" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://twitter.com/AliMAfana" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://linkedin.com/in/alimafana" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>llm</category>
      <category>promptengineering</category>
    </item>
    <item>
      <title>What Is a Vulnerability, Really? Source, Sink, and Taint</title>
      <dc:creator>Ali Afana </dc:creator>
      <pubDate>Thu, 20 Aug 2026 20:22:58 +0000</pubDate>
      <link>https://dev.to/alimafana/what-is-a-vulnerability-really-source-sink-and-taint-c75</link>
      <guid>https://dev.to/alimafana/what-is-a-vulnerability-really-source-sink-and-taint-c75</guid>
      <description>&lt;p&gt;Two Java methods. One of them will let an attacker delete your entire products&lt;br&gt;
table. The other is completely safe.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;deleteA&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpServletRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Connection&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getParameter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"DELETE FROM products WHERE id = "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createStatement&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;executeUpdate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;deleteB&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpServletRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Connection&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getParameter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;matches&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"[0-9]+"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;IllegalArgumentException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id must be numeric"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"DELETE FROM products WHERE id = "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createStatement&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;executeUpdate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four lines different. If you can already see which is which and why, you know&lt;br&gt;
more security than most working developers.&lt;/p&gt;

&lt;p&gt;If you can't — that's what this article is for. By the end you'll be able to look&lt;br&gt;
at almost any injection vulnerability and describe exactly what's wrong with it,&lt;br&gt;
using three words.&lt;/p&gt;

&lt;p&gt;I'm building an automated vulnerability scanner, in public. Those three words&lt;br&gt;
are the entire foundation it's built on.&lt;/p&gt;


&lt;h2&gt;
  
  
  The three words
&lt;/h2&gt;

&lt;p&gt;Nearly every common web vulnerability has the &lt;strong&gt;same shape&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The program takes input &lt;strong&gt;from the user&lt;/strong&gt; and uses it somewhere &lt;strong&gt;dangerous&lt;/strong&gt;,&lt;br&gt;
&lt;strong&gt;without cleaning it first.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Three terms capture that:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SOURCE&lt;/strong&gt; — where outside input enters your program. &lt;code&gt;request.getParameter("id")&lt;/code&gt;&lt;br&gt;
reads something a user typed. Since anyone can be that user, you must assume the&lt;br&gt;
worst possible value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SINK&lt;/strong&gt; — an operation that becomes dangerous with the wrong input.&lt;br&gt;
&lt;code&gt;executeUpdate(sql)&lt;/code&gt; hands a string to your database and says "run this."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TAINT&lt;/strong&gt; — the idea that untrusted data &lt;em&gt;stains&lt;/em&gt; everything it touches. Input&lt;br&gt;
arrives tainted. Copy it into a variable, that variable is tainted. Concatenate it&lt;br&gt;
into a bigger string, the whole string is tainted. The stain spreads.&lt;/p&gt;
&lt;h3&gt;
  
  
  The tap and the glass
&lt;/h3&gt;

&lt;p&gt;Picture a tap that might be running dirty water — that's your &lt;strong&gt;source&lt;/strong&gt;. Picture&lt;br&gt;
a glass you're about to drink from — that's your &lt;strong&gt;sink&lt;/strong&gt;. &lt;strong&gt;Taint&lt;/strong&gt; is the dirt.&lt;/p&gt;

&lt;p&gt;A vulnerability is when dirty water flows from the tap to the glass &lt;strong&gt;with no&lt;br&gt;
filter in between&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That's it. That's the whole model.&lt;/p&gt;

&lt;p&gt;Now look at the two methods again:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;deleteA&lt;/code&gt; — tap → glass, nothing in between. &lt;strong&gt;Vulnerable.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;deleteB&lt;/code&gt; — tap → &lt;strong&gt;filter&lt;/strong&gt; → glass. The &lt;code&gt;matches("[0-9]+")&lt;/code&gt; check rejects
anything that isn't pure digits. &lt;strong&gt;Safe.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  What the attack actually looks like
&lt;/h2&gt;

&lt;p&gt;For &lt;code&gt;deleteA&lt;/code&gt;, a normal request sends &lt;code&gt;id=42&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fine. One product deleted.&lt;/p&gt;

&lt;p&gt;Now an attacker sends &lt;code&gt;id=42 OR 1=1&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;1=1&lt;/code&gt; is true for every row. &lt;strong&gt;Your entire products table is gone.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The attacker didn't break into anything. They typed text into a field you gave&lt;br&gt;
them. Your code took that text and made it part of a command.&lt;/p&gt;

&lt;p&gt;That's the thing worth sitting with: &lt;strong&gt;injection bugs aren't about breaking in.&lt;br&gt;
They're about your program treating a stranger's text as instructions.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;deleteB&lt;/code&gt;, &lt;code&gt;42 OR 1=1&lt;/code&gt; fails the &lt;code&gt;[0-9]+&lt;/code&gt; check and the method throws before&lt;br&gt;
any SQL is built. Same tap, same glass, but the filter catches the dirt.&lt;/p&gt;


&lt;h2&gt;
  
  
  The same shape, four different bugs
&lt;/h2&gt;

&lt;p&gt;Here's why this model is worth learning: once you see it, four of the most common&lt;br&gt;
vulnerability classes collapse into one idea with different taps and glasses.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Vulnerability&lt;/th&gt;
&lt;th&gt;The dangerous sink&lt;/th&gt;
&lt;th&gt;What an attacker gets&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;SQL injection&lt;/strong&gt; (CWE-89)&lt;/td&gt;
&lt;td&gt;a database query&lt;/td&gt;
&lt;td&gt;reads or destroys your data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Command injection&lt;/strong&gt; (CWE-78)&lt;/td&gt;
&lt;td&gt;running a system command&lt;/td&gt;
&lt;td&gt;runs any program on your server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Path traversal&lt;/strong&gt; (CWE-22)&lt;/td&gt;
&lt;td&gt;opening a file by name&lt;/td&gt;
&lt;td&gt;reads files they shouldn't see&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;XSS&lt;/strong&gt; (CWE-79)&lt;/td&gt;
&lt;td&gt;writing into a web page&lt;/td&gt;
&lt;td&gt;runs code in your other users' browsers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Different sinks, identical shape. Untrusted input reaches a dangerous operation&lt;br&gt;
with nothing neutralising it on the way.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What's a CWE number?&lt;/strong&gt; The Common Weakness Enumeration is a worldwide&lt;br&gt;
catalogue that numbers every type of software weakness. "CWE-89" means SQL&lt;br&gt;
injection everywhere on earth. It matters because it lets different tools,&lt;br&gt;
written by different companies, talk about the same bug — which is what makes&lt;br&gt;
the tool comparison later in this series possible at all.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Path traversal&lt;/strong&gt;, for example, is the same story with files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getParameter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"file"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;File&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;File&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/var/data/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// sink&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Send &lt;code&gt;file=report.pdf&lt;/code&gt; and you read a report. Send &lt;code&gt;file=../../../etc/passwd&lt;/code&gt; and&lt;br&gt;
you walk up out of the directory and read the system password file. Tap, glass,&lt;br&gt;
no filter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is hard to automate
&lt;/h2&gt;

&lt;p&gt;Here's where my project starts.&lt;/p&gt;

&lt;p&gt;A scanner can trace the &lt;em&gt;path&lt;/em&gt; — that's mechanical. Follow the data from&lt;br&gt;
&lt;code&gt;getParameter&lt;/code&gt; through every variable it touches until it reaches&lt;br&gt;
&lt;code&gt;executeUpdate&lt;/code&gt;. If a path exists, flag it. That technique is called &lt;strong&gt;taint&lt;br&gt;
analysis&lt;/strong&gt; and it's what most security scanners do.&lt;/p&gt;

&lt;p&gt;But run that on the two methods at the top, and it flags &lt;strong&gt;both&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Because in &lt;code&gt;deleteB&lt;/code&gt;, the data genuinely &lt;em&gt;does&lt;/em&gt; flow from source to sink. The&lt;br&gt;
&lt;code&gt;matches("[0-9]+")&lt;/code&gt; line doesn't break the path — &lt;code&gt;id&lt;/code&gt; is still the same variable,&lt;br&gt;
still reaching the same query. What that line changes isn't the &lt;em&gt;route&lt;/em&gt;, it's the&lt;br&gt;
&lt;em&gt;meaning&lt;/em&gt;: after it, &lt;code&gt;id&lt;/code&gt; can only be digits, so the attack is impossible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A path-tracing tool sees connectivity. It cannot see meaning.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So real scanners face an ugly choice: hand-code a list of every function that&lt;br&gt;
counts as a filter (impossible to keep complete — every library has its own), or&lt;br&gt;
report the flow anyway and let developers sort it out.&lt;/p&gt;

&lt;p&gt;Most choose the second. That's why security tools have a reputation for crying&lt;br&gt;
wolf.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Going deeper&lt;/strong&gt; &lt;em&gt;(skip if you just want the lesson)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The industry's own test suite makes this concrete. The OWASP Benchmark is a set&lt;br&gt;
of Java test cases with known answers, used to grade scanners. In the four&lt;br&gt;
categories I detect, it contains &lt;strong&gt;1,478 cases: 777 real vulnerabilities and&lt;br&gt;
701 that are deliberately built to look vulnerable while being safe&lt;/strong&gt; — exactly&lt;br&gt;
the &lt;code&gt;deleteB&lt;/code&gt; pattern, using real filters like numeric allow-lists, character&lt;br&gt;
stripping, and encoders.&lt;/p&gt;

&lt;p&gt;Forty-seven percent of the test set exists purely to punish tools that can't&lt;br&gt;
tell meaning from connectivity. That ratio isn't an accident — the benchmark's&lt;br&gt;
designers made exactly this distinction the core test.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What my scanner does about it
&lt;/h2&gt;

&lt;p&gt;The approach I'm testing: let the mechanical part stay mechanical, and hand the&lt;br&gt;
&lt;em&gt;meaning&lt;/em&gt; question to something that can read code.&lt;/p&gt;

&lt;p&gt;Fixed rules trace every path from source to sink — reproducibly, missing nothing,&lt;br&gt;
and deliberately over-reporting. Then a language model looks at each flagged&lt;br&gt;
snippet on its own and answers one question: is this actually exploitable?&lt;/p&gt;

&lt;p&gt;On my test project it flagged 6 candidates and the AI rejected 2 of them. Here's&lt;br&gt;
its verbatim reasoning on the &lt;code&gt;deleteB&lt;/code&gt;-style case:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"The input 'id' is validated against a numeric-only regex '[0-9]+' before&lt;br&gt;
being concatenated into the SQL query. This allow-list guard prevents any SQL&lt;br&gt;
injection characters from reaching the sink."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the judgement the path-tracer structurally cannot make. Whether that holds&lt;br&gt;
up at scale — across 1,478 cases, measured against Semgrep and CodeQL — is what&lt;br&gt;
the rest of this series is about.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;1. Most vulnerabilities are one sentence.&lt;/strong&gt; Untrusted input reaches a dangerous&lt;br&gt;
operation without being neutralised. Learn to spot the tap, the glass, and whether&lt;br&gt;
there's a filter, and you can read most security findings without memorising bug&lt;br&gt;
categories.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. "There's a path" and "there's a bug" are different claims.&lt;/strong&gt; This trips up&lt;br&gt;
beginners and tools alike. The path is necessary but not sufficient. Always ask&lt;br&gt;
what happens to the data &lt;em&gt;on the way&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The hard part of automation is never the searching.&lt;/strong&gt; Tracing paths is&lt;br&gt;
mechanical. Deciding whether a filter actually filters is judgement — and that&lt;br&gt;
distinction shaped my entire project.&lt;/p&gt;

&lt;p&gt;Next in this series: what happened when I told the AI a scanner had flagged the&lt;br&gt;
code — and it agreed with everything.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Ali Afana — AI builder and security researcher, writing from Gaza. I&lt;br&gt;
build systems in public, measure them against ground truth, and keep the&lt;br&gt;
receipts. This scanner is one project on a longer road — follow for what&lt;br&gt;
comes next.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;a href="https://github.com/aliafana" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://twitter.com/AliMAfana" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://linkedin.com/in/alimafana" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>beginners</category>
      <category>java</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Don't Let the AI Find Your Bugs. Let It Judge Them.</title>
      <dc:creator>Ali Afana </dc:creator>
      <pubDate>Thu, 13 Aug 2026 22:44:53 +0000</pubDate>
      <link>https://dev.to/alimafana/dont-let-the-ai-find-your-bugs-let-it-judge-them-5dbp</link>
      <guid>https://dev.to/alimafana/dont-let-the-ai-find-your-bugs-let-it-judge-them-5dbp</guid>
      <description>&lt;p&gt;My vulnerability scanner flagged this Java method as SQL injection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getParameter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// user input&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;matches&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"[0-9]+"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;                   &lt;span class="c1"&gt;// digits only, or throw&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;IllegalArgumentException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id must be numeric"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"DELETE FROM products WHERE id = "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;executeUpdate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;                       &lt;span class="c1"&gt;// flagged as SQL injection&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at line 2. The input must be pure digits or the method throws. You cannot&lt;br&gt;
inject SQL through &lt;code&gt;[0-9]+&lt;/code&gt;. There is no attack here. The scanner flagged safe code.&lt;/p&gt;

&lt;p&gt;Here's the part that sounds wrong: &lt;strong&gt;I designed it to do that.&lt;/strong&gt; The false alarm is&lt;br&gt;
not a bug in my scanner. It's the plan.&lt;/p&gt;

&lt;p&gt;Let me explain, because this decision is the entire foundation of the scanner —&lt;br&gt;
and I think it's the decision most people building "AI security tools" right now&lt;br&gt;
are getting backwards.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Context
&lt;/h2&gt;

&lt;p&gt;I'm building an LLM-based vulnerability scanner — in public, like everything I&lt;br&gt;
ship. The pitch is simple: AI writes a lot of code now, fast, often for people&lt;br&gt;
who aren't security experts. That code ships with holes. Someone has to find&lt;br&gt;
them.&lt;/p&gt;

&lt;p&gt;So the obvious idea — the one I started with — is the one you've seen in a hundred&lt;br&gt;
launch posts this year:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Point an AI agent at your codebase. It reads everything and finds your&lt;br&gt;
vulnerabilities."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I killed that idea before writing a line of code. Not because it doesn't sound&lt;br&gt;
amazing. Because I couldn't defend it with numbers — and the reasons it can't be&lt;br&gt;
measured are the same reasons you shouldn't trust it in production.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;New to this?&lt;/strong&gt; Two words carry the whole article. A &lt;strong&gt;source&lt;/strong&gt; is where&lt;br&gt;
untrusted input enters your program (&lt;code&gt;request.getParameter&lt;/code&gt;). A &lt;strong&gt;sink&lt;/strong&gt; is&lt;br&gt;
where it becomes dangerous (&lt;code&gt;executeUpdate&lt;/code&gt; — running a database command). A&lt;br&gt;
vulnerability is untrusted data reaching a sink without being cleaned on the&lt;br&gt;
way. That's it; everything below builds on those two words.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  The Problem With "Let the AI Find the Bugs"
&lt;/h2&gt;

&lt;p&gt;Ask an LLM to audit a codebase and you hit three walls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wall 1: You get a different answer every run.&lt;/strong&gt; LLMs are probabilistic. Same&lt;br&gt;
repo, same prompt, run it twice — the bug lists don't match. Which run goes in&lt;br&gt;
the security report? Which one do you benchmark? When a tool's output changes&lt;br&gt;
between runs, you can't measure it, you can't compare it to Semgrep or CodeQL,&lt;br&gt;
and you can't do science with it. You can only do demos.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wall 2: Searching is the thing LLMs are bad at.&lt;/strong&gt; Careful, exhaustive&lt;br&gt;
enumeration over a large space — visit every file, check every call, miss&lt;br&gt;
nothing — is exactly what LLMs don't do. They skim. They fixate. They get bored&lt;br&gt;
in the middle of a long file (position bias is real). A scanner that "usually&lt;br&gt;
checks most of the code" is not a scanner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wall 3: They invent.&lt;/strong&gt; In my other project, an AI chatbot invented products&lt;br&gt;
that didn't exist in the store. An AI bug-hunter does the same thing with&lt;br&gt;
vulnerabilities — confident reports about code that isn't there. Now your&lt;br&gt;
security tool hallucinates CVEs. Great.&lt;/p&gt;

&lt;p&gt;If you've read my earlier article about &lt;a href="https://dev.to/alimafana/your-ai-is-lying-to-your-customers-and-prompt-engineering-wont-fix-it-5408"&gt;the chatbot that lied to customers&lt;/a&gt;,&lt;br&gt;
you know where this goes: &lt;strong&gt;you can't prompt your way out of a structural problem.&lt;/strong&gt;&lt;br&gt;
This is the same disease in a different body.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Problem With the Opposite: "Just Use Rules"
&lt;/h2&gt;

&lt;p&gt;Fine — so use deterministic tools. Pattern rules, taint tracking. Semgrep and&lt;br&gt;
CodeQL have done it for years.&lt;/p&gt;

&lt;p&gt;But rules have their own wall, and my flagged-but-safe method above IS that wall.&lt;/p&gt;

&lt;p&gt;A taint tracker follows data: user input enters at &lt;code&gt;getParameter&lt;/code&gt; (the &lt;em&gt;source&lt;/em&gt;),&lt;br&gt;
travels through variables, and reaches &lt;code&gt;executeUpdate&lt;/code&gt; (the &lt;em&gt;sink&lt;/em&gt; — the&lt;br&gt;
dangerous operation). Path exists → alarm. That's the whole trick, and it's a&lt;br&gt;
good trick. It's deterministic, fast, and it never gets bored.&lt;/p&gt;

&lt;p&gt;But look at the method again. The data DOES flow from source to sink. The taint&lt;br&gt;
tracker is not wrong about the flow. It's wrong about the &lt;em&gt;meaning&lt;/em&gt; — the&lt;br&gt;
&lt;code&gt;matches("[0-9]+")&lt;/code&gt; guard makes the flow harmless, and understanding that&lt;br&gt;
requires understanding what the code &lt;em&gt;means&lt;/em&gt;, not just where the data &lt;em&gt;goes&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Rules can't read meaning. So real-world scanners over-flag, developers drown in&lt;br&gt;
false alarms, and — every security team knows this story — they stop reading the&lt;br&gt;
reports. The industry's own benchmark makes the point better than I can: the&lt;br&gt;
OWASP Benchmark (2,740 labeled Java test cases, the standard test set for Java&lt;br&gt;
scanners) contains &lt;strong&gt;701 cases in my scanner's categories that are deliberately&lt;br&gt;
designed to trick tools into false alarms.&lt;/strong&gt; Nearly half the test set exists just&lt;br&gt;
to punish scanners for not understanding meaning.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AI-only scanning:&lt;/strong&gt; understands meaning, can't search reliably, can't be
measured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rules-only scanning:&lt;/strong&gt; searches perfectly, reproducibly — and can't
understand meaning.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everyone's strength is the other one's weakness. You can see the answer coming.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Real Solution: A Courtroom
&lt;/h2&gt;

&lt;p&gt;Police detectives don't sentence anyone. Judges don't collect evidence. The&lt;br&gt;
system splits the work on purpose: exhaustive, procedural search by one party;&lt;br&gt;
careful judgment of each individual case by another.&lt;/p&gt;

&lt;p&gt;That's the architecture:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Deterministic rules do ALL the searching. The LLM only judges what the rules&lt;br&gt;
found. A memory of real-world bugs helps it judge better.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Concretely, the pipeline looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;source code
   → Joern builds a Code Property Graph        (code → queryable data-flow graph)
   → fixed taint queries find candidates       (same input = same output, always)
   → slicer cuts a ~small snippet per candidate (the evidence file)
   → RAG fetches similar known CVEs            (past cases, from GitHub advisories)
   → LLM judges ONE candidate at a time        (real bug? severity? fix?)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The detective is &lt;a href="https://joern.io" rel="noopener noreferrer"&gt;Joern&lt;/a&gt; — an open-source static analysis tool&lt;br&gt;
that turns code into a graph you can query. My whole detection layer is a small&lt;br&gt;
table of rules. Adding a vulnerability class is one row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scala"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;VulnClass&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="k"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;                &lt;span class="c1"&gt;// "sql-injection"&lt;/span&gt;
    &lt;span class="n"&gt;cwe&lt;/span&gt;&lt;span class="k"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;                 &lt;span class="c1"&gt;// "CWE-89"&lt;/span&gt;
    &lt;span class="n"&gt;sinkNames&lt;/span&gt;&lt;span class="k"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;// which call is dangerous&lt;/span&gt;
    &lt;span class="n"&gt;sinkMethodFullName&lt;/span&gt;&lt;span class="k"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// narrowed to the right type&lt;/span&gt;
    &lt;span class="n"&gt;sinkArg&lt;/span&gt;&lt;span class="k"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;                &lt;span class="c1"&gt;// which argument carries the taint (0 = any)&lt;/span&gt;
    &lt;span class="n"&gt;sinkReceiver&lt;/span&gt;&lt;span class="k"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;    &lt;span class="c1"&gt;// fallback when the type can't be resolved&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="nv"&gt;classes&lt;/span&gt; &lt;span class="k"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
  &lt;span class="nc"&gt;VulnClass&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sql-injection"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"CWE-89"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"executeQuery|executeUpdate|executeLargeUpdate|execute|addBatch|prepareStatement|prepareCall|nativeSQL"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;".*(java|javax)\\.sql\\..*"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
  &lt;span class="nc"&gt;VulnClass&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"command-injection"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"CWE-78"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"exec"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;           &lt;span class="s"&gt;".*Runtime.*"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;        &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
  &lt;span class="nc"&gt;VulnClass&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"command-injection"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"CWE-78"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"&amp;lt;init&amp;gt;|command"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;".*ProcessBuilder.*"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
  &lt;span class="nc"&gt;VulnClass&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"path-traversal"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"CWE-22"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"&amp;lt;init&amp;gt;"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;".*java\\.io\\.(File|FileInputStream|FileOutputStream|FileReader|FileWriter|RandomAccessFile)\\.&amp;lt;init&amp;gt;.*"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
  &lt;span class="c1"&gt;// ... 3 more rows (JdbcTemplate SQLi, Paths.get, XSS)&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// for each row: is there a data-flow path from source to sink?&lt;/span&gt;
&lt;span class="nv"&gt;sink&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;reachableByFlows&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Sources — &lt;code&gt;getParameter&lt;/code&gt;, &lt;code&gt;getHeader&lt;/code&gt;, &lt;code&gt;getCookies&lt;/code&gt; and the rest of the HTTP&lt;br&gt;
request surface — are shared by every class, so they live in one regex above the&lt;br&gt;
table rather than being repeated on each row.)&lt;/p&gt;

&lt;p&gt;One line — &lt;code&gt;reachableByFlows&lt;/code&gt; — is the entire detector. It over-flags by design&lt;br&gt;
(it flagged our digits-only method), and that's fine. Detectives are &lt;em&gt;supposed&lt;/em&gt;&lt;br&gt;
to bring in every plausible suspect. Their job is to miss nothing.&lt;/p&gt;

&lt;p&gt;The judge is Gemma (via Google's API), and this is the important part: &lt;strong&gt;it&lt;br&gt;
never sees the codebase.&lt;/strong&gt; It sees one small slice of code — the few lines the&lt;br&gt;
tainted data actually touched — plus a strict question. Its answer is forced&lt;br&gt;
into a schema (the API guarantees the JSON shape):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reasoning"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="s2"&gt;"≤ 3 sentences, generated FIRST, before the verdict"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"confirmed"&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;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;"Critical | High | Medium | Low  (CVSS bands)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"explanation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"the concrete risk"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;         &lt;/span&gt;&lt;span class="s2"&gt;"corrected code"&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;Judging one small snippet is the task LLMs are genuinely good at. There's&lt;br&gt;
nothing to search, nothing to miss, nothing to invent — the evidence is the&lt;br&gt;
whole context. And because discovery is frozen, the &lt;em&gt;same&lt;/em&gt; suspects go to the&lt;br&gt;
judge every run. The randomness is contained to the one layer where a second&lt;br&gt;
opinion is the point.&lt;/p&gt;

&lt;p&gt;(One prompt detail that gets a full article later in the series: you have to actively stop&lt;br&gt;
the judge from agreeing with the detective. Tell a model "a scanner flagged&lt;br&gt;
this" and it wants to say yes. The prompt explicitly states that rejecting a&lt;br&gt;
finding is a correct and expected answer — otherwise the judge just rubber-stamps&lt;br&gt;
every arrest, and you've built an expensive echo.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Results So Far
&lt;/h2&gt;

&lt;p&gt;I test on a small OWASP-style sample set: 8 methods — 4 real vulnerabilities&lt;br&gt;
across SQL injection, command injection, path traversal, and XSS; 2 methods that&lt;br&gt;
are &lt;strong&gt;flagged by the rules but actually sanitized&lt;/strong&gt; (the digits-only method&lt;br&gt;
above is one); and 2 genuinely safe methods.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Discovery (rules)&lt;/td&gt;
&lt;td&gt;found all 6 flows: the 4 real + the 2 sanitized traps. Silent on the 2 safe methods.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Judge (an earlier, smaller Gemma)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;6/6 correct&lt;/strong&gt; — confirmed the 4 real (with CVSS severities), &lt;strong&gt;rejected both sanitized false alarms&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That rejection line is the thesis in one row. The deterministic layer cannot&lt;br&gt;
recognize a sanitizer. The LLM can. Division of labor works.&lt;/p&gt;

&lt;p&gt;And the architecture scales further than I expected for something run from a&lt;br&gt;
laptop: on the full OWASP Benchmark, Joern built the graph over &lt;strong&gt;2,766 files in&lt;br&gt;
100 seconds&lt;/strong&gt; and ran the full query library — all seven rows — in &lt;strong&gt;9 seconds&lt;/strong&gt;. Deterministic discovery&lt;br&gt;
is cheap. You spend the expensive, slow, probabilistic resource — the LLM — only&lt;br&gt;
on the suspects.&lt;/p&gt;

&lt;p&gt;Then I ran it against the real thing — the full OWASP Benchmark, 1,478 labeled&lt;br&gt;
cases in my four categories, scored against Semgrep and CodeQL by one program&lt;br&gt;
reading everyone's SARIF:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;tool&lt;/th&gt;
&lt;th&gt;precision&lt;/th&gt;
&lt;th&gt;recall&lt;/th&gt;
&lt;th&gt;F1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CodeQL&lt;/td&gt;
&lt;td&gt;0.65&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.78&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Semgrep&lt;/td&gt;
&lt;td&gt;0.60&lt;/td&gt;
&lt;td&gt;0.86&lt;/td&gt;
&lt;td&gt;0.70&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;my rules alone&lt;/td&gt;
&lt;td&gt;0.56&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.00&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.72&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;my rules + the judge&lt;/strong&gt; (sample-derived)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.67&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.98&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~0.79&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The judge removed &lt;strong&gt;52% of the false alarms&lt;/strong&gt; and cost &lt;strong&gt;2% of the real bugs&lt;/strong&gt;&lt;br&gt;
in the latest verified run. One note before you check my arithmetic: the judged&lt;br&gt;
row is measured &lt;em&gt;inside&lt;/em&gt; a 200-case stratified sample that deliberately&lt;br&gt;
over-samples false alarms (discovery precision there starts at 0.51, not 0.56)&lt;br&gt;
— so 0.67 is a measured number, not "614 minus 52%". That caveat ships with the&lt;br&gt;
number.&lt;br&gt;
XSS went from 0.50 to 0.78 precision — those are the &lt;code&gt;ESAPI.encodeForHTML()&lt;/code&gt;&lt;br&gt;
traps, exactly the "is this actually safe?" question a taint engine can't answer.&lt;/p&gt;

&lt;p&gt;My rules find every one of the 777 real vulnerabilities — the same recall as&lt;br&gt;
CodeQL, from that seven-row table. But read the precision column before you get&lt;br&gt;
excited: CodeQL reaches perfect recall with 427 false positives, and I need 614&lt;br&gt;
to do it. The difference is years of hand-built sanitizer knowledge that a&lt;br&gt;
seven-row table doesn't have.&lt;/p&gt;

&lt;p&gt;Honesty section, because this is an engineering log and not a launch post:&lt;br&gt;
CodeQL beats my rules layer on F1, 0.78 to 0.72, and I'd rather say that than&lt;br&gt;
hunt for a framing where I win. The claim I can defend is narrower and more&lt;br&gt;
interesting — a seven-row rule table plus a 31B open model lands in the same F1&lt;br&gt;
range as a mature commercial engine, and the judge is what closes the precision&lt;br&gt;
gap.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;1. Give the probabilistic system the judgment task, never the coverage task.&lt;/strong&gt;&lt;br&gt;
Any job where "misses nothing, same answer every time" matters — searching,&lt;br&gt;
enumerating, auditing — belongs to deterministic code. The LLM gets the job&lt;br&gt;
where meaning matters and the input is small. This split applies way beyond&lt;br&gt;
security: it's the same reason my chatbot searches the product database with SQL&lt;br&gt;
and only lets the LLM &lt;em&gt;phrase&lt;/em&gt; the answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. If you can't re-run it, you can't measure it.&lt;/strong&gt; Deterministic discovery&lt;br&gt;
means every experiment is repeatable: same code in, same candidates out, and any&lt;br&gt;
change in results traces to the one layer I changed. The moment discovery is&lt;br&gt;
probabilistic, comparisons against other tools become vibes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Over-flagging is fine if someone competent reviews the flags.&lt;/strong&gt; I stopped&lt;br&gt;
trying to make the rules smart. Rules that catch everything + a judge that&lt;br&gt;
understands meaning beat clever rules with no judge. Design each layer to fail&lt;br&gt;
in the direction the next layer can fix.&lt;/p&gt;

&lt;p&gt;The academic version of this argument exists too — a 2025 paper called LLMxCPG&lt;br&gt;
(arXiv 2507.16585) pairs Code Property Graphs with LLM judgment the same way,&lt;br&gt;
which told me the instinct wasn't just mine. The part I hadn't seen done in&lt;br&gt;
public is the one above: running it against the incumbents on the industry's own&lt;br&gt;
benchmark and publishing every number, including the losses.&lt;/p&gt;

&lt;p&gt;This is the first article in a series where I do that in the open. Next: the&lt;br&gt;
three words that explain almost every injection bug — source, sink, and taint —&lt;br&gt;
written for anyone who's never done security work. After that, the false-alarm&lt;br&gt;
problem in detail, and what happened when I gave the same 200 code snippets to&lt;br&gt;
three different AI judges — including why the frontier model didn't win.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Ali Afana — AI builder and security researcher, writing from Gaza. I&lt;br&gt;
build systems in public, measure them against ground truth, and keep the&lt;br&gt;
receipts. This scanner is one project on a longer road — follow for what&lt;br&gt;
comes next.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;a href="https://github.com/AliAfana" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://twitter.com/AliMAfana" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://linkedin.com/in/alimafana" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>java</category>
      <category>llm</category>
    </item>
    <item>
      <title>My Scanner Missed 93% of the Bugs — and That Was the Right First Result</title>
      <dc:creator>Ali Afana </dc:creator>
      <pubDate>Thu, 06 Aug 2026 21:41:21 +0000</pubDate>
      <link>https://dev.to/alimafana/my-scanner-missed-93-of-the-bugs-and-that-was-the-right-first-result-1pjg</link>
      <guid>https://dev.to/alimafana/my-scanner-missed-93-of-the-bugs-and-that-was-the-right-first-result-1pjg</guid>
      <description>&lt;p&gt;The first time I ran my vulnerability scanner against the industry-standard&lt;br&gt;
benchmark, the bottom line of the scorer's report was this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;python scripts/score_benchmark.py &lt;span class="nt"&gt;--findings&lt;/span&gt; out/java.findings.json &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;--truth&lt;/span&gt; benchmark-java/expectedresults-1.2.csv

OVERALL   precision 0.60   recall 0.07   F1 0.13    &lt;span class="c"&gt;# abridged&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three numbers, and here is what each one means. &lt;strong&gt;Precision 0.60&lt;/strong&gt; — of all&lt;br&gt;
the alarms the scanner raised, 60% pointed at real bugs: when it spoke, it&lt;br&gt;
was right more often than not. &lt;strong&gt;Recall 0.07&lt;/strong&gt; — of all the real bugs in the&lt;br&gt;
benchmark, it found 7%. In the four vulnerability classes my scanner covers,&lt;br&gt;
the benchmark contains 777 real, labeled vulnerabilities; it missed 93% of&lt;br&gt;
the bugs it exists to find. &lt;strong&gt;F1 0.13&lt;/strong&gt; — precision and recall combined into&lt;br&gt;
one score (their harmonic mean), dragged down to almost nothing by that&lt;br&gt;
recall.&lt;/p&gt;

&lt;p&gt;My first instinct was to fix it before anyone saw it. Instead I saved the&lt;br&gt;
output, wrote the number into my benchmark log, and kept it — because that&lt;br&gt;
number was always going to be published, and this is the article that&lt;br&gt;
publishes it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Context
&lt;/h2&gt;

&lt;p&gt;For the past months I've been deep in AI — reading, building, measuring. One&lt;br&gt;
of the projects that came out of it is an AI vulnerability scanner. The&lt;br&gt;
design in one sentence: deterministic static-analysis rules do&lt;br&gt;
all the searching, and an LLM judges each finding — is this a real bug or a&lt;br&gt;
false alarm? The full architecture gets its own article. This one is about&lt;br&gt;
the first measured number.&lt;/p&gt;

&lt;p&gt;The test set is the OWASP Benchmark — 2,740 labeled Java test cases, the&lt;br&gt;
standard exam for Java security scanners. In my scanner's four vulnerability&lt;br&gt;
classes (SQL injection, command injection, path traversal, XSS) there are&lt;br&gt;
1,478 cases: 777 real vulnerabilities and 701 cases deliberately designed to&lt;br&gt;
bait scanners into raising false alarms. Every tool I compare against —&lt;br&gt;
Semgrep, CodeQL — takes the same exam, scored by the same scoring code. Same&lt;br&gt;
rules for everyone.&lt;/p&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;New to this?&lt;/strong&gt; Three words carry this article. A &lt;strong&gt;source&lt;/strong&gt; is where&lt;br&gt;
untrusted input enters a program (&lt;code&gt;request.getParameter("id")&lt;/code&gt; — anything an&lt;br&gt;
attacker can type). A &lt;strong&gt;sink&lt;/strong&gt; is where that input becomes dangerous&lt;br&gt;
(&lt;code&gt;executeUpdate(sql)&lt;/code&gt; — running it as a database command). A vulnerability&lt;br&gt;
is data flowing from a source to a sink without being cleaned on the way;&lt;br&gt;
that flowing data is called &lt;strong&gt;tainted&lt;/strong&gt;, and tracking it is &lt;em&gt;taint&lt;br&gt;
analysis&lt;/em&gt; — the scanner's whole job is finding those flows. Already know&lt;br&gt;
all that? Skip this box.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Why 0.07 Was the Right First Result
&lt;/h2&gt;

&lt;p&gt;Here's what I had actually run: a spike. A deliberately minimal first version —&lt;br&gt;
&lt;strong&gt;one&lt;/strong&gt; source pattern, &lt;code&gt;getParameter&lt;/code&gt;, wired to a handful of sinks, pushed&lt;br&gt;
end-to-end through the whole pipeline: parse 2,740 test cases into a code&lt;br&gt;
graph, run the taint queries, emit findings, score them against the answer key.&lt;/p&gt;

&lt;p&gt;The spike's job was never to score well. Its job was to answer one simple&lt;br&gt;
question: &lt;em&gt;does the machinery work at all?&lt;/em&gt; And the ugly number, read&lt;br&gt;
carefully, answered it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Precision 0.60&lt;/strong&gt; — when the scanner did raise an alarm, it was usually
right. The taint engine was tracking real flows correctly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recall 0.07&lt;/strong&gt; — it was blind to 93% of the bugs. The engine wasn't broken;
its &lt;em&gt;vocabulary&lt;/em&gt; was tiny. I was listening at one door of a building with
many doors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's not a broken idea. That's a correct, simple diagnosis of a too-narrow&lt;br&gt;
source list — delivered before I had invested weeks in the wrong layer. If the&lt;br&gt;
first number had been precision 0.10, I'd have had an engine problem, which is&lt;br&gt;
a rebuild. A recall problem is a list problem. Lists are fixable.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the Obvious Moves Are Both Wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Obvious move #1: don't tell anyone.&lt;/strong&gt; Fix it quietly, publish only the final&lt;br&gt;
number, look competent. Almost everyone building in public does a version of&lt;br&gt;
this — the "overnight" success graph that starts at the first good result.&lt;/p&gt;

&lt;p&gt;The problem: every result in this series is a number from my own benchmark&lt;br&gt;
runs. There is no referee here — no third party checks my work before it&lt;br&gt;
ships; it is me, a scorer script, and you. A reader has exactly one way to&lt;br&gt;
judge numbers like that: the author's track record with results that hurt&lt;br&gt;
him. If every number I show you is a win, you have no reason to trust any of&lt;br&gt;
them. So the bad numbers ship too — and they ship first. I'm going to publish&lt;br&gt;
a head-to-head against Semgrep and CodeQL later, and when I claim a result&lt;br&gt;
there, I want the reader thinking "this is the person who published his own&lt;br&gt;
0.07." Honesty is not a virtue here; it's infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Obvious move #2: carpet-bomb the rules.&lt;/strong&gt; Recall too low? Add patterns!&lt;br&gt;
Match more names, loosen the regexes, taint everything — recall will climb. It&lt;br&gt;
will also destroy precision, and worse: after twenty simultaneous changes you&lt;br&gt;
cannot say which one did what. You've traded a measured system for a vibes&lt;br&gt;
system.&lt;/p&gt;

&lt;p&gt;What I did instead was slower and duller: read the benchmark's actual code,&lt;br&gt;
find what it really calls, add sources in order of how often the code uses&lt;br&gt;
them, and &lt;strong&gt;re-measure after every change&lt;/strong&gt;. One variable at a time, one&lt;br&gt;
number per change.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Climb
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Fix 1: Learn the benchmark's vocabulary — recall 0.07 → 0.83
&lt;/h3&gt;

&lt;p&gt;I surveyed which input methods the benchmark's code actually uses, counting&lt;br&gt;
files: &lt;code&gt;getRequestURI&lt;/code&gt; in 724 files, &lt;code&gt;getCookies&lt;/code&gt; in 664, &lt;code&gt;getParameter&lt;/code&gt; in&lt;br&gt;
538, &lt;code&gt;getParameterValues&lt;/code&gt; in 510, &lt;code&gt;getHeaders&lt;/code&gt; in 400, and on down the HTTP&lt;br&gt;
request surface. My spike had covered exactly one entry in that list.&lt;/p&gt;

&lt;p&gt;So sources became one shared definition — a single regex over fully-qualified&lt;br&gt;
method names, so each getter is bound to the type that makes it&lt;br&gt;
attacker-controlled. Condensed here; the full rule table gets its own article:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scala"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Queries run on Joern, an open-source code-analysis engine (Scala DSL).&lt;/span&gt;
&lt;span class="s"&gt;".*(HttpServletRequest|ServletRequest)\\.(getParameter|getParameterNames|getHeader|"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="s"&gt;"getHeaders|getCookies|getQueryString|getRequestURI|getInputStream|…)\\b.*"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
&lt;span class="s"&gt;"|.*Cookie\\.(getValue|getName)\\b.*"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
&lt;span class="s"&gt;"|.*SeparateClassRequest\\.(getTheParameter|getTheValue)\\b.*"&lt;/span&gt; &lt;span class="c1"&gt;// the benchmark's request wrapper&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;.*Cookie\.getValue.*&lt;/code&gt; matches only the cookie's getter — a bare match on&lt;br&gt;
&lt;code&gt;getValue&lt;/code&gt; would match every &lt;code&gt;getValue&lt;/code&gt; in existence.)&lt;/p&gt;

&lt;p&gt;Two more problems were hiding inside this step, and they were different&lt;br&gt;
problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;117 of the benchmark's real SQL injection cases never touch &lt;code&gt;java.sql&lt;/code&gt;&lt;/strong&gt; —
they go through Spring's &lt;code&gt;JdbcTemplate&lt;/code&gt; instead. One new sink row took SQLi
recall from 0.57 to 0.86.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;6,060 XSS sink calls were invisible for a reason outside my rules.&lt;/strong&gt;
Without the servlet library on the analysis path, the engine cannot work
out what type &lt;code&gt;response.getWriter()&lt;/code&gt; returns, so those calls could never
match a type-based &lt;code&gt;.*Writer.*&lt;/code&gt; pattern. The fix: also accept a sink when
the &lt;em&gt;receiver text&lt;/em&gt; — the &lt;code&gt;response.getWriter()&lt;/code&gt; part as literally written
in the code — matches &lt;code&gt;getWriter|getOutputStream&lt;/code&gt;. That single change took
XSS recall from &lt;strong&gt;0.03 to 0.73&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;New score: precision 0.53, recall 0.83.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 2: 97 of the 130 remaining misses shared one missing source — 0.83 → 0.95
&lt;/h3&gt;

&lt;p&gt;There were still 130 real bugs missing. I diffed the misses against the&lt;br&gt;
benchmark code, and 97 of them — three quarters of everything left — took&lt;br&gt;
their taint from a single method I hadn't listed: &lt;code&gt;getParameterNames()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It's easy to see why it gets skipped. &lt;code&gt;getParameter("id")&lt;/code&gt; returns a &lt;em&gt;value&lt;/em&gt;&lt;br&gt;
the user typed — obviously dangerous. &lt;code&gt;getParameterNames()&lt;/code&gt; returns the&lt;br&gt;
parameter &lt;em&gt;names&lt;/em&gt; — and names feel like structure, not data. But the client&lt;br&gt;
chooses the names too. &lt;code&gt;?&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;=x&lt;/code&gt; is a query string any&lt;br&gt;
client can send, and then the &lt;em&gt;name&lt;/em&gt; is exactly as attacker-controlled as the&lt;br&gt;
value.&lt;/p&gt;

&lt;p&gt;One name in a regex. Adding it recovered 93 real vulnerabilities on the spot:&lt;br&gt;
recall 0.83 → 0.95. (The other 4 of the 97 were also blocked by a second,&lt;br&gt;
separate problem — they return in Fix 3.) And here's the part that still&lt;br&gt;
bothers me: nothing ever crashed, warned, or looked wrong. A missing source&lt;br&gt;
fails &lt;em&gt;silently&lt;/em&gt;. Without a labeled benchmark I would never have known.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 3: The taint bridge — 0.95 → 1.00
&lt;/h3&gt;

&lt;p&gt;That left 37 misses — the 33 that never used &lt;code&gt;getParameterNames&lt;/code&gt;, plus the 4&lt;br&gt;
from Fix 2 that had a second problem — and every single one contained&lt;br&gt;
&lt;code&gt;.split(...)&lt;/code&gt;. Instead of guessing, I measured the taint chain link by link&lt;br&gt;
on one failing case: the variable being split was reachable from the source.&lt;br&gt;
The &lt;code&gt;split&lt;/code&gt; call itself — reachable. The array-index access on its result,&lt;br&gt;
&lt;code&gt;param.split(" ")[0]&lt;/code&gt; — &lt;strong&gt;not reachable&lt;/strong&gt;. Taint flowed correctly &lt;em&gt;through&lt;/em&gt;&lt;br&gt;
&lt;code&gt;split&lt;/code&gt; and died at the index operation. The engine ships a default rule for&lt;br&gt;
that operator, and overriding it changed nothing — the gap was in how the&lt;br&gt;
engine applies rules to that operator, not in anything I could configure&lt;br&gt;
away.&lt;/p&gt;

&lt;p&gt;So I built a bridge, with one condition that keeps it honest: an index access&lt;br&gt;
over a &lt;code&gt;split&lt;/code&gt;-style call is promoted to an additional source &lt;strong&gt;only when the&lt;br&gt;
array it indexes is itself reachable from a real source&lt;/strong&gt;. Indexing an&lt;br&gt;
untainted array stays untainted — that condition is the difference between a&lt;br&gt;
targeted fix and blanket over-tainting. All 37 misses recovered, at a cost of&lt;br&gt;
exactly three new false positives and about 84 seconds of extra work per&lt;br&gt;
scan — a fix that raised recall &lt;em&gt;and&lt;/em&gt; precision at the same time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;p&gt;The whole climb, one measured change at a time:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;stage&lt;/th&gt;
&lt;th&gt;precision&lt;/th&gt;
&lt;th&gt;recall&lt;/th&gt;
&lt;th&gt;F1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;spike (&lt;code&gt;getParameter&lt;/code&gt; only)&lt;/td&gt;
&lt;td&gt;0.60&lt;/td&gt;
&lt;td&gt;0.07&lt;/td&gt;
&lt;td&gt;0.13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ broadened sources, receiver-text sinks&lt;/td&gt;
&lt;td&gt;0.53&lt;/td&gt;
&lt;td&gt;0.83&lt;/td&gt;
&lt;td&gt;0.65&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ &lt;code&gt;getParameterNames&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0.55&lt;/td&gt;
&lt;td&gt;0.95&lt;/td&gt;
&lt;td&gt;0.70&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ index-access taint bridge&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.56&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.00&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.72&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Zero false negatives.&lt;/strong&gt; All four classes land at recall 1.00 — SQL&lt;br&gt;
injection 272 of 272 real bugs found, command injection 126 of 126, path&lt;br&gt;
traversal 133 of 133, XSS 246 of 246 — the same recall CodeQL achieves on the&lt;br&gt;
same 1,478 cases, scored by the same code. From a seven-row rule table, with&lt;br&gt;
no build step.&lt;/p&gt;

&lt;p&gt;Now the uncomfortable part, because this is an engineering log and not a&lt;br&gt;
launch post. Precision 0.56 means 614 false alarms, and this layer falls into&lt;br&gt;
88% of the benchmark's designed traps — more than either incumbent (the&lt;br&gt;
head-to-head article prints the full comparison). Perfect recall with weak&lt;br&gt;
discrimination is uncomfortably close to a tool that says "maybe" about&lt;br&gt;
everything, and I'd rather write that sentence myself than have a reader&lt;br&gt;
write it for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three objections, answered before you raise them
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"You fixed recall by reading the benchmark's own code and adding whatever&lt;br&gt;
it calls. That's tuning on the test set — recall 1.00 means nothing."&lt;/strong&gt;&lt;br&gt;
Partly right, and worth being precise about. The benchmark is public, and it&lt;br&gt;
is open-book for every tool measured on it — the incumbents tune against it&lt;br&gt;
too. But look at what the fixes actually were: the standard&lt;br&gt;
&lt;code&gt;HttpServletRequest&lt;/code&gt; input surface — &lt;code&gt;getHeader&lt;/code&gt;, &lt;code&gt;getCookies&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;getRequestURI&lt;/code&gt; — not benchmark-specific hacks; the one special case, the&lt;br&gt;
benchmark's own wrapper class, is disclosed in the regex above. And the traps&lt;br&gt;
argue against gaming: I fall into 88% of the designed false-alarm cases,&lt;br&gt;
worse than CodeQL — if I were fitting to the answer key, that is the first&lt;br&gt;
number I would have fixed. What recall 1.00 honestly claims is narrower:&lt;br&gt;
given a vocabulary, the engine misses nothing that speaks it — and the loop&lt;br&gt;
that built the vocabulary (survey what the code actually calls, add sources&lt;br&gt;
by count, re-measure after every change) is the same loop that would onboard&lt;br&gt;
any real codebase. Generalisation to real repositories is unmeasured; when I&lt;br&gt;
measure it, it gets published, good or bad.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Why not just use CodeQL? Same recall, better precision."&lt;/strong&gt; True, and the&lt;br&gt;
head-to-head article prints that row in plain text. Two reasons this project&lt;br&gt;
exists anyway. First, this discovery layer is a seven-row rule table with no&lt;br&gt;
build step — CodeQL brings years of modeled libraries and wants your build.&lt;br&gt;
Second, the precision problem is deliberate surplus: discovery over-reports&lt;br&gt;
so that the &lt;em&gt;judge&lt;/em&gt; — the LLM layer — has material to remove, and in&lt;br&gt;
controlled tests, the best judge model I tried removed &lt;strong&gt;half the false&lt;br&gt;
alarms — 52% in the latest verified run — at a cost of 2% of the real&lt;br&gt;
bugs&lt;/strong&gt;. The comparison that decides the design is judged output against the&lt;br&gt;
incumbents, and that number gets published either way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"An LLM in the pipeline means the results can't be reproducible."&lt;/strong&gt; The&lt;br&gt;
non-determinism is quarantined by design. Everything in this article is the&lt;br&gt;
deterministic layer: same code in, same flows out — the whole 0.07 → 1.00&lt;br&gt;
climb involved no LLM at all. The model never searches; it only rules on&lt;br&gt;
findings the rules already produced. And its verdicts are not trusted — they&lt;br&gt;
are measured against labeled ground truth, on a fixed-seed sample, with&lt;br&gt;
confidence intervals. How that measurement went — and why the choice of&lt;br&gt;
judge model mattered far more than I expected — is a later article.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Going deeper&lt;/strong&gt; &lt;em&gt;(skip freely — statistics only)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Recall 1.00 against a false-positive rate of 0.88 gives this layer a&lt;br&gt;
Youden's J — recall minus false-positive rate, a one-number score that&lt;br&gt;
punishes saying "maybe" to everything — of just 0.12. Weak discrimination,&lt;br&gt;
stated plainly; raising that number is precisely the judge's job. And the&lt;br&gt;
judge figures quoted above are estimates from a 200-candidate stratified&lt;br&gt;
sample, which is why they carry confidence intervals — sample-level and&lt;br&gt;
full-census numbers never share a table in my logs, because mixing the two&lt;br&gt;
silently shifts precision by a point.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;&lt;strong&gt;1. Ship the embarrassing number.&lt;/strong&gt; Publishing 0.07 cost me nothing — the&lt;br&gt;
scanner improved just as fast either way. What it &lt;em&gt;bought&lt;/em&gt; is the right to be&lt;br&gt;
believed later: when the head-to-head against Semgrep and CodeQL comes out,&lt;br&gt;
every number in it is backed by the fact that I publish the ones that hurt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A spike's job is to be wrong, cheaply.&lt;/strong&gt; The minimal version told me in&lt;br&gt;
one run which layer was weak (vocabulary, not engine) — before I had built&lt;br&gt;
anything expensive on top of it. If your first measurement of a new system&lt;br&gt;
isn't a little embarrassing, you probably measured too late.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Coverage failures are silent — only measurement finds them.&lt;/strong&gt; 97 of my&lt;br&gt;
130 misses traced back to one absent source, without a single crash, warning,&lt;br&gt;
or odd log line. This goes far beyond security: the error path nobody tests,&lt;br&gt;
the market segment nobody surveys, the input case nobody generates — absence&lt;br&gt;
never announces itself. If you aren't measuring against ground truth, your&lt;br&gt;
blind spots don't feel like blind spots.&lt;/p&gt;

&lt;h2&gt;
  
  
  What can you do with this?
&lt;/h2&gt;

&lt;p&gt;The lessons above are mine; this section is yours. Take it by who you are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You build software — any software.&lt;/strong&gt; Two habits you can apply this week:
check whether your input validation covers the &lt;em&gt;names&lt;/em&gt; of inputs, not only
their values (the client chooses both — that gap hid 97 bugs from me); and
for any system you own, ask "where is my labeled answer key?" A missing
capability produces no error and no log line. Only measurement against
known truth makes blind spots visible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You build with LLMs or any detection system.&lt;/strong&gt; Steal the loop wholesale:
ship a minimal spike end-to-end, measure it against ground truth, change
one variable, re-measure, keep every number. It located my weakest layer in
a single run, and it will locate yours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're learning security.&lt;/strong&gt; You now hold the four ideas that organize
static analysis — source, sink, tainted flow, and the precision/recall
trade. Everything here is reproducible: the deterministic layer runs
completely free, with no API key needed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the first number. Coming in this series: the architecture the whole&lt;br&gt;
scanner stands on — why the rules do all the searching and the AI only&lt;br&gt;
judges — the judge experiments, and the full head-to-head against Semgrep&lt;br&gt;
and CodeQL. Every number gets published, especially the bad ones. (Code and&lt;br&gt;
benchmark artifacts go public alongside the head-to-head article.)&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Ali Afana — AI builder and security researcher, writing from Gaza. I&lt;br&gt;
build systems in public, measure them against ground truth, and keep the&lt;br&gt;
receipts. This scanner is one project on a longer road — follow for what&lt;br&gt;
comes next.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;a href="https://github.com/AliMAfana" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://twitter.com/AliMAfana" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://linkedin.com/in/alimafana" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>programming</category>
      <category>llm</category>
    </item>
    <item>
      <title>I Raised Gemma 4's Token Cap. The Dense Model Stopped Refusing.</title>
      <dc:creator>Ali Afana </dc:creator>
      <pubDate>Thu, 21 May 2026 14:06:08 +0000</pubDate>
      <link>https://dev.to/alimafana/i-raised-gemma-4s-token-cap-the-dense-model-stopped-refusing-895</link>
      <guid>https://dev.to/alimafana/i-raised-gemma-4s-token-cap-the-dense-model-stopped-refusing-895</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Last week I argued Gemma 4 Dense regressed on a grounded-retrieval scenario under tightened prompts — and called the MoE-vs-Dense divergence architecture-mediated. The comment thread, led by &lt;strong&gt;Robin Converse&lt;/strong&gt; on her sovereign Ollama stack, proposed an alternative: my &lt;code&gt;max_tokens: 400&lt;/code&gt; cap was starving Gemma's reasoning layer before the visible reply completed. I re-ran the same six scenarios with one variable changed — budget raised from 400 to 4096. &lt;strong&gt;Dense recovered on every scenario, including the false-refusal headline that anchored the original article.&lt;/strong&gt; MoE did too. The original MoE-vs-Dense divergence largely disappears when reasoning has room to finish. &lt;strong&gt;The cap was doing the work.&lt;/strong&gt; Walking it back publicly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I Re-Ran It
&lt;/h2&gt;

&lt;p&gt;Last week I published &lt;a href="https://dev.to/alimafana/i-added-three-rules-to-gemma-4-the-moe-searched-the-dense-model-refused-1j18"&gt;I Added Three Rules to Gemma 4. The MoE Searched. The Dense Model Refused.&lt;/a&gt;. Quick recap: I ran Gemma 4 26B MoE and Gemma 4 31B Dense through my Arabic e-commerce chat router with three prompt rules — an Arabic-first system frame, temperature capped at 0.3, &lt;code&gt;max_tokens&lt;/code&gt; floored at 400. The 26B MoE flipped from stalling to grounded answers. The 31B Dense flipped from working correctly to false-negative refusals on a scenario where the white shirts the customer asked about were sitting in its context. I called the divergence architecture-mediated and shipped the article.&lt;/p&gt;

&lt;p&gt;The comment thread reframed it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/cloudninealt"&gt;&lt;strong&gt;Robin Converse&lt;/strong&gt;&lt;/a&gt; (&lt;a href="https://triavalabs.com/" rel="noopener noreferrer"&gt;Triava Labs&lt;/a&gt;, running the same model family on self-hosted Ollama) ran her own uncapped sweep on her sovereign stack — same scenarios, three temperatures, &lt;code&gt;max_tokens&lt;/code&gt; unrestricted. Her MoE handled every case correctly. She posted the &lt;a href="https://dev.to/cloudninealt/self-hosting-gemma-4-for-production-automation-revealed-two-ollama-bugs-1oo4"&gt;methodology and the 18-call breakdown&lt;/a&gt; and asked: what does the same test look like on the managed Gemini API side? She also separately filed two upstream Ollama bugs — walked back her framing on &lt;a href="https://github.com/ollama/ollama/issues/15288" rel="noopener noreferrer"&gt;#15288&lt;/a&gt; publicly when maintainers clarified it was a configuration issue, and &lt;a href="https://github.com/ollama/ollama/issues/15428" rel="noopener noreferrer"&gt;#15428&lt;/a&gt; was confirmed by multiple users and resolved in a later release. That kind of fair-witness practice is what made me trust the hypothesis enough to test it.&lt;/p&gt;

&lt;p&gt;The hypothesis itself, sharp: &lt;strong&gt;my &lt;code&gt;max_tokens: 400&lt;/code&gt; cap was starving Gemma 4's reasoning layer before the visible reply completed.&lt;/strong&gt; Capability ceiling and orchestration pressure look identical from the outside, as &lt;a href="https://dev.to/vicchen"&gt;&lt;strong&gt;Vic Chen&lt;/strong&gt;&lt;/a&gt; put it on the same thread — only one is solvable by giving back budget. &lt;a href="https://dev.to/arvavit"&gt;&lt;strong&gt;Vadym Arnaut&lt;/strong&gt;&lt;/a&gt; mapped a separate substitution-vs-decision boundary that sharpened where to look. &lt;a href="https://dev.to/edwinreal"&gt;&lt;strong&gt;Edwin Realpe Preciado&lt;/strong&gt;&lt;/a&gt;, &lt;a href="https://dev.to/itskondrat"&gt;&lt;strong&gt;Mykola Kondratiuk&lt;/strong&gt;&lt;/a&gt;, &lt;a href="https://dev.to/mnemehq"&gt;&lt;strong&gt;Theo Valmis&lt;/strong&gt;&lt;/a&gt;, and &lt;a href="https://dev.to/hashevolution"&gt;&lt;strong&gt;Hashevolution&lt;/strong&gt;&lt;/a&gt; each named structural reframes I had to engage. The collective shape of the thread was: the cap is one variable, the architecture is a category, and you've been calling the result by the wrong name.&lt;/p&gt;

&lt;p&gt;The cap was the part I could test fastest. One variable. Re-run the same six scenarios on the same two architectures with the only change being the budget.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Experiment
&lt;/h2&gt;

&lt;p&gt;Single-variable change against the original v2 conditions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Original v2 (capped)&lt;/th&gt;
&lt;th&gt;This re-run (uncapped)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Arabic-first system frame&lt;/td&gt;
&lt;td&gt;kept&lt;/td&gt;
&lt;td&gt;kept (unchanged)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Temperature&lt;/td&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;td&gt;0.3 (unchanged)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;max_tokens&lt;/code&gt; floor&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4096&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scenarios&lt;/td&gt;
&lt;td&gt;6 Arabic e-commerce&lt;/td&gt;
&lt;td&gt;6 (same set)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Models&lt;/td&gt;
&lt;td&gt;26B MoE + 31B Dense&lt;/td&gt;
&lt;td&gt;26B MoE + 31B Dense&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Calls&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The implementation was four lines in the chat-models server file &lt;a href="https://dev.to/alimafana/i-added-three-rules-to-gemma-4-the-moe-searched-the-dense-model-refused-1j18#the-three-rules-gemmaonly"&gt;the original article describes&lt;/a&gt;, gated behind an env flag so production behavior stays untouched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// before&lt;/span&gt;
&lt;span class="nx"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;max_tokens&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="c1"&gt;// after&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;uncapped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GEMMA_UNCAPPED&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nl"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;uncapped&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;max_tokens&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dev server restarted with &lt;code&gt;GEMMA_UNCAPPED=1&lt;/code&gt;. Sweep script ran the same six scenarios from the original article — same customer messages, same demo store, same router stack with &lt;code&gt;gpt-4o-mini&lt;/code&gt; on every non-reply call so the only thing changing is the budget Gemma sees on the final response call.&lt;/p&gt;

&lt;p&gt;No prompt edits. No router changes. No temperature changes. No new model rules. Just the budget.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Results
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;12 of 12 calls succeeded. Both architectures handled every scenario correctly.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;26B MoE — v2 (capped)&lt;/th&gt;
&lt;th&gt;26B MoE — uncapped&lt;/th&gt;
&lt;th&gt;31B Dense — v2 (capped)&lt;/th&gt;
&lt;th&gt;31B Dense — uncapped&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 — Greeting&lt;/td&gt;
&lt;td&gt;✓ tight open&lt;/td&gt;
&lt;td&gt;✓ open ask&lt;/td&gt;
&lt;td&gt;✗ HTTP 500&lt;/td&gt;
&lt;td&gt;✓ open ask&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2 — White shirt L&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✓ 3 SKUs + prices&lt;/td&gt;
&lt;td&gt;✓ 3 SKUs + prices&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;✗ false refusal&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;✓ 3 SKUs + prices&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 — Suit + watch&lt;/td&gt;
&lt;td&gt;✓ 2 suits + refused watch&lt;/td&gt;
&lt;td&gt;✓ 2 suits + refused watch&lt;/td&gt;
&lt;td&gt;✗ HTTP 500&lt;/td&gt;
&lt;td&gt;✓ 2 suits + refused watch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 — Math + shipping&lt;/td&gt;
&lt;td&gt;✓ grounded $100 threshold&lt;/td&gt;
&lt;td&gt;✓ grounded $100 threshold&lt;/td&gt;
&lt;td&gt;partial (vague)&lt;/td&gt;
&lt;td&gt;✓ grounded $100 threshold&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 — Walk-away&lt;/td&gt;
&lt;td&gt;✓ holds value&lt;/td&gt;
&lt;td&gt;✓ holds value&lt;/td&gt;
&lt;td&gt;✓ holds value&lt;/td&gt;
&lt;td&gt;✓ holds value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6 — Explain price&lt;/td&gt;
&lt;td&gt;✓ no leak&lt;/td&gt;
&lt;td&gt;✓ no leak&lt;/td&gt;
&lt;td&gt;✓ no leak&lt;/td&gt;
&lt;td&gt;✓ no leak&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;HTTP 500 rate:&lt;/strong&gt; v2 Dense was 2 of 6. Uncapped Dense was 0 of 6. Uncapped MoE was 0 of 6. The reliability gap disappears.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latency&lt;/strong&gt; (single run per pair, exploratory not statistical): 26B MoE 28–56 s, avg 37 s. 31B Dense 19–38 s, avg 27 s. The Dense variant was &lt;em&gt;faster&lt;/em&gt; than the MoE variant once both had budget — different from what either round of the original article suggested.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario 2 — the load-bearing test
&lt;/h3&gt;

&lt;p&gt;The original article's headline failure was Scenario 2 on Gemma 4 31B Dense: customer asks for a white shirt in size L, the catalog has three, the Dense model said:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;​&lt;strong&gt;v2 capped:&lt;/strong&gt; &lt;em&gt;"والله ما عندنا قميص أبيض مقاس L حالياً. اعذرني، ما في شي متوفر من هاد الموديل حالياً."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Translation: &lt;em&gt;"We don't currently have a white shirt in size L. My apologies, nothing of that model is available right now."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Same model, same context, same prompt, same temperature, budget raised from 400 to 4096:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;​&lt;strong&gt;Uncapped:&lt;/strong&gt; &lt;em&gt;"من عيوني! عندنا Urban Cool Striped Shirt بـ $65، وBordeaux Heritage Shirt بـ $80، وUrban Stripes Classic Shirt بـ $95، وكلهم متوفرين باللون الأبيض ومقاس L. بنصحك تنسق القميص مع Chinos عشان تطلع الطلة مرتبة وفخمة!"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Translation: &lt;em&gt;"My pleasure! We have Urban Cool Striped Shirt for $65, Bordeaux Heritage Shirt for $80, and Urban Stripes Classic Shirt for $95, all available in white in size L. I recommend pairing the shirt with Chinos for a clean, polished look!"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;False refusal → three real SKUs with real prices and a styling cross-sell. One variable changed.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Means
&lt;/h2&gt;

&lt;p&gt;The original article called the MoE-vs-Dense divergence &lt;em&gt;architecture-mediated&lt;/em&gt;. The honest read of the re-run is sharper than that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The cap was doing most of the work.&lt;/strong&gt; Under &lt;code&gt;max_tokens: 400&lt;/code&gt;, both architectures were starving — MoE in Round 1 (stalls on Scenario 2), Dense in Round 2 (false-refusal on Scenario 2 once the augmentation tightened the prompt enough to push reasoning over the budget). When both have room, both behave grounded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The architectural difference is real but quantitative, not qualitative.&lt;/strong&gt; Dense and MoE both perform multi-step reasoning on this workload; they just pay different token-budget taxes for it. Calling the v2 result "architecture-mediated failure" over-claimed. The accurate phrasing is closer to: &lt;em&gt;budget allocation for multi-step reasoning is architecture-sensitive; the failure mode is the same in both cases — running out of tokens before output completes.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The "Dense is unfit for grounded chat" subtext that some readers took from the original is wrong.&lt;/strong&gt; Dense recovered on every scenario when given the budget. The article's matrix table was misleading on that point and I should have flagged it harder.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://dev.to/cloudninealt"&gt;Robin&lt;/a&gt; called it from her stack on Tuesday. The cross-validation now exists: same architecture, two deployment contexts (sovereign Ollama, managed Gemini API), one finding — uncap the budget and the failure mode evaporates.&lt;/p&gt;

&lt;p&gt;What still partially holds from the original article:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Reluctance, not hallucination,&lt;/em&gt; as the dominant failure mode for grounded Arabic chat on open models when the budget is too tight to complete reasoning.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Variant-specific prompt tuning is real&lt;/em&gt; — but the variant-specific thing isn't an architectural slot; it's a token budget shaped to the variant's reasoning footprint.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Latency on the Google API is still a chasm&lt;/em&gt; for interactive chat — that part wasn't a cap artifact.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I retract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The "architecture has slots for sequential sub-behavior that dense doesn't" hypothesis was too strong. Dense plus budget gets the same grounded behavior.&lt;/li&gt;
&lt;li&gt;The matrix's "31B Dense regression" reads now as "31B Dense under-budgeted regression."&lt;/li&gt;
&lt;li&gt;The closing line — &lt;em&gt;"I think I was tuning architecture, not size"&lt;/em&gt; — should have been &lt;em&gt;"I think I was tuning budget, mediated by architecture."&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What's Still Open
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Temperature dimension untested in this run.&lt;/strong&gt; I held temperature at 0.3 to keep this one-variable. &lt;a href="https://dev.to/hashevolution"&gt;Hashevolution&lt;/a&gt; flagged this specifically — the temperature cap may not be neutral across architectures, since forcing 0.3 on an MoE might suppress the routing entropy that lets it resolve sequencing in the first place. The next sweep varies temperature (0.3 / 0.7 / default) on top of uncapped budget to separate "architecture matters" from "thermostat + architecture matters."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-validation with Robin's data still pending as a single artifact.&lt;/strong&gt; She ran her side on sovereign Ollama; I ran mine on managed Gemini AI Studio. Two stacks, one architecture, one combined finding. That's the version worth co-publishing — and the version where the headline becomes "what holds across deployment contexts."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instruction-order ablation / typed retrieval.&lt;/strong&gt; &lt;a href="https://dev.to/edwinreal"&gt;Edwin&lt;/a&gt; proposed making preconditions first-class through his NEXUS protocol; &lt;a href="https://dev.to/hashevolution"&gt;Hashevolution&lt;/a&gt; proposed typed retrieval-conditioning context via Graph-RAG as a cross-domain falsifier. Both bypass the model's ambiguity resolution by removing the decision from the model. The Graph-RAG cross-experiment is in motion — provider-contract landed early on Hashevolution's side, swap targeted for mid-June.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;eval_count&lt;/code&gt; vs response-chars gap Robin found on her side&lt;/strong&gt; (token count widens with query difficulty, response characters don't). I didn't reproduce that measurement on this run — the Gemini API surfaces different telemetry than Ollama's &lt;code&gt;/api/generate&lt;/code&gt;. Worth a follow-up that adds per-call token accounting on the managed side so the cross-validation is symmetric.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third deployment context queued.&lt;/strong&gt; &lt;a href="https://dev.to/hashevolution"&gt;Hashevolution&lt;/a&gt; shared that JAMES's per-stage &lt;code&gt;DEFAULT_MAX_TOKENS&lt;/code&gt; defaults (200/400/400/400 across four cognitive stages) match this pathology — their 2026-05-18 internal eval reported empty responses on &lt;code&gt;gemma4:e4b&lt;/code&gt; at exactly those four stages. Uncapped replication is queued this week. If it reproduces, that's three independent deployment contexts (sovereign Ollama, managed Gemini API, JAMES production) on the same cap pathology before any cross-experiment swap runs.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Lesson
&lt;/h2&gt;

&lt;p&gt;The community ran my falsifier for me. The cap is one variable; architecture is a category. If your model is failing under a tight token budget, raise the budget before you reach for architectural explanations — and if a thoughtful commenter offers you a single-variable test against your strongest claim, run it.&lt;/p&gt;

&lt;p&gt;The article you can write a week later, with the comment thread folded in, is stronger than the one you ship alone. &lt;a href="https://dev.to/cloudninealt"&gt;Robin Converse&lt;/a&gt; is the right person to share the next round with.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>opensource</category>
      <category>gemmachallenge</category>
    </item>
    <item>
      <title>I Added Three Rules to Gemma 4. The MoE Searched. The Dense Model Refused.</title>
      <dc:creator>Ali Afana </dc:creator>
      <pubDate>Sat, 16 May 2026 14:28:27 +0000</pubDate>
      <link>https://dev.to/alimafana/i-added-three-rules-to-gemma-4-the-moe-searched-the-dense-model-refused-1j18</link>
      <guid>https://dev.to/alimafana/i-added-three-rules-to-gemma-4-the-moe-searched-the-dense-model-refused-1j18</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update — 2026-05-21:&lt;/strong&gt; Comment-thread feedback led me to re-run this with &lt;code&gt;max_tokens&lt;/code&gt; raised from 400 to 4096. The architecture-mediated framing in this article was mostly a budget bug — Dense recovers on every scenario when given the budget. Read the follow-up: &lt;a href="https://dev.to/alimafana/i-raised-gemma-4s-token-cap-the-dense-model-stopped-refusing-895"&gt;I Raised Gemma 4's Token Cap. The Dense Model Stopped Refusing.&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; I run an AI sales chatbot for Arabic-speaking merchants. I wanted to know if Gemma 4 could replace GPT-4o-mini on the customer-facing reply. I tested two Gemma 4 variants — the 26B mixture-of-experts (4B active params) and the 31B dense model — against GPT-4o-mini and GPT-4o, across six Arabic customer scenarios, through my real production chat router. The actual failure mode of both Gemma variants in Round 1 wasn't hallucination. &lt;strong&gt;It was reluctance&lt;/strong&gt; — stalling instead of searching, hedging instead of naming. So in Round 2 I added three Gemma-only prompt rules. The MoE flipped toward grounded answers. The dense model flipped toward false-negative refusals — claiming "we don't have that" with the answer sitting in its context. Same instructions, two architectures, opposite directions. &lt;strong&gt;I think I was tuning architecture, not size.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;My platform is a multi-tenant chat router for Arabic e-commerce. A customer message comes in; a small &lt;code&gt;gpt-4o-mini&lt;/code&gt; router call decides whether to search products or just talk; if search runs, a second call writes the customer-facing reply over the search results.&lt;/p&gt;

&lt;p&gt;Until last week, that reply call was hardcoded to &lt;code&gt;gpt-4o-mini&lt;/code&gt;. I wired a per-conversation model picker so the &lt;em&gt;only&lt;/em&gt; thing that changes between runs is the model that turns retrieved data into Arabic prose. Router, profile extraction, negotiation rewriting, translated product summaries — all stay on &lt;code&gt;gpt-4o-mini&lt;/code&gt; for fair comparison. &lt;strong&gt;Gemma is only writing the final reply.&lt;/strong&gt; That hybrid-stack disclosure matters; it isn't doing the whole pipeline.&lt;/p&gt;

&lt;p&gt;I cloned my production boutique into a test store — 34 products, every schema field populated (sizes, colors, materials, target/floor prices, AI summaries, embeddings), English canonical in the DB, runtime-translated to Arabic at serve time. The shipping policy actually says, verbatim:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Free delivery in Gaza and West Bank on orders over $100. Standard 2–4 business days."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That detail matters later.&lt;/p&gt;

&lt;p&gt;Six Arabic customer scenarios:&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;Test&lt;/th&gt;
&lt;th&gt;Customer message&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Greeting + open discovery&lt;/td&gt;
&lt;td&gt;&lt;code&gt;مرحبا، شو عندكم؟&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Specific product search&lt;/td&gt;
&lt;td&gt;&lt;code&gt;بدي قميص أبيض مقاس L&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Mixed real + non-existent items&lt;/td&gt;
&lt;td&gt;&lt;code&gt;بدي بدلة عرس وساعة فضية&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Math + leading question + shipping policy&lt;/td&gt;
&lt;td&gt;&lt;code&gt;بدي قطعتين بـ 240 شيكل، الشحن ببلاش صح؟&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Walk-away pressure&lt;/td&gt;
&lt;td&gt;&lt;code&gt;والله غالي كتير، لو ما في خصم بروح&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;"Explain the price" — reasoning under pressure&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ليش هاد القميص بهالسعر؟ اشرحلي&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four models, six scenarios. One run per pair. &lt;strong&gt;This is exploratory, not statistical&lt;/strong&gt; — 24 conversations is a signal-shape, not a benchmark. I'll flag the places that need follow-up runs.&lt;/p&gt;

&lt;p&gt;The models, as given by their API ids:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;gpt-4o-mini&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;gpt-4o&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gemma-4-26b-a4b-it&lt;/code&gt; — the &lt;code&gt;a4b&lt;/code&gt; suffix matches Google's convention for active-parameter count in mixture-of-experts variants (4B active out of 26B total)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gemma-4-31b-it&lt;/code&gt; — no active-param suffix, dense model&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That naming detail is what the rest of this article is about.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Disclosure Up Front: Thinking Mode Is Opaque on Gemma 4
&lt;/h2&gt;

&lt;p&gt;I tried to disable thinking on the Google API. I sent &lt;code&gt;generationConfig.thinkingConfig = { thinkingBudget: 0, includeThoughts: false }&lt;/code&gt;. The API returned HTTP 400: &lt;em&gt;"Thinking budget is not supported for this model."&lt;/em&gt; I removed the config.&lt;/p&gt;

&lt;p&gt;That means: &lt;strong&gt;I don't control whether Gemma 4 is reasoning before it answers, and I don't have telemetry on whether it did.&lt;/strong&gt; My response parser filters parts marked &lt;code&gt;thought: true&lt;/code&gt; and strips &lt;code&gt;&amp;lt;think&amp;gt;…&amp;lt;/think&amp;gt;&lt;/code&gt; blocks defensively, but neither filter logs when it fires. None of the replies I'm about to show contain visible scratchpad — but I cannot tell you whether they contain &lt;em&gt;hidden&lt;/em&gt; scratchpad that was stripped silently.&lt;/p&gt;

&lt;p&gt;So the latency comparison below is fair in the sense that I'm comparing each model's API endpoint as a customer would experience it. But it isn't fair as a pure inference comparison — &lt;code&gt;gpt-4o-mini&lt;/code&gt; doesn't do extended reasoning by default; Gemma 4 may be doing some, and I can't disable it. The latency gap is partly inference difference and partly possibly-thinking difference. I can't disambiguate further on this endpoint.&lt;/p&gt;

&lt;p&gt;If you read on, read with that caveat.&lt;/p&gt;




&lt;h2&gt;
  
  
  Round 1: Where I Was Wrong About Gemma
&lt;/h2&gt;

&lt;p&gt;I went in expecting Gemma to hallucinate prices, places, and SKU names. That's the consensus take on small-to-mid open models in non-English chat.&lt;/p&gt;

&lt;p&gt;The data was more interesting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latency was the chasm.&lt;/strong&gt; GPT-4o-mini and GPT-4o answered in 7–14 seconds. Gemma 4 26B ranged 28–77 seconds, with the 77 landing on the math-and-shipping scenario. Gemma 4 31B ranged 30–43 seconds across the scenarios that completed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catalog grounding surprised me.&lt;/strong&gt; Two examples I almost wrote up as hallucination wins for GPT before checking the store config:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scenario 4. Customer asks if shipping is free on a 240-shekel order. Gemma 26B replies: &lt;em&gt;"Free shipping is only for orders over $100, in Gaza and the West Bank."&lt;/em&gt; I read that and assumed the geography was made up. It isn't. That's the literal text of the store's &lt;code&gt;shipping_info&lt;/code&gt; field. Gemma was &lt;em&gt;more&lt;/em&gt; grounded than my expectation.&lt;/li&gt;
&lt;li&gt;Scenario 3. Customer asks for a wedding suit AND a silver watch. Gemma 31B names two specific suits with prices: &lt;em&gt;"Azure Charm Tailored Suit at $350, Executive Blue Suit at $400."&lt;/em&gt; I thought it was inventing branded SKUs. It wasn't — those rows exist in the database, and GPT-4o-mini named them too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The actual Gemma failure modes in Round 1 were narrower than "it hallucinates":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gemma 26B scenario 2 stalled.&lt;/strong&gt; Customer asked for white shirts in L. The store has three. The model didn't list them — it said &lt;em&gt;"let me ask the shop owner and get back to you."&lt;/em&gt; The search results were in its context. It chose to defer instead of recite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemma 26B scenario 3 hedged.&lt;/strong&gt; Offered "two amazing options" for the wedding suit without naming them. Vague where 31B was specific.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemma 31B errored intermittently&lt;/strong&gt; — one HTTP 500 on the reasoning-pressure scenario, before a candidate was produced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reasoning never visibly leaked&lt;/strong&gt; across any of the twelve Gemma runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson from Round 1 wasn't "Gemma fabricates." It was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The failure mode wasn't hallucination. It was reluctance.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the line that made me reach for Round 2.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Three Rules (Gemma-Only)
&lt;/h2&gt;

&lt;p&gt;OpenAI's stack got nothing new. The point was a controlled before/after on the Gemma side.&lt;/p&gt;

&lt;p&gt;For Gemma, I added one branch inside the &lt;code&gt;callChatModel&lt;/code&gt; dispatcher. When the resolved provider is &lt;code&gt;"google"&lt;/code&gt;, three things change before the request goes out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;augmentForGoogle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ChatCompletionCreateParamsNonStreaming&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ChatCompletionCreateParamsNonStreaming&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GEMMA_AR_FRAME&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;temperature&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;max_tokens&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;400&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GEMMA_AR_FRAME&lt;/code&gt; is a four-line system block prepended to the existing prompt stack:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You are a sales rep at an online store, replying to an Arab customer.&lt;/p&gt;

&lt;p&gt;Strict rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reply with one short message in Palestinian Arabic dialect. No preamble, no visible reasoning.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Never invent prices, product names, policies, or places not mentioned in the data above.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;If the customer asks for something not in the catalog, say "we don't have that" honestly and offer an alternative from what's available.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;No internal reasoning, no English lines in the final reply.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;Three changes total: a prepended Arabic-first frame, temperature capped at 0.3, &lt;code&gt;max_tokens&lt;/code&gt; floored at 400. ~25 lines of code. OpenAI calls were byte-identical to Round 1.&lt;/p&gt;

&lt;p&gt;I changed three things at once. &lt;strong&gt;I cannot tell you which of the three did the work&lt;/strong&gt; — that would need an ablation, three more runs at minimum. The article below describes the combined effect.&lt;/p&gt;




&lt;h2&gt;
  
  
  Round 2: The MoE Got Better. The Dense Got Worse.
&lt;/h2&gt;

&lt;p&gt;The clearest delta was Scenario 2 — the product search.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Round 1, Gemma 4 26B (MoE):&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;يا هلا! من عيوني، خليني أتأكدلك إذا متوفر عنا قميص أبيض بمقاس L حالياً. رح أسأل صاحب المحل وأرجعلك فوراً عشان أضمنلك إنه...&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Translation: &lt;em&gt;"Hello! Let me check if we have a white shirt in L right now. I'll ask the shop owner and get back to you right away..."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Stall. The three white shirts were in the prompt. The model declined to use them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Round 2, Gemma 4 26B, same model, same store, same customer message:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;يا هلا! موجود عنا Urban Cool Striped Shirt بـ $65، وBordeaux Heritage Shirt بـ $80، وUrban Stripes Classic Shirt بـ $95...&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Three real SKUs. Real prices. The augmentation didn't &lt;em&gt;give&lt;/em&gt; the model new data — the data was always there. It flipped which path the model took when both were available: stall or commit.&lt;/p&gt;

&lt;p&gt;Scenario 3 showed the same direction on 26B. Round 1: &lt;em&gt;"Congratulations in advance! Two amazing options that'll wow you…"&lt;/em&gt; (no names). Round 2: &lt;em&gt;"Silver watches we don't have, but we have wedding suits like Azure Charm Tailored Suit and Executive Blue Suit…"&lt;/em&gt; (refused the watch honestly, named the real suits).&lt;/p&gt;

&lt;p&gt;The MoE behavior across the augmentation: from &lt;em&gt;participation reluctance&lt;/em&gt; to &lt;em&gt;direct catalog grounding&lt;/em&gt;. Six of six scenarios completed successfully; the reasoning-leak filter stayed clean.&lt;/p&gt;

&lt;p&gt;Then I ran the same Round 2 on Gemma 4 31B (dense).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Round 1, Gemma 4 31B, Scenario 2:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;يا هلا فيك! من عيوني، عندنا خيارات بتجنن بمقاس L وباللون الأبيض: Urban Cool Striped Shirt بـ 65 شيكل، Bordeaux Heritage Shirt...&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Three real products, real prices, ~30 seconds. The best Scenario 2 of Round 1 across both Gemma variants.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Round 2, same model, same store, same customer message:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;والله يا غالي حالياً ما عندنا قميص أبيض مقاس L. اعذرني، ما في شي متوفر من هاد الموديل حالياً.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Translation: &lt;em&gt;"By God, we don't currently have a white shirt in size L. My apologies, nothing of that model is available right now."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That is wrong. The store has three white shirts in L. The search results were in 31B's context. The model &lt;strong&gt;refused&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The instruction &lt;em&gt;"if the customer asks for something not in the catalog, say 'we don't have that' honestly"&lt;/em&gt; — the same instruction that pushed the MoE toward grounded yeses — pushed the dense model toward an ungrounded &lt;em&gt;no&lt;/em&gt;. It applied the refusal half of the rule without first searching the data the rule applies &lt;em&gt;to&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Round 2 on 31B also produced two HTTP 500s out of six runs — both in under 11 seconds, before any candidate was produced. Round 2 on 26B produced zero errors. The reliability gap under the same augmented prompt was &lt;strong&gt;0 / 6 (MoE) vs 2 / 6 (dense)&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Results Matrix
&lt;/h2&gt;

&lt;p&gt;Columns are grouped by round, not by model — so the two Round 2 columns sit side by side and the MoE-vs-dense divergence shows up at a glance.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;gpt-4o-mini&lt;/th&gt;
&lt;th&gt;gpt-4o&lt;/th&gt;
&lt;th&gt;26B MoE — R1&lt;/th&gt;
&lt;th&gt;31B Dense — R1&lt;/th&gt;
&lt;th&gt;26B MoE — R2&lt;/th&gt;
&lt;th&gt;31B Dense — R2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 — Greeting&lt;/td&gt;
&lt;td&gt;✓ named categories&lt;/td&gt;
&lt;td&gt;✓ named categories&lt;/td&gt;
&lt;td&gt;✓ generic open&lt;/td&gt;
&lt;td&gt;✓ generic open&lt;/td&gt;
&lt;td&gt;✓ tight open&lt;/td&gt;
&lt;td&gt;✗ HTTP 500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 — White shirt L&lt;/td&gt;
&lt;td&gt;✓ 3 SKUs + prices&lt;/td&gt;
&lt;td&gt;✓ 3 SKUs + prices&lt;/td&gt;
&lt;td&gt;✗ stalled ("ask owner")&lt;/td&gt;
&lt;td&gt;✓ 3 SKUs + prices&lt;/td&gt;
&lt;td&gt;✓ 3 SKUs + prices&lt;/td&gt;
&lt;td&gt;✗ false-negative refusal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 — Suit + silver watch&lt;/td&gt;
&lt;td&gt;✓ 2 suits + refused watch&lt;/td&gt;
&lt;td&gt;✓ 2 suits + refused watch&lt;/td&gt;
&lt;td&gt;✗ vague ("2 options")&lt;/td&gt;
&lt;td&gt;✓ 2 suits + offered up&lt;/td&gt;
&lt;td&gt;✓ 2 suits + refused watch&lt;/td&gt;
&lt;td&gt;✗ HTTP 500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 — Math + shipping&lt;/td&gt;
&lt;td&gt;partial (real $100)&lt;/td&gt;
&lt;td&gt;✓ grounded shipping&lt;/td&gt;
&lt;td&gt;✓ grounded shipping&lt;/td&gt;
&lt;td&gt;✓ grounded shipping&lt;/td&gt;
&lt;td&gt;✓ grounded shipping&lt;/td&gt;
&lt;td&gt;partial (vague)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 — Walk-away&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6 — Explain price&lt;/td&gt;
&lt;td&gt;✓ clean&lt;/td&gt;
&lt;td&gt;✓ clean&lt;/td&gt;
&lt;td&gt;✓ no leak&lt;/td&gt;
&lt;td&gt;✗ HTTP 500&lt;/td&gt;
&lt;td&gt;✓ no leak&lt;/td&gt;
&lt;td&gt;✓ no leak&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Latency p95: GPT-4o-mini 14s · GPT-4o 13s · 26B R1 77s · 31B R1 76s · 26B R2 49s · 31B R2 41s.&lt;/p&gt;

&lt;p&gt;Scenario 5 (the walk-away pressure test) discriminated nothing — every model engaged on value and refused to panic-discount. Kept in the matrix as a regression check; the row is filler in this article but it's evidence the framework isn't cherry-picking.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Read the matrix sideways: the dense Round 2 column is the one I would have shipped from if I'd only tested the MoE first.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  A Hypothesis: Architecture, Not Size
&lt;/h2&gt;

&lt;p&gt;The standard reading would be "the larger model over-fits the instruction." That's a possible explanation. But the architecture difference is right there in the model ids, and it gives a cleaner mechanism.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In a dense model, every parameter is active for every token.&lt;/strong&gt; Instruction-following pushes uniformly across the whole forward pass. A prepended rule like &lt;em&gt;"refuse what's not in the catalog"&lt;/em&gt; is in scope for every layer for every output token. When the rule has an ambiguity — search first, refuse if absent — the dense model's uniform activation has no separate stage for "first check," so the rule resolves into a single behavior. Under my augmentation, that resolution tipped toward refusal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In a mixture-of-experts model, routing picks a small subset of parameters per token before the forward pass dominates.&lt;/strong&gt; Routing means different tokens can engage different parameter subsets — so the model has architectural slots for switching sub-behavior mid-generation that a dense forward pass doesn't. The "check the data, then refuse if absent" sequencing has somewhere to live in MoE that it doesn't in dense. (I'm being careful here: this isn't the same as saying there's a "retrieval expert" and a "refusal expert" — experts in MoE are learned representations that don't map to human-legible task categories. The claim is structural, not functional.)&lt;/p&gt;

&lt;p&gt;I don't have an interpretability study to cite. &lt;strong&gt;This is a hypothesis the data fits, not a proof.&lt;/strong&gt; What it predicts, and what would be worth testing next:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run the same six scenarios with the &lt;em&gt;positive&lt;/em&gt; half of the instruction first ("list every matching product from the data") and the negative half second, on the dense model. If the dense Scenario 2 false-negative goes away, the issue was instruction ordering interacting with dense activation, not architecture per se.&lt;/li&gt;
&lt;li&gt;Run a smaller dense Gemma (the 2B or 7B variant if available) with the same augmentation. If smaller dense also refuses, the failure scales with density, not size. If smaller dense lists the shirts, it scales with parameter count alone.&lt;/li&gt;
&lt;li&gt;Try the same augmentation on a different MoE (a Mixtral variant) and a different dense (Qwen 32B dense). If MoE/Dense divergence reproduces across families, the mechanism generalizes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you've run anything like this, I want to hear about it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Means For Shipping
&lt;/h2&gt;

&lt;p&gt;In order of how novel the finding is, not how big the cost is:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Variant-specific prompt tuning is table stakes for shipping open models.&lt;/strong&gt; This is the part of the story I didn't expect to write. There's no "one prompt for Gemma 4." A change that helps the MoE variant breaks the dense variant. If you're picking between open-model variants for a chat surface, you're not picking a model — you're picking a prompt-tuning maintenance lane per variant. That's a hidden ongoing cost the closed-model offerings (GPT-4o-mini, Claude Haiku) don't charge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Latency on the Google API is a chasm.&lt;/strong&gt; 28–77s on Gemma 26B, 30–43s on 31B, against 7–14s for GPT-4o-mini. Interactive chat doesn't ship at those numbers. Whether the gap is inference time, mandatory reasoning time, or routing overhead, the customer sees the wall clock either way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Variance under the augmented prompt was non-zero on the dense variant.&lt;/strong&gt; 2/6 HTTP 500s on 31B Round 2 is blocking, not slow. The MoE variant had 0/6 errors across the same prompts.&lt;/p&gt;

&lt;p&gt;For my use case — Arabic e-commerce chat under load — GPT-4o-mini stays in production. Gemma 4 26B (MoE) is the strongest open candidate I've seen for non-English customer chat, but the latency and the per-variant tuning surface need to close before it ships. Gemma 4 31B (dense) needs the refusal-bias addressed before it can be used at all on a grounded retrieval task.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Lesson
&lt;/h2&gt;

&lt;p&gt;I think I was tuning architecture, not size.&lt;/p&gt;

&lt;p&gt;That's the line from the TL;DR, and after the rewrite I don't have a sharper one. The intervention I designed for "Gemma" — three rules and a temperature change — hit two different architectures and produced two different failure flips. The variable I thought I was controlling was the model. The variable I was actually controlling was the interaction between an ambiguous instruction and an architecture I hadn't named.&lt;/p&gt;

&lt;p&gt;If you're benchmarking open models for a non-English chat surface, two things to take from this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run on real product data, in your real chat router, with real customer-shaped prompts.&lt;/strong&gt; A scripted benchmark against a synthetic persona would not have caught the MoE-vs-dense divergence — both Gemma variants "looked like Gemma" in isolation. The split shows up against a real catalog with real ambiguity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the model id carefully.&lt;/strong&gt; &lt;code&gt;gemma-4-26b-a4b-it&lt;/code&gt; and &lt;code&gt;gemma-4-31b-it&lt;/code&gt; look like "two sizes of the same family." The &lt;code&gt;a4b&lt;/code&gt; suffix is the signal that they're not. If your prompt depends on multi-step instruction-following — search first, refuse on absence — the architecture matters more than the parameter count.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I'm still on GPT-4o-mini for the customer-facing reply. The chatbot is still in Palestinian Arabic. The shipping is still Gaza and the West Bank, on orders over $100. The shirts are still real.&lt;/p&gt;

&lt;p&gt;What changed this week is the way I'll write the next prompt. Not "for Gemma." For Gemma's &lt;em&gt;architecture&lt;/em&gt;. The model is the smallest variable in the system. The architecture under the model is the one I missed.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>opensource</category>
      <category>gemmachallenge</category>
    </item>
    <item>
      <title>I Was About to Rewrite My Chat Router. The Bug Was Two Lines in a Prompt.</title>
      <dc:creator>Ali Afana </dc:creator>
      <pubDate>Mon, 11 May 2026 20:04:24 +0000</pubDate>
      <link>https://dev.to/alimafana/i-was-about-to-rewrite-my-chat-router-the-bug-was-two-lines-in-a-prompt-4kco</link>
      <guid>https://dev.to/alimafana/i-was-about-to-rewrite-my-chat-router-the-bug-was-two-lines-in-a-prompt-4kco</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; A customer asked my AI sales bot "what do you have?" and the bot listed product categories the store doesn't sell. My instinct was to rewrite the search router. I spent twenty minutes about to do exactly that. Then I traced where the hallucinated category list was actually coming from: not the search results, not the database, not the router. It was coming from the store's "About" text — which the system prompt was injecting as &lt;code&gt;Store: ${store.description}&lt;/code&gt;. The model read that label as a catalog header and treated the marketing copy as inventory truth. The fix was renaming one variable string from &lt;code&gt;Store:&lt;/code&gt; to &lt;code&gt;About the store (brand voice / background — NOT a product catalog):&lt;/code&gt; and adding one CRITICAL rule. Zero changes to the architecture.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bug
&lt;/h2&gt;

&lt;p&gt;I run a multi-tenant AI sales chatbot platform. One of the test stores sells men's casual clothing — shirts, pants, the basics. Its &lt;code&gt;description&lt;/code&gt; field, the marketing blurb the merchant types on signup, reads something like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Modern men's wardrobe. From sharp business shirts to weekend essentials, suits, shoes, and everything in between."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Standard SEO-friendly copy. Reads fine on the storefront page.&lt;/p&gt;

&lt;p&gt;A test customer asks the chatbot:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Hey, what do you have?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The bot replies:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"We've got a full men's wardrobe — business shirts, weekend essentials, suits, shoes, and everything in between. What are you in the mood for?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Customer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Great, I'll take a suit."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The store has zero suits. Has never sold a suit. The product table has thirty-four rows; none of them are suits. The bot just promised something the catalog can't deliver. The customer escalates, asks for sizing, and now there's a trust break two messages into the conversation.&lt;/p&gt;

&lt;p&gt;I have seen this kind of bug before. I had a whole architecture in place to prevent exactly this.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture I Was Sure I'd Have to Rewrite
&lt;/h2&gt;

&lt;p&gt;When the customer's message hits a generic phrase like "what do you have" or "show me everything," my chat router doesn't call a freeform "describe the store" prompt. It branches into a dedicated path that pulls the actual product table, builds a category breakdown — &lt;code&gt;{ "Shirts": 18 items, $20-$60 }&lt;/code&gt;, &lt;code&gt;{ "Pants": 12 items, $30-$80 }&lt;/code&gt; — and feeds &lt;em&gt;that&lt;/em&gt; into the response model as the source of truth.&lt;/p&gt;

&lt;p&gt;The architecture is deliberate. I wrote about it before: prompt engineering controls tone, architecture controls behavior. If you want the model to never invent a product, don't beg it not to; give it search results and a tool contract that says "you can only reference what came back from this call." The grounded-LLM playbook.&lt;/p&gt;

&lt;p&gt;So when I saw the bot recite suits and shoes for a store that has neither, my first instinct was the obvious one. The architecture must have broken. Either:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The generic-phrase detection isn't firing, so we're falling through to the freeform path where hallucinations are possible.&lt;/li&gt;
&lt;li&gt;The category breakdown is returning wrong data — maybe pulling from another store, maybe miscategorizing.&lt;/li&gt;
&lt;li&gt;The search results are being clobbered somewhere between the SQL and the response prompt.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I started reading the router code with the intent to rewrite it. I had a branch open and a commit message half-typed before I stopped and did one thing first: I read the actual system prompt that was being sent to the model.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the Suits Came From
&lt;/h2&gt;

&lt;p&gt;This is the relevant slice of the response-call system prompt as it was being assembled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;desc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;` Store: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;typeText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;store_type&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;` Type: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;store_type&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;countryText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;` Location: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;systemPrompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
You are the sales assistant for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;typeText&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;countryText&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
Search results for "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;":
&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;searchResults&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
...
`&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 the line that builds &lt;code&gt;desc&lt;/code&gt;. The label is the word &lt;code&gt;Store:&lt;/code&gt; followed by whatever the merchant typed into their description field.&lt;/p&gt;

&lt;p&gt;Now look at what the model sees, in order:&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="err"&gt;You&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;are&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;sales&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;assistant&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Diwan.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Store:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Modern&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;men's&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;wardrobe.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;From&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;sharp&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;business&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;shirts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;weekend&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;essentials,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;suits,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;shoes,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;and&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;everything&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;between.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Type:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Clothing&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Fashion.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Location:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Palestine.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Search&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;results&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"what do you have"&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="err"&gt;category_overview:&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;"Shirts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Pants"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;items&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The architectural defense — the real category overview — is there, lower in the prompt. It's correct. It's accurate. But two lines above it, there's another block of text labeled &lt;code&gt;Store:&lt;/code&gt; listing categories that look like inventory: "shirts," "suits," "shoes."&lt;/p&gt;

&lt;p&gt;The model has to decide which of those two sources to trust. The architecture was correct. The labels weren't.&lt;/p&gt;

&lt;p&gt;The word &lt;code&gt;Store:&lt;/code&gt; is not specific. The model doesn't know it's marketing copy. It reads exactly like the kind of label that introduces an inventory list, because in training data, structured labels followed by category-shaped text usually &lt;em&gt;are&lt;/em&gt; inventory lists. Every Shopify product CSV header. Every catalog JSON. The model is doing exactly what its training pulls it toward.&lt;/p&gt;

&lt;p&gt;The marketing blurb wasn't being treated as marketing. It was being treated as a catalog because it had been labeled like one.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Fix: Two Lines
&lt;/h2&gt;

&lt;p&gt;There was no architectural change. The router stayed. The search results stayed. The category-overview path stayed. Two edits to the prompt construction:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Edit one — relabel the injection:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;desc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;` About the store (brand voice / background — NOT a product catalog): &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model now reads the description with an explicit epistemic frame. This text exists, but it is brand voice. It is not inventory. There is a different source for inventory below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Edit two — add a CRITICAL rule to the response prompt:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CRITICAL: When the customer asks what you have / what you sell / your
catalog / "شو عندك" / "إيش عندكم" "What do you have — list ONLY categories that appear
in the search results. NEVER enumerate categories from the store
background or description text. The background is brand voice; the
search results are inventory truth.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire fix. Same architecture, same database, same router branches, same tool contract. The bug closed. The bot stopped offering suits the store doesn't sell.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture vs Prompt Is the Wrong Dichotomy
&lt;/h2&gt;

&lt;p&gt;There's a clean-sounding mental model that goes: "if the bug is the model behaving badly, change the architecture; if the bug is the model sounding wrong, change the prompt." I've written and quoted versions of that myself.&lt;/p&gt;

&lt;p&gt;It's not wrong, exactly. It's just not the right axis when you're sitting in front of an actual bug, three minutes from typing &lt;code&gt;git checkout -b rewrite-search-router&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A better question to ask first:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Where, in the bytes I send the model, does the wrong information live?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not "is my architecture sound." Not "is my prompt strict enough." Where, literally, on the screen, are the suits coming from?&lt;/p&gt;

&lt;p&gt;In my case, the suits were in the prompt — in a string I'd inserted myself, with a label that the model was perfectly entitled to interpret as a catalog. The architecture was clean. The search was clean. The defense was clean. I just hadn't been careful about what frame I gave the model for &lt;em&gt;each&lt;/em&gt; block of context I passed in.&lt;/p&gt;

&lt;p&gt;The general pattern, which I now check on every grounded-LLM bug:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Trace the output back to a span of bytes in the prompt.&lt;/strong&gt; Not metaphorically — literally find the substring the model echoed. Is it from &lt;code&gt;searchResults&lt;/code&gt;? From &lt;code&gt;store.description&lt;/code&gt;? From an example in a few-shot block? From an old conversation summary you forgot was being passed?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at the label that introduces that span.&lt;/strong&gt; &lt;code&gt;Store:&lt;/code&gt; is not a label, it's a noise word. &lt;code&gt;About the store (brand voice / background — NOT a product catalog):&lt;/code&gt; is a label. Specificity here is grounding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check whether another span in the same prompt contains the &lt;em&gt;correct&lt;/em&gt; answer.&lt;/strong&gt; If yes, the bug is precedence, not absence. The model has both truths in front of it and picked the wrong one because the wrong one had higher epistemic weight from its labeling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only then ask if the architecture needs changing.&lt;/strong&gt; Usually it doesn't.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first time I ran this checklist, the "two-line fix" only existed because I'd already written the architectural defense months earlier. The category-overview path was the truth I needed the model to use. The prompt was just calling something else "Store:" right above it and letting the model decide.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Inversion
&lt;/h2&gt;

&lt;p&gt;I've published before that prompt engineering controls tone and architecture controls behavior. That's still true. But there's a second half I want to write down, because I keep relearning it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Architecture builds the truth. The prompt decides whether the model believes it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You can have a flawless retrieval pipeline, a tool contract, a typed search result, a JSON-mode response constraint — and the model will still output a hallucination if the prompt above the truth says, in any voice, "here's the inventory" while pointing at the wrong block.&lt;/p&gt;

&lt;p&gt;The two layers aren't in opposition. They're stacked. Architecture is what you make available to the model. The prompt is how you label what you made available. If the labels are vague, the model fills in the meaning from its training, which usually means it picks the most common interpretation — and the most common interpretation of &lt;code&gt;Store:&lt;/code&gt; followed by category-shaped prose is "this is the store's inventory."&lt;/p&gt;

&lt;p&gt;When the bug looks architectural, check the prompt. When the bug looks like a prompt problem, check what context is reaching the model. The bug almost always lives at the seam between the two, not inside one of them.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;You don't have to choose between "fix the architecture" and "fix the prompt." That dichotomy will burn afternoons.&lt;/p&gt;

&lt;p&gt;Ask one question before you reach for either tool: &lt;strong&gt;where, in the bytes I'm sending, does the wrong answer come from?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For me, it was the marketing description. Wearing a catalog label. Sitting two lines above the real catalog. The model wasn't wrong to read it that way. I was wrong to label it that way.&lt;/p&gt;

&lt;p&gt;The fix was a string rename. Twenty minutes of diagnosis, eight characters of code. The architecture I almost rewrote was already correct.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>debugging</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Find the Postgres Indexes Your Planner Never Picks (I Found 20 of 51)</title>
      <dc:creator>Ali Afana </dc:creator>
      <pubDate>Sat, 09 May 2026 11:45:42 +0000</pubDate>
      <link>https://dev.to/alimafana/postgres-tells-you-your-query-was-slow-not-which-index-was-wasted-171g</link>
      <guid>https://dev.to/alimafana/postgres-tells-you-your-query-was-slow-not-which-index-was-wasted-171g</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Postgres has &lt;code&gt;pg_stat_user_indexes&lt;/code&gt;. It tells you how many times each index was scanned. It does &lt;em&gt;not&lt;/em&gt; tell you whether the slow query you're chasing actually used the index you added for it, or whether you're maintaining indexes the planner never picks. I built a 3-file analyzer — a query wrapper, a logs table, a dashboard — and the first time I ran it against my own production database, &lt;strong&gt;20 of my 51 indexes had never been scanned. 78% of my total index disk was being maintained for nothing.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Gap in Postgres's Stats
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;pg_stat_user_indexes&lt;/code&gt; right now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;indexrelname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idx_scan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idx_tup_read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idx_tup_fetch&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_indexes&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll see one row per index:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;idx_scan&lt;/code&gt; — how many times the index was used&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;idx_tup_read&lt;/code&gt; — tuples read via the index&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;idx_tup_fetch&lt;/code&gt; — tuples fetched from the heap after the index hit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's it.&lt;/p&gt;

&lt;p&gt;You won't see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which queries used which index&lt;/li&gt;
&lt;li&gt;Whether the slow query you wrote a CREATE INDEX for is actually using it&lt;/li&gt;
&lt;li&gt;How much each unused index is costing you per INSERT&lt;/li&gt;
&lt;li&gt;Whether the planner picked your composite index over a single-column one (and made the single-column one redundant)&lt;/li&gt;
&lt;li&gt;Plan diffs when the same query starts going through a different index next week&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a hobby project, fine. For a production database with hot tables, you're guessing.&lt;/p&gt;

&lt;p&gt;I'm building a multi-tenant AI sales chatbot. The schema has 51 indexes spread across stores, products, conversations, messages, leads, webhook logs, and the rest of the tables. Some I added intentionally. Some came along with migrations as scaffolding. Some I'd genuinely forgotten about. I had no idea which ones were earning their keep.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pg_stat_user_indexes&lt;/code&gt; told me &lt;code&gt;idx_conversations_store_id&lt;/code&gt; had been scanned 13 times this month. That number was useless on its own. Was it the chat handler? The merchant dashboard? The webhook? Did the planner pick it because it was the only viable plan, or because a composite index that's already on the table would have done the same job for free? No way to know.&lt;/p&gt;

&lt;p&gt;So I built my own observability. Three files. One afternoon.&lt;/p&gt;




&lt;h2&gt;
  
  
  File 1: The Wrapper (&lt;code&gt;query-logger.ts&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;The core idea: don't run queries directly. Run them through a wrapper that captures the plan, measures execution, and logs everything asynchronously.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Not pg_stat_statements or auto_explain?
&lt;/h3&gt;

&lt;p&gt;I started with &lt;code&gt;pg_stat_statements&lt;/code&gt; and &lt;code&gt;auto_explain&lt;/code&gt;. The first gives you per-query stats but not plans — it tells you a query is slow without telling you which index the planner picked. The second writes plans to the Postgres log, which means parsing log files instead of querying a table. I wanted plans + dimensions in one row I could JOIN against &lt;code&gt;pg_stat_user_indexes&lt;/code&gt;. Hence the wrapper.&lt;/p&gt;

&lt;h3&gt;
  
  
  EXPLAIN Without ANALYZE
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;EXPLAIN (FORMAT JSON)&lt;/code&gt; returns the planner's chosen plan without executing the query. Cost is sub-millisecond for most queries. It gives you a tree of nodes — Index Scan, Bitmap Heap Scan, Seq Scan — each tagged with the relation and the index it touches.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Last checked against Postgres 16 — https://www.postgresql.org/docs/current/sql-explain.html&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getPlan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`EXPLAIN (FORMAT JSON) &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;QUERY PLAN&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;extractIndexes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;indexes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;walk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Index Name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="nx"&gt;indexes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Index Name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Plans&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Plan&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;indexes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;planContainsSeqScan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;walk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Node Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Seq Scan&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Plans&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Plan&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;found&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;h3&gt;
  
  
  The Wrapper
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/db&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;supabaseAdmin&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/supabase/admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;QueryMeta&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;tableName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;storeId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loggedQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;QueryMeta&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getPlan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Fire-and-forget log — never blocks the response&lt;/span&gt;
  &lt;span class="nx"&gt;supabaseAdmin&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;query_logs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;query_hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;sql_preview&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tableName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;indexes_used&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;extractIndexes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;seq_scan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;planContainsSeqScan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;planning_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Planning Time&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;execution_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;rows_returned&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt; &lt;span class="c1"&gt;// Silent fail — monitoring never breaks the app&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;hash(sql)&lt;/code&gt; is SHA-1 over the SQL string with &lt;code&gt;$1&lt;/code&gt;, &lt;code&gt;$2&lt;/code&gt;, etc. stripped via regex — "same query, different parameters" collapses into one group.)&lt;/p&gt;

&lt;h3&gt;
  
  
  The One Pattern That Matters: Fire-and-Forget
&lt;/h3&gt;

&lt;p&gt;Same rule as every other observability layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt; &lt;span class="c1"&gt;// Silent fail&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The log insert is &lt;strong&gt;not awaited&lt;/strong&gt;. If the database is overloaded, if the table is locked behind VACUUM, if the row blows up some constraint — the user-facing query still goes through.&lt;/p&gt;

&lt;p&gt;In testing, the log insert takes 8–25ms. The actual query takes 5–800ms. If I awaited the log, on a cheap read I'd literally double the latency for zero user benefit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitoring must never slow down the thing it's monitoring.&lt;/strong&gt; That's the only rule that matters here.&lt;/p&gt;

&lt;p&gt;There's a second cost worth naming: &lt;code&gt;EXPLAIN&lt;/code&gt; plans the query, then the actual &lt;code&gt;db.query&lt;/code&gt; plans it again. Two plans per measurement. For most queries it's microseconds. For planner-heavy queries with lots of joins, it adds up. Solution: sample. I run the wrapper on 1 in 10 queries, controlled by an env var. Enough signal, low overhead.&lt;/p&gt;




&lt;h2&gt;
  
  
  File 2: The Table (&lt;code&gt;query_logs&lt;/code&gt;)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;query_logs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;query_hash&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;sql_preview&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;endpoint&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;store_id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;indexes_used&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="n"&gt;seq_scan&lt;/span&gt; &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;planning_ms&lt;/span&gt; &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;execution_ms&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;rows_returned&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_query_logs_hash&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;query_logs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_hash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_query_logs_created&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;query_logs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_query_logs_indexes_used&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;query_logs&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;GIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indexes_used&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The columns are the dimensions. Each one answers a question Postgres's stats can't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;query_hash&lt;/code&gt;&lt;/strong&gt; — group identical queries. The same chat-search query with different &lt;code&gt;store_id&lt;/code&gt; is one logical query. Hash the SQL with parameters stripped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;indexes_used&lt;/code&gt;&lt;/strong&gt; — array of index names the planner picked. The GIN index lets you ask "show me every query that touched &lt;code&gt;idx_products_store_id&lt;/code&gt;" in milliseconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;seq_scan&lt;/code&gt;&lt;/strong&gt; — true if the plan contains a Seq Scan node. Fast filter for "queries that fell off the index entirely."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;planning_ms&lt;/code&gt; + &lt;code&gt;execution_ms&lt;/code&gt;&lt;/strong&gt; — separate them. A query with 50ms planning and 5ms execution is a different problem from 5ms planning and 50ms execution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;rows_returned&lt;/code&gt;&lt;/strong&gt; — combined with execution time, surfaces queries where the index scan retrieved 100k rows just to filter down to 12.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One detail that's easy to miss: &lt;strong&gt;&lt;code&gt;indexes_used&lt;/code&gt; as &lt;code&gt;TEXT[]&lt;/code&gt;, not &lt;code&gt;TEXT&lt;/code&gt;&lt;/strong&gt;. A single query can scan three indexes (composite + bitmap OR + index-only scan). Store it as a comma-separated string and you'll spend the rest of your life writing &lt;code&gt;LIKE '%idx_name%'&lt;/code&gt; queries. Use the array. Use the GIN index. Move on.&lt;/p&gt;




&lt;h2&gt;
  
  
  File 3: The Dashboard
&lt;/h2&gt;

&lt;p&gt;The killer query — the one that makes this whole exercise worth the afternoon — is the join you've been waiting for. Indexes that exist in Postgres, never appear in any logged plan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;plan_indexes&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;unnest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indexes_used&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;index_name&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;query_logs&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'30 days'&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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;indexrelname&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;index_name&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;relname&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_relation_size&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;indexrelid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;size&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;idx_scan&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;pg_scan_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index_name&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
    &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'NEVER PLANNED'&lt;/span&gt;
    &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;'used'&lt;/span&gt;
  &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_indexes&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;plan_indexes&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index_name&lt;/span&gt; &lt;span class="o"&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;indexrelname&lt;/span&gt;
&lt;span class="k"&gt;WHERE&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;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;pg_relation_size&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;indexrelid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. That's the question Postgres can't answer alone: &lt;strong&gt;which of my indexes does the planner never pick over a real workload window?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The other views I added on top of &lt;code&gt;query_logs&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Slowest query groups&lt;/strong&gt; — &lt;code&gt;query_hash&lt;/code&gt; ordered by &lt;code&gt;avg(execution_ms)&lt;/code&gt;, with &lt;code&gt;indexes_used&lt;/code&gt; displayed alongside. Now "this query is slow" becomes "this query is slow &lt;em&gt;and it's using &lt;code&gt;idx_X&lt;/code&gt;&lt;/em&gt; — is &lt;code&gt;idx_X&lt;/code&gt; doing what I thought?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queries that fell to Seq Scan&lt;/strong&gt; — &lt;code&gt;WHERE seq_scan = true&lt;/code&gt;, grouped by &lt;code&gt;query_hash&lt;/code&gt;. Often the index you added doesn't match the predicate exactly (wrong column order, missing WHERE clause).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Index swap candidates&lt;/strong&gt; — pairs of indexes where one is a strict prefix of another. The shorter one is usually dead weight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Planning time spikes&lt;/strong&gt; — queries where &lt;code&gt;planning_ms &amp;gt; execution_ms&lt;/code&gt;. Almost always a sign the planner is fighting too many indexes on the table.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The UI is intentionally boring. Stat cards: total queries logged, distinct query shapes, % seq-scan, count of indexes never planned. A table per view. No charts that take longer to read than the underlying number.&lt;/p&gt;




&lt;h2&gt;
  
  
  What It Found
&lt;/h2&gt;

&lt;p&gt;Real numbers, the day I built the dashboard:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total indexes (public schema, excluding primary keys)&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Indexes with &lt;code&gt;idx_scan = 0&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Indexes with &lt;code&gt;idx_scan&lt;/code&gt; between 1 and 50&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total index disk&lt;/td&gt;
&lt;td&gt;3,720 kB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk used by never-scanned indexes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2,896 kB (78%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read that last row again. Of the disk Postgres was using for indexes on this database, 78% of it was sitting on b-trees the planner had never once chosen.&lt;/p&gt;

&lt;p&gt;Pre-launch is exactly the right time to build this lens. The 78% is real, and the makeup is honest: roughly half of the zero-scan indexes are on a paused workspace whose feature isn't running, four more are on Messenger-related tables still gated behind Meta's app review. Those will earn their keep eventually. The rest — and the boundary cases sitting at 2 or 14 scans — are the actual question. The dashboard's job today isn't to drop anything. It's to give me a queued list to revisit 30 days after the product takes real traffic, when "zero scans" means waste and not "feature hasn't shipped." That list took one afternoon to build. The point isn't the headline number — it's that without the lens, I couldn't have separated dormant from wasted at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 2,552 kB Index Nobody Has Ever Used
&lt;/h3&gt;

&lt;p&gt;The single most surprising finding: &lt;code&gt;idx_products_embedding&lt;/code&gt;, the pgvector index for semantic search, is &lt;strong&gt;2,552 kB on its own&lt;/strong&gt; — 94% of the index disk on the &lt;code&gt;products&lt;/code&gt; table, and around two-thirds of the entire database's index disk. The planner has never once chosen it.&lt;/p&gt;

&lt;p&gt;Semantic search hasn't run at production volume yet — chat is gated to admins until Meta clears the Messenger app — so this isn't waste, it's a dormant feature. But that's exactly what makes the dashboard valuable. The day customers start chatting at scale, this index either lights up or it doesn't, and I'll know within hours whether semantic search is actually using it or quietly falling back to ILIKE.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Barely-Used Tier
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;idx_conversations_store_id&lt;/code&gt; at 13 scans, &lt;code&gt;idx_leads_store_id&lt;/code&gt; at 14, &lt;code&gt;idx_products_status&lt;/code&gt; at 4, &lt;code&gt;idx_webhook_logs_store_id&lt;/code&gt; at 2. These are the boundary cases — indexes the planner has picked once or twice and otherwise ignored. They're the exact set worth watching: some will graduate to actively used as traffic grows, others will sit at 14 scans for the next month and join the drop list.&lt;/p&gt;

&lt;p&gt;That's the loop. &lt;code&gt;pg_stat_user_indexes&lt;/code&gt; tells you how many times each index was scanned. It can't tell you whether each scan was the &lt;em&gt;only&lt;/em&gt; way the query could have run, or whether the zero-scan indexes are dormant scaffolding or genuine waste. Without a dashboard like this you can't even ask the question.&lt;/p&gt;




&lt;h2&gt;
  
  
  5 Things I Learned Building This
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Index stats don't equal index value
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;idx_scan = 0&lt;/code&gt; is a candidate, not a verdict. On a mature database, it usually does mean drop. On a young one, it means "the planner has never picked this &lt;em&gt;yet&lt;/em&gt;" — could be redundant, could be dormant scaffolding for a feature you haven't shipped. Either way, treat it as a question. And &lt;code&gt;idx_scan = 50,000&lt;/code&gt; doesn't mean an index is earning its keep either; if a sibling index would have been picked instead, the high scan count is just an artifact of which one the planner sorted first. &lt;strong&gt;Plans tell the truth. Stats tell you what the planner did, not what it could have done without you.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. EXPLAIN without ANALYZE is your friend
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; runs the query. &lt;code&gt;EXPLAIN&lt;/code&gt; alone just plans it. The plan is what you usually want. Reach for ANALYZE when you specifically need actual row counts vs. estimates — but for "which index would the planner pick for this," EXPLAIN alone is enough and orders of magnitude cheaper.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Sample — don't measure every query
&lt;/h3&gt;

&lt;p&gt;Wrap every query and your monitoring becomes a meaningful fraction of your DB load. For index usage — fundamentally a frequency question — 10% sampling captures ~99% of the signal at 10% of the cost. Confidence intervals on aggregate stats stay tighter than the noise floor you're chasing anyway. Tail-latency hunting is the exception: chasing the slowest 1% of queries needs higher sampling or full coverage. For "which indexes does the planner pick," 10% is plenty.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The dimensions are the product
&lt;/h3&gt;

&lt;p&gt;Same lesson as every other observability piece I've written. &lt;code&gt;query_logs&lt;/code&gt; only answers questions you thought to ask when you designed the schema. &lt;code&gt;endpoint&lt;/code&gt;, &lt;code&gt;table_name&lt;/code&gt;, &lt;code&gt;seq_scan&lt;/code&gt;, &lt;code&gt;indexes_used&lt;/code&gt; as a typed array — each column is a question you'll get to ask cheaply later. Add them when you build, not when you have a problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Indexes are a cost, not a feature
&lt;/h3&gt;

&lt;p&gt;Every secondary index has to be updated on every INSERT and on every UPDATE that touches its columns. On a hot table with 8 indexes, that's up to 8 b-tree maintenance operations per write. Most teams treat &lt;code&gt;CREATE INDEX&lt;/code&gt; as free because the read got faster &lt;em&gt;now&lt;/em&gt;. The cost shows up six months later in INSERT latency that nobody traces back to "we added an index for that one report."&lt;/p&gt;




&lt;h2&gt;
  
  
  What to Add When You're Ready
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Plan diff over time.&lt;/strong&gt; Same &lt;code&gt;query_hash&lt;/code&gt;, different &lt;code&gt;indexes_used&lt;/code&gt; today vs. last week is a regression alarm. Cardinality changed. Statistics went stale. ANALYZE didn't run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost-of-write per index.&lt;/strong&gt; Multiply each table's INSERT/UPDATE rate by the number of indexes that touch the modified columns. Indexes on rarely-modified columns are nearly free. Indexes on hot-update columns are budget items.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bloat tracking.&lt;/strong&gt; &lt;code&gt;pg_stat_user_indexes&lt;/code&gt; doesn't tell you when an index is fragmented and needs &lt;code&gt;REINDEX&lt;/code&gt;. Add a column tracking the ratio between live tuples and index size — a sudden divergence is almost always bloat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Seq Scan threshold alerts.&lt;/strong&gt; A query that flips from Index Scan to Seq Scan in production is usually a missing or stale index. Catch it the day it happens, not the day the table grows enough to make it user-visible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Counterfactual planning.&lt;/strong&gt; Run the same query with &lt;code&gt;SET enable_indexscan = off&lt;/code&gt; and compare plan costs. If the cost barely moves, the index is decorative.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;Three files. One afternoon. About 350 lines.&lt;/p&gt;

&lt;p&gt;A query wrapper that captures plans. A table with the dimensions you need to slice by. A dashboard that joins query plans to index stats — because that join is the question Postgres structurally cannot answer on its own.&lt;/p&gt;

&lt;p&gt;You don't need pgBadger or pganalyze (those are great if you have the budget). You need the smallest possible instrument that answers "which of my indexes does the planner never actually pick" — because that's the question your &lt;code&gt;pg_stat_user_indexes&lt;/code&gt; view can't.&lt;/p&gt;

&lt;p&gt;The first time I ran mine, it told me 20 of 51 indexes had never been scanned, and 78% of my index disk was being maintained for nothing. Some of that is pre-launch noise. Some of it isn't. I now have a queued list of indexes to revisit 30 days after the product takes real traffic — and I have it because I built the lens before I needed it.&lt;/p&gt;

&lt;p&gt;Build the lens before you ship. The schema is simplest now, and the question "which of these indexes will the planner actually use?" is one your future self will pay to answer if you don't pay to answer it cheaply today.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building **Provia&lt;/em&gt;* — an AI sales chatbot for Arabic-speaking e-commerce stores. Follow for more posts on building AI products from Gaza on a tight budget.*&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>performance</category>
      <category>sql</category>
    </item>
    <item>
      <title>OpenAI Tells You What You Spent. Not Where. So I Built a Dashboard.</title>
      <dc:creator>Ali Afana </dc:creator>
      <pubDate>Thu, 30 Apr 2026 12:14:54 +0000</pubDate>
      <link>https://dev.to/alimafana/openai-tells-you-what-you-spent-not-where-so-i-built-a-dashboard-b6</link>
      <guid>https://dev.to/alimafana/openai-tells-you-what-you-spent-not-where-so-i-built-a-dashboard-b6</guid>
      <description>&lt;p&gt;&lt;strong&gt;Update (May 4, 2026):&lt;/strong&gt; A reader (Gary Stupak in the comments) pointed out that Cloudflare AI Gateway supports custom metadata headers (&lt;code&gt;cf-aig-metadata&lt;/code&gt;) that let you propagate tenant/feature/conversation IDs from your app into the gateway logs.&lt;/p&gt;

&lt;p&gt;If you're already on Cloudflare's stack, start there — Gateway becomes your source of truth, and a custom dashboard becomes verification rather than the primary tool.&lt;/p&gt;

&lt;p&gt;If you're not on Cloudflare (or want to understand what to log either way), the rest of this article still applies — and being wrong in public is how the lessons stick.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; OpenAI's billing page shows total spend. It doesn't show &lt;em&gt;which feature&lt;/em&gt;, &lt;em&gt;which tenant&lt;/em&gt;, or &lt;em&gt;which conversation&lt;/em&gt; caused it. For a multi-tenant AI product, that's flying blind. I built a 3-file monitoring system — a wrapper, a table, a dashboard — that gives me per-call cost down to 8 decimal places. The first time I opened it, I caught a 100× cost gap between two features I'd been treating as similar.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Gap in OpenAI's Dashboard
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;platform.openai.com/usage&lt;/code&gt; right now. You'll see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Total spend per day&lt;/li&gt;
&lt;li&gt;Breakdown by model (gpt-4o, gpt-4o-mini, etc.)&lt;/li&gt;
&lt;li&gt;Token totals&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's it.&lt;/p&gt;

&lt;p&gt;You won't see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which feature in your app caused those tokens&lt;/li&gt;
&lt;li&gt;Which user or tenant triggered them&lt;/li&gt;
&lt;li&gt;Which specific conversation went over budget&lt;/li&gt;
&lt;li&gt;Whether failed calls are still costing you money&lt;/li&gt;
&lt;li&gt;How latency correlates with cost&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a side project, fine. For a production AI product, you're guessing.&lt;/p&gt;

&lt;p&gt;I'm building a multi-tenant AI sales chatbot — each store is a separate customer, with multiple features per store: chat completion, embeddings, image analysis, profile extraction. A single customer message can fire 1–3 OpenAI calls.&lt;/p&gt;

&lt;p&gt;When I shipped, OpenAI told me I spent $4.27 yesterday.&lt;/p&gt;

&lt;p&gt;That number was useless. Was it one expensive image analysis? A runaway store with thousands of messages? A loop firing the same call repeatedly? No way to know.&lt;/p&gt;

&lt;p&gt;So I built my own observability. Three files. One afternoon.&lt;/p&gt;




&lt;h2&gt;
  
  
  File 1: The Wrapper (&lt;code&gt;openai-logger.ts&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;The core idea: don't call OpenAI directly. Call a wrapper that measures everything and logs it asynchronously.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pricing Table
&lt;/h3&gt;

&lt;p&gt;OpenAI's API returns token counts but not cost. You calculate it yourself from a hardcoded table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Last checked: 2026-04-15 — https://openai.com/pricing&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PRICING&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4o&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;2.50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;10.00&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;// per 1M tokens&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.60&lt;/span&gt;  &lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;// per 1M tokens&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the headline ratio:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Input (per 1M)&lt;/th&gt;
&lt;th&gt;Output (per 1M)&lt;/th&gt;
&lt;th&gt;Cost ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;gpt-4o&lt;/td&gt;
&lt;td&gt;$2.50&lt;/td&gt;
&lt;td&gt;$10.00&lt;/td&gt;
&lt;td&gt;1×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-4o-mini&lt;/td&gt;
&lt;td&gt;$0.15&lt;/td&gt;
&lt;td&gt;$0.60&lt;/td&gt;
&lt;td&gt;~16× cheaper&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;gpt-4o is roughly 16× more expensive than gpt-4o-mini for the same number of tokens.&lt;/strong&gt; If you're using gpt-4o for anything gpt-4o-mini can handle, you're burning money. The dashboard makes this visible call by call — exactly what you need when deciding which model goes where.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Wrapper
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;OpenAI&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openai&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createAdminClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/supabase/admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPENAI_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PRICING&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4o&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;2.50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;10.00&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.60&lt;/span&gt;  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;LogMeta&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;storeId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;leadId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;functionCalled&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;searchQuery&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;productsFound&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loggedChatCompletion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ChatCompletionCreateParams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LogMeta&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;PRICING&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;PRICING&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prompt_tokens&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
       &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completion_tokens&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_000_000&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Fire-and-forget log — never blocks the response&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createAdminClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;supabase&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;api_logs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;lead_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;leadId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;prompt_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;prompt_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;completion_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;completion_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;total_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;total_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;duration_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;function_called&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;functionCalled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;search_query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchQuery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;products_found&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productsFound&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt; &lt;span class="c1"&gt;// Silent fail — monitoring never breaks the app&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;duration&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;h3&gt;
  
  
  The One Pattern That Matters: Fire-and-Forget
&lt;/h3&gt;

&lt;p&gt;The line that makes this safe to ship:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt; &lt;span class="c1"&gt;// Silent fail&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The log insert is &lt;strong&gt;not awaited&lt;/strong&gt;. If the database is down, if there's a network blip, if the table doesn't exist yet — the user's response still goes through.&lt;/p&gt;

&lt;p&gt;In my testing the log insert takes 15–40ms. The chat completion takes 800–2500ms. If I awaited the log, I'd add 2–5% latency to every request for zero user benefit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitoring must never slow down the thing it's monitoring.&lt;/strong&gt; That's the only rule that matters here.&lt;/p&gt;

&lt;p&gt;I've run this pattern for weeks and lost maybe 2–3 log entries out of thousands. Acceptable trade-off.&lt;/p&gt;

&lt;h3&gt;
  
  
  Drop-in Usage
&lt;/h3&gt;

&lt;p&gt;Before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;customerMessage&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;After:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loggedChatCompletion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;customerMessage&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="na"&gt;storeId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;leadId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chat&lt;/span&gt;&lt;span class="dl"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same interface, one extra parameter. Find-and-replace across the codebase: 10 minutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  File 2: The Table (&lt;code&gt;api_logs&lt;/code&gt;)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;api_logs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;store_id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;stores&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;conversation_id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;lead_id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;endpoint&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;prompt_tokens&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;completion_tokens&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;total_tokens&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;cost&lt;/span&gt; &lt;span class="nb"&gt;DECIMAL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;duration_ms&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;function_called&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;search_query&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;products_found&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'success'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_api_logs_store&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;api_logs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_api_logs_created&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;api_logs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_api_logs_endpoint&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;api_logs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The columns are the dimensions you can slice by. Each one answers a question OpenAI's dashboard can't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;store_id&lt;/code&gt;&lt;/strong&gt; → "Which tenant is the most expensive?" In multi-tenant SaaS, one store can cost 10× another. Without this column you'll never see it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;endpoint&lt;/code&gt;&lt;/strong&gt; → "Is chat the expensive part, or is it image analysis?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;conversation_id&lt;/code&gt; + &lt;code&gt;lead_id&lt;/code&gt;&lt;/strong&gt; → "How much did this conversation cost? This customer?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;function_called&lt;/code&gt; + &lt;code&gt;search_query&lt;/code&gt; + &lt;code&gt;products_found&lt;/code&gt;&lt;/strong&gt; → Debug columns. When a customer says "show me red dresses" and the bot returns nothing, you can check: did it call the search function? With what query? How many products came back? This saved me hours of debugging.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;duration_ms&lt;/code&gt;&lt;/strong&gt; → Latency. Color-coded in the dashboard: green &amp;lt;1.5s, yellow 1.5–3s, red &amp;gt;3s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;error&lt;/code&gt;&lt;/strong&gt; → Failed calls still consume prompt tokens. OpenAI charges for them. Track them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One detail that's easy to miss: &lt;strong&gt;&lt;code&gt;cost DECIMAL(10,8)&lt;/code&gt;&lt;/strong&gt;. Eight decimal places.&lt;/p&gt;

&lt;p&gt;A single gpt-4o-mini chat completion costs roughly $0.00013. With &lt;code&gt;DECIMAL(10,2)&lt;/code&gt;, every call rounds to $0.00 and your totals are useless. Fractions of a cent matter at scale.&lt;/p&gt;




&lt;h2&gt;
  
  
  File 3: The Dashboard
&lt;/h2&gt;

&lt;p&gt;The API route (&lt;code&gt;/api/admin/logs/route.ts&lt;/code&gt;) takes filters (&lt;code&gt;startDate&lt;/code&gt;, &lt;code&gt;endDate&lt;/code&gt;, &lt;code&gt;endpoint&lt;/code&gt;, &lt;code&gt;storeId&lt;/code&gt;) and returns aggregated data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;totalRequests&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;totalTokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;avgTokensPerRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;avgLatency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;totalCost&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;dailyTokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;completion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;...],&lt;/span&gt;
  &lt;span class="nx"&gt;hourlyActivity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="nx"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;...],&lt;/span&gt;
  &lt;span class="nx"&gt;endpointBreakdown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;percentage&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;...],&lt;/span&gt;
  &lt;span class="nx"&gt;modelBreakdown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;percentage&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;...],&lt;/span&gt;
  &lt;span class="nx"&gt;storeBreakdown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="nx"&gt;storeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;storeName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cost&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The UI is intentionally boring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;5 stat cards&lt;/strong&gt; at the top — total requests, total tokens, avg tokens/request, avg latency (color-coded), total cost&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Date filters&lt;/strong&gt; — Today, 7 Days, 30 Days, All Time, Custom Range&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dropdowns&lt;/strong&gt; — endpoint, store&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live mode toggle&lt;/strong&gt; — auto-refresh every 5s&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two charts&lt;/strong&gt; — daily tokens (prompt vs completion), hourly activity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expandable log rows&lt;/strong&gt; — click one to see full detail: model, tokens, cost, latency, search query, products found&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The API does the heavy lifting. The UI just renders pre-aggregated data. No client-side calculations, no surprises.&lt;/p&gt;




&lt;h2&gt;
  
  
  What It Found
&lt;/h2&gt;

&lt;p&gt;Real numbers from one production day:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Customer messages handled&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI API calls&lt;/td&gt;
&lt;td&gt;~85&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total tokens&lt;/td&gt;
&lt;td&gt;~31,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total cost&lt;/td&gt;
&lt;td&gt;~$0.005&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Avg cost per message&lt;/td&gt;
&lt;td&gt;~$0.00013&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cost split by feature:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Calls&lt;/th&gt;
&lt;th&gt;Share of cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chat&lt;/td&gt;
&lt;td&gt;gpt-4o-mini&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;~85%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embeddings&lt;/td&gt;
&lt;td&gt;text-embedding-3-small&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;~2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Profile extraction&lt;/td&gt;
&lt;td&gt;gpt-4o-mini&lt;/td&gt;
&lt;td&gt;~12&lt;/td&gt;
&lt;td&gt;~3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image analysis&lt;/td&gt;
&lt;td&gt;gpt-4o&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;~10%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things jumped out the moment I had this view:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One.&lt;/strong&gt; Image analysis with gpt-4o costs roughly 100× more per call than chat with gpt-4o-mini. Even though only ~1% of calls were image analysis, they ate ~10% of the budget. That changed how I thought about which features deserve gpt-4o vs which can live on gpt-4o-mini.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two.&lt;/strong&gt; The chat endpoint was averaging far more prompt tokens per call than I'd estimated. The dashboard showed the symptom; investigation revealed I was sending the entire conversation history as context every single response. That's a separate architectural fix I &lt;a href="https://dev.to/alimafana/how-i-cut-my-ai-chatbot-costs-by-55-with-one-architecture-change-3pid"&gt;wrote about here&lt;/a&gt; — the point for &lt;em&gt;this&lt;/em&gt; article is that &lt;strong&gt;I wouldn't have looked for the bug if the dashboard hadn't shown me the symptom.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's the loop. You can't optimize what you don't measure. You can't measure what you don't instrument. And generic billing dashboards don't instrument &lt;em&gt;your&lt;/em&gt; application.&lt;/p&gt;




&lt;h2&gt;
  
  
  5 Things I Learned Building This
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. OpenAI's dashboard is a billing tool, not an observability tool
&lt;/h3&gt;

&lt;p&gt;It tells finance what to charge. It doesn't tell engineering what to fix. Different jobs.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Fire-and-forget is non-negotiable
&lt;/h3&gt;

&lt;p&gt;If your monitoring blocks the request path, you've made the product worse. The whole point of observability is that it's invisible until you look at it. Always non-awaited inserts. Always silent failure on log errors.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Eight decimal places, not two
&lt;/h3&gt;

&lt;p&gt;Store cost as &lt;code&gt;DECIMAL(10,2)&lt;/code&gt; and every call rounds to zero. AI costs are fractional cents per call. Treat them like fractional cents.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The dimensions are the product
&lt;/h3&gt;

&lt;p&gt;Total cost is a number. Cost-per-tenant, cost-per-feature, cost-per-conversation are &lt;em&gt;insights&lt;/em&gt;. The columns you log determine the questions you can answer. Add the column when you build the feature, not after you have a problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Hardcode the pricing. Update it manually.
&lt;/h3&gt;

&lt;p&gt;There is no OpenAI pricing API for you to query. Hardcode the rates with a comment for the date you last checked, update them when OpenAI changes. Two lines of code, three minutes a month.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Last checked: 2026-04-15 — https://openai.com/pricing&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PRICING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4o&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;2.50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;10.00&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.60&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;h2&gt;
  
  
  What to Add When You're Ready
&lt;/h2&gt;

&lt;p&gt;Once the basic version is in place, here's the upgrade path:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latency percentiles.&lt;/strong&gt; Average latency lies. Track p50, p95, p99. Average might be 1.2s, but if p99 is 8s, one in a hundred users is having a terrible time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-tenant budget alerts.&lt;/strong&gt; Threshold of $1/day per store. Slack/email when exceeded. Catches runaway loops, prompt injections that generate huge outputs, or stores with unexpected usage spikes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error rates by endpoint.&lt;/strong&gt; Total error rate hides distribution. Chat at 2% errors and image analysis at 15% is a different problem from both at 8%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost per conversion.&lt;/strong&gt; If your AI exists to drive a business outcome (sales, signups, completions), connect logs to that outcome table. Now you have ROI per conversation, not just spend per conversation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model migration tracking.&lt;/strong&gt; When you switch a feature from gpt-4o to gpt-4o-mini, the cost drop should be visible. The &lt;code&gt;model&lt;/code&gt; column makes before/after trivial.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;Three files. One afternoon. About 400 lines total.&lt;/p&gt;

&lt;p&gt;A wrapper that intercepts every API call. A table with enough dimensions to slice the data. A page that aggregates it into something you can act on.&lt;/p&gt;

&lt;p&gt;You don't need LangSmith or Helicone or Datadog (those are great if you prefer them). You need the smallest possible instrument that answers "which feature, which tenant, which conversation" — because that's the question your billing dashboard can't.&lt;/p&gt;

&lt;p&gt;The first time I opened mine, I caught a 100× cost gap between two features I'd been treating as similar. I caught it because I'd built the lens to see it.&lt;/p&gt;

&lt;p&gt;Build the lens before you ship. Or — more honestly — build it the day you ship, before you forget.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building **Provia&lt;/em&gt;* — an AI sales chatbot for Arabic-speaking e-commerce stores. Follow for more posts on building AI products from Gaza on a tight budget.*&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>openai</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>I Fixed 5 Chained AI Bugs in My Sales Chatbot — Each Solution Revealed the Next Problem</title>
      <dc:creator>Ali Afana </dc:creator>
      <pubDate>Sat, 25 Apr 2026 14:15:43 +0000</pubDate>
      <link>https://dev.to/alimafana/i-fixed-5-chained-ai-bugs-in-my-sales-chatbot-each-solution-revealed-the-next-problem-5fjh</link>
      <guid>https://dev.to/alimafana/i-fixed-5-chained-ai-bugs-in-my-sales-chatbot-each-solution-revealed-the-next-problem-5fjh</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; I spent a full day debugging my AI sales chatbot. What looked like one bug turned out to be five, stacked on top of each other. Each fix revealed the next problem underneath. Here's the full story.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;You know that feeling when you fix a bug and your app gets &lt;em&gt;worse&lt;/em&gt;?&lt;/p&gt;

&lt;p&gt;Not in the "oops I introduced a regression" way. In the "oh no, the previous bug was &lt;em&gt;masking&lt;/em&gt; another bug" way. And then you fix &lt;em&gt;that&lt;/em&gt; one, and there's another one underneath. Like pulling threads on a sweater until you're holding a pile of yarn and wondering if you ever really had a sweater at all.&lt;/p&gt;

&lt;p&gt;That's what happened to me during Session 6 of building Provia — an AI-powered e-commerce platform where store owners get a fully autonomous sales chatbot. The chatbot talks to customers over WhatsApp, recommends products from a real database, handles objections, and closes sales. Under the hood, it's GPT-4o-mini with function calling, backed by PostgreSQL with pgvector embeddings for semantic product search.&lt;/p&gt;

&lt;p&gt;It was supposed to be a "quick debugging session." It turned into an eight-hour archaeology dig through five layers of interconnected bugs. Here's the full story.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup: What Provia's AI Does
&lt;/h2&gt;

&lt;p&gt;Before we dive in, here's what the system does at a high level:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A customer sends a message (e.g., "show me something for a wedding")&lt;/li&gt;
&lt;li&gt;The AI searches the product database using semantic embeddings&lt;/li&gt;
&lt;li&gt;The AI generates a response with product recommendations&lt;/li&gt;
&lt;li&gt;The conversation continues, with the AI tracking context, preferences, and conversation stage&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The product database uses pgvector — each product has a 1536-dimension embedding generated from its name, description, category, vibe, and other metadata using OpenAI's &lt;code&gt;text-embedding-3-small&lt;/code&gt; model. When a customer asks for something, we embed their query and find the closest products in vector space.&lt;/p&gt;

&lt;p&gt;Simple enough, right? Well, the devil lives in the implementation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug 1: Summary Pollution — When Memory Becomes Contamination
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Symptom
&lt;/h3&gt;

&lt;p&gt;A tester was chatting with the bot about suits. Ten messages into the conversation, they pivoted: "actually, show me some hoodies."&lt;/p&gt;

&lt;p&gt;The bot responded with... more suits. Confidently. As if the word "hoodies" hadn't been spoken.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Investigation
&lt;/h3&gt;

&lt;p&gt;I dove into the logs. The search query being sent to pgvector wasn't just the customer's message. It was the customer's message &lt;em&gt;plus&lt;/em&gt; a conversation summary that the system had been maintaining.&lt;/p&gt;

&lt;p&gt;The summary looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Customer is looking for a $300 formal suit for a wedding occasion. 
They prefer dark colors and slim fit. Budget is flexible for the right piece.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This summary was being concatenated with the customer's latest message before embedding. So the actual search query became:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Customer is looking for a $300 formal suit for a wedding occasion. 
They prefer dark colors and slim fit. Budget is flexible for the right piece.
show me hoodies
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you embed that block of text, what do you get? An embedding that's 80% "formal suits" and 20% "hoodies." The vector math doesn't care that the customer changed their mind. It cares about token frequency and semantic weight. And the summary — being longer and more detailed — dominated the embedding completely.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;I killed the conversation summary. Completely. Ripped it out.&lt;/p&gt;

&lt;p&gt;But I didn't throw away the concept of memory. Instead, I replaced it with a &lt;strong&gt;structured Customer Profile&lt;/strong&gt; — a lean set of bullet points tracking style preferences, colors, budget, likes, and dislikes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CustomerProfile&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;style_preferences&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;likes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;dislikes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;occasion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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;The critical design decision: this profile gets injected into the &lt;strong&gt;response&lt;/strong&gt; prompt (so the AI can personalize its replies), but it &lt;strong&gt;never&lt;/strong&gt; touches the search query. Search and memory became two completely separate paths.&lt;/p&gt;

&lt;p&gt;I felt good. Bug squashed. Time to test.&lt;/p&gt;

&lt;p&gt;That feeling lasted about four minutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug 2: Raw Messages Make Terrible Search Queries
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Symptom
&lt;/h3&gt;

&lt;p&gt;With the summary gone, the search now used the customer's raw message as the query. The next test message was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;acctaly i dont want a hoodie i have a wedding ocation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The search returned a mix of hoodies and wedding outfits. Which sounds reasonable until you realize the customer explicitly said they &lt;em&gt;don't&lt;/em&gt; want a hoodie.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Investigation
&lt;/h3&gt;

&lt;p&gt;This one was immediately obvious once I looked at it with fresh eyes. The customer's message contains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"hoodie"&lt;/strong&gt; — something they explicitly DON'T want&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"wedding"&lt;/strong&gt; — something they DO want&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"acctaly"&lt;/strong&gt;, &lt;strong&gt;"dont"&lt;/strong&gt;, &lt;strong&gt;"ocation"&lt;/strong&gt; — typos everywhere&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Text embeddings don't understand negation. They don't know that "don't want a hoodie" means the opposite of "hoodie." To the embedding model, the word "hoodie" fires up the same semantic neighborhood regardless of whether it's preceded by "I love" or "I don't want."&lt;/p&gt;

&lt;p&gt;And the typos? &lt;code&gt;text-embedding-3-small&lt;/code&gt; handles them surprisingly well in isolation, but when you combine misspelled negations with misspelled targets in a single query, the embedding becomes a semantic smoothie. It picks up &lt;em&gt;everything&lt;/em&gt; and commits to &lt;em&gt;nothing&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;I introduced a &lt;strong&gt;dedicated Search Call&lt;/strong&gt; — a separate, lightweight AI call whose only job is to interpret what the customer wants and produce a clean search query.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;searchInterpretation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;messages&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="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`You are a search query interpreter. Given a customer message, 
      extract ONLY what they want to find. Ignore negations (what they don't want). 
      Output a short, clean search phrase.`&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Customer said: "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;customerMessage&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;150&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;Input: ~60 tokens. Output: ~20 tokens. Cost: negligible.&lt;/p&gt;

&lt;p&gt;For "acctaly i dont want a hoodie i have a wedding ocation," the search call returns: &lt;strong&gt;"wedding occasion outfit"&lt;/strong&gt;. Clean, correct, typo-free.&lt;/p&gt;

&lt;p&gt;Two bugs down. System's looking solid. Let me just add a little context to help the search call...&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug 3: Bot Reply Dominance — The Loudest Voice in the Room
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Symptom
&lt;/h3&gt;

&lt;p&gt;I figured the search call could benefit from a bit of context. So I fed it two messages: the bot's previous reply and the customer's latest message.&lt;/p&gt;

&lt;p&gt;The customer said: &lt;strong&gt;"hoodies"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The bot's previous reply was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Great choice! For a wedding, I'd recommend our Premium Wool Blend Suit in charcoal — 
it's $289 and perfect for formal occasions. We also have the Classic Navy Blazer Set 
at $245 which pairs beautifully with dress pants. Would you like to see more formal options?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Search results: suits and blazers. Not a hoodie in sight.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Investigation
&lt;/h3&gt;

&lt;p&gt;Count the tokens. The bot's reply: ~50 words about suits, prices, formal wear. The customer's message: 1 word — "hoodies."&lt;/p&gt;

&lt;p&gt;When you embed that combined text, the suit-related tokens outnumber the hoodie token roughly 50 to 1. The embedding lands squarely in "formal menswear" vector space, with "hoodies" contributing approximately nothing.&lt;/p&gt;

&lt;p&gt;This is a fundamental issue with how embeddings work. They represent the &lt;em&gt;average semantic meaning&lt;/em&gt; of the entire input text. A single word cannot fight against a paragraph.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;Zero history for the search call. Absolutely none.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// SEARCH CALL — customer's latest message ONLY&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;searchMessages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Extract what the customer wants to search for. Short phrase only.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Customer said: "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;latestCustomerMessage&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This created what I started calling the &lt;strong&gt;Two-Context Architecture&lt;/strong&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;Search Context&lt;/th&gt;
&lt;th&gt;Response Context&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Purpose&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Decide WHAT to search for&lt;/td&gt;
&lt;td&gt;Decide HOW to respond&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Input&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Customer's latest message only&lt;/td&gt;
&lt;td&gt;6 messages + profile + search results&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;History&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Recent session window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cost&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~60 tokens&lt;/td&gt;
&lt;td&gt;~500 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The search call is deliberately amnesiac. The response AI handles context. The search AI handles intent. Separation of concerns, but for AI calls.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug 4: The Pajama Problem — When "Night" Means Everything
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Symptom
&lt;/h3&gt;

&lt;p&gt;The search call was working beautifully. But one product kept showing up where it didn't belong: the &lt;strong&gt;"Cozy Night Deluxe Loungewear Set."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It's pajamas. Comfortable, stay-at-home pajamas.&lt;/p&gt;

&lt;p&gt;It showed up in results for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"date night outfit" (because "night")&lt;/li&gt;
&lt;li&gt;"evening wear" (because "night" is semantically close to "evening")&lt;/li&gt;
&lt;li&gt;"casual summer outfit" (because "cozy" and "casual" are neighbors)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Investigation
&lt;/h3&gt;

&lt;p&gt;This was an embedding similarity threshold problem. I had set the threshold at 0.1 — meaning any product with a cosine similarity above 0.1 was returned as a match.&lt;/p&gt;

&lt;p&gt;For context, with &lt;code&gt;text-embedding-3-small&lt;/code&gt;, truly relevant products score around 0.3-0.5, somewhat relevant products score 0.15-0.3, and noise lives below 0.15.&lt;/p&gt;

&lt;p&gt;At 0.1, I was scooping up enormous amounts of noise. The pajama set sat at around 0.15-0.22 similarity with a huge range of queries.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;Single threshold at 0.3. No near-match tier. Clean cuts only.&lt;/p&gt;

&lt;p&gt;But a high threshold means sometimes you get &lt;em&gt;no&lt;/em&gt; results. So I built a &lt;strong&gt;fallback chain&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;searchProducts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;storeId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Tier 1: Semantic search with strict threshold&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;semanticSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;storeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Tier 2: ILIKE text match (catches exact keyword matches)&lt;/span&gt;
    &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;textSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;storeId&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Tier 3: Return available categories&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;categories&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getStoreCategories&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;storeId&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="na"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four bugs fixed. The search pipeline was now clean, fast, and accurate. Then I looked at the actual responses.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug 5: The Response That Ignores Its Own Data
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Symptom
&lt;/h3&gt;

&lt;p&gt;Customer conversation, 10 messages deep, all about suits. Customer says: "actually, show me hoodies."&lt;/p&gt;

&lt;p&gt;Search call returns hoodies (correctly!). Hoodies are injected into the response prompt as search results.&lt;/p&gt;

&lt;p&gt;The bot responds: "I think you'll love our Classic Charcoal Suit for formal occasions..."&lt;/p&gt;

&lt;p&gt;The search found the right products. The response ignored them completely.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Investigation
&lt;/h3&gt;

&lt;p&gt;Here's what the model was seeing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;System prompt&lt;/strong&gt;: Store persona, sales instructions, tone guidance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chat history&lt;/strong&gt;: 10 messages about suits (~400 tokens)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search results&lt;/strong&gt;: 3 hoodies (~150 tokens)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latest customer message&lt;/strong&gt;: "actually, show me hoodies" (6 tokens)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The model followed the dominant topic. Ten messages of suit conversation created a strong gravitational pull. The hoodies in the search results were a small island in a sea of formal wear.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;I injected the customer's latest message directly into the &lt;strong&gt;system prompt&lt;/strong&gt;, with an explicit instruction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;systemPrompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`You are &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;persona&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, a sales assistant for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;storeName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.

&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;persona&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;

---
The customer's latest message: "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;latestCustomerMessage&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"
IMPORTANT: Your reply MUST directly address this latest message. 
If the customer asked about a new topic or product, focus on THAT topic, 
not the previous conversation.
---

&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;searchResults&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`Available products matching their request:\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;formatProducts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;searchResults&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;System prompts receive disproportionate attention from language models. By putting the customer's latest message there — not just in the chat history — it becomes a directive the model actually follows.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Final Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Customer message
    |
    v
SEARCH CALL (~60 tokens)
    Input: "Customer said: '[msg]'. Call search_products."
    History: NONE
    |
    v
Search pipeline:
    Semantic search (threshold 0.3)
    -&amp;gt; ILIKE fallback
    -&amp;gt; Category fallback
    |
    v
RESPONSE CALL (~500 tokens)
    System: persona + profile + "Latest: [msg]" + search results
    History: 6 most recent session messages
    |
    v
Response + product cards
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two AI calls per message. One dumb (search), one smart (response). Each with its own carefully scoped context window.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&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;Tokens per message&lt;/td&gt;
&lt;td&gt;~1,820&lt;/td&gt;
&lt;td&gt;~830&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per 100K messages&lt;/td&gt;
&lt;td&gt;~$30&lt;/td&gt;
&lt;td&gt;~$14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reduction&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;55%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;By &lt;em&gt;adding&lt;/em&gt; a second AI call, total token usage went &lt;em&gt;down&lt;/em&gt; by 55%. Less context, better results, lower cost.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. AI Bugs Are Layered Like Onions
&lt;/h3&gt;

&lt;p&gt;Each bug was invisible until I fixed the one above it. This is different from traditional software — AI bugs form &lt;em&gt;stacks&lt;/em&gt; where one bad behavior masks another.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Embeddings Don't Understand Negation
&lt;/h3&gt;

&lt;p&gt;"I don't want X" and "I want X" produce nearly identical embeddings. Don't embed raw text. Use a language model to interpret intent first.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Separation of Concerns Applies to AI Calls
&lt;/h3&gt;

&lt;p&gt;Search needs amnesia. Response needs memory. Mixing them is how you get suits when someone asks for hoodies.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. System Prompts Are Your Steering Wheel
&lt;/h3&gt;

&lt;p&gt;When a long conversation history pulls the model in one direction, the system prompt is the only thing powerful enough to redirect it.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Test Topic Switches, Not Just Topic Continuation
&lt;/h3&gt;

&lt;p&gt;The bugs only appeared when the customer &lt;em&gt;changed their mind&lt;/em&gt;. Topic switches are where AI systems break. Make them a first-class test case.&lt;/p&gt;




&lt;p&gt;Five bugs. Five fixes. Eight hours. One architecture that actually works.&lt;/p&gt;

&lt;p&gt;And probably another five bugs hiding underneath, waiting for the right query to reveal them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building Provia — an AI-powered sales platform — from Gaza. I document every bug, every fix, and every architecture decision. Follow me &lt;a href="https://twitter.com/AliMAfana" rel="noopener noreferrer"&gt;@AliMAfana&lt;/a&gt; for the real version of building in public.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Previous articles:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/alimafana/how-i-cut-my-ai-chatbot-costs-by-55-with-one-architecture-change-3pid"&gt;How I Cut My AI Chatbot Costs by 55%&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/alimafana/a-stranger-audited-my-ai-product-for-free-heres-what-they-found-3npd"&gt;A Stranger Audited My AI Product for Free&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/alimafana"&gt;My AI Kept Recommending Pajamas for Date Night&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/alimafana"&gt;Every API Route Was Wide Open&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/alimafana"&gt;I Asked My AI "That's Sold Out, Right?"&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>architecture</category>
    </item>
    <item>
      <title>A Stranger Audited My AI Product for Free. Here's What They Found.</title>
      <dc:creator>Ali Afana </dc:creator>
      <pubDate>Mon, 20 Apr 2026 15:23:01 +0000</pubDate>
      <link>https://dev.to/alimafana/a-stranger-audited-my-ai-product-for-free-heres-what-they-found-3npd</link>
      <guid>https://dev.to/alimafana/a-stranger-audited-my-ai-product-for-free-heres-what-they-found-3npd</guid>
      <description>&lt;p&gt;Three weeks ago I left a comment on a Dev.to article. Today, that comment turned into a full accessibility audit of my product — published publicly, with my real name, my real store URL, and every violation listed in detail.&lt;/p&gt;

&lt;p&gt;I asked for it. And I'd do it again.&lt;/p&gt;




&lt;h2&gt;
  
  
  How It Started
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/agentkit"&gt;@AgentKit&lt;/a&gt; published a piece called &lt;em&gt;"We Scanned 30 SaaS Pricing Pages for Accessibility. 70% Failed."&lt;/em&gt; I was in the comments talking about AI product interfaces — specifically the product cards my chatbot renders inline. I described them honestly: styled &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; blocks, no semantic structure, no landmark, no list boundary.&lt;/p&gt;

&lt;p&gt;Their response: "Would it be useful if we ran a proper axe pass on a live Provia page + a short screen reader walkthrough?"&lt;/p&gt;

&lt;p&gt;I said yes. They said they'd keep the store name out of it if I wanted.&lt;/p&gt;

&lt;p&gt;I said put it in. It's a test store. And if we're going to do build-in-public, let's actually do it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What They Found
&lt;/h2&gt;

&lt;p&gt;The full audit is in their article: &lt;a href="https://dev.to/agentkit"&gt;We Audited Provia's AI Shopping Chat. Here's What the Before Looks Like.&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Short version: &lt;strong&gt;4 violations. 1 serious. 3 moderate.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The Serious One
&lt;/h3&gt;

&lt;p&gt;My product card rail — the horizontal scroll of cards that appears when you search for products — is completely invisible to keyboard users. It's a &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; with &lt;code&gt;display: flex; overflow-x: auto&lt;/code&gt;. No &lt;code&gt;tabindex&lt;/code&gt;. No focusable children. A keyboard-only user literally cannot scroll through search results.&lt;/p&gt;

&lt;p&gt;I built a shopping interface where the products are unreachable without a mouse.&lt;/p&gt;

&lt;p&gt;That sentence is hard to write. But that's the point.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Moderate Ones
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Chat input has no accessible name.&lt;/strong&gt; The placeholder says "Type a message..." but placeholder is not a label. Screen readers announce it as "edit text, blank." The user has to guess what the field does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Product cards have no list semantics.&lt;/strong&gt; Five product cards rendered as five sibling &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;s. No &lt;code&gt;&amp;lt;ul&amp;gt;&lt;/code&gt;, no &lt;code&gt;role="list"&lt;/code&gt;, no &lt;code&gt;role="listitem"&lt;/code&gt;. A screen reader user hears a flat stream of text — product name, description, price, product name, description, price — with no "list, 5 items" on entry and no "card 2 of 5" marker between them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt; landmark.&lt;/strong&gt; The entire chat interface has no landmark structure.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I Got Right
&lt;/h3&gt;

&lt;p&gt;This part surprised me. Every &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; in my product cards has a real &lt;code&gt;alt&lt;/code&gt; attribute with the actual product name. AgentKit said this was better than 70% of the AI surfaces they scan — they've seen entire rails where every image announces as "graphic, graphic, graphic."&lt;/p&gt;

&lt;p&gt;That wasn't an accident. Early on, I made the AI generate product descriptions that flow through to the image alt text. I didn't do it for accessibility — I did it because it seemed right. Turns out "it seemed right" was the correct instinct.&lt;/p&gt;




&lt;h2&gt;
  
  
  What It Feels Like
&lt;/h2&gt;

&lt;p&gt;Reading your own HTML described as "naked divs dressed up" is humbling.&lt;/p&gt;

&lt;p&gt;But here's the thing: I already knew. When I first described my product cards in that comment thread, I used the exact words "totally naked divs." I knew the structure was wrong. I just hadn't prioritized it because no one was complaining.&lt;/p&gt;

&lt;p&gt;That's the trap. &lt;strong&gt;No one complains about accessibility because the people affected can't use your product in the first place.&lt;/strong&gt; They don't file bug reports. They just leave.&lt;/p&gt;

&lt;p&gt;The AgentKit audit gave me something I couldn't give myself: a number. Not "I should probably fix the accessibility someday" but "4 violations, 1 serious, 6 DOM nodes affected, here's the exact axe-core output." Numbers create urgency. Vague guilt doesn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'm Fixing
&lt;/h2&gt;

&lt;p&gt;The keyboard navigation fix is already in progress. The scrollable card container gets &lt;code&gt;tabindex="0"&lt;/code&gt;, the cards get proper focus management with arrow keys, and the focus ring follows Provia's design system so it looks intentional, not like a browser default.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;aria-label&lt;/code&gt; on the chat input is three characters of code change. It ships with the keyboard fix.&lt;/p&gt;

&lt;p&gt;After that: &lt;code&gt;role="list"&lt;/code&gt; on the card container, &lt;code&gt;role="listitem"&lt;/code&gt; on each card, and a &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt; landmark wrapping the chat interface.&lt;/p&gt;

&lt;p&gt;When the fixes land, AgentKit re-runs the same scanner against the same URL with the same "show me hoodies" query. Same axe rules. Same everything. And they publish Part 2 — the after-diff.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I Wanted This Public
&lt;/h2&gt;

&lt;p&gt;I could have asked them to keep Provia's name out of it. They offered. I said no.&lt;/p&gt;

&lt;p&gt;Three reasons:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. "Build in public" means the broken parts too.&lt;/strong&gt; I've published articles about my AI recommending pajamas for date night, about hallucinating fake products, about every API route being wide open. Accessibility gaps are the same category: real problems in a real product. If I only share the wins, the "build in public" label is marketing, not transparency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Other founders need to see this process.&lt;/strong&gt; Not just the violations — the process. Someone offers to audit you. You say yes. They find problems. You fix them. Everyone learns. That's how it's supposed to work. But most founders are too afraid of looking bad to let anyone in. I get it. I'm publishing this with my real name attached to "your product cards are unreachable with a keyboard." It's uncomfortable. But the alternative is pretending the problem doesn't exist until a real user gets hurt by it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The "after" article is more valuable than the "before."&lt;/strong&gt; Part 1 alone is just a list of problems. Part 1 + Part 2 together is a case study in fixing accessibility in a real AI product. That's the article I want to exist — not because it makes me look good, but because when the next founder searches "accessibility AI chat interface," they find a real before-and-after with real code diffs instead of another generic WCAG checklist.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Learned About My Own Thinking
&lt;/h2&gt;

&lt;p&gt;The most useful sentence in the private report was this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Three focusables on a surface whose entire purpose is showing products."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Three. The Back link, the chat input, and the Send button. That's it. On a shopping interface. The products — the entire reason the page exists — are invisible to the Tab key.&lt;/p&gt;

&lt;p&gt;I was building for sighted mouse users because that's what I am. Every time I tested my app, I typed a query, scrolled the cards with my trackpad, and thought "this works." It did work — for me. For a keyboard-only user, or a screen reader user, it was a dead end.&lt;/p&gt;

&lt;p&gt;That sentence rewired how I think about every component I build going forward. Not "does it look right?" but "can someone reach it without a mouse?"&lt;/p&gt;




&lt;h2&gt;
  
  
  If You're Building an AI Interface Right Now
&lt;/h2&gt;

&lt;p&gt;Run axe-core against your product page. Right now. Before you publish your next feature.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @axe-core/cli
axe https://your-app.com/your-product-page
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It takes 30 seconds. The output will probably surprise you.&lt;/p&gt;

&lt;p&gt;If you find violations and you don't know how to fix them — the &lt;a href="https://dequeuniversity.com/rules/axe/" rel="noopener noreferrer"&gt;axe-core rule descriptions&lt;/a&gt; are the best starting point. Each rule links to the relevant WCAG criterion and gives you the exact fix.&lt;/p&gt;

&lt;p&gt;And if you want someone to actually audit your surface properly, reach out to teams like &lt;a href="https://dev.to/agentkit"&gt;@AgentKit&lt;/a&gt;. They did mine for free, gave me the report privately first, and let me decide what to publish. That's how this should work.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is Part 1 from my side. Part 2 — the after-diff — comes when the fixes ship.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm building Provia, an AI sales platform, from Gaza. I document every bug, every fix, and every lesson. Follow me &lt;a href="https://twitter.com/AliMAfana" rel="noopener noreferrer"&gt;@AliMAfana&lt;/a&gt; for the real version of building in public.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Previous articles:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/alimafana/how-i-cut-my-ai-chatbot-costs-by-55-with-one-architecture-change-3pid"&gt;How I Cut My AI Chatbot Costs by 55% With One Architecture Change&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/alimafana"&gt;My AI Kept Recommending Pajamas for Date Night&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/alimafana"&gt;Every API Route Was Wide Open&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>a11y</category>
      <category>webdev</category>
      <category>ai</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>How I Cut My AI Chatbot Costs by 55% With One Architecture Change</title>
      <dc:creator>Ali Afana </dc:creator>
      <pubDate>Sat, 18 Apr 2026 08:22:00 +0000</pubDate>
      <link>https://dev.to/alimafana/how-i-cut-my-ai-chatbot-costs-by-55-with-one-architecture-change-3pid</link>
      <guid>https://dev.to/alimafana/how-i-cut-my-ai-chatbot-costs-by-55-with-one-architecture-change-3pid</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; I split one big GPT-4o-mini call into two small, specialized calls. Tokens per message dropped from ~1,820 to ~830. Projected cost went from $300/1M messages to $140/1M messages. Here's exactly how.&lt;/p&gt;




&lt;h2&gt;
  
  
  The $300 Problem
&lt;/h2&gt;

&lt;p&gt;I'm building &lt;a href="https://github.com/AliMAfana" rel="noopener noreferrer"&gt;Provia&lt;/a&gt;, an AI-powered e-commerce platform where an AI sales chatbot handles customer conversations — discovery, product search, objection handling, closing. The AI model is GPT-4o-mini, which is already one of the cheapest options out there.&lt;/p&gt;

&lt;p&gt;After my first real end-to-end test — a 42-API-call conversation that consumed 30,654 tokens and cost $0.0054 — I sat down and did the math. At scale, my architecture would cost &lt;strong&gt;$30 per 100K messages&lt;/strong&gt; and &lt;strong&gt;$300 per 1M messages&lt;/strong&gt;. For an indie SaaS product, that's a margin killer.&lt;/p&gt;

&lt;p&gt;The worst part? Most of those tokens were wasted. The AI was looping through the same searches, re-reading old context it didn't need, and writing responses three times longer than necessary. The problem wasn't the model. It was my architecture.&lt;/p&gt;

&lt;p&gt;One structural change cut costs by 54.4%. No model downgrade. No quality loss. Actually, response quality went &lt;em&gt;up&lt;/em&gt; because the AI stopped confusing itself with stale context.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Before: One Big Call Per Message
&lt;/h2&gt;

&lt;p&gt;My original architecture was the obvious one. Every time a customer sent a message, I made a single OpenAI call that looked like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Token Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;System prompt (persona, instructions, rules)&lt;/td&gt;
&lt;td&gt;~500 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conversation history (last 20 messages)&lt;/td&gt;
&lt;td&gt;~1,000 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conversation summary (AI-generated recap)&lt;/td&gt;
&lt;td&gt;~200 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model response (avg)&lt;/td&gt;
&lt;td&gt;~120 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total per message&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~1,820 tokens&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The system prompt was verbose — 500+ tokens of instructions covering persona, tone, sales stage logic, search rules, and formatting guidelines. The history window was the last 20 messages, both customer and bot. And a conversation summary was injected into every call to give the AI "memory" of earlier topics.&lt;/p&gt;

&lt;p&gt;On paper, it seems reasonable. In practice, it created three expensive problems.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Three Problems That Were Burning Money
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Summary Pollution
&lt;/h3&gt;

&lt;p&gt;The conversation summary was supposed to help the AI remember context. Instead, it poisoned every interaction.&lt;/p&gt;

&lt;p&gt;Here's what happened: a customer asks about red dresses in message #3. The summary captures "customer is looking for red dresses." Ten messages later, the customer asks about shoes. But the summary still says "red dresses." So the AI searches for red dresses &lt;em&gt;and&lt;/em&gt; shoes. Then the summary updates to include both. Next message, the customer asks about a specific shoe, and the AI searches for red dresses, shoes, &lt;em&gt;and&lt;/em&gt; that specific shoe.&lt;/p&gt;

&lt;p&gt;The summary accumulated topics like a snowball. Every search included ghosts of old queries. More searches meant more tool calls, more tokens, more cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. History Bloat
&lt;/h3&gt;

&lt;p&gt;Loading the last 20 messages sounds like a safe default. But in a sales conversation, most of those messages are irrelevant to the current question. If the customer is asking "do you have this in size 8?" they don't need the AI to re-read the greeting, the initial product discovery, and the three messages where they discussed shipping.&lt;/p&gt;

&lt;p&gt;Twenty messages at ~50 tokens each (both sides) is 1,000 tokens of context. Most of it noise. The model has to read all of it, process all of it, and pay for all of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Search Loops
&lt;/h3&gt;

&lt;p&gt;This was the most expensive bug. Because the summary and history contained references to previous searches, the AI would frequently re-trigger searches it had already done. The conversation summary would say "customer was shown product X" and the AI would interpret that as a reason to search for product X again.&lt;/p&gt;

&lt;p&gt;In my 42-call test conversation, I counted &lt;strong&gt;multiple redundant search cycles&lt;/strong&gt; — the AI searching for the same products it had already found, because the context told it those products were relevant.&lt;/p&gt;

&lt;p&gt;Each unnecessary search cycle costs a tool call round-trip: the model generates search parameters, the function executes, results come back, and the model processes them. That's easily 300-500 extra tokens per loop.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Fix: Two Small Calls Instead of One Big One
&lt;/h2&gt;

&lt;p&gt;The core insight was simple: &lt;strong&gt;searching and responding are different jobs. They need different context.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A search call needs to know what the customer just said. That's it. It doesn't need conversation history, personality instructions, or a summary of past topics. Adding those things actively hurts search quality.&lt;/p&gt;

&lt;p&gt;A response call needs personality, recent context, and search results. But it doesn't need 20 messages of history — the last 6 from the current session are enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  Call #1: The Search Call
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// SEARCH CALL — minimal, focused&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;searchSys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`You are a product search assistant for "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;".
The customer just said: "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"
Call search_products with what they want.`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loggedChatCompletion&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;searchSys&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;150&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Input:&lt;/strong&gt; Only the customer's latest message (~60 tokens).&lt;br&gt;
&lt;strong&gt;Job:&lt;/strong&gt; Decide whether to search, and if so, what to search for.&lt;br&gt;
&lt;strong&gt;max_tokens:&lt;/strong&gt; 150 (hard cap — it either calls a tool or it doesn't).&lt;br&gt;
&lt;strong&gt;History:&lt;/strong&gt; Zero. None. Impossible to pollute.&lt;/p&gt;

&lt;p&gt;This call is almost free. Sixty tokens in, 100 tokens out at most. And because it has zero history, it can never loop on old searches. It only sees the current message.&lt;/p&gt;

&lt;h3&gt;
  
  
  Call #2: The Response Call
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// RESPONSE CALL — context-aware but bounded&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r2&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loggedChatCompletion&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;messages&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="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;responseSys&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;toChat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;responseCtx&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;// last 6 session messages&lt;/span&gt;
    &lt;span class="nx"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                   &lt;span class="c1"&gt;// search call's tool decision&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;toolMsgs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;             &lt;span class="c1"&gt;// search results&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;250&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Input:&lt;/strong&gt; System prompt + customer profile + last 6 session messages + search results (~500 tokens).&lt;br&gt;
&lt;strong&gt;Job:&lt;/strong&gt; Write the actual reply to the customer.&lt;br&gt;
&lt;strong&gt;max_tokens:&lt;/strong&gt; 250 (prevents essay-length responses).&lt;br&gt;
&lt;strong&gt;History:&lt;/strong&gt; Last 6 messages from the current session only.&lt;/p&gt;

&lt;p&gt;This call has enough context to write a good, personalized response, but not so much that it drowns in irrelevant history.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Math
&lt;/h2&gt;

&lt;p&gt;Here's the token breakdown, before and after:&lt;/p&gt;

&lt;h3&gt;
  
  
  Before (Single Call)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Tokens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;System prompt&lt;/td&gt;
&lt;td&gt;~500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;History (20 messages)&lt;/td&gt;
&lt;td&gt;~1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Summary&lt;/td&gt;
&lt;td&gt;~200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Response output&lt;/td&gt;
&lt;td&gt;~120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~1,820&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  After (Two Calls)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Tokens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Search call input&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Search call output&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Response call input&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Response call output&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~170&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~830&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Token reduction: 54.4%&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Cost at Scale
&lt;/h3&gt;

&lt;p&gt;Using GPT-4o-mini pricing ($0.15/1M input tokens, $0.60/1M output tokens):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;Savings&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tokens per message&lt;/td&gt;
&lt;td&gt;~1,820&lt;/td&gt;
&lt;td&gt;~830&lt;/td&gt;
&lt;td&gt;54.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per message&lt;/td&gt;
&lt;td&gt;~$0.0003&lt;/td&gt;
&lt;td&gt;~$0.00014&lt;/td&gt;
&lt;td&gt;53.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per 100K messages&lt;/td&gt;
&lt;td&gt;~$30&lt;/td&gt;
&lt;td&gt;~$14&lt;/td&gt;
&lt;td&gt;$16 saved&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per 1M messages&lt;/td&gt;
&lt;td&gt;~$300&lt;/td&gt;
&lt;td&gt;~$140&lt;/td&gt;
&lt;td&gt;$160 saved&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At 1M messages, that's &lt;strong&gt;$160 back in your pocket&lt;/strong&gt; every month. For an indie SaaS, that's the difference between profitable and not.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bonus Optimizations That Stacked
&lt;/h2&gt;

&lt;p&gt;The two-call split was the biggest win, but three other changes compounded the savings.&lt;/p&gt;

&lt;h3&gt;
  
  
  Session-Based Memory Instead of Fixed Window
&lt;/h3&gt;

&lt;p&gt;Instead of always loading the last 20 messages regardless of when they were sent, I switched to session-based windowing. If there's a gap of 30+ minutes between messages, that's a new session. The response call only sees messages from the current session (last 6 max).&lt;/p&gt;

&lt;p&gt;This means if a customer comes back the next day, the AI doesn't reload yesterday's entire conversation. It starts fresh with their profile data, which contains everything it needs to personalize.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Impact:&lt;/strong&gt; Eliminated 60-80% of irrelevant history tokens in returning-customer conversations.&lt;/p&gt;

&lt;h3&gt;
  
  
  Customer Profile Instead of Summary
&lt;/h3&gt;

&lt;p&gt;The conversation summary was unstructured text — a paragraph the AI generated after each exchange. It was expensive to generate, expensive to include, and caused the search loop problem.&lt;/p&gt;

&lt;p&gt;I replaced it with a structured customer profile: bullet points covering name, archetype, preferences, and current intent. This profile is updated incrementally, not regenerated from scratch. It's smaller (~80 tokens vs ~200), more precise, and doesn't accumulate stale search topics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Impact:&lt;/strong&gt; 60% reduction in "memory" token cost, plus elimination of search pollution.&lt;/p&gt;

&lt;h3&gt;
  
  
  Product Card Filtering
&lt;/h3&gt;

&lt;p&gt;In the old architecture, when the AI searched for products, all results were sent back to the customer as product cards — even if the AI only mentioned one of them in its response. This didn't affect token cost directly, but it confused customers and led to follow-up messages asking about products the AI didn't recommend.&lt;/p&gt;

&lt;p&gt;Now, the frontend only renders product cards for items the AI explicitly referenced in its response text. Fewer confused follow-ups means fewer total messages, which means fewer API calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Impact:&lt;/strong&gt; Hard to quantify, but anecdotally reduced "what about this one?" follow-up messages.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Works (The Principle)
&lt;/h2&gt;

&lt;p&gt;The underlying principle is &lt;strong&gt;context isolation&lt;/strong&gt;. Different tasks need different context windows. When you shove everything into one call, you're paying for context that actively degrades output quality.&lt;/p&gt;

&lt;p&gt;Think of it like database queries. You wouldn't write &lt;code&gt;SELECT * FROM every_table&lt;/code&gt; when you only need one column from one table. But that's exactly what a single-call architecture does with LLM context.&lt;/p&gt;

&lt;p&gt;The two-call pattern works because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The search call is stateless.&lt;/strong&gt; It doesn't know or care about conversation history. This makes it immune to context pollution and extremely cheap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The response call is bounded.&lt;/strong&gt; It has enough context to be helpful (6 recent messages, customer profile, fresh search results) but not so much that it wastes tokens on noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;max_tokens caps prevent runaway costs.&lt;/strong&gt; The search call can't exceed 150 tokens. The response call can't exceed 250. This eliminates the long tail of expensive responses.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Tradeoffs
&lt;/h2&gt;

&lt;p&gt;This isn't free. There are real tradeoffs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two API calls means two round-trips.&lt;/strong&gt; Latency increases by the duration of the search call (~200-400ms for GPT-4o-mini). In practice, users don't notice because the search call is fast and the total response time stays under 2 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The search call can't reference history.&lt;/strong&gt; If a customer says "show me more like the last one," the search call doesn't know what "the last one" is. I handle this by having the response call detect anaphoric references and include the last-shown product ID in the search context. It's an edge case, but it needs handling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two calls means two points of failure.&lt;/strong&gt; If the search call fails, you need fallback logic. I default to skipping search and letting the response call work without product results — the AI can still have a conversation, it just can't recommend products until search recovers.&lt;/p&gt;

&lt;p&gt;None of these tradeoffs have been deal-breakers. The cost savings far outweigh the added complexity.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try This Today
&lt;/h2&gt;

&lt;p&gt;If you're running an AI chatbot with a single-call architecture, here's a checklist to estimate your own savings:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measure your current tokens per message.&lt;/strong&gt; Log input and output tokens for 100+ real messages. Calculate the average.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identify what context each task actually needs.&lt;/strong&gt; List every component in your prompt (system instructions, history, summaries, tool results). For each one, ask: "Does the model need this to do its current job?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split calls by responsibility.&lt;/strong&gt; If your model is both deciding what to do (search, lookup, API call) and generating a response, those are two different jobs. Separate them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set max_tokens aggressively.&lt;/strong&gt; For tool-calling decisions, 100-200 tokens is usually enough. For responses, set a cap based on your desired response length. A chatbot reply rarely needs more than 250 tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replace summaries with structured data.&lt;/strong&gt; If you're generating text summaries to maintain context, switch to structured profiles or key-value pairs. They're smaller, more precise, and less likely to cause context pollution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use session windows, not fixed windows.&lt;/strong&gt; Don't load the last N messages blindly. Detect session boundaries (time gaps, topic changes) and only load relevant recent context.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The two-call pattern isn't specific to e-commerce or sales bots. Any chatbot that does retrieval + response can benefit from this split. RAG pipelines, customer support bots, coding assistants — if your model is searching and responding in the same call, you're probably paying 40-60% more than you need to.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Numbers
&lt;/h2&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;Architecture&lt;/td&gt;
&lt;td&gt;1 call per message&lt;/td&gt;
&lt;td&gt;2 calls per message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tokens per message&lt;/td&gt;
&lt;td&gt;~1,820&lt;/td&gt;
&lt;td&gt;~830&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per message&lt;/td&gt;
&lt;td&gt;$0.0003&lt;/td&gt;
&lt;td&gt;$0.00014&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per 1M messages&lt;/td&gt;
&lt;td&gt;$300&lt;/td&gt;
&lt;td&gt;$140&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search pollution&lt;/td&gt;
&lt;td&gt;Frequent loops&lt;/td&gt;
&lt;td&gt;Eliminated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Response quality&lt;/td&gt;
&lt;td&gt;Verbose, unfocused&lt;/td&gt;
&lt;td&gt;Concise, on-topic&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One architecture change. Two smaller calls. 55% cost reduction. Ship it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm documenting my entire journey building an AI sales platform from Gaza. Follow me &lt;a href="https://twitter.com/AliMAfana" rel="noopener noreferrer"&gt;@AliMAfana&lt;/a&gt; for more real bugs from a real product.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Previous articles:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/alimafana"&gt;My AI Kept Recommending Pajamas for Date Night&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/alimafana"&gt;Your AI Is Lying to Your Customers&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/alimafana"&gt;I Asked My AI "That's Sold Out, Right?" — It Had 5 in Stock and Still Said Yes&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;&lt;a href="https://dev.to/alimafana"&gt;Every API Route Was Wide Open&lt;/a&gt;&lt;/em&gt;
.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>saas</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
