<?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: Paul-S</title>
    <description>The latest articles on DEV Community by Paul-S (@paul-s).</description>
    <link>https://dev.to/paul-s</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%2F3112427%2F826409dc-379c-42dd-b263-05b74b80c4b9.png</url>
      <title>DEV Community: Paul-S</title>
      <link>https://dev.to/paul-s</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/paul-s"/>
    <language>en</language>
    <item>
      <title>Python 3.15 Switched UTF-8 On. One Export Broke</title>
      <dc:creator>Paul-S</dc:creator>
      <pubDate>Thu, 12 Nov 2026 09:47:02 +0000</pubDate>
      <link>https://dev.to/paul-s/python-315-switched-utf-8-on-one-export-broke-3mgb</link>
      <guid>https://dev.to/paul-s/python-315-switched-utf-8-on-one-export-broke-3mgb</guid>
      <description>&lt;p&gt;The deployment looked harmless.&lt;/p&gt;

&lt;p&gt;We upgraded a Windows export service from Python 3.14 to Python 3.15. The application started correctly, the tests passed, and the scheduled job produced its CSV at the usual time.&lt;/p&gt;

&lt;p&gt;Then finance opened the file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Customer ID,Name,City
1042,RenÃ©e,MontrÃ©al
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The records were complete. The columns were in the right order. No exception had been logged.&lt;/p&gt;

&lt;p&gt;The code had not changed. Its default encoding had.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Changed in Python 3.15?
&lt;/h2&gt;

&lt;p&gt;Python 3.15 was released on October 9, 2026. One of its important compatibility changes comes from &lt;a href="https://peps.python.org/pep-0686/" rel="noopener noreferrer"&gt;PEP 686&lt;/a&gt;: UTF-8 mode is now enabled by default.&lt;/p&gt;

&lt;p&gt;That means text operations without an explicit encoding now use UTF-8, including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;open()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;standard input and output&lt;/li&gt;
&lt;li&gt;pipes&lt;/li&gt;
&lt;li&gt;filesystem encoding operations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before Python 3.15, the default could depend on the operating system and locale. A Windows application might therefore write text using a local encoding such as Windows-1252.&lt;/p&gt;

&lt;p&gt;This code contains that dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;export_customers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nb"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;newline&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="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;writer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writerow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Customer ID"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"City"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writerow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;city&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;There is no &lt;code&gt;encoding&lt;/code&gt; argument. The export contract is being decided by the Python runtime.&lt;/p&gt;

&lt;p&gt;Python 3.15 now makes that decision consistently: UTF-8.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Did the Export Succeed?
&lt;/h2&gt;

&lt;p&gt;The failure happened after Python wrote the file.&lt;/p&gt;

&lt;p&gt;Consider the name &lt;code&gt;Renée&lt;/code&gt;. Its &lt;code&gt;é&lt;/code&gt; character is represented differently in UTF-8 and Windows-1252.&lt;/p&gt;

&lt;p&gt;Python 3.15 wrote valid UTF-8 bytes. The legacy finance application still assumed Windows-1252, so it interpreted those bytes incorrectly and displayed &lt;code&gt;RenÃ©e&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;No encoding error occurred because both programs completed their own operations successfully. They simply disagreed about the file format.&lt;/p&gt;

&lt;p&gt;This is why encoding bugs can survive unit tests. A test may confirm that the file exists and contains the correct number of rows without checking the bytes another system will receive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make Encoding Part of the Export Contract
&lt;/h2&gt;

&lt;p&gt;The correct fix depends on the consumer.&lt;/p&gt;

&lt;p&gt;If the receiving system requires Windows-1252, preserve that requirement explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nb"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"cp1252"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"strict"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;newline&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="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;writer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using &lt;code&gt;errors="strict"&lt;/code&gt; is intentional. Replacing unsupported characters silently can damage customer names, addresses and product data.&lt;/p&gt;

&lt;p&gt;If the consumer accepts UTF-8 but needs a byte-order mark to detect it correctly, use &lt;code&gt;utf-8-sig&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nb"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"utf-8-sig"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;newline&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="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;writer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For ordinary UTF-8 integrations that do not require a byte-order mark, use &lt;code&gt;encoding="utf-8"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The important part is that the decision lives in the code rather than the machine running it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the File, Not Only the Rows
&lt;/h2&gt;

&lt;p&gt;An export test should verify the encoding expected by the receiving application.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;codecs&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_customer_export_uses_utf8_bom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tmp_path&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="s2"&gt;"customers.csv"&lt;/span&gt;

    &lt;span class="nf"&gt;export_customers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="nc"&gt;Customer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1042&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Renée"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Montréal"&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="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;codecs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;BOM_UTF8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"utf-8-sig"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="s2"&gt;"Renée"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;decoded&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="s2"&gt;"Montréal"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;decoded&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This test checks the actual integration boundary. It will fail if someone changes the encoding, removes the required byte-order mark or writes the file using a locale-dependent default.&lt;/p&gt;

&lt;p&gt;A fixture should also include characters beyond basic ASCII. Tests containing only names such as &lt;code&gt;John Smith&lt;/code&gt; cannot expose this class of problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find Other Hidden Defaults Before Upgrading
&lt;/h2&gt;

&lt;p&gt;Python can warn when code opens text files without specifying an encoding.&lt;/p&gt;

&lt;p&gt;Run the test suite with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python -X warn_default_encoding -m pytest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or enable the same behavior through an environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PYTHONWARNDEFAULTENCODING&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 python -m pytest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each &lt;code&gt;EncodingWarning&lt;/code&gt; deserves review.&lt;/p&gt;

&lt;p&gt;Configuration files, templates and application-owned data should usually specify UTF-8. A file that intentionally follows the operating system locale can use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nb"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"locale"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Python 3.15 can temporarily restore the earlier behavior with &lt;code&gt;PYTHONUTF8=0&lt;/code&gt; or &lt;code&gt;-X utf8=0&lt;/code&gt;. Those options are useful for comparing behaviors during diagnosis. They should not become the permanent fix for an undefined file contract.&lt;/p&gt;

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

&lt;p&gt;Python 3.15 choosing UTF-8 by default is a sensible change. It removes a major source of cross-platform inconsistency.&lt;/p&gt;

&lt;p&gt;It also exposes applications that allowed their environment to choose an integration format.&lt;/p&gt;

&lt;p&gt;Before upgrading, inspect every place the application exchanges text with another system: CSV exports, fixed-width files, command-line pipes, email attachments, uploaded documents and legacy database imports.&lt;/p&gt;

&lt;p&gt;For teams preparing to &lt;a href="https://spaculus.com/services/hire-python-developers/" rel="noopener noreferrer"&gt;hire Python developers&lt;/a&gt;, encoding migrations are a useful interview topic. Strong candidates should discuss downstream consumers, byte-level tests and compatibility requirements rather than simply adding &lt;code&gt;encoding="utf-8"&lt;/code&gt; everywhere.&lt;/p&gt;

&lt;p&gt;Python 3.15 did not corrupt the export. It made an undocumented assumption visible.&lt;/p&gt;

&lt;p&gt;Which text integration in your application still depends on the machine running it?&lt;/p&gt;

</description>
      <category>python</category>
      <category>python315</category>
      <category>debugging</category>
      <category>programming</category>
    </item>
    <item>
      <title>OpenAI Agents Can Swap Tools Mid-Session. Can Permissions Keep Up?</title>
      <dc:creator>Paul-S</dc:creator>
      <pubDate>Thu, 05 Nov 2026 08:10:20 +0000</pubDate>
      <link>https://dev.to/paul-s/openai-agents-can-swap-tools-mid-session-can-permissions-keep-up-4i1a</link>
      <guid>https://dev.to/paul-s/openai-agents-can-swap-tools-mid-session-can-permissions-keep-up-4i1a</guid>
      <description>&lt;p&gt;At 10:02, a support agent could search customers and read open cases.&lt;/p&gt;

&lt;p&gt;At 10:06, the application added two tools to the same session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;issue_refund
close_account
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The conversation did not restart. The customer context, earlier instructions, and read-only approval were still there.&lt;/p&gt;

&lt;p&gt;The agent now had a different job without receiving a new identity.&lt;/p&gt;

&lt;p&gt;OpenAI’s Python SDK 3.42.0, released on October 31, 2026, added agent-session tool settings. The current &lt;a href="https://developers.openai.com/api/reference/resources/beta/subresources/agents/subresources/sessions/methods/update" rel="noopener noreferrer"&gt;Agents API reference&lt;/a&gt; says an update can replace a session’s tools.&lt;/p&gt;

&lt;p&gt;Functions and MCP servers change on the next model call, while built-in tools change by the next turn.&lt;/p&gt;

&lt;p&gt;That creates a security question that cannot be solved inside the prompt:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When the available tools change, which earlier decisions are still valid?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A Tool Update Is a Replacement
&lt;/h2&gt;

&lt;p&gt;The update operation does not append one harmless capability. The submitted array replaces the session’s tool list.&lt;/p&gt;

&lt;p&gt;Omitting &lt;code&gt;tools&lt;/code&gt; keeps the current list. Sending &lt;code&gt;null&lt;/code&gt; or an empty array clears it.&lt;/p&gt;

&lt;p&gt;An abbreviated request has this shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /v1/agents/sessions/session_123
Authorization: Bearer $OPENAI_API_KEY
Content-Type: application/json

