<?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>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>
    <item>
      <title>I Built a Laravel Agent That Must Ask Permission</title>
      <dc:creator>Paul-S</dc:creator>
      <pubDate>Mon, 28 Sep 2026 11:59:36 +0000</pubDate>
      <link>https://dev.to/paul-s/i-built-a-laravel-agent-that-must-ask-permission-366i</link>
      <guid>https://dev.to/paul-s/i-built-a-laravel-agent-that-must-ask-permission-366i</guid>
      <description>&lt;p&gt;The agent had already done the difficult work.&lt;/p&gt;

&lt;p&gt;It found the customer’s order, checked the payment status, read the refund policy, and calculated the amount.&lt;/p&gt;

&lt;p&gt;Then it proposed this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Action: Issue refund
Order: ORD-1842
Amount: $129.00
Reason: Customer received a damaged product
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The calculation looked reasonable. The customer qualified for a refund.&lt;/p&gt;

&lt;p&gt;I still did not want the model pressing the final button.&lt;/p&gt;

&lt;p&gt;A refund changes real business data and moves money. The agent could recommend the action, but a person needed to approve it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Dangerous Line Was One Method Call
&lt;/h2&gt;

&lt;p&gt;My first version exposed an &lt;code&gt;IssueRefund&lt;/code&gt; tool directly to the agent.&lt;/p&gt;

&lt;p&gt;The instructions included a sentence like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Always ask a manager before issuing refunds above $25.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That sounded safe until I considered what enforced it.&lt;/p&gt;

&lt;p&gt;The same model deciding to issue the refund was also responsible for remembering when it needed permission. A changed prompt, incomplete conversation, or different model response could bypass that instruction.&lt;/p&gt;

&lt;p&gt;The permission boundary belonged in PHP.&lt;/p&gt;

&lt;p&gt;Laravel released &lt;a href="https://laravel.com/blog/introducing-laravel-ai-sdk-v1" rel="noopener noreferrer"&gt;AI SDK v1.0&lt;/a&gt; on September 23, 2026. Among its production features is human approval for agent tools. An approvable tool pauses before its &lt;code&gt;handle()&lt;/code&gt; method executes.&lt;/p&gt;

&lt;p&gt;That was the behavior I needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the Refund Tool Approvable
&lt;/h2&gt;

&lt;p&gt;Here is a simplified version of the tool:&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\Tools&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\Approvals\Approval&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\Concerns\InteractsWithApprovals&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\Approvable&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;IssueRefund&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Approvable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;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;InteractsWithApprovals&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;'Issue an approved refund for an existing order.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;needsApproval&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;Approval&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&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;'amount_cents'&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;$amount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;2500&lt;/span&gt;
            &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
            &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Approval&lt;/span&gt;&lt;span class="o"&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;'Refunds above $25 require manager approval.'&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RefundService&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;issueOnce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&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;'order_id'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;amountInCents&lt;/span&gt;&lt;span class="o"&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;'amount_cents'&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;$request&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="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;'order_id'&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;'amount_cents'&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;integer&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;min&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="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;Refunds of $25 or less can continue automatically. Anything above that amount produces an approval request.&lt;/p&gt;

&lt;p&gt;The threshold is ordinary application logic. The model cannot negotiate with it or reinterpret it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Pause Is a Real Workflow State
&lt;/h2&gt;

&lt;p&gt;When the agent selects &lt;code&gt;IssueRefund&lt;/code&gt;, Laravel returns the pending tool call instead of executing it.&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;RefundAgent&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;$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="s1"&gt;'Refund the damaged order ORD-1842.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasPendingApprovals&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;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;pendingApprovals&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$approval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Display the tool name, arguments and reason.&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 approval screen should show the exact order, amount, reason, and requested action. A vague “Allow this agent to continue?” message does not give the reviewer enough information.&lt;/p&gt;

&lt;p&gt;After the manager approves or rejects the request, the application resumes the conversation:&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\Approvals\Decision&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\Approvals\Decisions&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;RefundAgent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$conversationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$manager&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="nc"&gt;Decisions&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;$toolCallId&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Decision&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="p"&gt;]));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Laravel also supports rejecting the request or editing its arguments before execution. The &lt;a href="https://laravel.com/framework/docs/ai-sdk#human-tool-approval" rel="noopener noreferrer"&gt;human tool approval documentation&lt;/a&gt; covers the full flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approval Is Not Authorization
&lt;/h2&gt;

&lt;p&gt;This was the most important lesson from the build.&lt;/p&gt;

