<?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: DOS AI</title>
    <description>The latest articles on DEV Community by DOS AI (@dosai).</description>
    <link>https://dev.to/dosai</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%2F4079668%2F6fc023fe-420b-46d9-8b9b-2996d97ab8a6.png</url>
      <title>DEV Community: DOS AI</title>
      <link>https://dev.to/dosai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dosai"/>
    <language>en</language>
    <item>
      <title>A check that cannot fail is not a check</title>
      <dc:creator>DOS AI</dc:creator>
      <pubDate>Sun, 16 Aug 2026 16:30:19 +0000</pubDate>
      <link>https://dev.to/dosai/a-check-that-cannot-fail-is-not-a-check-9dl</link>
      <guid>https://dev.to/dosai/a-check-that-cannot-fail-is-not-a-check-9dl</guid>
      <description>&lt;p&gt;Two of my guardrails reported clean for weeks. Neither of them was capable of reporting anything else.&lt;/p&gt;

&lt;p&gt;That is a different failure from a broken check. A broken check throws an error and you go fix it. These returned the exact output you get when everything is fine, so I read them as confirmation and moved on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The grep that searched for a string nobody has ever written
&lt;/h2&gt;

&lt;p&gt;We have a house rule against em dashes in published text. To enforce it on myself I grepped my own diffs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;$'—&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s1"&gt;–'&lt;/span&gt; file.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero. Every time. The rule looked healthy for months.&lt;/p&gt;

&lt;p&gt;On macOS, BSD &lt;code&gt;grep&lt;/code&gt; in basic mode treats &lt;code&gt;\|&lt;/code&gt; as two literal characters instead of alternation. So that pattern searches for one literal five-character sequence, &lt;code&gt;—\|–&lt;/code&gt;, which appears in no file I will ever write. It returns &lt;code&gt;0&lt;/code&gt; for a file stuffed with em dashes and &lt;code&gt;0&lt;/code&gt; for an empty file. Those two zeros look identical.&lt;/p&gt;

&lt;p&gt;The version that works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'—'&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'–'&lt;/span&gt; file.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting part is not the escaping rule. It is that I treated a zero as evidence without once asking what a non-zero would have required.&lt;/p&gt;

&lt;h2&gt;
  
  
  The analytics tag with nobody to send the hit
&lt;/h2&gt;

&lt;p&gt;Most web analytics snippets have a "defer" or "manual pageview" mode. You turn it on when your app is a SPA, because you want to control when a view is counted instead of letting the loader fire on script execution.&lt;/p&gt;

&lt;p&gt;Turning it on has a second effect that is easy to forget. The loader stops sending the &lt;em&gt;initial&lt;/em&gt; pageview too. From that moment the counter reports only what your code explicitly sends.&lt;/p&gt;

&lt;p&gt;If your code never sends anything, the outside view is indistinguishable from a working install:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the script loads, 200&lt;/li&gt;
&lt;li&gt;the counter appears as "installed" in the vendor dashboard&lt;/li&gt;
&lt;li&gt;no console errors&lt;/li&gt;
&lt;li&gt;no failed requests, because a request that is never made cannot fail&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You get an empty dashboard, and an empty dashboard has a much more likely explanation available: nobody visited. That explanation is wrong and it is very comfortable.&lt;/p&gt;

&lt;p&gt;The 20-second probe, using Yandex Metrica as the example, works the same way for most vendors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;open any page with ?_ym_debug=1
console must print: PageView. Counter &amp;lt;id&amp;gt;.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that line is missing, nothing is being counted, whatever the dashboard says.&lt;/p&gt;

&lt;p&gt;The fix is two lines and the comment above them matters more than the code:&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;// The counter runs deferred because this is a SPA and route changes are&lt;/span&gt;
&lt;span class="c1"&gt;// not document loads. Deferred means the loader sends NOTHING on its own.&lt;/span&gt;
&lt;span class="c1"&gt;// If you ever remove `defer`, remove this call in the same commit, or every&lt;/span&gt;
&lt;span class="c1"&gt;// view is counted twice.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;trackPageView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;referer&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ym&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;COUNTER_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;referer&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;referer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;Two settings that must agree, living in two files, with no error on either side when they disagree. That is the shape of the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What both of these have in common
&lt;/h2&gt;