{
  "agent": {
    "tools": [
      { "type": "function", "name": "search_cases" },
      { "type": "function", "name": "issue_refund" }
    ]
  }
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before the update, the agent could inspect a customer case.&lt;/p&gt;

&lt;p&gt;After the update, it could move money.&lt;/p&gt;

&lt;p&gt;If the application stores only &lt;code&gt;session_id&lt;/code&gt;, it has no durable record showing that the session’s authority changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Permission Has Three Clocks
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc1ivhyor3fnplsmsbmew.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc1ivhyor3fnplsmsbmew.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I find it useful to treat a live agent session as three clocks that can move independently.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;conversation clock&lt;/strong&gt; contains what the user requested, what the model learned, and what earlier turns decided.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;capability clock&lt;/strong&gt; represents the tools, schemas, and credentials currently available.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;authority clock&lt;/strong&gt; represents the user’s current role, tenant, approval, and policy version.&lt;/p&gt;

&lt;p&gt;A session is safe only when those clocks agree at execution time.&lt;/p&gt;

&lt;p&gt;The conversation might say, “The supervisor approved this case,” while the tool arguments now refer to another customer.&lt;/p&gt;

&lt;p&gt;A tool might still exist while its credential gains a wider scope.&lt;/p&gt;

&lt;p&gt;Neither situation requires malicious behavior. Stale state is enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bind Approval to a Capability Snapshot
&lt;/h2&gt;

&lt;p&gt;An approval should cover one concrete action under one known capability set. It should not become permanent permission attached to an entire conversation.&lt;/p&gt;

&lt;p&gt;One control is to create a fingerprint whenever the tool registry changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;capability_fingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;snapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;risk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;risk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;credential_scope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;credential_scope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;encoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;sort_keys&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;separators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&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="n"&gt;encoded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store that fingerprint with the proposed action and approval:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;approval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;principal_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_42&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant_acme&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;issue_refund&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arguments_hash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sha256:7c1...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;capability_fingerprint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sha256:a84...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;policy_version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expires_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-11-05T11:15:00Z&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before execution, compare that record with the current identity, tenant, arguments, policy, and capability fingerprint.&lt;/p&gt;

&lt;p&gt;A relevant mismatch should invalidate the approval and create a new review request.&lt;/p&gt;

&lt;p&gt;This prevents an old decision from silently authorizing a changed system.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Executor Still Owns the Boundary
&lt;/h2&gt;

&lt;p&gt;The model may request &lt;code&gt;issue_refund&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The tool handler must decide whether that request is allowed.&lt;/p&gt;

&lt;p&gt;That decision should use server-derived identity and tenant context, not values supplied by the model. It should also enforce business limits and prevent duplicate execution through a stable operation ID.&lt;/p&gt;

&lt;p&gt;OpenAI’s &lt;a href="https://developers.openai.com/api/docs/guides/agents-api/tools/mcp" rel="noopener noreferrer"&gt;MCP guidance&lt;/a&gt; provides &lt;code&gt;allowed_tools&lt;/code&gt; for restricting what an agent can discover and call. It also recommends keeping credentials outside reusable definitions and logs.&lt;/p&gt;

&lt;p&gt;However, the application still owns resource-level authorization.&lt;/p&gt;

&lt;p&gt;A tool allowlist answers:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can this agent call the refund tool?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The business policy answers:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can this user refund this order, for this tenant, for this amount, right now?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Both checks are necessary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the Transition, Not Only Each Tool
&lt;/h2&gt;

&lt;p&gt;Unit tests usually confirm that &lt;code&gt;search_cases&lt;/code&gt; works and &lt;code&gt;issue_refund&lt;/code&gt; rejects an unauthorized user.&lt;/p&gt;

&lt;p&gt;You also need a test for the moment between those states:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_old_approval_fails_after_toolset_change&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;tools_v1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;SEARCH_CASES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ISSUE_REFUND_V1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;tools_v2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;SEARCH_CASES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ISSUE_REFUND_V2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;approval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;approve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;issue_refund&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;capability_fingerprint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;capability_fingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tools_v1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;approval&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;requested_tool&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;issue_refund&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;current_fingerprint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;capability_fingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tools_v2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;REVIEW_REQUIRED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A removed tool&lt;/li&gt;
&lt;li&gt;A changed argument schema&lt;/li&gt;
&lt;li&gt;A narrower or broader credential&lt;/li&gt;
&lt;li&gt;An expired approval&lt;/li&gt;
&lt;li&gt;A tenant switch&lt;/li&gt;
&lt;li&gt;A changed policy version&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These transitions are where a valid session can become an invalid authority container.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Current Documentation Warning
&lt;/h2&gt;

&lt;p&gt;As of November 5, 2026, OpenAI’s API reference says session tools can be replaced. The &lt;a href="https://github.com/openai/openai-python/releases/tag/v3.42.0" rel="noopener noreferrer"&gt;Python SDK release notes&lt;/a&gt; also list agent-session tool settings.&lt;/p&gt;

&lt;p&gt;However, the separate configuration guide still says tools cannot be updated through that endpoint.&lt;/p&gt;

&lt;p&gt;Until the documentation is consistent, pin the SDK version, test the behavior in a disposable session, and recheck the API reference before deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool Changes Are Policy Changes
&lt;/h2&gt;

&lt;p&gt;Swapping tools mid-session can support phased workflows and keep one conversation alive across different tasks.&lt;/p&gt;

&lt;p&gt;It should receive the same security treatment as changing an API permission or production role.&lt;/p&gt;

&lt;p&gt;For teams planning to &lt;a href="https://spaculus.com/hire-ai-engineers/" rel="noopener noreferrer"&gt;hire AI engineers&lt;/a&gt;, this also creates a useful interview exercise:&lt;/p&gt;

&lt;p&gt;Give a candidate a read-only agent session, add a write tool, and ask which stored decisions must be invalidated.&lt;/p&gt;

&lt;p&gt;Their answer will reveal more about production readiness than another prompt-engineering demonstration.&lt;/p&gt;

&lt;p&gt;An agent session may keep its history when its tools change. Its authority should not silently survive a meaningful capability change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What would your application invalidate when a live agent receives a more powerful tool?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>openai</category>
      <category>security</category>
      <category>architecture</category>
    </item>
    <item>
      <title>The Agent Chose the Right Tool at the Wrong Time</title>
      <dc:creator>Paul-S</dc:creator>
      <pubDate>Mon, 02 Nov 2026 10:39:14 +0000</pubDate>
      <link>https://dev.to/paul-s/the-agent-chose-the-right-tool-at-the-wrong-time-1o50</link>
      <guid>https://dev.to/paul-s/the-agent-chose-the-right-tool-at-the-wrong-time-1o50</guid>
      <description>&lt;h1&gt;
  
  
  The Agent Chose the Right Tool at the Wrong Time
&lt;/h1&gt;

&lt;p&gt;Consider this fictional order-management trace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10:02:11  Customer asks to change the delivery address
10:02:12  Agent reads order: PAID, version 17
10:02:13  Agent selects update_delivery_address
10:02:15  Warehouse starts packing: PACKING, version 18
10:02:17  Support manager approves the change
10:02:18  Agent updates the address
10:02:20  Shipping label still contains the old address
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model understood the request.&lt;/p&gt;

&lt;p&gt;It selected the correct customer, order, and tool. The support manager even approved the action.&lt;/p&gt;

&lt;p&gt;The problem appeared in the five seconds between selection and execution. By the time the tool ran, the order had entered a different business state.&lt;/p&gt;

&lt;p&gt;This is a time-of-check to time-of-use problem inside an AI workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Correct Tool Call Can Become Stale
&lt;/h2&gt;

&lt;p&gt;Agent evaluations often test tool selection like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User request: Change the delivery address
Expected tool: update_delivery_address
Actual tool: update_delivery_address
Result: PASS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That test proves the model can map an instruction to a function. It says nothing about whether the function remains safe to run later.&lt;/p&gt;

&lt;p&gt;Real workflows introduce delays:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A person must approve the action.&lt;/li&gt;
&lt;li&gt;Another service updates the record.&lt;/li&gt;
&lt;li&gt;A background job changes its status.&lt;/li&gt;
&lt;li&gt;A webhook arrives while the agent is paused.&lt;/li&gt;
&lt;li&gt;The model retries after a timeout.&lt;/li&gt;
&lt;li&gt;A queue resumes the run on another worker.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The selected tool may still be semantically correct while its original assumptions are no longer true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give Every Proposed Action a Shelf Life
&lt;/h2&gt;

&lt;p&gt;A tool call that changes business data should carry more than a function name and arguments.&lt;/p&gt;

&lt;p&gt;It should also contain the conditions under which the action was proposed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ActionProposal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;expected_version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;allowed_states&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt;
    &lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the address-change example, the proposal could say:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;proposal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ActionProposal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;update_delivery_address&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;entity_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_4821&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;expected_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;allowed_states&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PAID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;QUEUED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;approval_deadline&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 agent can recommend the action. The application records the state that made the recommendation valid.&lt;/p&gt;

&lt;p&gt;Execution then becomes a separate decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recheck State Inside the Tool Boundary
&lt;/h2&gt;

&lt;p&gt;Do not rely on the model to inspect the order again and decide whether its earlier plan is still safe.&lt;/p&gt;

&lt;p&gt;The tool executor should enforce the rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;execute_address_change&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_address&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_for_update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;datetime&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="n"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;REPLAN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;proposal_expired&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expected_version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;REPLAN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_changed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allowed_states&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ESCALATE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shipping_started&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_address_if_version&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;expected_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expected_version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;new_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;COMPLETED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;get_for_update()&lt;/code&gt; and &lt;code&gt;update_address_if_version()&lt;/code&gt; are conceptual repository methods. In a real system, they could use a database transaction, row lock, or compare-and-swap operation.&lt;/p&gt;

&lt;p&gt;The important part is where the checks happen: immediately before the write, inside the trusted execution layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approval Does Not Last Forever
&lt;/h2&gt;

&lt;p&gt;Current agent frameworks support pausing a run until a person approves a sensitive tool call. OpenAI’s &lt;a href="https://openai.github.io/openai-agents-python/human_in_the_loop/" rel="noopener noreferrer"&gt;human-in-the-loop documentation&lt;/a&gt; describes serializing a paused run and resuming it after the decision.&lt;/p&gt;

&lt;p&gt;That solves one problem: the agent cannot perform the action before approval.&lt;/p&gt;

&lt;p&gt;It does not prove that the world remained unchanged while approval was pending.&lt;/p&gt;

&lt;p&gt;An approval should therefore apply to a specific proposal, entity version, parameter set, and time window. If any of those change, the previous approval should expire.&lt;/p&gt;

&lt;p&gt;A manager approving a $50 refund should not accidentally approve a recalculated $500 refund. Approval for order version 17 should not authorize an operation against version 19.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the Delay, Not Only the Prompt
&lt;/h2&gt;

&lt;p&gt;A useful test suite needs to change state between planning and execution.&lt;/p&gt;

&lt;p&gt;Test cases should include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Agent proposes action → entity changes → action is rejected
Agent proposes action → approval expires → workflow replans
Agent proposes action → duplicate callback arrives → one write occurs
Agent proposes action → permission changes → action is denied
Agent proposes action → parameters change → approval is requested again
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These tests evaluate the system surrounding the model. They are often more valuable than adding another set of prompt examples.&lt;/p&gt;

&lt;p&gt;OWASP describes excessive autonomy as part of its &lt;a href="https://genai.owasp.org/llmrisk/llm062025-excessive-agency/" rel="noopener noreferrer"&gt;Excessive Agency&lt;/a&gt; risk. Its guidance recommends independent authorization and approval for high-impact operations.&lt;/p&gt;

&lt;p&gt;Temporal checks add another boundary: authorization must still be valid at the moment of execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Log the Decision Boundary
&lt;/h2&gt;

&lt;p&gt;When this failure reaches production, a simple “tool succeeded” log will hide the problem.&lt;/p&gt;

&lt;p&gt;Record operational facts such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When the action was proposed&lt;/li&gt;
&lt;li&gt;When approval was granted&lt;/li&gt;
&lt;li&gt;When execution started&lt;/li&gt;
&lt;li&gt;Which entity version was read&lt;/li&gt;
&lt;li&gt;Which version existed at execution&lt;/li&gt;
&lt;li&gt;Which precondition rejected or allowed the write&lt;/li&gt;
&lt;li&gt;Whether the call was new, resumed, or retried&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This creates an audit trail without storing private chain-of-thought. Engineers can reconstruct what the system observed and why the executor allowed the action.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Engineering Question I Would Ask
&lt;/h2&gt;

&lt;p&gt;When evaluating someone for an agent project, I would provide the opening trace and ask:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where should this workflow recheck state, and what should happen when the state has changed?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A strong answer should cover versioning, transactions, expiring approvals, idempotency, retries, cancellation, and escalation. Framework knowledge matters, but production judgment is revealed at these boundaries.&lt;/p&gt;

&lt;p&gt;For teams planning to &lt;a href="https://spaculus.com/hire-ai-engineers/" rel="noopener noreferrer"&gt;hire AI engineers&lt;/a&gt;, this type of scenario is more informative than asking candidates to build another tool-calling demo. It reflects how Spaculus Software approaches workflow automation: models can propose actions, while the application decides whether those actions are still valid.&lt;/p&gt;

&lt;p&gt;The agent chose the correct tool.&lt;/p&gt;

&lt;p&gt;The system failed because it treated a correct decision as permanent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which state change could make a tool call unsafe in your current workflow?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>architecture</category>
      <category>security</category>
    </item>
    <item>
      <title>Laravel AI Failover Worked. My JSON Contract Didn’t.</title>
      <dc:creator>Paul-S</dc:creator>
      <pubDate>Thu, 29 Oct 2026 12:30:22 +0000</pubDate>
      <link>https://dev.to/paul-s/laravel-ai-failover-worked-my-json-contract-didnt-2ljj</link>
      <guid>https://dev.to/paul-s/laravel-ai-failover-worked-my-json-contract-didnt-2ljj</guid>
      <description>&lt;p&gt;A Laravel AI failover can produce a surprisingly successful failure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;09:42:11  primary_provider  429 rate_limit
09:42:12  fallback_provider  200 success
09:42:12  application        Missing key: recommended_action
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The primary provider was unavailable. Laravel moved the request to the backup provider, which returned a valid response.&lt;/p&gt;

&lt;p&gt;Infrastructure saw success.&lt;/p&gt;

&lt;p&gt;The application crashed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Prompt Was Pretending to Be a Contract
&lt;/h2&gt;

&lt;p&gt;The agent had been instructed to return JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Return JSON using this structure:

{
  "risk": "low|medium|high",
  "recommended_action": "approve|review|reject",
  "reason": "string"
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The primary model usually followed that instruction.&lt;/p&gt;

&lt;p&gt;The fallback model returned this instead:&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;"risk_level"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"medium"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"manual_review"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"explanation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The account requires additional verification."&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;Nothing about that answer was nonsensical. A person could understand it immediately.&lt;/p&gt;

&lt;p&gt;PHP could not.&lt;/p&gt;

&lt;p&gt;The parser expected &lt;code&gt;recommended_action&lt;/code&gt;. The fallback supplied &lt;code&gt;action&lt;/code&gt;. Even its value changed from &lt;code&gt;review&lt;/code&gt; to &lt;code&gt;manual_review&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Failover had restored availability without preserving substitutability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Provider Failover Is Not Contract Failover
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://laravel.com/framework/docs/12.x/ai-sdk#failover" rel="noopener noreferrer"&gt;Laravel AI SDK&lt;/a&gt; allows an ordered list of providers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Enums\Lab&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RefundDecision&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'Evaluate refund request RF-1084.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;Lab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;Lab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Anthropic&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;If the primary provider is rate-limited or unavailable, the SDK can move to the next one.&lt;/p&gt;

&lt;p&gt;That answers one question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can another provider complete the request?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It does not automatically answer another:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Will every provider behave identically enough for my application?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Providers differ in model behavior, supported tools, configuration options, and structured-output implementations. Laravel hides much of the transport complexity, but the application still owns its business contract.&lt;/p&gt;

&lt;p&gt;The Laravel AI SDK’s recent changelog includes provider-specific structured-output fixes. That is a useful reminder that multi-provider compatibility must be tested, not assumed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replace JSON Instructions With a Schema
&lt;/h2&gt;

&lt;p&gt;Laravel agents can implement &lt;code&gt;HasStructuredOutput&lt;/code&gt; instead of asking for JSON in natural-language instructions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Ai\Agents&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Contracts\JsonSchema\JsonSchema&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\Agent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\HasStructuredOutput&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RefundDecision&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HasStructuredOutput&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Promptable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;&amp;lt;&amp;lt;&amp;lt;'PROMPT'
        Evaluate the refund request.

        Return a risk level, a recommended action,
        and a short explanation based only on the
        supplied account and policy information.
        PROMPT;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;JsonSchema&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'risk'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'recommended_action'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'reason'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;required&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;The response can then be accessed like an array:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RefundDecision&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'Evaluate refund request RF-1084.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;Lab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;Lab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Anthropic&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="nv"&gt;$action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'recommended_action'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is stronger than “please return JSON” because the output structure becomes part of the agent definition.&lt;/p&gt;

&lt;p&gt;It still should not be the final validation layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Convert Model Output Into a Domain Type
&lt;/h2&gt;

&lt;p&gt;A string can satisfy the schema and still contain an unsupported business value.&lt;/p&gt;

&lt;p&gt;The application should reject anything outside its own vocabulary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;RefundRisk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;Low&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'low'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;Medium&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'medium'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;High&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'high'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;RefundAction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;Approve&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'approve'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;Review&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'review'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;Reject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'reject'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RefundResult&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;RefundRisk&lt;/span&gt; &lt;span class="nv"&gt;$risk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;RefundAction&lt;/span&gt; &lt;span class="nv"&gt;$action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$reason&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;fromAiResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;risk&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;RefundRisk&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'risk'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
            &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;RefundAction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'recommended_action'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'reason'&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;Now &lt;code&gt;manual_review&lt;/code&gt;, &lt;code&gt;approve_now&lt;/code&gt;, or an empty value fails before it reaches refund logic.&lt;/p&gt;

&lt;p&gt;That failure is useful. It protects the business operation from a response that is valid JSON but invalid for the application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test Every Provider as a Primary Provider
&lt;/h2&gt;

&lt;p&gt;A test that always uses the normal provider does not test failover.&lt;/p&gt;

&lt;p&gt;Run a small contract suite against each configured provider independently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$providers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;Lab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;Lab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$providers&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RefundDecision&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'Account is seven days old. Policy requires review.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RefundResult&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fromAiResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&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="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;risk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeInstanceOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RefundRisk&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&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="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RefundAction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Review&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;Keep this separate from fast unit tests because it makes real provider calls. Run it in staging, before changing models, and after upgrading the SDK.&lt;/p&gt;

&lt;p&gt;Also test what happens when validation fails. The safe behavior might be to retry once, send the request to manual review, or stop the workflow. It should not silently invent a default action.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Backup Provider Is a Different Dependency
&lt;/h2&gt;

&lt;p&gt;The important lesson is not that Laravel’s failover failed.&lt;/p&gt;

&lt;p&gt;It worked exactly where it was supposed to work.&lt;/p&gt;

&lt;p&gt;The mistake was treating two probabilistic providers as interchangeable implementations of the same deterministic interface.&lt;/p&gt;

&lt;p&gt;If you are evaluating the &lt;a href="https://spaculus.com/services/best-laravel-development-company/" rel="noopener noreferrer"&gt;best Laravel development company&lt;/a&gt; for an AI-enabled application, ask how the team tests structured outputs across every fallback provider. At Spaculus Software, provider availability and business-contract validity are treated as separate production concerns.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;200 OK&lt;/code&gt; means the provider answered.&lt;/p&gt;

&lt;p&gt;It does not mean your application received an answer it can safely use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What contract would break first if your Laravel application switched AI providers today?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>ai</category>
      <category>testing</category>
    </item>
    <item>
      <title>SaaS Rate Limits Break Differently With AI Agents</title>
      <dc:creator>Paul-S</dc:creator>
      <pubDate>Mon, 26 Oct 2026 12:31:22 +0000</pubDate>
      <link>https://dev.to/paul-s/saas-rate-limits-break-differently-with-ai-agents-54nj</link>
      <guid>https://dev.to/paul-s/saas-rate-limits-break-differently-with-ai-agents-54nj</guid>
      <description>&lt;p&gt;A production trace for one user request might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RUN 7F21
Goal: Prepare a renewal quote

Model calls:        4
Database queries:   7
CRM calls:          3
Pricing API calls:  2
CRM response:       429
SDK retry:          1
Agent retry:        1
Final status:       Unknown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The customer clicked one button.&lt;/p&gt;

&lt;p&gt;The system performed eighteen operations, retried the same dependency at two different layers and no longer knew whether the CRM update had succeeded.&lt;/p&gt;

&lt;p&gt;That is how rate limiting changes when an AI agent enters a SaaS product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Traditional SaaS rate limiting controls how many requests a user or tenant can send. Agent rate limiting must also control how much work a single goal is allowed to create.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  One Request Is No Longer One Unit of Work
&lt;/h2&gt;

&lt;p&gt;A normal API route has a reasonably predictable cost.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /reports
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It validates input, queries data, creates a report and returns a response.&lt;/p&gt;

&lt;p&gt;An agent starts with an objective rather than a fixed execution path. It may search documents, ask the model to choose a tool, call an API, inspect the response and repeat the process.&lt;/p&gt;

&lt;p&gt;Two users can submit the same request and create very different workloads.&lt;/p&gt;

&lt;p&gt;One run may finish after three tool calls. Another may search five times, load more context after each result and repeatedly call a throttled service.&lt;/p&gt;

&lt;p&gt;The user-level limit still sees one request.&lt;/p&gt;

&lt;p&gt;The infrastructure sees a small distributed system.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 429 Can Leave the Workflow Half Finished
&lt;/h2&gt;

&lt;p&gt;In an ordinary request-response flow, &lt;code&gt;429 Too Many Requests&lt;/code&gt; usually means the requested operation was rejected.&lt;/p&gt;

&lt;p&gt;That assumption becomes dangerous after an agent has already completed earlier steps.&lt;/p&gt;

&lt;p&gt;Imagine an onboarding agent that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Creates a customer account&lt;/li&gt;
&lt;li&gt;Adds the customer to the CRM&lt;/li&gt;
&lt;li&gt;Generates an invoice&lt;/li&gt;
&lt;li&gt;Sends a welcome email&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the invoice API returns &lt;code&gt;429&lt;/code&gt;, the first two changes may already exist. Restarting the entire agent can create duplicate accounts or CRM entries.&lt;/p&gt;

&lt;p&gt;The failure is no longer simply “rate limited.” It is &lt;strong&gt;partially completed&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is why agent workflows need persistent step state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CREATE_ACCOUNT     completed
UPDATE_CRM         completed
CREATE_INVOICE     waiting
SEND_EMAIL         not_started
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resume from the failed step. Do not ask the model to reconstruct progress from conversation history.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate-Limit the Run, Not Only the Endpoint
&lt;/h2&gt;

&lt;p&gt;Each agent run should receive an execution budget before it starts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;monotonic&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RunBudget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;model_calls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;
    &lt;span class="n"&gt;tool_calls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;
    &lt;span class="n"&gt;external_writes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="n"&gt;retries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="n"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;45&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;consume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RUN_DEADLINE_EXCEEDED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_BUDGET_EXCEEDED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="nf"&gt;setattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This budget answers questions an endpoint limiter cannot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How many times may the model reconsider its plan?&lt;/li&gt;
&lt;li&gt;How many tools may one run call?&lt;/li&gt;
&lt;li&gt;How many external writes may it perform?&lt;/li&gt;
&lt;li&gt;How many retries are allowed across every layer?&lt;/li&gt;
&lt;li&gt;How long can the run remain active?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent can choose its next action. The surrounding application decides whether enough budget remains to execute it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retries Need One Owner
&lt;/h2&gt;

&lt;p&gt;Agent systems often contain several automatic retry layers.&lt;/p&gt;

&lt;p&gt;The model retries a tool. The orchestration framework retries the model. The API client retries a &lt;code&gt;429&lt;/code&gt;. The queue worker retries the entire job.&lt;/p&gt;

&lt;p&gt;Two retries at each of four layers can turn one failed call into many attempts.&lt;/p&gt;

&lt;p&gt;OpenAI’s &lt;a href="https://developers.openai.com/api/docs/guides/rate-limits" rel="noopener noreferrer"&gt;rate-limit guidance&lt;/a&gt; specifically warns that nested retry loops can multiply requests. It recommends respecting &lt;code&gt;Retry-After&lt;/code&gt;, applying exponential backoff with jitter and limiting both attempts and total retry time. Unsuccessful requests can also count toward the rate limit.&lt;/p&gt;

&lt;p&gt;Anthropic separately measures requests, input tokens and output tokens. Its documentation notes that short bursts can trigger throttling even when the apparent minute-level rate looks safe.&lt;/p&gt;

&lt;p&gt;Choose one layer to own retries. Every other layer should report the failure upward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backoff Does Not Prevent Duplicate Actions
&lt;/h2&gt;

&lt;p&gt;Backoff controls &lt;em&gt;when&lt;/em&gt; another attempt happens. It does not make that attempt safe.&lt;/p&gt;

&lt;p&gt;Before retrying a write, assign it a stable idempotency key derived from the agent run and intended business action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;renewal-run-7F21:create-invoice:customer-1842
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same logical action must reuse the same key, even when the model generates slightly different wording.&lt;/p&gt;

&lt;p&gt;Stripe’s documentation explains that repeating a request with the same idempotency key can return the original result instead of creating another object.&lt;/p&gt;

&lt;p&gt;AWS also recommends combining capped retries with exponential backoff, jitter and idempotent operations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure Work Amplification
&lt;/h2&gt;

&lt;p&gt;Requests per minute are no longer enough.&lt;/p&gt;

&lt;p&gt;Track an &lt;strong&gt;amplification ratio&lt;/strong&gt; for every agent workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(model calls + tool calls + retries) / user requests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also record the ratio by tenant, workflow version, model and failure type.&lt;/p&gt;

&lt;p&gt;A sudden increase can reveal a broken stopping condition, poor retrieval, a looping planner or a dependency repeatedly returning incomplete data.&lt;/p&gt;

&lt;p&gt;This metric connects reliability, cost and abuse detection. A tenant sending only ten requests could still consume more capacity than another tenant sending one thousand predictable API calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Limit Is Part of the Product
&lt;/h2&gt;

&lt;p&gt;Agent limits should not exist only as invisible infrastructure settings.&lt;/p&gt;

&lt;p&gt;When a run exhausts its budget, the product needs a defined outcome: pause, request approval, schedule continuation, return partial work or escalate to a human.&lt;/p&gt;

&lt;p&gt;For teams working with a &lt;a href="https://spaculus.com/services/saas-application-development-company/" rel="noopener noreferrer"&gt;SaaS application development company&lt;/a&gt;, this is an important architecture question. Ask how the system limits complete agent runs, prevents duplicate side effects and allocates usage fairly between tenants—not only how many requests its API gateway allows.&lt;/p&gt;

&lt;p&gt;A rate limit should not merely stop traffic.&lt;/p&gt;

&lt;p&gt;It should stop one uncertain goal from becoming an unlimited amount of work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does your SaaS product limit today: users, endpoints, tenants or complete agent runs?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>sass</category>
      <category>programming</category>
      <category>ai</category>
      <category>architecture</category>
    </item>
    <item>
      <title>What a customer support chatbot really costs per month (real API bill breakdown)</title>
      <dc:creator>Paul-S</dc:creator>
      <pubDate>Thu, 22 Oct 2026 11:00:32 +0000</pubDate>
      <link>https://dev.to/paul-s/what-a-customer-support-chatbot-really-costs-per-month-real-api-bill-breakdown-26ff</link>
      <guid>https://dev.to/paul-s/what-a-customer-support-chatbot-really-costs-per-month-real-api-bill-breakdown-26ff</guid>
      <description>&lt;p&gt;A chatbot can cost less than expected to generate answers and more than expected to retrieve the information behind them.&lt;/p&gt;

&lt;p&gt;That sounds contradictory until you examine an actual workload.&lt;/p&gt;

&lt;p&gt;This breakdown models a customer-support chatbot handling &lt;strong&gt;50,000 conversations per month&lt;/strong&gt;. It uses published API prices available in October 2026.&lt;/p&gt;

&lt;p&gt;This is not presented as a private customer invoice. It is a reproducible estimate with every assumption exposed, so you can replace the numbers with your own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start With the Workload, Not the Model Price
&lt;/h2&gt;

&lt;p&gt;Assume the chatbot handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;50,000 conversations per month&lt;/li&gt;
&lt;li&gt;Four generated replies per conversation&lt;/li&gt;
&lt;li&gt;200,000 total model responses&lt;/li&gt;
&lt;li&gt;1,800 input tokens and 250 output tokens for a normal reply&lt;/li&gt;
&lt;li&gt;Five percent of requests routed to a stronger model&lt;/li&gt;
&lt;li&gt;One knowledge-base search for each generated reply&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The input includes system instructions, conversation history, customer information, and retrieved support content.&lt;/p&gt;

&lt;p&gt;That last part matters. A chatbot with a growing conversation history can consume far more input tokens than a single-question demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Model Bill: $147.95
&lt;/h2&gt;

&lt;p&gt;For routine questions, the system uses GPT-6 Luna.&lt;/p&gt;

&lt;p&gt;OpenAI’s &lt;a href="https://developers.openai.com/api/docs/pricing" rel="noopener noreferrer"&gt;published standard pricing&lt;/a&gt; lists GPT-6 Luna at &lt;strong&gt;$0.10 per million input tokens&lt;/strong&gt; and &lt;strong&gt;$0.50 per million output tokens&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Ninety-five percent of 200,000 responses equals 190,000 Luna calls.&lt;/p&gt;

&lt;p&gt;Those calls process:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;342 million input tokens   = $34.20
47.5 million output tokens = $23.75

Luna subtotal              = $57.95
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The remaining 10,000 requests are harder cases. They may involve conflicting policies, unusual account histories, or complicated troubleshooting.&lt;/p&gt;

&lt;p&gt;Route those requests to GPT-6.1 Sol, currently priced at &lt;strong&gt;$2 per million input tokens&lt;/strong&gt; and &lt;strong&gt;$10 per million output tokens&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Assuming 2,500 input tokens and 400 output tokens for each escalated request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;25 million input tokens = $50.00
4 million output tokens = $40.00

Sol subtotal            = $90.00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The combined generation bill is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$57.95 + $90.00 = $147.95
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using the strongest model for every request would be simpler, but it would waste money on password resets, delivery questions, and other predictable tasks.&lt;/p&gt;

&lt;p&gt;Model routing only works when evaluations prove that the cheaper model can handle those requests safely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retrieval Costs More Than Generation
&lt;/h2&gt;

&lt;p&gt;A support chatbot normally searches product documentation, policies, previous tickets, or account information before answering.&lt;/p&gt;

&lt;p&gt;OpenAI currently charges &lt;strong&gt;$2.50 per 1,000 file-search calls&lt;/strong&gt; through the Responses API. Storage costs &lt;strong&gt;$0.10 per GB per day&lt;/strong&gt;, with the first gigabyte free.&lt;/p&gt;

&lt;p&gt;At 200,000 searches per month:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;200,000 / 1,000 × $2.50 = $500
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a four-gigabyte knowledge base:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3 billable GB × $0.10 × 30 days = $9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Retrieval therefore adds &lt;strong&gt;$509&lt;/strong&gt;, more than three times the model-generation bill.&lt;/p&gt;

&lt;p&gt;This is the line item many estimates miss.&lt;/p&gt;

&lt;p&gt;Not every message needs a new search. Greetings, confirmations, and follow-up questions can sometimes reuse existing context. Query classification and retrieval caching can reduce costs, but only if they do not introduce outdated answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Infrastructure Still Has a Bill
&lt;/h2&gt;

&lt;p&gt;The chatbot also needs an application around the model.&lt;/p&gt;

&lt;p&gt;For this example, reserve:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Backend, database and queue: $140
Logs, traces and alerts:      $90
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are planning allowances rather than universal vendor prices.&lt;/p&gt;

&lt;p&gt;Your number will depend on traffic patterns, retention periods, cloud provider, availability requirements, and how much conversation data you store.&lt;/p&gt;

&lt;p&gt;Add a ten-percent allowance for failed requests, retries, traffic spikes, and evaluation runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Metered AI and retrieval: $656.95
Ten-percent headroom:      $65.70
Infrastructure:           $230.00

Estimated monthly total:  $952.65
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That equals approximately &lt;strong&gt;$0.019 per conversation&lt;/strong&gt;, or just under two cents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build Your Own Cost Calculator
&lt;/h2&gt;

&lt;p&gt;Here is the complete Python calculation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;conversations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50_000&lt;/span&gt;
&lt;span class="n"&gt;replies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conversations&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;

&lt;span class="n"&gt;luna_calls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;replies&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;sol_calls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;replies&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;luna_calls&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;model_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;input_rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;output_rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;input_cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;calls&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;input_tokens&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;input_rate&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;output_cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;calls&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;output_tokens&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;output_rate&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;input_cost&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;output_cost&lt;/span&gt;


&lt;span class="n"&gt;luna&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;model_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;luna_calls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;input_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;output_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;input_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;output_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;sol&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;model_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;sol_calls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;input_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;output_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;input_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;2.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;output_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;10.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;file_search&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;replies&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;2.50&lt;/span&gt;
&lt;span class="n"&gt;storage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;

&lt;span class="n"&gt;metered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;luna&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;sol&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;file_search&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;storage&lt;/span&gt;
&lt;span class="n"&gt;infrastructure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;140&lt;/span&gt;
&lt;span class="n"&gt;observability&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;
&lt;span class="n"&gt;headroom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;metered&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.10&lt;/span&gt;

&lt;span class="n"&gt;monthly_total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;metered&lt;/span&gt;
    &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;infrastructure&lt;/span&gt;
    &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;observability&lt;/span&gt;
    &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;headroom&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;cost_per_conversation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;monthly_total&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;conversations&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Monthly cost: $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;monthly_total&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cost per conversation: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cost_per_conversation&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Monthly cost: $952.65
Cost per conversation: $0.0191
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change the conversation volume, token counts, model rates, and retrieval frequency to estimate your own workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Estimate Does Not Include
&lt;/h2&gt;

&lt;p&gt;The $952.65 total excludes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Initial chatbot development&lt;/li&gt;
&lt;li&gt;Customer-support platform fees&lt;/li&gt;
&lt;li&gt;Human review&lt;/li&gt;
&lt;li&gt;Security testing&lt;/li&gt;
&lt;li&gt;Maintenance and evaluation&lt;/li&gt;
&lt;li&gt;Engineering time spent investigating failures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Human escalation may eventually become the largest expense.&lt;/p&gt;

&lt;p&gt;If five percent of 50,000 conversations require five minutes of agent review, that creates more than &lt;strong&gt;208 hours of monthly support work&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A chatbot that routes uncertain cases correctly can still be valuable, but those hours belong in the business case.&lt;/p&gt;

&lt;p&gt;Regional processing can also change the invoice. OpenAI currently applies a ten-percent uplift to eligible regional-processing and FedRAMP endpoints for newer models.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask for the Worksheet, Not One Monthly Number
&lt;/h2&gt;

&lt;p&gt;When evaluating an &lt;a href="https://spaculus.com/ai-chatbot-development-company/" rel="noopener noreferrer"&gt;AI chatbot development company&lt;/a&gt;, ask to see the assumptions behind its operating-cost estimate.&lt;/p&gt;

&lt;p&gt;You should be able to identify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Expected conversation volume&lt;/li&gt;
&lt;li&gt;Model calls and token usage&lt;/li&gt;
&lt;li&gt;Retrieval frequency&lt;/li&gt;
&lt;li&gt;Escalation rate&lt;/li&gt;
&lt;li&gt;Storage requirements&lt;/li&gt;
&lt;li&gt;Failed calls and retries&lt;/li&gt;
&lt;li&gt;Monitoring costs&lt;/li&gt;
&lt;li&gt;Human-review workload&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model price is only one line on the invoice.&lt;/p&gt;

&lt;p&gt;For this example, generating answers costs about &lt;strong&gt;$148&lt;/strong&gt;. Finding the information behind those answers costs &lt;strong&gt;$509&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is the cost relationship worth designing around.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>backend</category>
      <category>programming</category>
    </item>
    <item>
      <title>The Chatbot Refunded Twice. The Retry Looked Successful</title>
      <dc:creator>Paul-S</dc:creator>
      <pubDate>Tue, 20 Oct 2026 11:40:56 +0000</pubDate>
      <link>https://dev.to/paul-s/the-chatbot-refunded-twice-the-retry-looked-successful-cgk</link>
      <guid>https://dev.to/paul-s/the-chatbot-refunded-twice-the-retry-looked-successful-cgk</guid>
      <description>&lt;p&gt;The production trace looked harmless:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;14:03:11  Refund requested for order 4821
14:03:12  Payment provider called
14:03:22  Request timed out
14:03:24  Agent selected retry
14:03:26  Refund completed: $89
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The chatbot reported success. The customer received confirmation. No exception remained open.&lt;/p&gt;

&lt;p&gt;Two days later, finance found two $89 refunds.&lt;/p&gt;

&lt;p&gt;The first payment request had not failed. Only the response had failed to reach the chatbot. When the agent retried, the payment provider processed the same business action again.&lt;/p&gt;

&lt;p&gt;The retry looked successful because it was successful.&lt;/p&gt;

&lt;p&gt;That was the bug.&lt;/p&gt;

&lt;p&gt;This is a simplified composite example of a failure pattern that appears whenever an AI agent can perform consequential actions through APIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The HTTP Request Failed. The Refund Did Not.
&lt;/h2&gt;

&lt;p&gt;Developers often treat a timeout as evidence that an operation failed.&lt;/p&gt;

&lt;p&gt;It is only evidence that the client did not receive a response within the expected time.&lt;/p&gt;

&lt;p&gt;Several things may have happened:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The provider rejected the request.&lt;/li&gt;
&lt;li&gt;The provider received nothing.&lt;/li&gt;
&lt;li&gt;The provider completed the refund but lost the response.&lt;/li&gt;
&lt;li&gt;The response is still travelling through an overloaded network.&lt;/li&gt;
&lt;li&gt;The provider is processing the request asynchronously.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The chatbot cannot distinguish these outcomes from a timeout alone.&lt;/p&gt;

&lt;p&gt;A human support agent might pause, check the payment dashboard, and confirm the order status. An AI agent may interpret the timeout as permission to repeat the tool call.&lt;/p&gt;

&lt;p&gt;This is why “retry on failure” is not a complete reliability policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Model Should Not Create the Retry Identity
&lt;/h2&gt;

&lt;p&gt;Suppose the agent calls this tool:&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="nf"&gt;refundPayment&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;4821&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;89&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After a timeout, the model generates the same arguments and calls the tool again. The two calls look identical to us, but the payment system may see two independent refund requests.&lt;/p&gt;

&lt;p&gt;The application should create a stable action identifier before execution:&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;RefundCommand&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;actionId&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;orderId&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;amount&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;requestedBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RefundCommand&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;actionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;refund:4821:approved-request-7&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;4821&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;89&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;requestedBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;support-agent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important field is &lt;code&gt;actionId&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It represents the business action, not one HTTP attempt. Every retry of that refund must reuse the same identifier.&lt;/p&gt;

&lt;p&gt;Do not ask the language model to invent or remember this value. The surrounding application should generate it from trusted workflow state and persist it before calling the payment provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency Must Exist at More Than One Layer
&lt;/h2&gt;

&lt;p&gt;If the provider supports idempotency keys, pass the stable identifier with every attempt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;issueRefund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RefundCommand&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;paymentProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actionId&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first request may complete without returning a response. When the retry arrives with the same key, the provider should return the original result instead of creating another refund.&lt;/p&gt;

&lt;p&gt;That protects the external transaction, but the application still needs its own record:&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;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refundCommands&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;actionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PENDING&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;Place a unique constraint on &lt;code&gt;actionId&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If two workers receive the same command simultaneously, only one should be allowed to create the execution record. The other should read the existing state instead of starting another action.&lt;/p&gt;

&lt;p&gt;If an external API does not support idempotency, a database check alone may not be sufficient. Two processes can both check before either records completion. Use a lock, serialized queue, transactional outbox, or another mechanism that guarantees one executor for that business action.&lt;/p&gt;

&lt;h2&gt;
  
  
  “Completed” Should Not Be the Only Useful State
&lt;/h2&gt;

&lt;p&gt;The chatbot’s original workflow understood three outcomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SUCCESS
FAILURE
RETRY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was too simple.&lt;/p&gt;

&lt;p&gt;The refund command needed states that described what the system actually knew:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PENDING
EXECUTING
CONFIRMATION_UNKNOWN
COMPLETED
REJECTED
REQUIRES_REVIEW
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A timeout after sending the request should move the command to &lt;code&gt;CONFIRMATION_UNKNOWN&lt;/code&gt;, not directly to &lt;code&gt;FAILURE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;From there, the system can query the provider using the idempotency key, inspect a webhook, or send the case to a human. The chatbot should not independently decide that repeating a financial action is safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the Missing Response, Not Just the Failed Request
&lt;/h2&gt;

&lt;p&gt;Most tests simulate an API rejecting a request before doing any work.&lt;/p&gt;

&lt;p&gt;The more dangerous case is a successful operation followed by a lost response.&lt;/p&gt;

&lt;p&gt;A useful integration test should reproduce this sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The provider accepts the refund.&lt;/li&gt;
&lt;li&gt;The provider records the transaction.&lt;/li&gt;
&lt;li&gt;The client receives a timeout.&lt;/li&gt;
&lt;li&gt;The workflow retries with the same action ID.&lt;/li&gt;
&lt;li&gt;The provider returns the original result.&lt;/li&gt;
&lt;li&gt;The ledger contains one refund.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then run the test with two workers executing the same command concurrently.&lt;/p&gt;

&lt;p&gt;The assertion that matters is not how many HTTP attempts occurred. It is how many financial transactions were created.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry Safety Is Part of Chatbot Quality
&lt;/h2&gt;

&lt;p&gt;A 2026 &lt;a href="https://www.kore.ai/research/agent-productivity-index-report" rel="noopener noreferrer"&gt;Kore.ai survey&lt;/a&gt; of 408 enterprise IT and engineering leaders found that 79% of consequential autonomous actions required manual reversal. Among those reversals, 93% were described as costly or disruptive.&lt;/p&gt;

&lt;p&gt;Better prompts cannot solve a missing transaction boundary.&lt;/p&gt;

&lt;p&gt;If you are evaluating an &lt;a href="https://spaculus.com/ai-chatbot-development-company/" rel="noopener noreferrer"&gt;AI chatbot development company&lt;/a&gt;, do not ask only whether its chatbot can process a refund. Ask what happens when the refund succeeds but its response disappears.&lt;/p&gt;

&lt;p&gt;A reliable chatbot must know more than how to complete an action.&lt;/p&gt;

&lt;p&gt;It must know when the action may already be complete.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>backend</category>
      <category>testing</category>
      <category>programming</category>
    </item>
    <item>
      <title>Laravel AI SDK v1.0 Needs a Tenant Boundary</title>
      <dc:creator>Paul-S</dc:creator>
      <pubDate>Thu, 15 Oct 2026 12:16:07 +0000</pubDate>
      <link>https://dev.to/paul-s/laravel-ai-sdk-v10-needs-a-tenant-boundary-2p1k</link>
      <guid>https://dev.to/paul-s/laravel-ai-sdk-v10-needs-a-tenant-boundary-2p1k</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Authenticated tenant: 41
Authenticated user: 842
Agent tool: FindInvoices
Invoice returned: INV-1098
Invoice tenant: 17
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model found an invoice.&lt;/p&gt;

&lt;p&gt;The tool executed successfully. No exception appeared in the logs. The response was factually correct.&lt;/p&gt;

&lt;p&gt;It also exposed another customer’s data.&lt;/p&gt;

&lt;p&gt;This is the dangerous kind of AI failure because every component appears to work. The problem is not hallucination. It is missing tenant isolation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SDK Did Exactly What We Asked
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://laravel.com/blog/introducing-laravel-ai-sdk-v1" rel="noopener noreferrer"&gt;Laravel AI SDK v1.0&lt;/a&gt; gives PHP developers a first-party system for agents, conversation storage, streaming, tool approvals and middleware.&lt;/p&gt;

&lt;p&gt;It can decide when to call a tool and validate the arguments against a schema. Laravel also supports runtime provider configuration for tenant-specific API keys.&lt;/p&gt;

&lt;p&gt;Those capabilities reduce integration work. They do not decide which records the current tenant is allowed to access.&lt;/p&gt;

&lt;p&gt;Consider this tool call:&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;"find_invoice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&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;"customer_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;842&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 arguments look valid. But &lt;code&gt;customer_id&lt;/code&gt; is not an authorization boundary. IDs may collide across imported datasets, users may belong to multiple workspaces, and a malicious prompt may ask for a different account.&lt;/p&gt;

&lt;p&gt;The tenant must come from authenticated application state, never from the prompt or model-generated arguments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make Tenant Context Impossible to Omit
&lt;/h2&gt;

&lt;p&gt;Start by representing the current boundary explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TenantContext&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$userId&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;Resolve it after authentication and authorization:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TenantContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;current_team_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SupportAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;forUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'message'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Calling &lt;code&gt;forUser()&lt;/code&gt; is important because Laravel can associate stored conversations with a user. It is not the complete boundary when one user can access several tenants or workspaces.&lt;/p&gt;

&lt;p&gt;The active tenant must still be authorized independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Model Chooses the Tool, Not the Scope
&lt;/h2&gt;

&lt;p&gt;Pass &lt;code&gt;TenantContext&lt;/code&gt; into every data-access tool through its constructor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Models\Invoice&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Contracts\JsonSchema\JsonSchema&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Contracts\Tool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel\Ai\Tools\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Stringable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FindInvoices&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Tool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;TenantContext&lt;/span&gt; &lt;span class="nv"&gt;$context&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Stringable&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'Find recent invoices for the authenticated account.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;JsonSchema&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$schema&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Stringable&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$invoices&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Invoice&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tenant_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$status&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="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'total'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'issued_at'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;limit&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$invoices&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toJson&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;Notice what is missing from the schema: &lt;code&gt;tenant_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The model cannot supply it, replace it or persuade the tool to ignore it. Even a successful prompt injection remains trapped inside the authenticated tenant boundary.&lt;/p&gt;

&lt;p&gt;Laravel’s own guidance for &lt;a href="https://laravel.com/blog/laravel-ai-sdk-building-production-safe-database-tools-for-agents" rel="noopener noreferrer"&gt;production-safe database tools&lt;/a&gt; recommends the same underlying pattern: pass identity through application code, restrict visible columns, cap results and use a read-only database connection where possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five Places Tenant Data Can Still Escape
&lt;/h2&gt;

&lt;p&gt;Fixing the Eloquent query is necessary, but AI applications move data through more than one layer.&lt;/p&gt;

&lt;p&gt;Check these boundaries:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Conversation history:&lt;/strong&gt; Authorize both the user and active tenant before loading an existing conversation ID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vector retrieval:&lt;/strong&gt; Store &lt;code&gt;tenant_id&lt;/code&gt; with every chunk and require it as a server-side search filter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache entries:&lt;/strong&gt; Include the tenant, user, model and permission version in the cache key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queued agents:&lt;/strong&gt; Serialize immutable IDs, then reload and reauthorize them when the worker executes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool approvals:&lt;/strong&gt; Bind an approval to the tenant, user, tool and original arguments. Reject it if any context changed while the request was waiting.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A cached answer generated for Tenant 41 must never be reusable by Tenant 17 simply because both asked the same question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write the Test Before the Prompt
&lt;/h2&gt;

&lt;p&gt;The most useful test is not “Does the agent find an invoice?”&lt;/p&gt;

&lt;p&gt;It is “Can the agent retrieve an invoice belonging to someone else?”&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'cannot return another tenant invoice'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TenantContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tenantA&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;userA&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$foreignInvoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Invoice&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'tenant_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tenantB&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FindInvoices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$foreignInvoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&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="nv"&gt;$result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;not&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$foreignInvoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;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;Add adversarial cases for guessed IDs, reused conversation IDs, poisoned retrieval metadata, delayed approvals and queue jobs executed after membership is revoked.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Tenant Boundary Is Application Code
&lt;/h2&gt;

&lt;p&gt;Laravel AI SDK v1.0 makes agents feel native to Laravel. That is valuable because tools, queues, models and tests can remain inside the same application.&lt;/p&gt;

&lt;p&gt;The abstraction should not hide the security model.&lt;/p&gt;

&lt;p&gt;The best Laravel development company for a multi-tenant AI product should be able to identify where tenant identity originates, how it reaches every tool and what prevents the model from changing it.&lt;/p&gt;

&lt;p&gt;A prompt can tell an agent to respect tenant boundaries.&lt;/p&gt;

&lt;p&gt;Only application code can enforce them.&lt;/p&gt;

&lt;p&gt;What tenant-isolation test would you run before connecting a Laravel agent to production data?&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>ai</category>
      <category>security</category>
    </item>
    <item>
      <title>My MEAN Agent Remembered Everything. That Was the Bug</title>
      <dc:creator>Paul-S</dc:creator>
      <pubDate>Tue, 13 Oct 2026 11:48:23 +0000</pubDate>
      <link>https://dev.to/paul-s/my-mean-agent-remembered-everything-that-was-the-bug-c0e</link>
      <guid>https://dev.to/paul-s/my-mean-agent-remembered-everything-that-was-the-bug-c0e</guid>
      <description>&lt;h1&gt;
  
  
  My MEAN Agent Remembered Everything. That Was the Bug
&lt;/h1&gt;

&lt;p&gt;I reproduced the failure during a red-team test.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10:14  Tenant: northwind
User: Where should we send the replacement?

10:14  Agent memory retrieved:
“Deliver replacement orders to the Chicago warehouse.”

10:14  Source tenant: copper
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent remembered a real instruction. It was relevant to the question and ranked highly in semantic search.&lt;/p&gt;

&lt;p&gt;It also belonged to another customer.&lt;/p&gt;

&lt;p&gt;This was not a hallucination. The model did not invent the warehouse. It accurately used information our application should never have placed in its context.&lt;/p&gt;

&lt;p&gt;Nothing in the MEAN stack had crashed. Angular rendered the conversation, Express accepted the request, Node completed the workflow, and MongoDB returned a matching document.&lt;/p&gt;

&lt;p&gt;The system was working exactly as we had designed it.&lt;/p&gt;

&lt;p&gt;That was the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  We Had Built a Memory Bucket
&lt;/h2&gt;

&lt;p&gt;The first version of the agent stored almost everything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;User messages&lt;/li&gt;
&lt;li&gt;Conversation summaries&lt;/li&gt;
&lt;li&gt;Tool responses&lt;/li&gt;
&lt;li&gt;Customer preferences&lt;/li&gt;
&lt;li&gt;Previous workflow results&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each record received an embedding. Before generating an answer, the Node service searched for semantically similar memories and added the strongest matches to the prompt.&lt;/p&gt;

&lt;p&gt;The retrieval query considered relevance, but it did not enforce ownership strongly enough. We had treated memory as a smarter version of chat history.&lt;/p&gt;

&lt;p&gt;It is not.&lt;/p&gt;

&lt;p&gt;An audit record, a temporary workflow value, and a durable customer preference may all contain text, but they should not have the same retention period or retrieval rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every Memory Needed a Receipt
&lt;/h2&gt;

&lt;p&gt;We replaced the generic memory document with an explicit contract:&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;AgentMemory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;tenantId&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;principalId&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;purpose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;current_task&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approved_preference&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;verified_tool&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;sensitivity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;standard&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;restricted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;content&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;embedding&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;createdAt&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="nl"&gt;expiresAt&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="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;revoked&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;A memory could no longer exist simply because the model considered it useful.&lt;/p&gt;

&lt;p&gt;The application had to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Who owned it?&lt;/li&gt;
&lt;li&gt;Why was it stored?&lt;/li&gt;
&lt;li&gt;Which verified source produced it?&lt;/li&gt;
&lt;li&gt;How long could it influence the agent?&lt;/li&gt;
&lt;li&gt;Could the user inspect and revoke it?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Anything that could not answer those questions stayed outside the agent’s retrievable memory.&lt;/p&gt;

&lt;p&gt;We also separated four concepts that had previously shared one collection: short-lived conversation context, workflow state, approved preferences, and audit evidence.&lt;/p&gt;

&lt;p&gt;Audit records remained available to authorized services, but they were no longer automatically fed back into the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  We Added Three Gates
&lt;/h2&gt;

&lt;p&gt;The repair was not another system prompt. It was three application-level gates.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The write gate
&lt;/h3&gt;

&lt;p&gt;Only approved memory types could be persisted.&lt;/p&gt;

&lt;p&gt;Raw tool output was not automatically trusted. A webpage, uploaded document, or CRM note could contain incorrect information or malicious instructions.&lt;/p&gt;

&lt;p&gt;OWASP’s &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/AI_Agent_Security_Cheat_Sheet.html" rel="noopener noreferrer"&gt;AI Agent Security Cheat Sheet&lt;/a&gt; identifies memory poisoning as a distinct risk and recommends validating content, isolating memory between users and sessions, and enforcing expiration and size limits.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The read gate
&lt;/h3&gt;

&lt;p&gt;Every retrieval included identity and lifecycle filters before semantic ranking:&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;memories&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;Memory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;aggregate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;$vectorSearch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;agent_memory&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;embedding&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;queryVector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;numCandidates&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="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$eq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;principalId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$eq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;principalId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$eq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$gt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&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;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The corresponding vector index must define those fields as filters.&lt;/p&gt;

&lt;p&gt;MongoDB’s &lt;a href="https://www.mongodb.com/docs/vector-search/deployment/multi-tenant-architecture/" rel="noopener noreferrer"&gt;multi-tenant Vector Search guidance&lt;/a&gt; recommends storing a &lt;code&gt;tenant_id&lt;/code&gt; and applying it as a pre-filter so queries only return documents belonging to that tenant.&lt;/p&gt;

&lt;p&gt;The important rule is simple: semantic relevance must never override authorization.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The forget gate
&lt;/h3&gt;

&lt;p&gt;Temporary memories received an expiry date, backed by a MongoDB TTL index:&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="nx"&gt;MemorySchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;expireAfterSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;However, TTL deletion is asynchronous. MongoDB explains that expired documents can remain until its background process removes them.&lt;/p&gt;

&lt;p&gt;That means TTL is useful for cleanup, but it is not an authorization boundary. The application must still exclude expired or revoked memory during every read.&lt;/p&gt;

&lt;p&gt;A user pressing “Forget this” should affect retrieval immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Angular Needed a Memory Interface
&lt;/h2&gt;

&lt;p&gt;The backend changes exposed a product problem: users had no way to see what the agent remembered.&lt;/p&gt;

&lt;p&gt;We added a small Angular memory panel showing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The saved fact&lt;/li&gt;
&lt;li&gt;Why it was remembered&lt;/li&gt;
&lt;li&gt;Its source&lt;/li&gt;
&lt;li&gt;Its expiry&lt;/li&gt;
&lt;li&gt;A revoke action&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This made incorrect memories observable before they silently influenced several future conversations.&lt;/p&gt;

&lt;p&gt;It also changed our testing. We stopped asking only, “Did the agent remember the preference?” We added a second question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can it still retrieve that preference after the user changes tenants, revokes it, or reaches its expiry date?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That second question caught more important defects than the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Better Recall Is Not Always Better Memory
&lt;/h2&gt;

&lt;p&gt;Agent benchmarks often reward successful recall. Production systems must also measure forbidden recall.&lt;/p&gt;

&lt;p&gt;Can the agent avoid retrieving another tenant’s data? Can it forget expired workflow state? Can it distinguish verified preferences from untrusted text? Can it explain why a memory entered the prompt?&lt;/p&gt;

&lt;p&gt;These boundaries deserve attention when teams &lt;a href="https://spaculus.com/services/hire-mean-stack-developers/" rel="noopener noreferrer"&gt;hire MEAN stack developers&lt;/a&gt; for agentic applications. At Spaculus Software, we treat memory as governed application data, not an unlimited extension of the model’s context window.&lt;/p&gt;

&lt;p&gt;The safest agent is not the one that remembers everything.&lt;/p&gt;

&lt;p&gt;It is the one that can prove why each memory exists, who owns it, and when it must stop influencing decisions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What would your agent need to forget before you trusted it with customer data?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mongodb</category>
      <category>node</category>
      <category>security</category>
    </item>
    <item>
      <title>My RAG Eval Passed. The Citations Were Still Wrong</title>
      <dc:creator>Paul-S</dc:creator>
      <pubDate>Thu, 08 Oct 2026 11:52:23 +0000</pubDate>
      <link>https://dev.to/paul-s/my-rag-eval-passed-the-citations-were-still-wrong-ilb</link>
      <guid>https://dev.to/paul-s/my-rag-eval-passed-the-citations-were-still-wrong-ilb</guid>
      <description>&lt;h1&gt;
  
  
  My RAG Eval Passed. The Citations Were Still Wrong
&lt;/h1&gt;

&lt;p&gt;Consider this small regression test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Question:
What is the current refund period?

Retrieved:
POLICY-2025: Customers may request refunds within 30 days.
POLICY-2026: Customers may request refunds within 14 days.

Generated answer:
Customers may request refunds within 14 days. [POLICY-2025]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The answer contains the current policy.&lt;/p&gt;

&lt;p&gt;The expected document was retrieved.&lt;/p&gt;

&lt;p&gt;A citation was present.&lt;/p&gt;

&lt;p&gt;Several common RAG checks could mark this response as successful. Yet the citation points to evidence that contradicts the answer.&lt;/p&gt;

&lt;p&gt;That is a dangerous kind of failure because the response looks verifiable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Green Dashboard Missed a Separate Contract
&lt;/h2&gt;

&lt;p&gt;RAG evaluation often combines retrieval and generation into a few broad scores:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did search find a relevant document?&lt;/li&gt;
&lt;li&gt;Was the response related to the question?&lt;/li&gt;
&lt;li&gt;Was the answer grounded in the supplied context?&lt;/li&gt;
&lt;li&gt;Did the model include a citation?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those questions matter, but none proves that a specific citation supports the exact claim beside it.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://pages.nist.gov/trec-browser/trec34/rag/proceedings/" rel="noopener noreferrer"&gt;TREC 2025 RAG Track&lt;/a&gt; treats relevance, response completeness, attribution verification and agreement as separate evaluation layers. That separation is useful because citation quality can fail even when retrieval and generation appear healthy.&lt;/p&gt;

&lt;p&gt;A citation is more than formatting. It is a contract between one claim and one piece of evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Break the Answer Into Atomic Claims
&lt;/h2&gt;

&lt;p&gt;Suppose a chatbot produces this response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The Pro plan allows 20 users and includes priority support. [DOC-7]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That sentence contains at least two claims:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claim 1: The Pro plan allows 20 users.
Claim 2: The Pro plan includes priority support.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DOC-7&lt;/code&gt; might support the user limit while saying nothing about priority support. A sentence-level evaluator may give the entire sentence a passing score because part of it is supported.&lt;/p&gt;

&lt;p&gt;A claim-level evaluator should test each statement independently.&lt;/p&gt;

&lt;p&gt;Recent Q-CARE research follows a similar direction by decomposing answers into atomic claims and measuring whether each claim can be verified. Its evaluation correlated more closely with human judgments than several existing RAG metrics across the tested datasets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add a Citation Gate After Generation
&lt;/h2&gt;

&lt;p&gt;The generation step should return structured claims instead of only formatted prose.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsvnodgbwjz3w066qmlh4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsvnodgbwjz3w066qmlh4.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The refund period is 14 days.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POLICY-2026&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source_version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-09&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A separate citation gate can then inspect each claim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;evaluate_citations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;supports&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;failures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;claim&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claim&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source_not_found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;})&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source_version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
            &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claim&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;version_mismatch&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;})&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;supports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
            &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claim&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claim_not_supported&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;failures&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;supports&lt;/code&gt; function could use deterministic rules for exact values, a natural-language-inference model for descriptive claims or a calibrated LLM judge.&lt;/p&gt;