&lt;p&gt;An approval button does not prove that the current user is allowed to approve the refund.&lt;/p&gt;

&lt;p&gt;Before resuming the agent, the application must verify that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The conversation belongs to the correct account&lt;/li&gt;
&lt;li&gt;The signed-in user has refund approval permission&lt;/li&gt;
&lt;li&gt;The order still qualifies for a refund&lt;/li&gt;
&lt;li&gt;The approved amount remains valid&lt;/li&gt;
&lt;li&gt;The action has not already been completed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The refund service also needs an idempotency rule. If a queue retries or a user submits the approval twice, the customer should still receive only one refund.&lt;/p&gt;

&lt;p&gt;The official documentation warns that paused turns are matched through the conversation and pending tool calls. The application is responsible for authorizing access before resuming them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tests That Matter
&lt;/h2&gt;

&lt;p&gt;A happy-path test proves that an approved refund can succeed. That is only the beginning.&lt;/p&gt;

&lt;p&gt;I would also test these cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An unauthorized employee attempts to approve the refund&lt;/li&gt;
&lt;li&gt;The order status changes while approval is pending&lt;/li&gt;
&lt;li&gt;A manager edits the amount before approval&lt;/li&gt;
&lt;li&gt;The same approval is submitted twice&lt;/li&gt;
&lt;li&gt;The approval references an unknown tool-call ID&lt;/li&gt;
&lt;li&gt;A rejected action returns a useful explanation&lt;/li&gt;
&lt;li&gt;An old approval expires before execution&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These tests check the business boundary around the model rather than the quality of its wording.&lt;/p&gt;

&lt;p&gt;If you are comparing teams under a search such as &lt;a href="https://spaculus.com/services/best-laravel-development-company/" rel="noopener noreferrer"&gt;best Laravel development company&lt;/a&gt;, ask how they would authorize, resume, audit, and retry this workflow. That answer reveals more than another framework quiz.&lt;/p&gt;

&lt;p&gt;The agent can collect evidence and recommend a refund.&lt;/p&gt;

&lt;p&gt;PHP decides whether the tool must pause. Laravel records the pending action. An authorized person makes the final decision. The service executes it once.&lt;/p&gt;

&lt;p&gt;That separation is what made the agent safe enough to use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which action in your application should an AI agent never execute without approval?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>agents</category>
      <category>ai</category>
    </item>
    <item>
      <title>Banks Know Their Customers. Now They Need to Know Their AI Agents.</title>
      <dc:creator>Paul-S</dc:creator>
      <pubDate>Thu, 17 Sep 2026 09:58:56 +0000</pubDate>
      <link>https://dev.to/paul-s/banks-know-their-customers-now-they-need-to-know-their-ai-agents-ka5</link>
      <guid>https://dev.to/paul-s/banks-know-their-customers-now-they-need-to-know-their-ai-agents-ka5</guid>
      <description>&lt;p&gt;“Who authorized this transaction?”&lt;/p&gt;

&lt;p&gt;“The customer.”&lt;/p&gt;

&lt;p&gt;“Which agent submitted it?”&lt;/p&gt;

&lt;p&gt;“We do not record that separately.”&lt;/p&gt;

&lt;p&gt;“Which tools was the agent allowed to use?”&lt;/p&gt;

&lt;p&gt;“It used the customer’s session.”&lt;/p&gt;

&lt;p&gt;“Can we revoke the agent without blocking the customer?”&lt;/p&gt;

&lt;p&gt;Silence.&lt;/p&gt;

&lt;p&gt;This is the architecture review many banks will eventually face.&lt;/p&gt;

&lt;p&gt;Banks already know how to identify customers, employees, applications, and service accounts. AI agents introduce a less familiar actor: software that can interpret a goal, select tools, collect data, and initiate actions on someone else’s behalf.&lt;/p&gt;

&lt;p&gt;Confirming the customer’s identity is no longer enough. The bank must also know which agent is acting, who owns it, what authority it received, and where that authority ends.&lt;/p&gt;

&lt;h2&gt;
  
  
  Know Your Agent Is a Chain of Trust
&lt;/h2&gt;

&lt;p&gt;At the Global Fintech Fest 2026, State Bank of India Chairman CS Setty proposed a “Know Your Agent” framework covering agent identity, authentication, customer consent, transaction limits, audit trails, and revocation.&lt;/p&gt;

&lt;p&gt;His warning was important. An autonomous error can travel through connected systems at machine speed. The same automation that reduces waiting time can also multiply the effect of a bad decision.&lt;/p&gt;

