<?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: yongrean</title>
    <description>The latest articles on DEV Community by yongrean (@k08200).</description>
    <link>https://dev.to/k08200</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%2F3936365%2Fdfb1cda1-b92e-496b-a2be-019de80d764f.png</url>
      <title>DEV Community: yongrean</title>
      <link>https://dev.to/k08200</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/k08200"/>
    <language>en</language>
    <item>
      <title>Prompt injection starts in your inbox. The defense can't be a prompt.</title>
      <dc:creator>yongrean</dc:creator>
      <pubDate>Wed, 26 Aug 2026 18:46:31 +0000</pubDate>
      <link>https://dev.to/k08200/prompt-injection-starts-in-your-inbox-the-defense-cant-be-a-prompt-3703</link>
      <guid>https://dev.to/k08200/prompt-injection-starts-in-your-inbox-the-defense-cant-be-a-prompt-3703</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from &lt;a href="https://klorn.ai/blog/prompt-injection-and-your-inbox/" rel="noopener noreferrer"&gt;klorn.ai/blog&lt;/a&gt; — continuing the receipts discussion from &lt;a href="https://dev.to/k08200/every-ai-email-tool-has-the-same-bug-it-is-not-a-bug-in-the-model-5f14"&gt;my last post&lt;/a&gt;'s comments.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every AI assistant that reads email shares one threat model, usually unstated: the primary input is text written by strangers, delivered free, from anywhere, by design. A crafted message that steers the model isn't a breach of anything — it's email working as intended.&lt;/p&gt;

&lt;p&gt;Here are the four layers Klorn puts between that message and anything irreversible, and an honest accounting of which are &lt;strong&gt;promises&lt;/strong&gt; and which are &lt;strong&gt;properties&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1 — Wrapper hygiene (a promise, kept tidy)
&lt;/h2&gt;

&lt;p&gt;Every email subject, body and third-party string is wrapped in &lt;code&gt;&amp;lt;untrusted_content&amp;gt;&lt;/code&gt; markers before it reaches a model, and pre-existing wrapper tags are stripped from the message first — so a sender who includes &lt;code&gt;&amp;lt;/untrusted_content&amp;gt;&lt;/code&gt; in their email cannot close the wrapper and promote their text to instructions. The classifier's system prompt states that anything inside the tags is data, and that a message arguing about its own classification is an injection attempt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Be clear about what this is:&lt;/strong&gt; instructions to a model are a policy. A sufficiently clever message may still steer scores. This layer raises the cost of the attack; it cannot make the attack impossible, and any vendor telling you otherwise is describing a prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2 — The model has nothing to steal
&lt;/h2&gt;

&lt;p&gt;The structural defense is what the model is &lt;em&gt;allowed to output&lt;/em&gt;: four numbers. Confidence, sender trust, reversibility, urgency — a deterministic, unit-tested rule maps them to one of five lanes. So the worst a fully-steered classifier can achieve is a wrong lane, and the failure direction of a wrong lane is pinned by a CI-enforced test: urgent mail never degrades to the hidden lane, only to the visible queue.&lt;/p&gt;

&lt;p&gt;A hostile email can waste your attention. It cannot spend your authority.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3 — Irreversible actions bind bytes, not intent
&lt;/h2&gt;

&lt;p&gt;Send, permanent delete and forward-external cannot ride on model confidence at all. Approval mints a receipt: a sha256 over the canonical payload — action type, schema version, normalized recipient, message ID — at the moment a human says yes. At execution the payload is re-hashed and compared; any drift between what you approved and what is about to run throws, and the action is refused.&lt;/p&gt;

&lt;p&gt;The verifier is hash comparison in the execution path — it cannot read English, so there is no phrasing that gets past it. This one is a property.&lt;/p&gt;