&lt;p&gt;I would avoid allowing the same model to write the answer and approve its own citations without another check. Research on citation attribution has found that LLM judges can also reject valid evidence, especially when terminology varies.&lt;/p&gt;

&lt;p&gt;High-risk claims may still need sampled human review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test Evidence Strength, Not Keyword Overlap
&lt;/h2&gt;

&lt;p&gt;A source can be relevant while remaining too weak for the wording used.&lt;/p&gt;

&lt;p&gt;Consider these two claims:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Evidence: The update may reduce processing time.

Claim A: The update may reduce processing time.
Claim B: The update will reduce processing time by 40%.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both discuss processing time. Only Claim A is supported.&lt;/p&gt;

&lt;p&gt;This failure is sometimes described as citation laundering: a related source makes an overstated claim appear trustworthy. FORCEBENCH research found that generic support prompts struggled to distinguish appropriately qualified claims from stronger unsupported versions.&lt;/p&gt;

&lt;p&gt;Your evaluator should check numbers, dates, conditions, scope and certainty. Text similarity alone cannot do that reliably.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the Failure Into CI
&lt;/h2&gt;

&lt;p&gt;Every citation bug found in production should become a regression case.&lt;/p&gt;

&lt;p&gt;Useful tests include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Two versions of the same policy&lt;/li&gt;
&lt;li&gt;One source supporting only half a sentence&lt;/li&gt;
&lt;li&gt;A correct fact attached to the wrong document&lt;/li&gt;
&lt;li&gt;A citation pointing to a deleted or inaccessible source&lt;/li&gt;
&lt;li&gt;Evidence using “may” while the answer says “will”&lt;/li&gt;
&lt;li&gt;Two documents that disagree about the same value&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For teams evaluating an &lt;a href="https://spaculus.com/ai-chatbot-development-company/" rel="noopener noreferrer"&gt;AI chatbot development company&lt;/a&gt;, asking how citations are tested is more revealing than asking whether the chatbot “uses RAG.”&lt;/p&gt;