&lt;p&gt;A banking agent should therefore never appear in a transaction as an invisible extension of the customer.&lt;/p&gt;

&lt;p&gt;The trust chain should remain visible:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Customer → Consent → Agent → Policy Decision → Banking API&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;If one link is missing, the bank cannot confidently explain why an action happened.&lt;/p&gt;

&lt;p&gt;Authentication proves which agent made the request. Authorization determines whether that particular agent may perform that particular action for that customer at that moment.&lt;/p&gt;

&lt;p&gt;Banks need both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give the Agent a Passport, Not a Master Key
&lt;/h2&gt;

&lt;p&gt;An agent identity should carry a small, verifiable description of its authority. Think of it as a temporary passport rather than a reusable API key.&lt;/p&gt;

&lt;p&gt;Here is an illustrative manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;agent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;card-dispute-prod&lt;/span&gt;
  &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;disputes-team&lt;/span&gt;
  &lt;span class="na"&gt;acting_for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;customer-8421&lt;/span&gt;

  &lt;span class="na"&gt;permitted&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;transactions.read&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;fee_reversal.draft&lt;/span&gt;

  &lt;span class="na"&gt;prohibited&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;money.transfer&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;beneficiary.create&lt;/span&gt;

  &lt;span class="na"&gt;approval_required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;fee_reversal.execute&lt;/span&gt;

  &lt;span class="na"&gt;expires_in&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This information should not exist only inside the model’s prompt. Prompts can be altered, misunderstood, or exposed to injection attacks.&lt;/p&gt;

&lt;p&gt;The passport must be issued, signed, and checked by infrastructure outside the model. Every banking API should evaluate it before accepting an action.&lt;/p&gt;

&lt;p&gt;This also separates three identities that teams often combine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The customer requesting help&lt;/li&gt;
&lt;li&gt;The agent interpreting the request&lt;/li&gt;
&lt;li&gt;The service executing the action&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That separation makes investigation and revocation possible.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.nccoe.nist.gov/sites/default/files/2026-02/accelerating-the-adoption-of-software-and-ai-agent-identity-and-authorization-concept-paper.pdf" rel="noopener noreferrer"&gt;NIST National Cybersecurity Center of Excellence&lt;/a&gt; is exploring similar questions around agent identification, authentication, least privilege, delegated access, human approval, and verifiable audit records. Its 2026 concept paper also considers established technologies such as OAuth, OpenID Connect, SPIFFE, and SCIM.&lt;/p&gt;

&lt;p&gt;Banks may not need a completely new identity system. They may need to adapt proven identity controls to a new class of non-human user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authority Should Shrink as Consequence Grows
&lt;/h2&gt;

&lt;p&gt;Not every agent action carries equal risk.&lt;/p&gt;

&lt;p&gt;A banking assistant might operate in four lanes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Explain:&lt;/strong&gt; Describe a fee or summarize account activity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prepare:&lt;/strong&gt; Collect details and draft a dispute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recommend:&lt;/strong&gt; Suggest a decision to an employee.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Execute:&lt;/strong&gt; Change data, move money, or approve an outcome.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Moving from explanation to execution should not simply unlock more tools. It should narrow the acceptable conditions.&lt;/p&gt;

&lt;p&gt;A balance explanation may require authenticated account access. Adding a beneficiary should require fresh customer confirmation. Reversing a fee may need an employee’s approval. Moving money should require stricter limits, stronger authentication, and an independent fraud check.&lt;/p&gt;

&lt;p&gt;My view is that banks should automate evidence gathering before automating irreversible decisions. It produces meaningful efficiency while keeping responsibility with a person who is accountable for the outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Financial-Crime Work Shows a Safer Starting Point
&lt;/h2&gt;

&lt;p&gt;FIS offers a useful example. Its &lt;a href="https://www.fisglobal.com/about-us/media-room/press-release/2026/fis-brings-agentic-ai-to-banking-with-anthropic-starting-with-financial-crimes" rel="noopener noreferrer"&gt;Financial Crimes AI Agent&lt;/a&gt; is designed to assemble evidence from banking systems, evaluate activity, and surface higher-risk cases.&lt;/p&gt;

&lt;p&gt;The investigator still controls the decision.&lt;/p&gt;

&lt;p&gt;That division of work matters. The agent handles the repetitive search across disconnected systems. A qualified human evaluates the evidence and accepts responsibility for the conclusion.&lt;/p&gt;

&lt;p&gt;This is a more defensible starting point than allowing a general-purpose agent to perform every available banking action simply because the customer is authenticated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Revocation Should Be Boring
&lt;/h2&gt;