&lt;p&gt;Neither check had a failing case I had ever observed.&lt;/p&gt;

&lt;p&gt;I had never seen the grep print a number greater than zero. I had never seen the analytics dashboard show a number at all. In both cases the absence was the expected output of the healthy state, so it carried no information, and I kept banking it as good news.&lt;/p&gt;

&lt;p&gt;Three habits came out of this, and they are cheap:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Feed the check something bad on purpose.&lt;/strong&gt; Before trusting a linter, a grep, a hook, or an alert, hand it input that must trip it. If you cannot make it fail, you do not have a check. You have a constant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Be suspicious of zero specifically.&lt;/strong&gt; Zero errors, zero matches, zero events. Every one of those is also what a disconnected pipe returns. A pipeline that reports "3 problems found, 3 fixed" tells you it ran. A pipeline that reports nothing tells you nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify at the far end, not at the near end.&lt;/strong&gt; "The script returns 200" is a fact about your server. "The console prints PageView" is a fact about the thing you actually wanted. They are not the same fact, and only the second one is worth anything.&lt;/p&gt;

&lt;p&gt;The pattern generalises past these two. Alerts wired to a metric nobody emits. Retry logic behind a condition that is never true. A test asserting a mock. In each case the system reports health, because reporting health is the only thing it can do.&lt;/p&gt;




&lt;p&gt;I write about the ugly parts of running an assistant product on top of WhatsApp and Telegram at &lt;a href="https://dosai.pro" rel="noopener noreferrer"&gt;dosai.pro&lt;/a&gt;. The public API and MCP server are documented at &lt;a href="https://dosai.pro/docs/guide/08-developers/80-public-api" rel="noopener noreferrer"&gt;dosai.pro/docs/guide/08-developers/80-public-api&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>devops</category>
      <category>webdev</category>
      <category>observability</category>
    </item>
    <item>
      <title>Automating a phone number you do not own: four invariants</title>
      <dc:creator>DOS AI</dc:creator>
      <pubDate>Sun, 16 Aug 2026 11:04:42 +0000</pubDate>
      <link>https://dev.to/dosai/automating-a-phone-number-you-do-not-own-four-invariants-356i</link>
      <guid>https://dev.to/dosai/automating-a-phone-number-you-do-not-own-four-invariants-356i</guid>
      <description>&lt;p&gt;If your product connects to WhatsApp through the linked-device protocol, your code operates a phone number that belongs to someone else. A small business owner scans a QR code and hands you their working line, the one printed on their van and saved in every customer's contacts.&lt;/p&gt;

&lt;p&gt;The cost of a mistake is not a 500 in your dashboard. It is a ban, and a ban is not something you can refund. Only the messenger lifts it, on the owner's appeal, on their timeline.&lt;/p&gt;

&lt;p&gt;What surprises most teams is that the anti-fraud system does not only read outgoing text. It watches how your protocol client behaves. A number can be flagged after two messages in a week if the client around it looks wrong.&lt;/p&gt;

&lt;p&gt;Below are four invariants I now treat as non-negotiable for any system of this kind. None of them are about content.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Reaction is allowed, initiative is not
&lt;/h2&gt;

&lt;p&gt;Replying to an incoming message is fine. Everything your process starts on its own is suspect.&lt;/p&gt;

&lt;p&gt;Never let automation tear down a live session, request a fresh QR, wipe stored credentials, or scan the server for numbers on a schedule. Those actions belong to an explicit human click in your UI, and nowhere else.&lt;/p&gt;

&lt;p&gt;The dangerous code is always well-intentioned. It reads like this:&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;// looks caring, behaves like an unstable client&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lastInbound&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;SILENCE_THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;restartSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The intent is "the channel has been quiet, let me reconnect just in case". From the outside, a client that re-authenticates a healthy session three times in a morning is indistinguishable from a compromised or emulated one. Silence is usually just a quiet Tuesday.&lt;/p&gt;

&lt;p&gt;If you need a liveness check, make it one auth-safe probe per silence episode with a long cooldown, and log the outcome. Anything that can loop must not touch authentication.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Any bulk lookup must converge
&lt;/h2&gt;