&lt;p&gt;A healthy RAG system should prove four things independently: it retrieved useful evidence, produced a correct answer, covered the important claims and attached each claim to evidence that actually supports it.&lt;/p&gt;

&lt;p&gt;My evaluation passed because it tested whether citations existed.&lt;/p&gt;

&lt;p&gt;The better test asks whether every citation deserves to be there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What citation failure would you add to this regression suite?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>python</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>AI SaaS Free Trials Are Becoming an Abuse Surface</title>
      <dc:creator>Paul-S</dc:creator>
      <pubDate>Tue, 06 Oct 2026 10:50:03 +0000</pubDate>
      <link>https://dev.to/paul-s/ai-saas-free-trials-are-becoming-an-abuse-surface-1053</link>
      <guid>https://dev.to/paul-s/ai-saas-free-trials-are-becoming-an-abuse-surface-1053</guid>
      <description>&lt;p&gt;Consider this composite incident trace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TRIAL INCIDENT 042

New accounts:          186
Unique email addresses: 186
Recognized devices:      4
High-cost generations: 912
Paid conversions:        0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing was technically compromised.&lt;/p&gt;

&lt;p&gt;No password was stolen. No database was breached. Every account used the signup form correctly and stayed within its advertised trial allowance.&lt;/p&gt;