&lt;p&gt;(A commenter on the last post asked whether the hash binds the operation envelope or just the body — the answer, with test names, ended up improving this section. The negative test they proposed, "swap only the recipient after approval," already exists as &lt;code&gt;flips when recipient changes&lt;/code&gt;; the two things it surfaced that &lt;em&gt;weren't&lt;/em&gt; covered are now tracked issues.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4 — Autonomy is opt-in, twice
&lt;/h2&gt;

&lt;p&gt;The assistant's default mode is suggest-only. Unattended replies require the account-level AUTO mode &lt;em&gt;and&lt;/em&gt; a per-message eligibility flag (reversible, high-confidence, trusted sender, not urgent) — and every unattended send still writes a receipt. Classification never silently becomes action.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still missing, stated plainly
&lt;/h2&gt;

&lt;p&gt;The repo's &lt;a href="https://github.com/k08200/klorn/blob/main/SECURITY.md" rel="noopener noreferrer"&gt;SECURITY.md&lt;/a&gt; maps each claim above to the test that enforces it and to what would falsify it — and it also names the gap: there is no end-to-end adversarial corpus yet, fixtures that start from a hostile email and assert containment across the whole path. That's written down rather than hidden, because the difference between a security page and security is whether the gaps are on it.&lt;/p&gt;

&lt;p&gt;Everything above is AGPL and checkable: &lt;a href="https://github.com/k08200/klorn" rel="noopener noreferrer"&gt;github.com/k08200/klorn&lt;/a&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>opensource</category>
      <category>llm</category>
    </item>
    <item>
      <title>Every AI email tool has the same bug. It is not a bug in the model.</title>
      <dc:creator>yongrean</dc:creator>
      <pubDate>Tue, 25 Aug 2026 17:45:39 +0000</pubDate>
      <link>https://dev.to/k08200/every-ai-email-tool-has-the-same-bug-it-is-not-a-bug-in-the-model-5f14</link>
      <guid>https://dev.to/k08200/every-ai-email-tool-has-the-same-bug-it-is-not-a-bug-in-the-model-5f14</guid>
      <description>&lt;p&gt;Every AI email product shipped in the last two years has the same architecture, and it is the wrong one.&lt;/p&gt;

&lt;p&gt;The model reads your mail. The model decides what matters. The model drafts a reply, and in the ambitious ones, the model sends it. Somewhere in the system prompt there is a paragraph asking it politely to check with you first.&lt;/p&gt;

&lt;p&gt;That paragraph is the entire safety story. That is the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug is not that models hallucinate
&lt;/h2&gt;

&lt;p&gt;Everyone knows models hallucinate. That is priced in. The bug is subtler and worse: &lt;strong&gt;these systems give the model authority, then try to constrain it with instructions.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instructions are input. Your email is also input. And your email is written by strangers.&lt;/p&gt;

&lt;p&gt;So the threat model is not "the model is sometimes wrong." It is: someone who wants your model to do something writes an email designed to make it do that. They do not need to breach your infrastructure. They just have to send you mail — which is a thing they are already allowed to do, by design, for free, from anywhere on earth.&lt;/p&gt;

&lt;p&gt;Ask whatever AI inbox tool you use one question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When it decides to hide a message from me, what artifact can I read to see why? Not a natural-language explanation the model generated afterward — the actual decision.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There usually isn't one. The decision was a forward pass. It left no trace you can audit, test, or pin.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is boring and nobody ships it
&lt;/h2&gt;

&lt;p&gt;Separate perception from authority.&lt;/p&gt;

&lt;p&gt;Let the model do what models are genuinely good at: read messy text, produce a few calibrated numbers. Then let &lt;em&gt;code&lt;/em&gt; make the decision.&lt;/p&gt;

&lt;p&gt;In my own tool the model never picks the outcome. It scores four features per email — confidence, sender trust, reversibility, urgency — and a deterministic, unit-tested rule maps those four numbers to exactly one of five lanes: interrupt me now, it's scheduling, queue it, file it as a record, or never render it. That rule is about 200 lines. You can read it. A test pins it. If its behaviour changes, CI fails.&lt;/p&gt;

&lt;p&gt;Three things fall out of that split, and they are why I think this is a correctness argument rather than a style preference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model choice stops being a vibe and becomes a measurement.&lt;/strong&gt; I re-ran my committed 56-email gate set across six current models today — same prompt, same rule, one run each:&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;overall&lt;/th&gt;
&lt;th&gt;urgent recall&lt;/th&gt;
&lt;th&gt;$/M input&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;gpt-5.4&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;100%&lt;/strong&gt; (56/56)&lt;/td&gt;
&lt;td&gt;13/13&lt;/td&gt;
&lt;td&gt;$2.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.5-flash&lt;/td&gt;
&lt;td&gt;98.2%&lt;/td&gt;
&lt;td&gt;13/13&lt;/td&gt;
&lt;td&gt;$1.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-2.5-flash&lt;/td&gt;
&lt;td&gt;96.4%&lt;/td&gt;
&lt;td&gt;13/13&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.30&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;grok-4.3&lt;/td&gt;
&lt;td&gt;94.6%&lt;/td&gt;
&lt;td&gt;12/13&lt;/td&gt;
&lt;td&gt;$1.25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;claude-opus-4.8&lt;/td&gt;
&lt;td&gt;91.1%&lt;/td&gt;
&lt;td&gt;10/13&lt;/td&gt;
&lt;td&gt;$5.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;claude-sonnet-5&lt;/td&gt;
&lt;td&gt;80.4%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5/13&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$2.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I had been telling people a cheap model beats the expensive ones. That is no longer true and I am retiring it — gpt-5.4 is perfect on this set. But look at what replaced it, because it is worse news for the industry, not better: &lt;strong&gt;price does not order the table.&lt;/strong&gt; The most expensive model lands fifth. Two frontier models fail my gate outright. And the spread &lt;em&gt;among&lt;/em&gt; frontier models is 19.6 points — five times the 3.6-point gap between the best of them and a pin that costs eight times less.&lt;/p&gt;

&lt;p&gt;If you picked your model because it benchmarks well, you picked on a number that has nothing to do with this job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A failure gets a mechanism instead of a shrug.&lt;/strong&gt; Sonnet 5 missed eight urgent emails. Seven of them failed on &lt;code&gt;confidence&lt;/code&gt;, not &lt;code&gt;urgency&lt;/code&gt; — it scored urgency 0.80–1.00, which is correct, and confidence 0.55–0.60, against a rule that needs 0.70 to interrupt you. It read the situation right and then declined to say it was sure.&lt;/p&gt;

&lt;p&gt;I know that because the threshold is a number in a file I can open. In a "the model picks the tier" design, this same result reads as &lt;em&gt;Sonnet 5 is worse at email&lt;/em&gt; — which is both wrong and useless. You cannot fix a vibe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It degrades instead of breaking.&lt;/strong&gt; With no LLM at all — provider down, rate limited, key expired — a keyword path produces the same four features and still classifies at 82.1%. I want to be precise about what that does and does not buy, because I have been sloppy about it before: urgent recall in that mode is 46.2%. Degraded mode costs you the &lt;em&gt;interrupt&lt;/em&gt;. It does not cost you the message — every urgent item it missed landed in the queue, visible, and none were hidden.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automation is not a lane
&lt;/h2&gt;

&lt;p&gt;This is the part people get wrong when they copy the pattern.&lt;/p&gt;

&lt;p&gt;Classification and delegation are separate decisions, and collapsing them is how "it sorted my mail" becomes "it answered my mail." A lane says what a message &lt;em&gt;is&lt;/em&gt;. A separate flag says whether this particular message is even eligible to be answered unattended — reversible, high confidence, trusted sender, not urgent. And a separate account-level mode says whether unattended answering is on at all.&lt;/p&gt;

&lt;p&gt;Three switches, not one. You can be wrong about the lane and still not be wrong about the outbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approval has to bind bytes, not intent
&lt;/h2&gt;

&lt;p&gt;The classification half is the easy half. The hard part is the three actions you cannot take back: send, permanent delete, forward.&lt;/p&gt;

&lt;p&gt;Most tools gate those behind a confirmation dialog. That is theater, because what you confirmed and what executes are connected by nothing but hope.&lt;/p&gt;

&lt;p&gt;So: approving mints a receipt — a sha256 over the exact canonical payload bytes at the moment you click yes. At execution the bytes are hashed again and compared. Any drift and it fails closed. No send, no delete, no forward.&lt;/p&gt;

&lt;p&gt;The check does not live in the prompt. It lives in the execution path. There is no phrasing that gets around it, because the thing doing the checking cannot read English.&lt;/p&gt;

&lt;p&gt;That is the difference between a safety &lt;em&gt;policy&lt;/em&gt; and a safety &lt;em&gt;property&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part where I make this falsifiable
&lt;/h2&gt;

&lt;p&gt;Claims like these are cheap, so here is mine, stated so you can break it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Given an inbound message crafted to maximize model confidence, no path exists from classification to an executed irreversible action without a human approval that byte-pins the payload.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Note what that does &lt;strong&gt;not&lt;/strong&gt; say. It does not say the model can't be fooled — it can. It does not say the lane will be right — a hostile message may well land in PUSH. That is a nuisance, not a breach. The floor is not a better classifier. It is a smaller blast radius.&lt;/p&gt;

&lt;p&gt;The repo's &lt;code&gt;SECURITY.md&lt;/code&gt; lists nine claims like that one, and for each: what would disprove it, and which test enforces it. It also names what has &lt;em&gt;no&lt;/em&gt; authority — including my own accuracy numbers. A 96% classification score is not a safety argument, and I would rather say so myself than let it quietly stand in for one.&lt;/p&gt;

&lt;p&gt;Here is the same rule applied to me. While writing this I re-ran my real-mail eval set and it came back at 86.8%, not the 94.3% my README had been claiming for weeks. The gap turned out to be fully explained — four of the seven misses are a lane the labels predate, and 46 + 4 = 50 is exactly the old number — but "fully explained" is not the same as "still true." The number stopped reproducing, so it is out of the README as of today, along with the model claim above. If you want to check either one, the eval sets and the runner are in the repo.&lt;/p&gt;

&lt;p&gt;That is the actual test of whether a project means the falsifier thing: what happens when its own number fails.&lt;/p&gt;

&lt;p&gt;It also lists what is missing. There is no end-to-end adversarial corpus yet — fixtures that start from a hostile email and assert containment across the whole path. That gap is written down in the repo, not hidden.&lt;/p&gt;

&lt;h2&gt;
  
  
  So
&lt;/h2&gt;

&lt;p&gt;If you use anything with an LLM near your inbox, go find out how it decides. If the answer is "the model decides, and we told it to be careful," you are trusting a probability distribution with your outbox.&lt;/p&gt;

&lt;p&gt;You do not have to use mine. It's AGPL, self-hostable, &lt;code&gt;docker compose up -d&lt;/code&gt; with prebuilt images, and it runs against a local model if you want nothing leaving your machine at all. But whatever you use — make it show you the decision.&lt;/p&gt;

&lt;p&gt;Six months solo, 101 releases, ~1,590 commits. If you think the architecture is wrong, the fastest way to prove it is a PR that breaks the claim above. I'll merge it.&lt;/p&gt;

&lt;p&gt;github.com/k08200/klorn&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My cost cap over-billed by 100x. That cost uptime, not money.</title>
      <dc:creator>yongrean</dc:creator>
      <pubDate>Tue, 18 Aug 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/k08200/my-cost-cap-over-billed-by-100x-that-cost-uptime-not-money-3m09</link>
      <guid>https://dev.to/k08200/my-cost-cap-over-billed-by-100x-that-cost-uptime-not-money-3m09</guid>
      <description>&lt;p&gt;A cost cap is supposed to be the boring part. You pick a number, you stop spending past it, you go back to work. Mine did the opposite: it became the thing most likely to take my service down, and it did that while the actual bill stayed under a dollar.&lt;/p&gt;

&lt;p&gt;The bug was a substring match in a pricing table. The interesting part is not the bug — it is that I had already "fixed" this table once, in the safe direction, and the safe direction is what broke it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing being capped
&lt;/h2&gt;

&lt;p&gt;Klorn classifies every inbound email into exactly one of four tiers. Every classification is one LLM call, so cost scales with mail volume, and mail volume is not something I control. That is the whole reason a ceiling exists.&lt;/p&gt;

&lt;p&gt;There are three of them:&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;// packages/api/src/config.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DAILY_COST_CAP_CENTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;intEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DAILY_COST_CAP_CENTS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// $1.00 / user / day&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FREE_DAILY_COST_CAP_CENTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;intEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;FREE_DAILY_COST_CAP_CENTS&lt;/span&gt;&lt;span class="dl"&gt;"&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="c1"&gt;// $0.10 free tier&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GLOBAL_DAILY_COST_CAP_CENTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;intEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GLOBAL_DAILY_COST_CAP_CENTS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// $50 / day fleet-wide&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The per-user caps are the real guard. The global one is the fatal-bill backstop — the thing that stops a runaway loop from producing a five-figure invoice while I sleep.&lt;/p&gt;

&lt;p&gt;None of these can meter a real invoice in real time. They estimate: token counts times a per-model rate, accumulated into a ledger. So there is a table mapping model id to price, and everything downstream trusts it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first bug: one price for everything
&lt;/h2&gt;

&lt;p&gt;The original table had a single rate for paid models. Whatever the model was, it got billed at roughly Gemini Flash prices.&lt;/p&gt;

&lt;p&gt;That is fine right up until a fallback chain routes a request to Claude Sonnet, which is 10x Flash on input and 6x on output. The ledger records Flash. The cap sees a number far below the truth, never fires, and the protection you think you have is decorative. This is the failure mode everyone anticipates when they build a cost cap: &lt;strong&gt;under-billing, which costs money.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So I fixed it in the obvious direction — a family table, and an explicit decision about what to do with models the table has never seen:&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="cm"&gt;/**
 * Unknown paid models price at the sonnet tier, NOT at flash rates: a new
 * frontier model landing in CHAT_MODEL before this table learns it must be
 * over-billed against the caps, never 20x under-billed (the original bug).
 */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_MODEL_RATE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ModelRateUsdPerMTok&lt;/span&gt; &lt;span class="o"&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="mi"&gt;3&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="mi"&gt;15&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unknown model? Charge it like Sonnet. Over-estimate on purpose. If I am wrong, I am wrong in the direction that stops spending too early rather than too late.&lt;/p&gt;

&lt;p&gt;That reasoning is correct, and it is also how I shipped the second bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second bug: the safe direction has a cost
&lt;/h2&gt;

&lt;p&gt;Klorn's judge has a fallback chain — cheap paid SKUs it drops to when the primary model is rate-limited or down. Those SKUs are genuinely cheap. Not "cheaper," cheap enough to change the shape of the arithmetic.&lt;/p&gt;

&lt;p&gt;The table matched them by substring. First row where every needle appears in the model id wins. So:&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;Real rate (in/out per 1M)&lt;/th&gt;
&lt;th&gt;What the table charged&lt;/th&gt;
&lt;th&gt;Off by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openai/gpt-oss-120b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;$0.037 / $0.17&lt;/td&gt;
&lt;td&gt;$2.50 / $10.00 — matched the generic &lt;code&gt;["gpt"]&lt;/code&gt; row&lt;/td&gt;
&lt;td&gt;~67x / ~59x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qwen/qwen3.7-flash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;$0.03 / $0.13&lt;/td&gt;
&lt;td&gt;$3.00 / $15.00 — matched nothing, took the Sonnet default&lt;/td&gt;
&lt;td&gt;~100x / ~115x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mistralai/mistral-nemo&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;$0.019 / $0.03&lt;/td&gt;
&lt;td&gt;$2.00 / $6.00 — matched the generic &lt;code&gt;["mistral"]&lt;/code&gt; row&lt;/td&gt;
&lt;td&gt;~105x / ~200x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of these is an over-estimate. Every one of them is "safe" by the rule I had just written down.&lt;/p&gt;

&lt;p&gt;Now put it against the load. Measured, not guessed: a classification is about 1,000 prompt tokens and 100 completion tokens. At 100 users doing 100 emails a day, that is 10,000 classifications, which at the primary model's real rates is about &lt;strong&gt;$5.50/day&lt;/strong&gt; — call it $7/day with reply drafts and briefings on top. Against a $50/day ceiling that is roughly 7x headroom, which is the amount of headroom I wanted.&lt;/p&gt;

&lt;p&gt;Now suppose the primary is rate-limited and the fleet spends the day on &lt;code&gt;qwen/qwen3.7-flash&lt;/code&gt;, the cheap middle of the chain. The real cost of that same work &lt;em&gt;drops&lt;/em&gt;, because the fallback is cheaper than the primary: 10,000 calls at $0.03/$0.13 per 1M is about &lt;strong&gt;$0.43/day&lt;/strong&gt;. The ledger, pricing it at the Sonnet default of $3/$15, records &lt;strong&gt;$45/day&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The same work. Two numbers, a hundredfold apart, and the cap acts on the wrong one.&lt;/p&gt;

&lt;p&gt;$45 against a $50 ceiling means the fleet trips the global cap inside a single day — and on any day with more mail than average, it trips mid-morning. When it trips, classification stops. For everyone. Not because anything cost too much, but because a substring did not match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Over-billing is not the safe direction. It is a different direction.
&lt;/h2&gt;

&lt;p&gt;This is the part I got wrong, and it generalizes past my codebase.&lt;/p&gt;

&lt;p&gt;I had been reasoning about the cap as a &lt;strong&gt;financial&lt;/strong&gt; control, where the two failure modes are "spent too much" and "spent too little," and only one of them is dangerous. Under that model, over-estimating is free.&lt;/p&gt;

&lt;p&gt;But a cap is not only a financial control. It is a &lt;strong&gt;runtime dependency of the request path&lt;/strong&gt;. Every classification asks it for permission. Which means the error budget has two axes, not one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Under-billing costs money.&lt;/strong&gt; It fails open. The system keeps working while the invoice grows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over-billing costs availability.&lt;/strong&gt; It fails closed. The invoice is fine and the product stops.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A protective ceiling that fires during normal operation has stopped protecting and started breaking. And over-billing is the sneakier of the two, because it never shows up on a bill you can go look at. The one artifact that would tell you something is wrong — a large invoice — is precisely the thing that does not happen. You get an outage with a healthy credit card.&lt;/p&gt;

&lt;p&gt;There is a second-order version of this too. I had separately raised the global cap from $10/day to $50/day, correctly, because $10 left barely one heavy day of headroom against $7/day steady state. A protective ceiling that fires in normal operation is not a ceiling, it is a scheduled outage. But raising the ceiling also hides mis-metering for longer: with a $10 cap the 100x error would have tripped in an hour and I would have found it immediately. Slack in the system is not free either — it buys you time and it buys the bug time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and the invariant that keeps it fixed
&lt;/h2&gt;

&lt;p&gt;The fix is unglamorous — explicit rows above the generic ones, because first match wins:&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;// Cheap fallback SKUs, priced from the live OpenRouter catalog 2026-08-10&lt;/span&gt;
&lt;span class="c1"&gt;// and rounded up. These rows exist because the generic family rows below&lt;/span&gt;
&lt;span class="c1"&gt;// mis-price them by 60-100x [...] Over-billing is "safe" for a protective&lt;/span&gt;
&lt;span class="c1"&gt;// cap in isolation, but at 100 users it burns the daily caps ~100x too fast&lt;/span&gt;
&lt;span class="c1"&gt;// and the fleet stops classifying mid-day — the caps stop protecting and&lt;/span&gt;
&lt;span class="c1"&gt;// start breaking. Specific rows MUST stay above generic.&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;match&lt;/span&gt;&lt;span class="p"&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-oss&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.05&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="mf"&gt;0.2&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;match&lt;/span&gt;&lt;span class="p"&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;qwen&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;rate&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.1&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.3&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;match&lt;/span&gt;&lt;span class="p"&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;mistral&lt;/span&gt;&lt;span class="dl"&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;nemo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;rate&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.05&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.1&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;match&lt;/span&gt;&lt;span class="p"&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;nemotron&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;rate&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.1&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.4&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;Note these are still rounded &lt;em&gt;up&lt;/em&gt; from the real catalog prices — between 1.2x and 3.3x, depending on the row. That is deliberate: the catalog moves, and a table that under-bills is the first bug again. Over-estimating by 3x is a rounding policy against a moving target. Over-estimating by 100x is an outage. The direction was never the problem; the magnitude was.&lt;/p&gt;

&lt;p&gt;Ordering is now load-bearing, which means a comment is not enough. It is pinned by a table-driven test:&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;// packages/api/src/__tests__/model-fallback.test.ts&lt;/span&gt;
&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fallback-chain SKUs are metered at their real rates (100-user economics)&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt;&lt;span class="p"&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;openai/gpt-oss-120b&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&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;qwen/qwen3.7-flash&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.1&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="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mistralai/mistral-nemo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&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;google/gemini-3.1-flash-lite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&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;openai/gpt-5-nano&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&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;%s is priced at $%s/$%s per M&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;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&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;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;resolveModelRateUsdPerMTok&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="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;input&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="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keeps the sonnet-tier default for genuinely unknown models&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;resolveModelRateUsdPerMTok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;acme/brand-new-frontier&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&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="mi"&gt;3&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="mi"&gt;15&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every entry in the chain is in that table now, not just the three that were wrong — because the next person to add a chain entry should see a row missing, not guess. And the second test pins the unknown-model default deliberately, so a future contributor does not "simplify" the over-billing away.&lt;/p&gt;

&lt;p&gt;The operational rule that falls out: &lt;strong&gt;any model added to the fallback chain must be added to the rate table in the same change.&lt;/strong&gt; A chain entry without a rate row is not a missing optimization, it is a latent 100x metering error pointed at your availability.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell past me
&lt;/h2&gt;

&lt;p&gt;Three things, in order of how much they cost to learn.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ask what your cap actually gates.&lt;/strong&gt; If the answer is "the request path," it is a dependency, and dependencies get availability budgets, not just correctness checks. I had been treating mine as an accounting feature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write down which direction of error is safe, and then check whether that is still true at scale.&lt;/strong&gt; "Over-estimate on purpose" was correct in isolation and wrong at 10,000 calls a day. The rule did not change; the multiplier did. Any heuristic phrased as a direction rather than a magnitude has this failure mode waiting in it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Instrument the ratio, not the total.&lt;/strong&gt; A dashboard of daily spend would have shown $45 and looked like a busy day. What would have caught this in minutes is metered cost divided by a sanity estimate of real cost — a number that should sit near 1 and was sitting near 100. I did not have that, and the thing that eventually surfaced it was working through the unit economics on paper before the user count made it urgent.&lt;/p&gt;

&lt;p&gt;The whole thing is open source under AGPL if you want to read the table, the ordering constraint, or the tests around it — &lt;a href="https://github.com/k08200/klorn" rel="noopener noreferrer"&gt;github.com/k08200/klorn&lt;/a&gt;. The pricing table and the fallback resolver live in &lt;code&gt;packages/api/src/llm/model-fallback.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you run LLM calls behind a spend ceiling, go check what your table does with the cheapest model in your fallback chain. The expensive models are the ones you remembered to price.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>opensource</category>
      <category>devops</category>
    </item>
    <item>
      <title>Gate on what the model can't author (my comment section redesigned my trust model)</title>
      <dc:creator>yongrean</dc:creator>
      <pubDate>Wed, 01 Jul 2026 15:58:02 +0000</pubDate>
      <link>https://dev.to/k08200/gate-on-what-the-model-cant-author-my-comment-section-redesigned-my-trust-model-57jk</link>
      <guid>https://dev.to/k08200/gate-on-what-the-model-cant-author-my-comment-section-redesigned-my-trust-model-57jk</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/k08200/confidence-is-the-one-signal-your-model-cant-corroborate-5hk8"&gt;Post four&lt;/a&gt; argued that of the four features my email classifier scores — confidence, sender trust, reversibility, urgency — confidence is the odd one out: the only one with no source outside the model's opinion of itself. Then the comment section did something better than the post. Four people — &lt;a href="https://dev.to/jugeni"&gt;@jugeni&lt;/a&gt;, &lt;a href="https://dev.to/txdesk"&gt;@txdesk&lt;/a&gt;, &lt;a href="https://dev.to/taekim"&gt;@hannune&lt;/a&gt;, and &lt;a href="https://dev.to/nazar_boyko"&gt;@nazar_boyko&lt;/a&gt; — took the loose idea and turned it into a spec. This post is that spec, credited to them, and it's now filed as issues on the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  The principle, stated properly
&lt;/h2&gt;

&lt;p&gt;Sort your features by whether their source is independent of the model. Gate on those. Treat the self-authored one as context, never authorization. That was &lt;a class="mentioned-user" href="https://dev.to/txdesk"&gt;@txdesk&lt;/a&gt;'s line, and it outlives the email case completely — it's the rule for any model-scored decision.&lt;/p&gt;

&lt;p&gt;The part I got wrong in post four: I called confidence a "tiebreaker." &lt;a class="mentioned-user" href="https://dev.to/jugeni"&gt;@jugeni&lt;/a&gt; corrected it, and the correction matters. Confidence doesn't demote to a weak signal — it &lt;em&gt;inverts&lt;/em&gt;. Self-graded confidence has the same computational shape on adversarial input as on cooperative input, and that sameness is the definition of a confident hallucination. A polished impersonation that reads as a trusted sender is exactly a high-confidence, high-sender-trust, reversible-looking email. So on cooperative input confidence is scenery; on adversarial input it's counter-evidence. The same number flips meaning depending on what the rest of the gate sees. It can't be a tiebreaker, because it's wrong precisely when you'd most want to trust it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wiring
&lt;/h2&gt;

&lt;p&gt;The gate decides on the world-anchored features only. &lt;code&gt;senderTrust&lt;/code&gt; grounded on observed sender history, &lt;code&gt;reversibility&lt;/code&gt; sourced from an action-type lookup — both belong to the runtime, not the model. The classifier proposes; the runtime arbitrates with facts the model has no access to author.&lt;/p&gt;

&lt;p&gt;Confidence gets a different job: the canary. After the gate decides, compare confidence to the gate's conclusion. If they agree, silence. If confidence is high and the gate rejected — that's the post-mortem you want, and it goes to a triage queue, not a log line nobody reads. &lt;a class="mentioned-user" href="https://dev.to/jugeni"&gt;@jugeni&lt;/a&gt;'s framing: confidence reads the gate, not the other way around. That keeps the self-authored number out of the vote and turns disagreement into something you can audit.&lt;/p&gt;

&lt;p&gt;One implementation detail makes the whole thing provable: pull the runtime corroborator into a named external-context object in the decision trace. The model reads it; it can't write it. That's what lets you prove after the fact that the decision was anchored to something outside the model — which is also where the eval gets its teeth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The eval that turns a belief into a number
&lt;/h2&gt;

&lt;p&gt;Post four's honest close was that "the floor saves us" is a belief until it's a measurement. &lt;a class="mentioned-user" href="https://dev.to/jugeni"&gt;@jugeni&lt;/a&gt; and &lt;a class="mentioned-user" href="https://dev.to/hannune"&gt;@hannune&lt;/a&gt; specced the measurement, and it's sharper than what I'd have built.&lt;/p&gt;

&lt;p&gt;Don't measure a threshold ("did the impersonation reach AUTO"). Measure the delta: whether the (confidence − world-anchored-corroboration) spread separates adversarial from cooperative samples in distribution. The cooperative set is held-out known-safe senders — that's your floor distribution. The adversarial set is hand-crafted to be high-confidence, thin-corroboration on purpose, and — this is the discipline I'd have skipped — matched to the cooperative set on confidence. If the adversarial set has lower confidence than the cooperative one, the eval is leaking signal somewhere else and the spread isn't measuring what it looks like.&lt;/p&gt;

&lt;p&gt;The canonical fixture: a sender impersonation that lands AUTO at 0.92 against an action the runtime reversibility table marks internal-only. That's the exact corner where the floor does all the work and the score does none.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's not just email
&lt;/h2&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/hannune"&gt;@hannune&lt;/a&gt; pointed out the principle generalizes straight into retrieval. A model's confidence in a retrieved answer is high precisely because the chunk sounded plausible, not because it's grounded — the same self-referential trap. Citation overlap across retrieved chunks, entity-level consistency with a knowledge graph: corroborators the model can't author. Confident-plus-external-signals-thin-or-contradictory is the canary in that domain too. Any time a model scores features for a decision, the same sort applies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest status
&lt;/h2&gt;

&lt;p&gt;None of this is shipped yet. Today confidence still gates AUTO at 0.85, and what makes that safe is the deterministic floor underneath — AUTO's autonomous execution is off, and the three irreversible actions fail closed regardless of any score. This is design hardening for when AUTO acts, not a live hole. I filed the two pieces as issues so the thread has somewhere to land: the &lt;a href="https://github.com/k08200/klorn/issues/678" rel="noopener noreferrer"&gt;world-anchored gate + canary&lt;/a&gt; and the &lt;a href="https://github.com/k08200/klorn/issues/679" rel="noopener noreferrer"&gt;delta eval&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Four posts and a comment section later, the thesis is smaller and sharper than where it started: keep the model in the perception layer, gate on what it can't author, and treat its opinion of itself as a canary, never a vote. Thanks to everyone who out-designed me in the replies. The repo's in the open if you want to keep going — and if the series was useful to you, a ⭐ helps me gauge whether these are worth continuing: &lt;a href="https://github.com/k08200/klorn" rel="noopener noreferrer"&gt;github.com/k08200/klorn&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>architecture</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Confidence is the one signal your model can't corroborate</title>
      <dc:creator>yongrean</dc:creator>
      <pubDate>Mon, 29 Jun 2026 12:16:19 +0000</pubDate>
      <link>https://dev.to/k08200/confidence-is-the-one-signal-your-model-cant-corroborate-5hk8</link>
      <guid>https://dev.to/k08200/confidence-is-the-one-signal-your-model-cant-corroborate-5hk8</guid>
      <description>&lt;p&gt;This series started as a cheap-model brag and keeps getting better comments than posts. Three readers — &lt;a href="https://dev.to/nazar_boyko"&gt;@nazar_boyko&lt;/a&gt;, &lt;a href="https://dev.to/txdesk"&gt;@txdesk&lt;/a&gt;, and &lt;a href="https://dev.to/jugeni"&gt;@jugeni&lt;/a&gt; — independently converged on the same seam, and &lt;a class="mentioned-user" href="https://dev.to/jugeni"&gt;@jugeni&lt;/a&gt; put it in one line I can't improve on:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;AUTO wants a corroborator the model cannot write, not a confidence it can.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here's what that means, and why it's the sharpest critique this design has taken.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four signals, but not four of a kind
&lt;/h2&gt;

&lt;p&gt;Quick recap of the &lt;a href="https://dev.to/k08200/i-dont-trust-the-llm-to-classify-my-email-so-i-dont-let-it-55d9"&gt;earlier posts&lt;/a&gt;: the LLM scores four features per email — &lt;code&gt;confidence&lt;/code&gt;, &lt;code&gt;senderTrust&lt;/code&gt;, &lt;code&gt;reversibility&lt;/code&gt;, &lt;code&gt;urgency&lt;/code&gt; — and a deterministic rule maps those to a tier. The model perceives; a rule I can read decides.&lt;/p&gt;

&lt;p&gt;But those four aren't the same &lt;em&gt;kind&lt;/em&gt; of thing. Three of them describe the world:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;senderTrust&lt;/code&gt;&lt;/strong&gt; can be anchored to observed history — have you actually corresponded with this person, and how often. There's a source outside the email.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;reversibility&lt;/code&gt;&lt;/strong&gt; is a property of the &lt;em&gt;action&lt;/em&gt; the system would take, not the message. Accepting a calendar invite is reversible because accepting is reversible — not because the email said so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;urgency&lt;/code&gt;&lt;/strong&gt; answers to the clock. A real deadline either exists or it doesn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;confidence&lt;/code&gt;&lt;/strong&gt; is different in kind. It's the model grading its own work — "how sure am I about the other three?" There is no source outside the model's opinion of itself. And in my rule, the AUTO branch gates when &lt;code&gt;confidence &amp;gt;= 0.85&lt;/code&gt; (alongside the others).&lt;/p&gt;

&lt;h2&gt;
  
  
  Where that bites
&lt;/h2&gt;

&lt;p&gt;The dangerous email isn't the one the model is unsure about — the low-confidence floor already routes that to the queue. It's the one the model is &lt;em&gt;confidently wrong&lt;/em&gt; about. A polished impersonation that reads as a trusted sender is exactly a high-confidence, high-&lt;code&gt;senderTrust&lt;/code&gt;, reversible-looking email. It walks toward AUTO through the one feature the model authors about itself, and self-graded confidence is the gate that structurally can't catch a confident lie.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually stops it today
&lt;/h2&gt;

&lt;p&gt;I want to be precise about the blast radius, because it's smaller than that paragraph sounds.&lt;/p&gt;

&lt;p&gt;AUTO is classify-only in the current build — an AUTO classification sets a tier and triggers no action. When execution does run, AUTO only ever maps to reversible, internal actions (archive, mark-read). And the three irreversible actions — send, hard-delete, forward-external — sit behind a &lt;a href="https://dev.to/k08200/confidence-is-enough-to-decide-its-not-enough-to-do-8ck"&gt;deterministic floor&lt;/a&gt; that ignores every score. So a confident impersonation that reaches AUTO gets &lt;em&gt;quietly handled in a recoverable way&lt;/em&gt;, never anything you can't undo.&lt;/p&gt;

&lt;p&gt;The seam is real; it just can't currently reach anything unrecoverable. But "bounded by the floor" is not the same as "designed right." The day AUTO starts taking even reversible actions on its own, leaning on a number the model wrote about itself is the wrong gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is the framing
&lt;/h2&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/jugeni"&gt;@jugeni&lt;/a&gt;'s line is the spec: gate on corroboration the model can't author.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Make &lt;code&gt;senderTrust&lt;/code&gt; a &lt;strong&gt;deterministic floor from observed history&lt;/strong&gt; when history exists — &lt;code&gt;manualOverrides &amp;gt;= N&lt;/code&gt; pins it, instead of merely suggesting it to the model in the prompt.&lt;/li&gt;
&lt;li&gt;Source &lt;code&gt;reversibility&lt;/code&gt; from the &lt;strong&gt;action the tier would trigger&lt;/strong&gt;, by lookup, not from the model's read of the email (that's &lt;a class="mentioned-user" href="https://dev.to/txdesk"&gt;@txdesk&lt;/a&gt;'s point, and it's already how the irreversible floor works — it just isn't how the AUTO gate works yet).&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;confidence&lt;/code&gt; as a tiebreaker, never as the thing that promotes to AUTO on its own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern generalizes past email. Any time you let a model score features for a decision, sort the features by whether their source is independent of the model's self-assessment. Gate on the ones that are. The self-graded one is scenery — useful context, never authorization.&lt;/p&gt;

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

&lt;p&gt;I haven't done this yet. Today &lt;code&gt;confidence&lt;/code&gt; still gates AUTO, and what makes that safe is the floor underneath, not the gate itself. The thing I owe is an adversarial eval: a high-confidence, polished impersonation, measured to see whether it actually reaches AUTO — turning "I think the floor saves us" into a number instead of a belief. That's next, and the eval set is in the open if you want to write the case before I do.&lt;/p&gt;

&lt;p&gt;Three posts in, the lesson keeps being the same shape: keep the model in the perception layer, and make every decision answer to something the model can't quietly author. AGPLv3, the whole thing: &lt;strong&gt;&lt;a href="https://github.com/k08200/klorn" rel="noopener noreferrer"&gt;github.com/k08200/klorn&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>architecture</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Confidence is enough to decide. It's not enough to do.</title>
      <dc:creator>yongrean</dc:creator>
      <pubDate>Thu, 25 Jun 2026 10:18:46 +0000</pubDate>
      <link>https://dev.to/k08200/confidence-is-enough-to-decide-its-not-enough-to-do-8ck</link>
      <guid>https://dev.to/k08200/confidence-is-enough-to-decide-its-not-enough-to-do-8ck</guid>
      <description>&lt;p&gt;A classifier confidence of 0.99 is enough to decide a tier. It is not enough to send an email you can't unsend.&lt;/p&gt;

&lt;p&gt;Those are two different bars, and most "autonomous" systems use the first one to clear the second. That's the bug.&lt;/p&gt;

&lt;p&gt;This is the third post in a series that started as a cheap-model brag and turned into an architecture argument. &lt;a href="https://dev.to/k08200/i-let-gpt-4o-and-a-cheaper-model-fight-over-my-inbox-gpt-4o-lost-fkj"&gt;Post one&lt;/a&gt;: a cheap model beat GPT-4o on email triage. &lt;a href="https://dev.to/k08200/i-dont-trust-the-llm-to-classify-my-email-so-i-dont-let-it-55d9"&gt;Post two&lt;/a&gt;: the model only scores four features, and a deterministic rule picks the tier. A commenter, &lt;a href="https://dev.to/hannune"&gt;@hannune&lt;/a&gt;, pointed at one of those four features:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Your reversibility signal is something I have not seen named explicitly before but it is exactly the right axis for anything that touches irreversible state.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;He's right, and it's the cleanest way into the last piece of the design. So: what &lt;code&gt;reversibility&lt;/code&gt; actually routes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line: can the user undo it with one click?
&lt;/h2&gt;

&lt;p&gt;Most of what a mail agent does is reversible. Archive, un-archive. Trash, restore. Apply a label, remove it. Mark read, mark unread. Re-tier. Snooze. Every one of those is a single click away from undone, so every one of those rides on exactly what post two described — classifier confidence plus a hash of the input bytes that drove the decision. If the model's confident and the inputs are pinned, ship it.&lt;/p&gt;

&lt;p&gt;Three actions are not like the others:&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FLOOR_ACTIONS&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;send_email&lt;/span&gt;&lt;span class="dl"&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;delete_permanent&lt;/span&gt;&lt;span class="dl"&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;forward_external&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Send (Gmail's undo-send window is 30 seconds, then it's gone). Permanent delete (skips Trash, no recovery path). Forward to an external party (same network effect as send — it's out). For these, &lt;code&gt;reversibility&lt;/code&gt; scores near zero, and near-zero reversibility is the signal that says: confidence is necessary but no longer sufficient. You need something the probabilistic layer can't give you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why confidence isn't enough: sign the artifact, not the narration
&lt;/h2&gt;

&lt;p&gt;The failure mode here has a name I borrowed from people doing this in crypto: &lt;strong&gt;agent-vs-ABI mismatch&lt;/strong&gt;. The agent narrates a high-level intent — "I sent a polite follow-up to Alice" — and the thing that actually executed did something the narration glossed over: wrong recipient, an edited body, a different attachment. The agent isn't lying. Natural language is lossy by definition; the description and the bytes are allowed to drift.&lt;/p&gt;

&lt;p&gt;The cure isn't to verify the narration harder. It's to stop signing on the narration and sign on the deterministic artifact — the actual bytes that will travel to Gmail.&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendEmailPayloadHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;to&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;subject&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;body&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="kr"&gt;string&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;canonical&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RECEIPT_SCHEMA_VERSION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;send_email&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;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NFC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NFC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NFC&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you approve a send, the system mints an &lt;code&gt;ActionReceipt&lt;/code&gt; that pins this hash — the bytes you actually approved, normalized so a cosmetic edit (&lt;code&gt;Alice@Example.com&lt;/code&gt; vs &lt;code&gt;alice@example.com&lt;/code&gt;) doesn't false-alarm, and NFC-normalized so composed/decomposed Unicode hashes identically (this matters the moment a body has Korean in it). At execute time it recomputes the hash from the about-to-send bytes and checks:&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifyReceipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ActionReceipt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FloorAction&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;currentPayloadHash&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="k"&gt;void&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;receipt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;RECEIPT_SCHEMA_VERSION&lt;/span&gt;&lt;span class="p"&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="nc"&gt;ActionReceiptSchemaError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;receipt&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;receipt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&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="nc"&gt;ActionReceiptMismatchError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentPayloadHash&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;receipt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payloadHash&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentPayloadHash&lt;/span&gt;&lt;span class="p"&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="nc"&gt;ActionReceiptMismatchError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentPayloadHash&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;Any drift between approve and execute throws and the action is refused. Reusing a &lt;code&gt;send_email&lt;/code&gt; receipt to authorize a &lt;code&gt;delete_permanent&lt;/code&gt; throws on the action check. Bumping the schema version deliberately invalidates every pending receipt and forces a re-approve under the new shape. The autonomous path fails closed: no valid receipt, no irreversible action.&lt;/p&gt;

&lt;h2&gt;
  
  
  So reversibility is the router
&lt;/h2&gt;

&lt;p&gt;That's the whole point of naming &lt;code&gt;reversibility&lt;/code&gt; as a first-class feature instead of folding it into "risk." It's not decoration on the tier decision — it's the axis that decides &lt;em&gt;which trust model an action even gets&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;High reversibility → the probabilistic layer is enough. Confidence + input hash, done.&lt;/li&gt;
&lt;li&gt;Near-zero reversibility → drop to the deterministic floor. Confidence got you to "this is worth doing"; the signed artifact is what gets you to "and the bytes are exactly the ones approved."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two layers, and the feature score picks which one applies. The probabilistic layer is allowed to stay probabilistic precisely because the floor catches the cases where probability isn't enough.&lt;/p&gt;

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

&lt;p&gt;Fair question: is this actually wired, or just a module with a TODO? It's wired. The receipt is minted at &lt;code&gt;/approve&lt;/code&gt; from the exact bytes you clicked on, &lt;code&gt;executeToolCall&lt;/code&gt; refuses any floor action that arrives without a verified receipt (&lt;code&gt;FloorReceiptRequiredError&lt;/code&gt;), and &lt;code&gt;send_email&lt;/code&gt; re-checks the payload hash in its own path before anything leaves.&lt;/p&gt;

&lt;p&gt;The honest edges, because there always are some: of the three floor actions, only &lt;code&gt;send_email&lt;/code&gt; is a callable tool today — &lt;code&gt;delete_permanent&lt;/code&gt; and &lt;code&gt;forward_external&lt;/code&gt; aren't wired as tool cases yet, but the central guard already fails them closed, so a future case physically can't ship a receipt-less side effect. And the autonomous agent runs in SUGGEST mode by default — read-only tools plus propose-only, no mutating power until you opt into AUTO, and even then the floor stands in front of the irreversible three. The brake went in before the autonomous engine gets switched on, which is the only order that isn't reckless. I'd rather show you the guard and its TODOs than claim more than the code does.&lt;/p&gt;

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

&lt;p&gt;Separate "confident enough to decide" from "verified enough to do." For anything your system can't undo with one user click, don't trust the model's description of what it's about to do — hash the deterministic artifact at approval, verify it at execution, and fail closed on any drift. Confidence is a fine reason to &lt;em&gt;decide&lt;/em&gt;. It is never, by itself, a reason to &lt;em&gt;do&lt;/em&gt; something you can't take back.&lt;/p&gt;

&lt;p&gt;The whole floor is ~210 readable lines in the open, AGPLv3: &lt;strong&gt;&lt;a href="https://github.com/k08200/klorn" rel="noopener noreferrer"&gt;github.com/k08200/klorn&lt;/a&gt;&lt;/strong&gt; — &lt;code&gt;packages/api/src/attention-floor.ts&lt;/code&gt;. Three posts, one idea: keep the model in the perception layer, and put everything you actually stand behind in code you can read.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>security</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I don't trust the LLM to classify my email. So I don't let it.</title>
      <dc:creator>yongrean</dc:creator>
      <pubDate>Thu, 25 Jun 2026 05:57:48 +0000</pubDate>
      <link>https://dev.to/k08200/i-dont-trust-the-llm-to-classify-my-email-so-i-dont-let-it-55d9</link>
      <guid>https://dev.to/k08200/i-dont-trust-the-llm-to-classify-my-email-so-i-dont-let-it-55d9</guid>
      <description>&lt;p&gt;My classifier calls an LLM on every single email. The LLM is not allowed to classify the email.&lt;/p&gt;

&lt;p&gt;That sounds like a contradiction. It's the most important design decision in the thing.&lt;/p&gt;

&lt;p&gt;A reader named &lt;a href="https://dev.to/nazar_boyko"&gt;@nazar_boyko&lt;/a&gt; left a comment on my &lt;a href="https://dev.to/k08200/i-let-gpt-4o-and-a-cheaper-model-fight-over-my-inbox-gpt-4o-lost-fkj"&gt;last post&lt;/a&gt; — the one where a cheap model beat GPT-4o on email triage — and put it better than I did:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Once the LLM is a feature scorer and not the decider, "consistency over genius" falls right out of it, and a cheap fast model is exactly what you want for reading the same four signals the same way every time.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The price upset was the fun headline. This is the actual thesis. So here it is on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model scores four numbers. That's all it does.
&lt;/h2&gt;

&lt;p&gt;Every inbound email goes to the LLM with one job: read the message and return four scores between 0 and 1.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;confidence&lt;/strong&gt; — how sure you are the other three scores are right&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;senderTrust&lt;/strong&gt; — 1.0 a known, important human; 0.3 an automated transactional notice you signed up for; 0.0 anonymous bulk marketing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;reversibility&lt;/strong&gt; — if this got auto-handled and that was wrong, how easy is the recovery? 1.0 trivial undo; 0.0 irreversible ("lost an investor")&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;urgency&lt;/strong&gt; — needs attention within hours (1.0) down to informational, no clock (0.0)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The response schema is literally:&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="nl"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"senderTrust"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"reversibility"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"urgency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"short phrase"&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;No tier. The model never sees the words PUSH, QUEUE, SILENT, AUTO in its output contract. It reads an email and describes it along four axes. It does not get a vote on what happens next.&lt;/p&gt;

&lt;h2&gt;
  
  
  A rule I can read decides the tier
&lt;/h2&gt;

&lt;p&gt;What happens next lives in one file, &lt;code&gt;tier-policy.ts&lt;/code&gt;, in a function with no model in it:&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;// 1. Very low confidence → QUEUE. Hiding uncertain mail is the worst failure.&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;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;QUEUE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Urgent AND sure → wake the user.&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;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;urgency&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;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="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PUSH&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 3. Anonymous, no clock, trivially reversible → SILENT (narrow: marketing only).&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;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;senderTrust&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;urgency&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reversibility&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SILENT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 4. Reversible, very sure, not urgent, trusted → AUTO.&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;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reversibility&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;urgency&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;senderTrust&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AUTO&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 5. Default → QUEUE. "I'll look at it on my own schedule" is the dominant bucket.&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;QUEUE&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;That's the whole decider. Every threshold is a named constant in one object above it, not a magic number sprinkled through a prompt. Order matters — earlier branches win. I can read this in thirty seconds, write a unit test for each branch, and change the policy without touching the model or re-running an eval.&lt;/p&gt;

&lt;p&gt;Try doing any of that to "I asked GPT-4o to pick a tier and it picked QUEUE." You can't test it. You can't diff it. You can't explain to yourself why message #4,012 got hidden. The decision isn't anywhere — it's smeared across a weight matrix and a paragraph of prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "consistency over genius" falls right out of it
&lt;/h2&gt;

&lt;p&gt;Once the model's only job is to score four signals, the question stops being "which model reasons best about email policy?" and becomes "which model reads the same four signals the same way every time?"&lt;/p&gt;

&lt;p&gt;Those are different questions with different answers. The first one points you at the biggest, most expensive model. The second one points you at a cheap, fast, low-variance one — because a frontier model's extra reasoning, applied to a 30-word email, mostly buys you &lt;em&gt;more ways to have an opinion&lt;/em&gt;, which is variance, which is the enemy when you've already moved the judgment into a rule. That's why the cheap model won the last post. It wasn't a cost compromise. Splitting scorer from decider is what made the cheap model the &lt;em&gt;correct&lt;/em&gt; choice, not just the affordable one.&lt;/p&gt;

&lt;p&gt;And because the contract is "four features → tier" and nothing else, the model isn't load-bearing for correctness — it's load-bearing for &lt;em&gt;perception&lt;/em&gt;. Proof: when the LLM is down or rate-limited, a keyword fallback produces the same four features with zero model calls, and the exact same rule runs on top. The plumbing doesn't change. The only thing a better model buys you is sharper feature scores on the genuinely ambiguous mail — which is why the one place I'll spend a frontier model is a dial that escalates &lt;em&gt;only&lt;/em&gt; the low-confidence tail, and nowhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this buys you that "let the model decide" can't
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Auditability.&lt;/strong&gt; The policy is a file. Code review covers it. A regression test pins every branch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stable learning.&lt;/strong&gt; When I correct a misclassification, the correction doesn't go fight the model for control of the answer. It becomes an example that nudges the &lt;em&gt;feature scores&lt;/em&gt; toward the right values, and the rule — the spine — stays fixed. The thing that learns and the thing that decides are separated on purpose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A blast radius you chose.&lt;/strong&gt; AUTO's thresholds sit deliberately high (reversibility ≥ 0.85, confidence ≥ 0.85, trusted sender) so the system structurally cannot auto-handle a destructive or low-trust action. That floor is a number I can point at, not a behavior I'm hoping the model keeps exhibiting.&lt;/p&gt;

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

&lt;p&gt;This doesn't make the model's judgment good — it makes the &lt;em&gt;decision layer&lt;/em&gt; honest. Garbage feature scores still produce garbage tiers; the rule only guarantees that identical scores always map to the identical tier, and that I can see why. The thresholds were hand-tuned against 50 emails, and calibrating them from accumulated real corrections is still ahead of me, not behind. The keyword fallback, by design, can't emit PUSH — so a total LLM outage degrades urgent mail to "visible in the queue," never "silently hidden," but it does degrade. I'd rather write that down than pretend the split is free.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway is portable
&lt;/h2&gt;

&lt;p&gt;This isn't really about email. Any time you're handing an LLM a decision with consequences, you can ask the same question: does the model need to &lt;em&gt;decide&lt;/em&gt;, or does it need to &lt;em&gt;read&lt;/em&gt;? Separate "what the model perceives" from "what the system does about it." Put the second half in code you can read, test, and stand behind. You get auditability, you get to use a cheaper model, and you stop being surprised by your own product.&lt;/p&gt;

&lt;p&gt;The judge, the rule, and the thresholds are all in the open — AGPLv3: &lt;strong&gt;&lt;a href="https://github.com/k08200/klorn" rel="noopener noreferrer"&gt;github.com/k08200/klorn&lt;/a&gt;&lt;/strong&gt;. The decider is &lt;code&gt;packages/api/src/tier-policy.ts&lt;/code&gt;, about sixty readable lines. Go see how few of them there are.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>architecture</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I let GPT-4o and a cheaper model fight over my inbox. GPT-4o lost.</title>
      <dc:creator>yongrean</dc:creator>
      <pubDate>Wed, 24 Jun 2026 16:59:28 +0000</pubDate>
      <link>https://dev.to/k08200/i-let-gpt-4o-and-a-cheaper-model-fight-over-my-inbox-gpt-4o-lost-fkj</link>
      <guid>https://dev.to/k08200/i-let-gpt-4o-and-a-cheaper-model-fight-over-my-inbox-gpt-4o-lost-fkj</guid>
      <description>&lt;p&gt;Here's the scoreboard. Same 50 emails, same prompt, same 4-tier task:&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;Accuracy&lt;/th&gt;
&lt;th&gt;Note&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;google/gemini-2.5-flash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;88%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;100% recall on urgent mail — never missed one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;google/gemini-2.5-pro&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;82%&lt;/td&gt;
&lt;td&gt;the "smarter" sibling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openai/gpt-4o&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;82%&lt;/td&gt;
&lt;td&gt;the reflex pick&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;anything cheaper than flash&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&amp;lt; 80%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;failed my floor, didn't ship&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cheap model didn't tie the expensive ones. It beat them by six points and never missed an email that should have woken me up. The two models I'd have reached for on instinct — the obviously-smarter ones, the ones that cost several times more per token — both came second.&lt;/p&gt;

&lt;p&gt;I almost didn't run this comparison at all. That's the part worth your time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The task
&lt;/h2&gt;

&lt;p&gt;I'm building an email firewall. Every inbound message gets exactly one of four tiers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SILENT&lt;/strong&gt; — recorded, never shown (marketing, receipts, FYI)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QUEUE&lt;/strong&gt; — visible when I choose to look, no notification&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PUSH&lt;/strong&gt; — actually interrupt me&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AUTO&lt;/strong&gt; — reversible, hands-off (classified only, for now)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the entire output surface. No suggestion cards, no "AI thinks you should reply" badges. One label per email.&lt;/p&gt;

&lt;p&gt;The thing doing the labeling is what I call the judge. It's the part I'd assumed needed a good model — reading an email and deciding whether it's allowed to ring your phone feels like a judgment call, and judgment calls are what you buy a frontier model for.&lt;/p&gt;

&lt;p&gt;So I had &lt;code&gt;gpt-4o&lt;/code&gt; wired in and I was ready to leave it there. Then I did the boring thing and measured it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "measured" means here
&lt;/h2&gt;

&lt;p&gt;The eval set is committed to the repo: &lt;code&gt;packages/api/eval/judge-eval-set.json&lt;/code&gt;. Fifty emails, synthetic and PII-free, hand-labeled to encode one specific person's policy — QUEUE is the default, SILENT is narrow (clear marketing only), PUSH is urgent &lt;em&gt;and&lt;/em&gt; confident, AUTO is reversible &lt;em&gt;and&lt;/em&gt; not urgent. The tier mix is 21 QUEUE / 13 PUSH / 12 SILENT / 4 AUTO.&lt;/p&gt;

&lt;p&gt;One command runs a model against it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm &lt;span class="nb"&gt;eval&lt;/span&gt;:judge   &lt;span class="c"&gt;# tsx scripts/poc-accuracy.ts --in=eval/judge-eval-set.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran it across the models I was actually choosing between. Flash won. Not "won on cost, tied on quality" — won on quality, and it happens to be the cheap one. I pinned production to it and wrote the result into the commit message so future-me can't quietly pretend the expensive model was a sacrifice I made for the budget:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;flash is not a compromise — it scores 88% / 100% PUSH recall, beating gemini-2.5-pro and gpt-4o (both 82%).&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why cheap wins &lt;em&gt;here&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;A frontier model sells you reasoning depth. Long chains, hard problems, hold-ten-things-in-your-head problems. Email triage is none of that. It's a short, repetitive, read-four-signals-and-be-consistent problem. You're not paying for capability that moves this needle — you're paying for capability you never touch, and a bigger model's extra "thinking" mostly buys you more chances to overthink a 30-word email.&lt;/p&gt;

&lt;p&gt;There's an architecture reason too, and it's the load-bearing one. &lt;strong&gt;The LLM never picks the tier.&lt;/strong&gt; It scores four features per email — confidence, sender trust, reversibility, urgency — and a ~20-line deterministic rule maps those four numbers to PUSH/QUEUE/SILENT/AUTO. The model is a feature-scorer, not a decider. So I don't need a model that reasons brilliantly about email policy. I need one that reads four signals the same way every time. Consistency, not genius. That's exactly the job a cheap fast model is good at — and exactly the job where a bigger model's cleverness becomes variance you don't want.&lt;/p&gt;

&lt;p&gt;It also means the policy is auditable without the model in the loop. I can read the rule. I can test it. If the model's down, a keyword fallback produces the same four features so urgent mail still gets through. None of that works if you let the LLM free-hand the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you @ me
&lt;/h2&gt;

&lt;p&gt;This is 50 emails. It's small on purpose — it's one person's mental model written down, not a benchmark, and I'm not going to dress it up as one. The set is synthetic, so it tests whether the model applies &lt;em&gt;my policy&lt;/em&gt; consistently, not whether it can read the real world. A different inbox with a different owner would draw the lines somewhere else and might rank the models differently.&lt;/p&gt;

&lt;p&gt;I'm also only claiming what I measured. Flash hit 100% recall on PUSH — it never sent an urgent email to a quiet tier. I'm not going to invent per-tier numbers for the models that lost; I have their headline accuracy and that's what I'm putting my name on.&lt;/p&gt;

&lt;p&gt;What I'm &lt;em&gt;not&lt;/em&gt; walking back: on the one task I actually care about, on the set that's sitting in the public repo for you to open, the expensive models lost. That result was stable enough to bet production on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson is annoyingly cheap
&lt;/h2&gt;

&lt;p&gt;Most of us never run this comparison. "Use the best model" is the default, the best model is the expensive one, and the leaderboard agrees, so why would you waste an afternoon proving the obvious?&lt;/p&gt;

&lt;p&gt;Because the leaderboard has never seen your task. MMLU doesn't know what &lt;em&gt;you&lt;/em&gt; mean by "urgent." The only eval that ranks models on your problem is the one you write — and when you write it, the ranking stops matching the price tag surprisingly often. The frontier model isn't smarter at your job. It's just smarter at the jobs in the press release.&lt;/p&gt;

&lt;p&gt;Write the small eval. Run the cheap model against it before you reach for the expensive one. Worst case you confirm the obvious. Best case you cut your bill and your accuracy goes &lt;em&gt;up&lt;/em&gt;, which is a sentence I didn't expect to type either.&lt;/p&gt;

&lt;p&gt;The judge, the eval set, and the deterministic rule are all in the open — AGPLv3, OpenAI-compatible, point it at Ollama or vLLM and keep your mail on your own box: &lt;strong&gt;&lt;a href="https://github.com/k08200/klorn" rel="noopener noreferrer"&gt;github.com/k08200/klorn&lt;/a&gt;&lt;/strong&gt;. The eval set is &lt;code&gt;packages/api/eval/judge-eval-set.json&lt;/code&gt;. Open it, label it your way, and go find out which model actually wins on &lt;em&gt;your&lt;/em&gt; inbox.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Treat upstream catalogs as mutable: how a free-tier model SKU retirement broke my AI agent</title>
      <dc:creator>yongrean</dc:creator>
      <pubDate>Thu, 11 Jun 2026 15:24:22 +0000</pubDate>
      <link>https://dev.to/k08200/treat-upstream-catalogs-as-mutable-how-a-free-tier-model-sku-retirement-broke-my-ai-agent-159l</link>
      <guid>https://dev.to/k08200/treat-upstream-catalogs-as-mutable-how-a-free-tier-model-sku-retirement-broke-my-ai-agent-159l</guid>
      <description>&lt;p&gt;Tuesday afternoon, every autonomous cycle in my agent started returning the same error:&lt;/p&gt;

&lt;p&gt;[AGENT] Cycle failed: 404 No endpoints found for model: google/gemma-2-9b-it:free&lt;/p&gt;

&lt;p&gt;The model hadn't changed in my config. The provider hadn't gone down. The endpoint just... wasn't there anymore. OpenRouter had retired the &lt;code&gt;:free&lt;/code&gt; SKU mid-week — no notification, no deprecation window, just gone. Every background classification, every briefing generation, every proactive scan started failing in the same way.&lt;/p&gt;

&lt;p&gt;I had a fallback. That was the embarrassing part.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fallback that didn't fall back
&lt;/h2&gt;

&lt;p&gt;My &lt;code&gt;createCompletion()&lt;/code&gt; wrapper had been catching the documented provider failure modes for months:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;402 insufficient_credits&lt;/code&gt; → walk to next provider&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;403 daily_quota_exceeded&lt;/code&gt; → walk to next provider&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;429 rate_limited&lt;/code&gt; → backoff + retry&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it didn't catch: "the model you asked for doesn't exist anymore." A &lt;code&gt;404 No endpoints found&lt;/code&gt; propagated as a generic error and killed the cycle. The fallback chain never even got consulted because nothing in the existing branches matched.&lt;/p&gt;

&lt;p&gt;The mental model was wrong. I'd been treating the model catalog as &lt;strong&gt;fixed configuration&lt;/strong&gt; — something you set once and forget. In reality it's &lt;strong&gt;upstream state&lt;/strong&gt; that can mutate at any moment, just like any other dependency. The retirement was a feature of the provider's catalog management, not a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: walk the free-model chain on retirement signals
&lt;/h2&gt;

&lt;p&gt;The actual patch was short. Two PRs:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
ts
// Before: only walked on credit/quota/rate failures
if (isCreditError(err) || isKeyLimitError(err)) {
  return walkFallbackChain(...);
}

// After: also walk when the model itself is gone
if (isModelUnavailableError(err)) {
  markModelUnavailable(model);
  return walkFallbackChain(...);
}
isModelUnavailableError matches on:

HTTP 404 with No endpoints found in body
HTTP 400 with model_not_found code
Anything else the provider emits when the SKU is gone
markModelUnavailable puts the model on a 24h cooldown so the next cycle doesn't try it again immediately. When the catalog refreshes (providers add new SKUs all the time too), the cooldown expires and we retry.

The fallback chain itself is per-provider:


const OPENROUTER_FALLBACK_CHAIN = [
  'meta-llama/llama-3.3-70b-instruct:free',
  'google/gemma-2-9b-it:free',
  'mistralai/mistral-7b-instruct:free',
  'qwen/qwen-2.5-7b-instruct:free',
];
When one entry 404s, we walk to the next. When all of them fail, we fail over to the secondary provider (Gemini direct), which has its own chain. Only when every chain across every provider has been exhausted does the agent give up and surface AllProvidersExhaustedError to the user.

What I should have done from day 1
Three rules I'm internalizing:

1. The upstream catalog is mutable. Hardcoding a single model ID is the same antipattern as hardcoding a single CDN URL. Always have a list. Always make the list cheap to rotate.

2. Distinguish "this model is unavailable" from "the provider is unavailable." They're different failures with different recovery paths. Treating them the same way means you either over-rotate (give up the provider when only one model is gone) or under-rotate (give up entirely when the provider is fine).

3. Cooldowns, not blacklists. When a model disappears, don't kill it forever. Put it on a window. Providers add models back, or you might be hitting a transient 404. A 24h cooldown is much friendlier than a permanent deny-list that requires a code change to undo.

Why this matters beyond one provider
If you're running an agent in production, your model isn't your only upstream dependency:

Vendor's catalog can change
Pricing can change (:free → :paid is a real failure mode)
Rate-limit policies can change
Authentication schemes can change (Google's AQ.-prefix keys rejected by their own OpenAI-compat endpoint is a fun one — I had to write a native adapter for it)
The pattern is the same: treat every assumption about the upstream as a potential dynamic value, and make the recovery path the default, not the exception.

Agents that survive in prod have failover chains, cooldown windows, and degraded modes built in from the start. Not because the upstream is unreliable — because the upstream is alive, and alive things change.

I've been writing about Klorn, an open-source attention firewall for Gmail, where this kind of failure mode hits constantly because the agent runs continuously. Repo: github.com/k08200/klorn · Doctrine: deterministic-floor.md.

If you've shipped agents to prod, what other upstream-mutation failure modes have caught you off-guard?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>webdev</category>
      <category>infrastructure</category>
    </item>
    <item>
      <title>MCP CI gates need retry receipts for flaky downstreams</title>
      <dc:creator>yongrean</dc:creator>
      <pubDate>Mon, 08 Jun 2026 04:43:52 +0000</pubDate>
      <link>https://dev.to/k08200/mcp-ci-gates-need-retry-receipts-for-flaky-downstreams-2akb</link>
      <guid>https://dev.to/k08200/mcp-ci-gates-need-retry-receipts-for-flaky-downstreams-2akb</guid>
      <description>&lt;p&gt;MCP CI gates need to distinguish two very different failures:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the server is actually broken&lt;/li&gt;
&lt;li&gt;the downstream dependency is temporarily flaky&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If both become hard failures, CI gets noisy.&lt;br&gt;
If both are ignored, the gate stops meaning anything.&lt;/p&gt;

&lt;p&gt;So I shipped &lt;code&gt;@k08200/mcp-probe@1.12.0&lt;/code&gt; with explicit sidecar retry policy for tool-call dry-runs.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;A readiness gate that calls real MCP tools can hit transient downstream failures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;503 Service Unavailable&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;502 Bad Gateway&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;504 Gateway Timeout&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;rate limits&lt;/li&gt;
&lt;li&gt;short network timeouts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But auth and permission failures are different. A &lt;code&gt;401&lt;/code&gt; or &lt;code&gt;403&lt;/code&gt; usually means the agent will fail in production too.&lt;/p&gt;

&lt;p&gt;Those should stay visible unless the contract explicitly says otherwise.&lt;/p&gt;
&lt;h2&gt;
  
  
  Retry is opt-in per tool
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;mcp-probe&lt;/code&gt; now lets a sidecar contract define retry behavior per tool:&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;"tools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"logs_query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"service:web status:error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"timeframe"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1h"&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;"retry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"attempts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"delayMs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"retryOn"&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="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;504&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rate limit"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"expect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pass"&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part: retry is not global magic.&lt;/p&gt;

&lt;p&gt;It only happens when the sidecar explicitly opts in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Receipts still show the flake
&lt;/h2&gt;

&lt;p&gt;If a call fails once and passes on retry, the final result can pass, but the receipt still records every attempt.&lt;/p&gt;

&lt;p&gt;That means CI can tolerate a transient downstream blip without pretending the run was clean.&lt;/p&gt;

&lt;p&gt;Example 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;"tool"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"flaky_read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pass"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sidecar"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"attempts"&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"attempt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fail"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"503 Service Unavailable: transient downstream"&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="nl"&gt;"attempt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pass"&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="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;That is the distinction I want MCP CI gates to preserve:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;hard failures should block&lt;/li&gt;
&lt;li&gt;transient failures can be retried&lt;/li&gt;
&lt;li&gt;pass-after-retry should still leave a receipt&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&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;-D&lt;/span&gt; @k08200/mcp-probe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or run directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @k08200/mcp-probe@latest &lt;span class="nt"&gt;--config&lt;/span&gt; mcp-probe.config.json &lt;span class="nt"&gt;--github-summary&lt;/span&gt; &lt;span class="nt"&gt;--receipt-file&lt;/span&gt; mcp-probe.receipt.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub release: &lt;a href="https://github.com/k08200/mcp-probe/releases/tag/v1.12.0" rel="noopener noreferrer"&gt;https://github.com/k08200/mcp-probe/releases/tag/v1.12.0&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;npm: &lt;a href="https://www.npmjs.com/package/@k08200/mcp-probe" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/@k08200/mcp-probe&lt;/a&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>devops</category>
      <category>testing</category>
      <category>ai</category>
    </item>
    <item>
      <title>Every "autonomous AI agent" is a customer-support ticket waiting to happen</title>
      <dc:creator>yongrean</dc:creator>
      <pubDate>Sun, 07 Jun 2026 16:23:09 +0000</pubDate>
      <link>https://dev.to/k08200/klorn-the-approval-layer-for-ai-agents-builder-log-1o8m</link>
      <guid>https://dev.to/k08200/klorn-the-approval-layer-for-ai-agents-builder-log-1o8m</guid>
      <description>&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/NbmQJG-kd7c"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;I'm tired of writing apology emails for my own AI.&lt;/p&gt;

&lt;p&gt;Last month an agent I was dogfooding cancelled a calendar event I actually cared about. Two weeks before that, a different one auto-replied to an investor with what read like a hostage note from a Slack bot. Both companies have raised more money than I'll see in five years.&lt;/p&gt;

&lt;p&gt;The pattern across every "agentic AI" demo on my timeline is the same:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Agent does a thing&lt;/li&gt;
&lt;li&gt;Agent emails the user that it did the thing&lt;/li&gt;
&lt;li&gt;The thing was wrong&lt;/li&gt;
&lt;li&gt;The company ships a fix the following Tuesday&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I stopped trusting them. Then I built one that &lt;strong&gt;can't do this&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wedge: agents that wait
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://klorn.ai" rel="noopener noreferrer"&gt;Klorn&lt;/a&gt; is an approval layer between AI agents and your Gmail / Calendar. The agent does the thinking — reads the email, checks your calendar, drafts the reply, creates the event proposal. Then it stops. Nothing fires until you click approve.&lt;/p&gt;

&lt;p&gt;Sounds boring. The constraint is what makes it real.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraint that kills "act first, apologize later"
&lt;/h2&gt;

&lt;p&gt;Every meaningful action in Klorn is signed with a payload hash &lt;em&gt;before&lt;/em&gt; it fires. &lt;code&gt;send_email&lt;/code&gt; literally cannot execute without an &lt;code&gt;ActionReceipt&lt;/code&gt; that matches the hash of what was shown to you.&lt;/p&gt;

&lt;p&gt;There's an invariant test in the repo that fails the build if anyone — me, a future contributor, an AI agent (the irony) — tries to bypass it. Remove the approval check, the test fails, the build fails, the deploy fails.&lt;/p&gt;

&lt;p&gt;You &lt;strong&gt;cannot ship&lt;/strong&gt; a Klorn version that sends emails silently. It's architecturally impossible.&lt;/p&gt;

&lt;p&gt;This is the part nobody is building. Every "autonomous agent" demo on my timeline is one feature flag away from the next apology email.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I shipped this week
&lt;/h2&gt;

&lt;p&gt;The agent loop now runs end-to-end:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Meeting request hits inbox → tier-classified (PUSH / QUEUE / SILENT / AUTO)&lt;/li&gt;
&lt;li&gt;Klorn reads the email, checks the calendar for conflicts&lt;/li&gt;
&lt;li&gt;Drafts the reply &lt;em&gt;and&lt;/em&gt; the calendar event proposal&lt;/li&gt;
&lt;li&gt;Both wait as PendingActions in your decision queue&lt;/li&gt;
&lt;li&gt;One click → fires&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus a production bug that would have killed a less paranoid agent: OpenRouter retired a &lt;code&gt;:free&lt;/code&gt; model SKU mid-week. Every autonomous cycle died with &lt;code&gt;404 No endpoints found&lt;/code&gt;. The existing failover only covered 402 / 403 / 429 — not "the model is gone." Shipped a multi-model fallback chain on the same provider so losing one upstream SKU never kills the agent.&lt;/p&gt;

&lt;p&gt;That fix is the kind of thing you only ship when you trust the boundary the agent runs inside.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop hype-cycling, start gating
&lt;/h2&gt;

&lt;p&gt;If you're shipping an "autonomous AI agent" in 2026, three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Can a user prove what was approved is what was sent?&lt;/li&gt;
&lt;li&gt;Can a future contributor bypass your approval check?&lt;/li&gt;
&lt;li&gt;What is your invariant test?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the answers are "no", "yes", and "we don't have one" — you're building the next apology email. Stop.&lt;/p&gt;

&lt;p&gt;I'd rather build the firewall.&lt;/p&gt;




&lt;p&gt;60-second walkthrough above (&lt;a href="https://youtu.be/NbmQJG-kd7c" rel="noopener noreferrer"&gt;YouTube&lt;/a&gt; · &lt;a href="https://youtu.be/RdxF3zcFhGo" rel="noopener noreferrer"&gt;Shorts cut&lt;/a&gt;).&lt;br&gt;
Try it free: &lt;a href="https://klorn.ai" rel="noopener noreferrer"&gt;klorn.ai&lt;/a&gt;. PRO auto-applied during private beta.&lt;/p&gt;

&lt;p&gt;If you've actually been thinking about where agents should and shouldn't act on their own, I'd love your honest take — even one-line replies. Disagreement especially welcome.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>buildinpublic</category>
      <category>agents</category>
    </item>
    <item>
      <title>tools/list is not a readiness check for MCP servers</title>
      <dc:creator>yongrean</dc:creator>
      <pubDate>Mon, 01 Jun 2026 06:48:53 +0000</pubDate>
      <link>https://dev.to/k08200/toolslist-is-not-a-readiness-check-for-mcp-servers-13j5</link>
      <guid>https://dev.to/k08200/toolslist-is-not-a-readiness-check-for-mcp-servers-13j5</guid>
      <description>&lt;p&gt;The first version of &lt;code&gt;mcp-probe&lt;/code&gt; checked the obvious things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;can the MCP server initialize?&lt;/li&gt;
&lt;li&gt;does &lt;code&gt;tools/list&lt;/code&gt; work?&lt;/li&gt;
&lt;li&gt;are tool schemas present?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That was useful, but not enough.&lt;/p&gt;

&lt;p&gt;The more I tested real MCP workflows, the clearer the problem became:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;tools/list&lt;/code&gt; is self-report. CI needs a receipt.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;An MCP server can advertise a clean tool catalog and still fail every real call because OAuth handoff, scopes, downstream credentials, row limits, tenant boundaries, or response shapes are broken.&lt;/p&gt;

&lt;p&gt;So the latest release of &lt;strong&gt;mcp-probe&lt;/strong&gt; focuses less on "does the process start?" and more on "is CI enforcing the contract an agent actually depends on?"&lt;/p&gt;

&lt;h2&gt;
  
  
  The new bootstrap flow
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @k08200/mcp-probe@latest init &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target&lt;/span&gt; @your-org/your-mcp-server &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--discover&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--lock-tools&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--github-actions&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;mcp-probe.config.json&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.mcp-probe.json&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.github/workflows/mcp-probe.yml&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important part is what happens during &lt;code&gt;--discover&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mcp-probe&lt;/code&gt; connects to the server, reads the live &lt;code&gt;tools/list&lt;/code&gt; catalog, and generates a starting contract from the observed tool schemas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Schema-aware sidecar samples
&lt;/h2&gt;

&lt;p&gt;Older generated samples were too naive. If a schema said:&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"required"&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="s2"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"enum"&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="s2"&gt;"Chicago"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"New York"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"integer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minimum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&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="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 old fallback might produce empty strings or zero values. That often hit input validation and never tested the real call path.&lt;/p&gt;

&lt;p&gt;v1.11.0 now uses schema hints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;default&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;enum&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;numeric &lt;code&gt;minimum&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;string &lt;code&gt;minLength&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;nested objects&lt;/li&gt;
&lt;li&gt;array &lt;code&gt;minItems&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the generated sample becomes:&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;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Chicago"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&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;It is still only a starting point. You should review generated samples before running them with production credentials, especially for mutating, admin, export, or environment-inspection tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catalog locking
&lt;/h2&gt;

&lt;p&gt;The other new piece is &lt;code&gt;--lock-tools&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;--discover&lt;/code&gt;, mcp-probe now writes the observed tool names into &lt;code&gt;expectedTools&lt;/code&gt;, so CI fails if a required tool disappears.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;--lock-tools&lt;/code&gt;, it also writes &lt;code&gt;allowedTools&lt;/code&gt;, so CI fails if unexpected tools appear.&lt;/p&gt;

&lt;p&gt;That matters for low-trust agent surfaces. If a server suddenly exposes &lt;code&gt;delete_user&lt;/code&gt;, &lt;code&gt;export_all&lt;/code&gt;, or &lt;code&gt;rotate_api_key&lt;/code&gt;, I do not want that to silently become available to an agent just because &lt;code&gt;tools/list&lt;/code&gt; still returns valid JSON.&lt;/p&gt;

&lt;p&gt;Example config:&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;"timeoutMs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"servers"&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"my-mcp-server"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@your-org/your-mcp-server"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"probeTools"&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;"toolsFile"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".mcp-probe.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"expectedTools"&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="s2"&gt;"search"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"read_record"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"allowedTools"&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="s2"&gt;"search"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"read_record"&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Receipts
&lt;/h2&gt;

&lt;p&gt;For CI, the workflow can also persist a redacted receipt artifact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @k08200/mcp-probe@latest &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--config&lt;/span&gt; mcp-probe.config.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--github-summary&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--fail-on-warn&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--receipt-file&lt;/span&gt; mcp-probe.receipt.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That receipt is the thing I want CI to trust: not the server claiming it has tools, and not an agent claiming what happened later, but an independent probe that actually ran against the boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @k08200/mcp-probe@latest @modelcontextprotocol/server-memory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub: &lt;a href="https://github.com/k08200/mcp-probe" rel="noopener noreferrer"&gt;k08200/mcp-probe&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Release: &lt;a href="https://github.com/k08200/mcp-probe/releases/tag/v1.11.0" rel="noopener noreferrer"&gt;v1.11.0&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I am especially looking for real Datadog, Supabase, and Gmail MCP recipes. The public fixtures are useful, but the real value is catching auth handoff, permission, tenant-scope, and response-contract failures in CI.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>typescript</category>
      <category>cli</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