&lt;p&gt;Sooner or later you will want to resolve identifiers: map contacts, check which numbers exist, backfill a table after a protocol change. That loop talks to the messenger about people who are not your users.&lt;/p&gt;

&lt;p&gt;Three properties make it acceptable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it remembers negative answers, not only successful ones&lt;/li&gt;
&lt;li&gt;it has a ceiling per pass&lt;/li&gt;
&lt;li&gt;it has a minimum interval between passes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Remembering only successes is the classic bug. Every pass re-asks about the same unresolvable numbers, the query count never drops, and the loop runs forever at full volume.&lt;/p&gt;

&lt;p&gt;The convergence probe is arithmetic. Count the lookups per day and compare consecutive days. Falling is healthy. Flat means the loop is asking the same questions forever, and from the provider's side a permanent high-volume stream of questions about third-party numbers looks exactly like scraping a contact database. That is a category they ban for on its own, no matter how few messages you send.&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;// the fix is one table, not a rewrite&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;recordLookupResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// including "not found"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pickPending&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CEILING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;olderThan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MIN_INTERVAL&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. A process restart is not a reason to touch every session
&lt;/h2&gt;

&lt;p&gt;Deploys are when fleets die together. The container comes up, the boot path iterates over stored sessions, and every number reconnects inside the same second.&lt;/p&gt;

&lt;p&gt;Stagger it. A dozen seconds between reconnects plus jitter, and no automatic connection at all for sessions with no stored credentials, since those can only produce a QR request that nobody asked for.&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasStoredAuth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// QR is a human decision&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. There are no silent touches
&lt;/h2&gt;

&lt;p&gt;Every disconnect, reconnect and re-auth gets a row in a durable log with a parsed reason, and that log has to outlive the process that writes it.&lt;/p&gt;

&lt;p&gt;This sounds like ordinary hygiene until the day you need it. When a number gets flagged, you have one question to answer: what did our client do in the hours before. If your telemetry lived in container stdout, the answer is a guess, and guesses are how teams end up blaming the messenger for their own restart loop.&lt;/p&gt;

&lt;p&gt;Write the events to a table. Mount the raw log on a volume. Archive it on rebuild. It costs nothing and it is the difference between a post-mortem and a shrug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test to run on your own code
&lt;/h2&gt;

&lt;p&gt;Take any automation you have that touches a session, and ask: if this misfires ten times in a row on a live customer number, what does the provider see?&lt;/p&gt;

&lt;p&gt;Ten replies to ten incoming messages, fine. Ten re-authentications, ten QR requests, or ten thousand queries about strangers' numbers, not fine. That question catches the whole class, and it catches it before someone loses their business line rather than after.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://dosai.pro" rel="noopener noreferrer"&gt;DOS AI&lt;/a&gt;, where these rules are enforced by the platform rather than left to the integrator. Happy to compare notes in the comments if you run a fleet of your own.&lt;/p&gt;

</description>
      <category>whatsapp</category>
      <category>automation</category>
      <category>reliability</category>
      <category>node</category>
    </item>
    <item>
      <title>Putting an MCP server in front of a real product: four things I got wrong first</title>
      <dc:creator>DOS AI</dc:creator>
      <pubDate>Sun, 16 Aug 2026 11:04:31 +0000</pubDate>
      <link>https://dev.to/dosai/putting-an-mcp-server-in-front-of-a-real-product-four-things-i-got-wrong-first-1no2</link>
      <guid>https://dev.to/dosai/putting-an-mcp-server-in-front-of-a-real-product-four-things-i-got-wrong-first-1no2</guid>
      <description>&lt;p&gt;If you ship an MCP server for a product that already has a REST API, the tempting move is to wire the tools straight into your service layer. Skip the network hop, call the function, return the object. I did the opposite, and after a full audit of the surface I am glad I did. Here are the four decisions that turned out to matter, three of which I only got right on the second pass.&lt;/p&gt;

&lt;p&gt;I build a platform where an AI assistant answers customers in WhatsApp and Telegram, and the owner manages projects, conversations and leads from a dashboard. The MCP server exposes a slice of that to AI agents.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Every tool calls your own HTTP route, not your service layer
&lt;/h2&gt;