&lt;p&gt;The product still lost money.&lt;/p&gt;

&lt;p&gt;Four devices had created 186 identities, consumed hundreds of expensive AI operations, exported the results, and disappeared. The free trial had become an unprotected compute endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trial Is Now Part of Your Attack Surface
&lt;/h2&gt;

&lt;p&gt;A traditional SaaS trial might expose dashboards, sample data, and limited storage. Supporting another trial user may add almost no immediate infrastructure cost.&lt;/p&gt;

&lt;p&gt;An AI product behaves differently.&lt;/p&gt;

&lt;p&gt;Every prompt can trigger model inference, retrieval, embeddings, image generation, document parsing, external API calls, or agent tools. A user does not need to breach the system to create damage. They only need to repeatedly consume something expensive.&lt;/p&gt;

&lt;p&gt;This problem is accelerating. Stripe reported that its models detected &lt;strong&gt;6.2 times more abusive free trials between November 2025 and February 2026&lt;/strong&gt;. The company also noted that AI businesses are particularly exposed because each generated output creates a direct compute cost.&lt;/p&gt;

&lt;p&gt;That changes the engineering question.&lt;/p&gt;

&lt;p&gt;It is no longer only:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How many free requests should a trial include?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It becomes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How much unverified economic risk should one identity control?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Signup Validation Is Necessary but Insufficient
&lt;/h2&gt;