&lt;p&gt;If an agent behaves unexpectedly, stopping it should not require disabling the customer’s account or rotating a shared production credential.&lt;/p&gt;

&lt;p&gt;Agent authority should be short-lived and revocable independently.&lt;/p&gt;

&lt;p&gt;A bank should be able to answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which agent is active?&lt;/li&gt;
&lt;li&gt;Who or what delegated its authority?&lt;/li&gt;
&lt;li&gt;Which resources can it access?&lt;/li&gt;
&lt;li&gt;What actions has it attempted?&lt;/li&gt;
&lt;li&gt;When does its permission expire?&lt;/li&gt;
&lt;li&gt;Can the bank terminate it immediately?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are ordinary identity-management questions. What changes is the speed and flexibility of the software being controlled.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Next Banking User May Not Be Human
&lt;/h2&gt;

&lt;p&gt;This shift also changes how conversational banking products should be built.&lt;/p&gt;

&lt;p&gt;For an &lt;a href="https://spaculus.com/ai-chatbot-development-company/" rel="noopener noreferrer"&gt;AI chatbot development company&lt;/a&gt;, improving response quality is no longer the complete assignment. Once a chatbot can call tools and modify business systems, its identity, permissions, approval boundaries, and audit trail become part of the product.&lt;/p&gt;

&lt;p&gt;At Spaculus Software, that means treating an AI agent as a governed system actor, not merely a conversational interface.&lt;/p&gt;

&lt;p&gt;Banks have spent decades learning who their customers are. The next challenge is knowing which software is standing beside those customers, what it has been asked to do, and whether it should be allowed to continue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If an AI agent contacted your banking API today, could your system identify the agent separately from the customer?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>fintech</category>
      <category>security</category>
      <category>architecture</category>
    </item>
    <item>
      <title>An AI Engineer’s Hardest Problem Is Knowing When the Model Should Stop</title>
      <dc:creator>Paul-S</dc:creator>
      <pubDate>Tue, 15 Sep 2026 12:05:21 +0000</pubDate>
      <link>https://dev.to/paul-s/an-ai-engineers-hardest-problem-is-knowing-when-the-model-should-stop-3fjo</link>
      <guid>https://dev.to/paul-s/an-ai-engineers-hardest-problem-is-knowing-when-the-model-should-stop-3fjo</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RUN 4821
Goal: Process customer refund
Order found: Yes
Refund policy found: Yes
Policy versions found: 2
Amounts calculated: $89 and $129
Tool retries: 4
Next action selected: Issue $129 refund
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent found conflicting policies, calculated two different amounts, and still selected an action.&lt;/p&gt;

&lt;p&gt;That last line might look like successful task completion.&lt;/p&gt;

&lt;p&gt;It is actually the failure.&lt;/p&gt;

&lt;p&gt;The agent should have stopped when it discovered that the evidence could not support one safe decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Missing State in Most Agent Workflows
&lt;/h2&gt;

&lt;p&gt;Many agent workflows define only two 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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But an agent can execute every tool successfully and still make the wrong decision. The API responded. The database was available. No exception occurred.&lt;/p&gt;

&lt;p&gt;The uncertainty existed in the business context, not the infrastructure.&lt;/p&gt;

&lt;p&gt;Production agents need more meaningful terminal states:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ASK_FOR_INPUT
WAIT_FOR_APPROVAL
ESCALATE_TO_HUMAN
DENY_ACTION
STOP_LIMIT_REACHED
COMPLETE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A stop is not necessarily a failure. Sometimes it is the most correct output the system can produce.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Another Prompt Will Not Fix This
&lt;/h2&gt;

&lt;p&gt;A common response is to add instructions such as:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Only proceed when you are confident.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That delegates the boundary decision to the same probabilistic model creating the uncertainty.&lt;/p&gt;

&lt;p&gt;The model may report high confidence even when its evidence is incomplete. It may interpret repeated tool calls as a reason to keep investigating. A different model version could interpret the instruction differently.&lt;/p&gt;

&lt;p&gt;Prompts can guide reasoning. They should not be the only mechanism protecting consequential actions.&lt;/p&gt;

&lt;p&gt;The application needs a deterministic gate between the model’s recommendation and the real operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turn Stop Conditions Into Code
&lt;/h2&gt;