&lt;p&gt;This looks like pure waste. You are inside the same process, you have the client, you could call &lt;code&gt;listConversations(projectId)&lt;/code&gt; directly and save 20ms.&lt;/p&gt;

&lt;p&gt;Do not.&lt;/p&gt;

&lt;p&gt;Our API keys carry a read-only flag, and the gate that enforces it reads the request method from a header that middleware sets. An in-process call has no request, no header, and therefore no gate. The same is true for role checks, project scoping and rate limits: all of them live on the route. Bypassing the route means reimplementing four security controls in a second place, where they will drift.&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;// tool definition, simplified&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;list_conversations&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`/api/projects/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/conversations`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tool layer becomes a projection: build a path, forward auth, shape the result. Nothing else. When we later added a new permission rank, no MCP code changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A 200 response is not a successful operation
&lt;/h2&gt;

&lt;p&gt;Our send-message route returns HTTP 200 with &lt;code&gt;{ ok: false, error: "channel refused" }&lt;/code&gt; when the messaging provider rejects the payload. The route did its job, so 200 is correct.&lt;/p&gt;

&lt;p&gt;The first version of the tool reported that as success. An agent told a human "message sent" about a message that never left the building. That is the worst class of bug in an agent product, because the agent is confident and the user has no reason to check.&lt;/p&gt;