&lt;p&gt;Email verification stops basic scripts. It does not establish that 20 verified email addresses represent 20 different people.&lt;/p&gt;

&lt;p&gt;Attackers can rotate disposable inboxes, virtual phone numbers, payment cards, residential proxies, and browser profiles. Blocking one signal usually pushes the activity toward another.&lt;/p&gt;

&lt;p&gt;Cloudflare’s account-abuse guidance recommends examining activity across users, IP addresses, devices, locations, browsers, logins, and registrations. The important word is &lt;strong&gt;across&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The application needs to connect events that appear legitimate when viewed separately.&lt;/p&gt;

&lt;p&gt;For example, one account generating 30 reports may be an engaged prospect. Twenty accounts generating the same reports from related devices are a different pattern.&lt;/p&gt;

&lt;p&gt;This is why a CAPTCHA at registration cannot be the entire defense. The system must continue evaluating behavior after access has been granted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give Every Trial a Risk Budget
&lt;/h2&gt;

&lt;p&gt;A useful design is to separate the advertised trial allowance from an internal risk budget.&lt;/p&gt;

&lt;p&gt;The allowance describes what a normal customer can use. The risk budget decides how much costly activity the system permits before requesting stronger verification.&lt;/p&gt;

&lt;p&gt;A simplified policy could look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;evaluate_trial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;linked_accounts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DENY_EXPENSIVE_ACTIONS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;estimated_cost_hour&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;REQUIRE_PAYMENT_METHOD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;concurrent_jobs&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;QUEUE_AND_REVIEW&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;identity_risk&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;REDUCE_RATE_LIMIT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALLOW&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These numbers are illustrative, not universal defaults. A coding assistant, video generator, and document-analysis platform have completely different cost profiles.&lt;/p&gt;