&lt;p&gt;Here is a framework-independent example:&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;next_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authorized&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;DENY_ACTION&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;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;conflicting_evidence&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;ESCALATE_TO_HUMAN&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;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;missing_required_data&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;ASK_FOR_INPUT&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;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;irreversible&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approved&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;WAIT_FOR_APPROVAL&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;run&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_attempts&lt;/span&gt;&lt;span class="sh"&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="mi"&gt;6&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;STOP_LIMIT_REACHED&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;COMPLETE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what this function does not check: the model’s self-reported confidence.&lt;/p&gt;

&lt;p&gt;It checks observable conditions such as authorization, missing data, conflicting evidence, reversibility, approval, and execution limits.&lt;/p&gt;

&lt;p&gt;The model can propose the next action. The surrounding software decides whether that action is allowed.&lt;/p&gt;

&lt;p&gt;OWASP calls the combination of excessive functionality, permissions, and autonomy &lt;a href="https://genai.owasp.org/llmrisk/llm062025-excessive-agency/" rel="noopener noreferrer"&gt;Excessive Agency&lt;/a&gt;. Its recommended controls include least-privilege access, limited tools, independent authorization checks, and human approval for high-impact actions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Break the Exit Paths Before Users Do
&lt;/h2&gt;

&lt;p&gt;Happy-path tests ask whether an agent can complete a task.&lt;/p&gt;

&lt;p&gt;Boundary tests ask whether it refuses to complete the wrong task.&lt;/p&gt;

&lt;p&gt;A small &lt;code&gt;pytest&lt;/code&gt; suite can make those decisions visible:&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;pytest&lt;/span&gt;


&lt;span class="nd"&gt;@pytest.mark.parametrize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;change, expected&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authorized&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DENY_ACTION&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;conflicting_evidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&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_TO_HUMAN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;missing_required_data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ASK_FOR_INPUT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;irreversible&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WAIT_FOR_APPROVAL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_attempts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;STOP_LIMIT_REACHED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_agent_exit_paths&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&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;base_run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;next_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These tests are simple, but they force the team to define expected behavior before an unusual request reaches production.&lt;/p&gt;

&lt;p&gt;The next layer is adversarial evaluation. Give the agent contradictory documents, expired authorization, unavailable tools, repeated timeouts, ambiguous user instructions, and requests that cross account boundaries.&lt;/p&gt;

&lt;p&gt;Do not score only the final answer. Record whether the agent chose to continue, ask, pause, deny, or escalate.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://nvlpubs.nist.gov/nistpubs/ai/NIST.AI.100-1.pdf" rel="noopener noreferrer"&gt;NIST AI Risk Management Framework&lt;/a&gt; recommends documenting knowledge limits, defining human oversight, testing under deployment-like conditions, and ensuring AI systems can fail safely when operating beyond their limits.&lt;/p&gt;

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

&lt;p&gt;I would not evaluate an AI engineer only by asking which models or agent frameworks they have used.&lt;/p&gt;

&lt;p&gt;I would give them the refund trace from the beginning of this article and ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Where should this workflow stop, and which component should enforce that decision?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A strong answer should discuss permissions, evidence quality, action reversibility, approval requirements, retry budgets, logging, and human handoff.&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 boundary-first evaluation is more revealing than another prompt-engineering exercise. It is also how Spaculus Software approaches production AI workflows: the model reasons, but the system retains control.&lt;/p&gt;

&lt;p&gt;The hardest part of agent engineering is not helping a model finish more tasks.&lt;/p&gt;

&lt;p&gt;It is making sure the system recognizes the tasks it must not finish alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What stop condition would you add before allowing an AI agent to change real business data?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>architecture</category>
      <category>security</category>
    </item>
    <item>
      <title>Your AI Engineer Is Debugging Workflows, Not Prompts</title>
      <dc:creator>Paul-S</dc:creator>
      <pubDate>Fri, 11 Sep 2026 11:10:44 +0000</pubDate>
      <link>https://dev.to/paul-s/your-ai-engineer-is-debugging-workflows-not-prompts-2e8l</link>
      <guid>https://dev.to/paul-s/your-ai-engineer-is-debugging-workflows-not-prompts-2e8l</guid>
      <description>&lt;p&gt;The support agent looked fine during testing.&lt;/p&gt;

&lt;p&gt;It understood refund requests, found the correct order, explained the policy, and asked for confirmation before taking action.&lt;/p&gt;

&lt;p&gt;Then it reached production and refunded the same order twice.&lt;/p&gt;

&lt;p&gt;The first reaction was predictable: rewrite the prompt.&lt;/p&gt;

&lt;p&gt;Add “never issue the same refund twice.” Put it in capital letters. Repeat it near the tool instructions. Lower the temperature.&lt;/p&gt;