&lt;p&gt;So tools carry an explicit failure predicate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;name&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_message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;failWhen&lt;/span&gt;&lt;span class="p"&gt;:&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;failMessage&lt;/span&gt;&lt;span class="p"&gt;:&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`Channel refused the message: &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="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rule of thumb: if your route can express failure inside a 200 body, your tool layer must know about it. HTTP status is a transport signal, not a business one.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Unwrap your envelope, or your confirmations arrive empty
&lt;/h2&gt;

&lt;p&gt;Most of our routes answer with an envelope: &lt;code&gt;{ data: ... }&lt;/code&gt;, and a couple with &lt;code&gt;{ success: true, data: ... }&lt;/code&gt;. Human clients unwrap it without thinking. The tool projection did not.&lt;/p&gt;

&lt;p&gt;The result was subtle. &lt;code&gt;update_prompt&lt;/code&gt; returned &lt;code&gt;{ ok: true }&lt;/code&gt; and nothing else, because the interesting part sat one level deeper and got dropped. The agent had no way to confirm what it had written, so it either stayed vague or invented the detail.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unwrap&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;unwrap&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="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test this per tool. A single shared &lt;code&gt;unwrap&lt;/code&gt; is not enough if some routes wrap and some do not, which is exactly the state most real APIs are in.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Idempotency keys must be built from meaning only
&lt;/h2&gt;

&lt;p&gt;Our send tool deduplicates repeated calls. The first key looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The minute bucket felt harmless. It is not. Two identical calls that straddle a bucket boundary produce two different keys, so the duplicate goes through. The failure is rare, non-deterministic and impossible to reproduce on demand, which is another way of saying it will happen in front of a customer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If time belongs in the key, it should come from the domain (a booking slot, a billing period), never from the clock at call time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I decided on purpose: no destructive tools
&lt;/h2&gt;

&lt;p&gt;There is no delete-project tool, no payment tool, no remove-member tool. Not because they are hard, but because an agent that can read a customer message can be talked into acting on it, and the blast radius of a confused delete is not recoverable by an apology.&lt;/p&gt;

&lt;p&gt;A test enumerates the registry and fails if a tool name matches a destructive verb. That test exists so that a future me, in a hurry, has to argue with a red build instead of quietly adding one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell someone starting today
&lt;/h2&gt;

&lt;p&gt;Treat the MCP layer as a thin, dumb projection of an API you already trust. Every piece of intelligence you put in it is a piece of intelligence that now exists twice.&lt;/p&gt;

&lt;p&gt;Check the three failure modes above with a real agent, not with curl. Curl reads a raw JSON body and you fill in the meaning yourself. An agent reads what your tool returns and reports it to a human as fact, so an empty confirmation or a false success becomes a lie with your name on it.&lt;/p&gt;

&lt;p&gt;The spec of what we expose is public if you want to compare notes: &lt;a href="https://dosai.pro/llms.txt" rel="noopener noreferrer"&gt;dosai.pro/llms.txt&lt;/a&gt;. Happy to go into the auth details in the comments.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>api</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Your prompt is not a security boundary</title>
      <dc:creator>DOS AI</dc:creator>
      <pubDate>Sun, 16 Aug 2026 03:56:19 +0000</pubDate>
      <link>https://dev.to/dosai/your-prompt-is-not-a-security-boundary-382e</link>
      <guid>https://dev.to/dosai/your-prompt-is-not-a-security-boundary-382e</guid>
      <description>&lt;p&gt;If your AI agent owns tools with side effects, one question decides whether it&lt;br&gt;
is safe to ship: what happens when the model confidently calls a money tool on&lt;br&gt;
invented grounds.&lt;/p&gt;

&lt;p&gt;This is a writeup of one mechanism that closes that hole, and of where the&lt;br&gt;
mechanism stops working. The context is assistants that talk to real customers&lt;br&gt;
in messengers and can do irreversible things: confirm a payment, issue an&lt;br&gt;
invoice, book a slot, notify the business owner.&lt;/p&gt;
&lt;h3&gt;
  
  
  Why an instruction in the prompt is not a control
&lt;/h3&gt;

&lt;p&gt;A line like "only confirm payment after you received the receipt" executes&lt;br&gt;
with a probability, not with a guarantee. That is not a quality problem with&lt;br&gt;
the model. It follows from the training objective: be helpful, agree with the&lt;br&gt;
person in front of you.&lt;/p&gt;

&lt;p&gt;The conversation goes like this. The customer writes "I already paid, I will&lt;br&gt;
send the receipt later, please confirm". There is no receipt. The model sees a&lt;br&gt;
polite persistent human, sees an instruction that contradicts him, and over a&lt;br&gt;
long context it picks cooperation. It answers "payment confirmed" and calls&lt;br&gt;
the tool.&lt;/p&gt;

&lt;p&gt;For tone of voice, probabilistic execution is fine. For money it is not.&lt;/p&gt;

&lt;p&gt;One more hope worth killing early: the tool config field that looks like a&lt;br&gt;
predicate. Most function schemas carry something like &lt;code&gt;trigger_type&lt;/code&gt;, and&lt;br&gt;
&lt;code&gt;ai_decides&lt;/code&gt; literally means "the model decides". That field controls when the&lt;br&gt;
tool is offered, never under which facts the tool is allowed to fire.&lt;/p&gt;
&lt;h3&gt;
  
  
  A precondition is a declarative fact about the conversation
&lt;/h3&gt;

&lt;p&gt;The idea is small. A function carries a list of facts that the executor checks&lt;br&gt;
against the database before dispatch. Not "the model believes a receipt&lt;br&gt;
exists", but "there is an inbound attachment in this conversation".&lt;/p&gt;

&lt;p&gt;Stored as JSONB next to the function:&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="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;"client_sent_media"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"within_messages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"media_kinds"&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;"image"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"document"&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;"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;"lead_field_filled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"phone"&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 type list is deliberately short and covers nearly every real requirement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;FunctionPrecondition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;client_sent_media&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;within_messages&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;media_kinds&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lead_field_filled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;function_called_before&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;min_client_messages&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;client_sent_media&lt;/code&gt; scans the last N messages written by the customer rather&lt;br&gt;
than the last N rows of the thread. An owner who configures "the last 10&lt;br&gt;
messages" means ten customer replies, not ten rows half of which the bot wrote&lt;br&gt;
itself. The window is capped by a constant so that &lt;code&gt;within_messages: 100000&lt;/code&gt;&lt;br&gt;
in a config cannot turn the check into a table scan.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;function_called_before&lt;/code&gt; reads the event log and requires an earlier&lt;br&gt;
successful call in the same conversation. That is how you build chains like&lt;br&gt;
"verify identity first, then modify the booking".&lt;/p&gt;
&lt;h3&gt;
  
  
  Three implementation properties that carry the whole design
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The check lives in exactly one place.&lt;/strong&gt; It sits in the tool executor, after&lt;br&gt;
argument validation and strictly before dispatch to any handler. Put it inside&lt;br&gt;
the handlers instead and you fix the class one handler at a time, which means&lt;br&gt;
the next money-touching tool ships without a guard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A block is returned to the model as a tool error with a reason.&lt;/strong&gt; Not a&lt;br&gt;
silent refusal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Blocked: the customer must have sent a image/document attachment in their
last 10 messages. This did NOT happen. Do not tell the customer it did.
Ask the customer for what is missing, then call this function again.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference matters more than it looks. After a silent refusal the model&lt;br&gt;
assumes the call went through and keeps lying to the customer. An error with a&lt;br&gt;
cause produces self correction inside the same round: the bot goes and asks&lt;br&gt;
for the receipt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The requirement is appended to the tool description&lt;/strong&gt;, so the model sees it&lt;br&gt;
before spending a call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HARD REQUIREMENT: this function is blocked and will refuse to run unless the
customer must have sent a image/document attachment in their last 10 messages.
Do not claim the action happened until the call actually succeeds.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Fail open, on purpose
&lt;/h3&gt;

&lt;p&gt;When the check itself throws, the call goes through. It is not blocked.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Precondition check failed, letting the call through&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="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;Here is the reasoning. A precondition defends against model hallucination, not&lt;br&gt;
against an attacker. An attacker has no reach into this layer at all: he&lt;br&gt;
speaks to the bot in words, while the facts come from our own database. So the&lt;br&gt;
failure mode should be chosen by cost. Blocking every function for every&lt;br&gt;
customer because Postgres blinked means breaking live conversations (no&lt;br&gt;
invoice, no booking, no answer) over a hypothesis. The failure goes loudly&lt;br&gt;
into the log, and the decision falls back to the prompt, exactly as it was&lt;br&gt;
before the guard existed.&lt;/p&gt;

&lt;p&gt;If this were access control the choice would be the opposite, fail closed. It&lt;br&gt;
is not access control, and pretending otherwise would be worse than having no&lt;br&gt;
guard.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it does not solve
&lt;/h3&gt;

&lt;p&gt;It does not replace authorization, idempotency or rate limits. It answers one&lt;br&gt;
question: is there a fact in this conversation without which the action makes&lt;br&gt;
no sense.&lt;/p&gt;

&lt;p&gt;It does not rescue a badly specified function. If your only guard is&lt;br&gt;
&lt;code&gt;min_client_messages: 2&lt;/code&gt;, you moved the problem one message down the road.&lt;/p&gt;

&lt;p&gt;It costs nothing where it is not used. A function with an empty precondition&lt;br&gt;
list issues zero queries, the branch returns on an empty array. That property&lt;br&gt;
is what keeps the guard alive past the second release: a check that slows down&lt;br&gt;
every conversation for the sake of one money flow gets removed by whoever is&lt;br&gt;
on call.&lt;/p&gt;

&lt;h3&gt;
  
  
  The takeaway
&lt;/h3&gt;

&lt;p&gt;An LLM in production behaves like a capable intern. Most of its calls are&lt;br&gt;
good, and nobody lets an intern sign the cheques. Boundaries belong in code,&lt;br&gt;
get verified against data, and get logged in a way that survives a restart.&lt;br&gt;
The prompt owns the quality of the conversation, and nothing beyond it.&lt;/p&gt;

&lt;p&gt;All of the above runs in the platform I build, DOS AI: AI assistants for&lt;br&gt;
WhatsApp and Telegram with a built in CRM, configured in plain text. If you&lt;br&gt;
are building your own, our REST API, webhooks and MCP server are public, so&lt;br&gt;
you can plug your agent in and look at the contract from the outside. The&lt;br&gt;
machine readable spec sits at &lt;a href="https://dosai.pro/llms.txt" rel="noopener noreferrer"&gt;https://dosai.pro/llms.txt&lt;/a&gt; and the code samples&lt;br&gt;
are on GitHub: &lt;a href="https://github.com/adsytd1/dosai-api" rel="noopener noreferrer"&gt;https://github.com/adsytd1/dosai-api&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to go deeper on failure modes in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