&lt;p&gt;The important design choice is that the decision uses more than request count. It considers linked identities, estimated cost, concurrency, verification, and behavior.&lt;/p&gt;

&lt;p&gt;Rate limiting one IP address is not enough when accounts can move between networks. Limiting one account is not enough when identities are inexpensive to create.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add Friction to the Expensive Moment
&lt;/h2&gt;

&lt;p&gt;Many teams put their strongest verification at signup. That can hurt conversion before the user has experienced any value.&lt;/p&gt;

&lt;p&gt;A better approach is progressive friction.&lt;/p&gt;

&lt;p&gt;Let a new user explore the interface or complete a small generation. Request additional verification when the user tries to run a GPU-heavy workflow, create an API credential, launch concurrent jobs, export large outputs, or consume more than a defined cost threshold.&lt;/p&gt;

&lt;p&gt;The response does not always need to be “block the account.” The system can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reduce generation speed&lt;/li&gt;
&lt;li&gt;Queue costly operations&lt;/li&gt;
&lt;li&gt;Limit concurrency&lt;/li&gt;
&lt;li&gt;Request a payment method&lt;/li&gt;
&lt;li&gt;Require business-email verification&lt;/li&gt;
&lt;li&gt;Route unusual activity for review&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This preserves a low-friction path for genuine prospects while increasing the cost of repeated abuse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop Measuring Trial Success by Signups
&lt;/h2&gt;