&lt;p&gt;None of that fixed the real problem.&lt;/p&gt;

&lt;p&gt;The payment API had completed the first refund, but its response timed out. The workflow interpreted the missing response as failure and retried the tool call. The model followed its instructions both times.&lt;/p&gt;

&lt;p&gt;This was not a prompt bug. It was a distributed-systems bug wearing an AI costume.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Prompt Is Only One Part of the System
&lt;/h2&gt;

&lt;p&gt;A demo often looks like this:&lt;/p&gt;

&lt;p&gt;user request -&amp;gt; model -&amp;gt; answer&lt;/p&gt;

&lt;p&gt;A production agent looks more like this:&lt;/p&gt;

&lt;p&gt;request -&amp;gt; context -&amp;gt; model -&amp;gt; tool -&amp;gt; external API&lt;br&gt;
        -&amp;gt; state update -&amp;gt; model -&amp;gt; approval -&amp;gt; final response&lt;/p&gt;

&lt;p&gt;Every arrow is another place where the workflow can fail.&lt;/p&gt;

&lt;p&gt;The wrong customer record may enter the context. A tool may receive valid JSON with the wrong business meaning. Authentication may expire halfway through a run. A background worker may retry after the external action has already succeeded.&lt;/p&gt;

&lt;p&gt;Prompt changes cannot repair those failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool Calls Create Real Side Effects
&lt;/h2&gt;

&lt;p&gt;Generating a poor paragraph is inconvenient. Sending an email, cancelling an order, or updating a CRM record changes something outside the model.&lt;/p&gt;

&lt;p&gt;That means consequential tools need the same protections as any other production service: input validation, authorization, timeouts, retry policies, idempotency, and audit logs.&lt;/p&gt;

&lt;p&gt;A simplified refund tool might protect repeated calls with an action identifier:&lt;/p&gt;