&lt;p&gt;An abuse campaign can make a growth dashboard look healthy.&lt;/p&gt;

&lt;p&gt;Registrations increase. Activation rises. Feature usage reaches a record level. Infrastructure spending follows it upward.&lt;/p&gt;

&lt;p&gt;If the team measures only signups and activation, abusive activity can look like product-market traction.&lt;/p&gt;

&lt;p&gt;AI SaaS teams should also monitor cost per verified trial, model spend per conversion, linked-account recurrence, challenge completion, failed payment patterns, and the percentage of usage created by newly registered accounts.&lt;/p&gt;

&lt;p&gt;Stripe’s guidance warns that trial abuse can distort product analytics in addition to increasing infrastructure and API costs. Polluted data can lead the product team to prioritize workflows that genuine customers barely use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design Revocation Before Issuing Access
&lt;/h2&gt;

&lt;p&gt;Blocking new requests is only part of containment.&lt;/p&gt;

&lt;p&gt;A trial may already have API keys, active agent runs, scheduled jobs, generated download links, or connected third-party tools. When an account is restricted, those capabilities must be revoked together.&lt;/p&gt;

&lt;p&gt;That means trial status should be checked when an expensive action executes, not only when the user logs in.&lt;/p&gt;

&lt;p&gt;For teams evaluating a &lt;a href="https://spaculus.com/services/saas-application-development-company/" rel="noopener noreferrer"&gt;SaaS application development company&lt;/a&gt;, this is an important architecture question to raise. At Spaculus Software, we treat trial controls as part of entitlement, cost, and security design rather than a billing-screen setting.&lt;/p&gt;

&lt;p&gt;A free trial should help a genuine user reach value quickly.&lt;/p&gt;

&lt;p&gt;It should not provide anonymous, renewable access to your most expensive infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If someone created 100 trial accounts tonight, which resource in your SaaS product would cost you the most?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>saas</category>
    </item>
    <item>
      <title>The Flutter Upgrade Passed CI. Plugins Failed on Device</title>
      <dc:creator>Paul-S</dc:creator>
      <pubDate>Wed, 30 Sep 2026 11:25:51 +0000</pubDate>
      <link>https://dev.to/paul-s/the-flutter-upgrade-passed-ci-plugins-failed-on-device-16lm</link>
      <guid>https://dev.to/paul-s/the-flutter-upgrade-passed-ci-plugins-failed-on-device-16lm</guid>
      <description>&lt;p&gt;The upgrade pipeline looked perfect.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flutter analyze                         PASS
flutter test                            PASS
flutter build apk --release             PASS
flutter build ios --release --no-codesign PASS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pull request was approved. The new build reached the testing team.&lt;/p&gt;

&lt;p&gt;Then the device reports arrived.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Android: Push notifications never register
iOS: Sign-in returns to a blank screen
Both: Unit tests remain green
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CI had not lied. It had successfully tested what we asked it to test.&lt;/p&gt;

&lt;p&gt;The problem was what we never asked it to test.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Green Build Does Not Prove Plugin Behaviour
&lt;/h2&gt;

&lt;p&gt;Most Flutter applications are not entirely Flutter.&lt;/p&gt;

&lt;p&gt;A plugin usually contains a Dart-facing API and a native implementation written in Kotlin, Java, Swift, or Objective-C. The two sides communicate through platform channels or native bindings.&lt;/p&gt;

&lt;p&gt;That creates a longer execution path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Dart code
   ↓
Platform channel
   ↓
Plugin registration
   ↓
Android or iOS lifecycle
   ↓
Native SDK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Dart unit test may validate the first layer while never loading the remaining four.&lt;/p&gt;

&lt;p&gt;Flutter’s documentation explains that plugin unit tests often replace the platform implementation with mocks. That is useful for testing application logic, but a mocked method channel cannot reveal that a native class failed to register or an operating-system callback stopped firing.&lt;/p&gt;

&lt;p&gt;The test passes because the native failure never enters the test process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Framework Upgrades Move Native Boundaries Too
&lt;/h2&gt;

&lt;p&gt;A Flutter upgrade is rarely just a Dart SDK change.&lt;/p&gt;

&lt;p&gt;On Android, it can affect the Android Gradle Plugin, Gradle itself, Java compatibility, Kotlin configuration, manifest handling, and plugin build scripts.&lt;/p&gt;

&lt;p&gt;Recent Flutter migration guidance illustrates this clearly. Built-in Kotlin support changes how Android projects and plugins are configured, while add-to-app projects may require manual migration because Flutter cannot rewrite the native host application automatically.&lt;/p&gt;

&lt;p&gt;On iOS, the move toward the &lt;code&gt;UIScene&lt;/code&gt; lifecycle changes where application events are delivered. Plugins relying on older &lt;code&gt;AppDelegate&lt;/code&gt; callbacks may compile correctly but fail when the application receives a URL, notification, or lifecycle event on a real device.&lt;/p&gt;

&lt;p&gt;Compilation answers one question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can these files become an application?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It does not answer another:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Will every native capability behave correctly when the operating system invokes it?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Debug the Boundary, Not Just the Dart Exception
&lt;/h2&gt;

&lt;p&gt;When a plugin fails after an upgrade, the Dart error is often the end of the trail rather than the beginning.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;MissingPluginException&lt;/code&gt;, frozen method call, or empty callback could originate from several places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The plugin was not registered with the current engine.&lt;/li&gt;
&lt;li&gt;The Android activity or iOS scene changed before a callback returned.&lt;/li&gt;
&lt;li&gt;A required manifest entry, entitlement, or permission is missing.&lt;/li&gt;
&lt;li&gt;The plugin supports debug builds but fails after release optimization.&lt;/li&gt;
&lt;li&gt;Its native dependency is incompatible with the new build toolchain.&lt;/li&gt;
&lt;li&gt;The plugin’s Dart package was updated without a matching native migration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Start by reproducing the failure on one known device. Then inspect native logs through Android Studio, &lt;code&gt;adb logcat&lt;/code&gt;, Xcode, or the macOS Console.&lt;/p&gt;

&lt;p&gt;Follow one call across the boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Did Dart send the method call?
Did the native handler receive it?
Did the operating system start the requested action?
Did the callback return?
Did Flutter receive the result?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is usually faster than repeatedly changing Dart code around a native failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put Plugin Workflows Into the Upgrade Gate
&lt;/h2&gt;

&lt;p&gt;A better upgrade pipeline includes tests that launch the application on the platforms it supports.&lt;/p&gt;

&lt;p&gt;Flutter’s &lt;code&gt;integration_test&lt;/code&gt; package can run tests on emulators, simulators, physical devices, and device farms. Use it for critical workflows that depend on plugins, such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Authentication redirects&lt;/li&gt;
&lt;li&gt;Camera and file access&lt;/li&gt;
&lt;li&gt;Push-notification registration&lt;/li&gt;
&lt;li&gt;Payments and in-app purchases&lt;/li&gt;
&lt;li&gt;Deep links and app links&lt;/li&gt;
&lt;li&gt;Location and background services&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not test only the happy path.&lt;/p&gt;

&lt;p&gt;Deny a permission and request it again. Put the app in the background during a callback. Launch it from a notification. Open a deep link after the process has been terminated. Run the signed release build, not only the debug build.&lt;/p&gt;

&lt;p&gt;The minimum upgrade evidence should look closer to this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Static analysis                 PASS
Dart unit tests                 PASS
Android release build           PASS
iOS release build               PASS
Android plugin workflows        PASS
iOS plugin workflows            PASS
Background and cold-start tests PASS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CI is still important. It simply needs coverage beyond compilation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change the Definition of “Upgrade Complete”
&lt;/h2&gt;

&lt;p&gt;An upgrade is not complete when &lt;code&gt;flutter build&lt;/code&gt; exits with code zero.&lt;/p&gt;

&lt;p&gt;It is complete when the application’s important native paths have been exercised on Android and iOS, using the build modes and operating-system behaviours customers will actually encounter.&lt;/p&gt;

&lt;p&gt;That may reveal that a plugin must be upgraded, patched, replaced, or temporarily pinned. It may also expose a missing native migration in the application itself.&lt;/p&gt;

&lt;p&gt;This is the kind of evidence teams should expect from a &lt;a href="https://spaculus.com/services/hybrid-app-development-company/" rel="noopener noreferrer"&gt;hybrid app development company&lt;/a&gt;: not only a green pipeline, but a clear device matrix showing which plugin-backed workflows were tested.&lt;/p&gt;

&lt;p&gt;The Flutter upgrade passed CI because the pipeline proved that the project could build.&lt;/p&gt;

&lt;p&gt;The plugins failed because the application had not yet proved that it could run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which Flutter plugin has caused your most difficult device-only failure?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
    </item>
  </channel>
</rss>