&lt;p&gt;async def refund_order(order_id: str, action_id: str):&lt;br&gt;
    previous = await actions.find(action_id)&lt;br&gt;
    if previous:&lt;br&gt;
        return previous.result&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;return await actions.run_once(
    action_id,
    lambda: payments.refund(order_id),
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The important instruction is not hidden in the prompt. It is enforced by the application: one logical action should produce one external effect.&lt;/p&gt;

&lt;p&gt;The production implementation still needs atomic storage and protection against concurrent requests, but the boundary is clear. The model can propose the action. The system decides whether that action is safe to execute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Traces Are More Useful Than Chat Logs
&lt;/h2&gt;

&lt;p&gt;A chat transcript shows what the user and model said. It rarely explains why an agent selected a tool, what arguments it generated, how long the API took, or which state was restored after a retry.&lt;/p&gt;

&lt;p&gt;An end-to-end trace should connect the model turn, tool call, approval, API response, state change, and final answer under one run identifier.&lt;/p&gt;

&lt;p&gt;The OpenAI Agents SDK tracing documentation reflects this approach. Its traces can capture generations, function calls, handoffs, guardrails, and custom events as parts of one workflow.&lt;/p&gt;

&lt;p&gt;This makes a useful debugging question possible:&lt;/p&gt;

&lt;p&gt;Where did the actual behavior first diverge from the expected behavior?&lt;/p&gt;

&lt;p&gt;That is much more precise than asking why the model was “confused.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Some Actions Should Pause
&lt;/h2&gt;

&lt;p&gt;Not every decision should be automated simply because the agent can call the necessary tool.&lt;/p&gt;

&lt;p&gt;Reading an order status may be low risk. Issuing a large refund, deleting a record, publishing content, or changing account permissions may require human approval.&lt;/p&gt;

&lt;p&gt;The human-in-the-loop guidance for the OpenAI Agents SDK demonstrates a pause, approve or reject, and resume pattern for sensitive tool calls. The specific framework is optional. The control is not.&lt;/p&gt;

&lt;p&gt;Experienced &lt;a href="https://spaculus.com/hire-ai-engineers/" rel="noopener noreferrer"&gt;AI engineers&lt;/a&gt; define these boundaries before production incidents define them instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evals Should Test the Workflow
&lt;/h2&gt;

&lt;p&gt;Testing ten prompts against ten expected answers is useful, but it does not test the complete system.&lt;/p&gt;

&lt;p&gt;Workflow evaluations should also ask whether the correct tool was selected, whether its arguments were valid, whether unauthorized data was excluded, whether duplicate actions were prevented, and whether low-confidence situations were escalated.&lt;/p&gt;

&lt;p&gt;Every production failure should become a regression case. Over time, the evaluation set becomes a record of how the system has failed in the real world.&lt;/p&gt;

&lt;p&gt;At Spaculus Software, this is why AI engineering work extends beyond model integration. Reliable systems require workflow design, tool boundaries, evaluation, observability, and safe recovery paths around the model.&lt;/p&gt;

&lt;p&gt;The prompt still matters. It guides intent, tone, reasoning, and tool selection.&lt;/p&gt;

&lt;p&gt;But when an AI application breaks in production, the most valuable engineer in the room is usually not asking, “How should we reword this?”&lt;/p&gt;

&lt;p&gt;They are asking, “What exactly happened between the request and the result?”&lt;/p&gt;

&lt;p&gt;What workflow failure has been hardest for you to diagnose?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>promptengineering</category>
      <category>programming</category>
    </item>
    <item>
      <title>I Connected an LLM to Production. Here’s What Broke</title>
      <dc:creator>Paul-S</dc:creator>
      <pubDate>Tue, 08 Sep 2026 10:54:30 +0000</pubDate>
      <link>https://dev.to/paul-s/i-connected-an-llm-to-production-heres-what-broke-4949</link>
      <guid>https://dev.to/paul-s/i-connected-an-llm-to-production-heres-what-broke-4949</guid>
      <description>&lt;p&gt;The demo looked ready.&lt;/p&gt;

&lt;p&gt;The application could search company documents, answer support questions, classify requests, and create tickets through an API. It handled every test prompt we gave it.&lt;/p&gt;

&lt;p&gt;Then real users arrived.&lt;/p&gt;

&lt;p&gt;They asked incomplete questions. They pasted entire email threads. They used terms that did not exist in our documentation. Some requests matched several policies, while others required information the system could not access.&lt;/p&gt;

&lt;p&gt;The LLM was still producing fluent answers. The workflow around it was falling apart.&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%2Flmwotnlb9v25r8rjnu2f.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%2Flmwotnlb9v25r8rjnu2f.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Output Was Valid Until It Wasn’t
&lt;/h2&gt;

&lt;p&gt;During testing, the model returned predictable JSON:&lt;/p&gt;

&lt;p&gt;{&lt;br&gt;
  "category": "billing",&lt;br&gt;
  "priority": "high",&lt;br&gt;
  "requires_human": true&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Production inputs were less predictable. Sometimes the model changed a field name, returned an unsupported category, or added an explanation outside the JSON object.&lt;/p&gt;

&lt;p&gt;A response that looks reasonable to a person can still break an application.&lt;/p&gt;

&lt;p&gt;The first improvement was to treat model output as untrusted input. Every response had to pass schema validation before the application could use it.&lt;/p&gt;

&lt;p&gt;from typing import Literal&lt;br&gt;
from pydantic import BaseModel&lt;/p&gt;

&lt;p&gt;class TicketAction(BaseModel):&lt;br&gt;
    category: Literal["billing", "technical", "account"]&lt;br&gt;
    priority: Literal["low", "medium", "high"]&lt;br&gt;
    requires_human: bool&lt;/p&gt;

&lt;p&gt;def validate_action(model_output: dict):&lt;br&gt;
    return TicketAction.model_validate(model_output)&lt;/p&gt;

&lt;p&gt;Structured output reduced parsing failures, but it did not prove that the selected category was correct. Format validation and business validation became separate steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  RAG Retrieved Something Relevant but Not Correct
&lt;/h2&gt;

&lt;p&gt;The knowledge base contained current policies, archived documents, internal notes, and several pages describing similar processes.&lt;/p&gt;

&lt;p&gt;The retrieval system often returned a document related to the question. That did not mean it returned the document needed to answer it.&lt;/p&gt;

&lt;p&gt;One customer asked about cancelling a subscription. The system retrieved an older cancellation policy because it shared more words with the question than the current policy did.&lt;/p&gt;

&lt;p&gt;The solution was not simply increasing the number of retrieved chunks.&lt;/p&gt;

&lt;p&gt;Documents needed version metadata, ownership, effective dates, and access rules. Archived content had to be removed from normal retrieval. The application also needed to recognise conflicting evidence and stop instead of asking the LLM to choose a convenient answer.&lt;/p&gt;

&lt;p&gt;RAG improved the model’s access to information. It did not remove the need to manage that information.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool Calling Turned Small Errors Into Real Actions
&lt;/h2&gt;

&lt;p&gt;A wrong answer is harmful. A wrong action can be worse.&lt;/p&gt;

&lt;p&gt;Once the LLM could create tickets, update CRM records, and trigger notifications, every uncertain decision had operational consequences.&lt;/p&gt;

&lt;p&gt;Retries created duplicate records. Incorrect parameters sent requests to the wrong queue. A broad service account gave the agent access to functions it never needed.&lt;/p&gt;

&lt;p&gt;We reduced this risk by giving each tool one narrow purpose. Tool arguments were validated outside the model, write operations used idempotency keys, and high-impact actions required confirmation.&lt;/p&gt;

&lt;p&gt;This follows the principle behind OWASP’s guidance on excessive agency: limit available tools, permissions, functionality, and autonomy.&lt;/p&gt;

&lt;p&gt;The model could recommend an action. The application decided whether that action was allowed.&lt;/p&gt;

&lt;h2&gt;
  
  
  One User Request Became Eight Model Calls
&lt;/h2&gt;

&lt;p&gt;The original demo made one request to an LLM.&lt;/p&gt;

&lt;p&gt;The production version classified the user’s intention, rewrote the search query, retrieved documents, reranked the results, generated an answer, checked the answer, selected a tool, and summarized the result.&lt;/p&gt;

&lt;p&gt;Each step appeared reasonable on its own. Together, they created noticeable latency and unpredictable costs.&lt;/p&gt;

&lt;p&gt;Agents made the problem harder because the number of steps could change for every request. A simple question might finish immediately, while an ambiguous request could enter a loop of repeated searches and tool calls.&lt;/p&gt;

&lt;p&gt;We added limits for execution time, model calls, retries, retrieved context, and total tokens. Smaller models handled basic classification, while stronger models were reserved for decisions that needed deeper reasoning.&lt;/p&gt;

&lt;p&gt;The goal was not to minimize every model call. It was to ensure that each call justified its cost and delay.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our Logs Said Everything Was Successful
&lt;/h2&gt;

&lt;p&gt;The API returned 200. The workflow was still wrong.&lt;/p&gt;

&lt;p&gt;Traditional logs showed that the request completed, but they did not explain which documents were retrieved, why a tool was selected, or where the answer changed.&lt;/p&gt;

&lt;p&gt;Production debugging required a trace of the complete workflow. We recorded the prompt version, model version, retrieved document IDs, tool arguments, tool results, latency, token usage, validation failures, and final outcome.&lt;/p&gt;

&lt;p&gt;Sensitive customer data was removed or masked before storage. Observability should help investigate failures without creating a new privacy problem.&lt;/p&gt;

&lt;p&gt;The NIST Generative AI Profile also emphasizes ongoing monitoring, documented responsibilities, incident handling, and human oversight for generative AI systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Evaluation Set Was Too Polite
&lt;/h2&gt;

&lt;p&gt;Our early tests asked clear questions with known answers.&lt;/p&gt;

&lt;p&gt;Real users did not behave like an evaluation dataset.&lt;/p&gt;

&lt;p&gt;They submitted vague instructions, conflicting requests, spelling mistakes, old account details, pasted web content, and questions requiring permissions they did not have.&lt;/p&gt;

&lt;p&gt;The evaluation set had to include those conditions. We added cases involving stale documents, conflicting sources, API timeouts, repeated actions, missing information, prompt injection, and requests that should be refused or escalated.&lt;/p&gt;

&lt;p&gt;Every production failure became a new regression test.&lt;/p&gt;

&lt;p&gt;That changed evaluation from a task completed before launch into a process that continued throughout the product’s life.&lt;/p&gt;

&lt;h2&gt;
  
  
  The LLM Was Only One Part of the Product
&lt;/h2&gt;

&lt;p&gt;The biggest lesson was simple: connecting an LLM to an application is not the same as building a production AI system.&lt;/p&gt;

&lt;p&gt;Reliable AI requires structured outputs, managed knowledge, secure tools, permission checks, human approvals, observability, cost controls, and realistic evaluations.&lt;/p&gt;

&lt;p&gt;This is also what teams should examine when they plan to &lt;a href="https://spaculus.com/hire-ai-engineers/" rel="noopener noreferrer"&gt;hire AI engineers&lt;/a&gt;. Prompting skills matter, but production AI development also requires backend engineering, API design, security, data management, testing, and operational thinking.&lt;/p&gt;

&lt;p&gt;The LLM did not suddenly become less capable after deployment. Production simply exposed every assumption that the demo never tested.&lt;/p&gt;

&lt;p&gt;What broke first when you moved an LLM application into production?&lt;/p&gt;

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