<?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: Orvi Das</title>
    <description>The latest articles on DEV Community by Orvi Das (@robat_das_3c6e956212f6408).</description>
    <link>https://dev.to/robat_das_3c6e956212f6408</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%2F3935636%2F521b2b21-6827-4e04-9db2-bdbe1f261ed0.jpg</url>
      <title>DEV Community: Orvi Das</title>
      <link>https://dev.to/robat_das_3c6e956212f6408</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/robat_das_3c6e956212f6408"/>
    <language>en</language>
    <item>
      <title>Pydantic vs Zod for Validating LLM Tool Call Arguments</title>
      <dc:creator>Orvi Das</dc:creator>
      <pubDate>Mon, 02 Nov 2026 04:30:09 +0000</pubDate>
      <link>https://dev.to/robat_das_3c6e956212f6408/pydantic-vs-zod-for-validating-llm-tool-call-arguments-5dj9</link>
      <guid>https://dev.to/robat_das_3c6e956212f6408/pydantic-vs-zod-for-validating-llm-tool-call-arguments-5dj9</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.robatdasorvi.com/chapters/ai-automation/pydantic-vs-zod-for-validating-llm-tool-call-arguments" rel="noopener noreferrer"&gt;robatdasorvi.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For validating LLM tool call arguments, Pydantic and Zod fail the same way out of the box. They quietly drop any key the model made up and accept the rest. Use Pydantic for Python agents and Zod for TypeScript ones, then close every object. The closing matters more than which library you pick.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a validator pass a tool call with a made-up field?
&lt;/h2&gt;

&lt;p&gt;Because nobody asked it to reject anything. Pydantic v2's &lt;code&gt;BaseModel&lt;/code&gt; defaults to &lt;code&gt;extra="ignore"&lt;/code&gt;, and Zod's &lt;code&gt;z.object&lt;/code&gt; strips unknown keys. So a call like &lt;code&gt;{"order_id": 4411, "refund_amount_usd": 50, "reason_code": "damaged"}&lt;/code&gt;, checked against a schema that only declares &lt;code&gt;order_id&lt;/code&gt; and &lt;code&gt;refund_amount_usd&lt;/code&gt;, comes back as a success with &lt;code&gt;reason_code&lt;/code&gt; removed. The check ran, the call passed, and the model's belief that it had given a reason vanishes without a trace in your logs.&lt;/p&gt;

&lt;p&gt;What bothers me about this failure is how quiet it is. No exception, no stack trace. The handler runs on a payload shorter than what the model wrote, and everything downstream looks normal. That is exactly why it survives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Has this kind of failure happened before?
&lt;/h2&gt;

&lt;p&gt;Yes, and the closest old case is an interface that accepted a number without saying what it meant. Mars Climate Orbiter, lost in September 1999, is the clearest example. The NASA Phase I Mishap Investigation Board report, dated 10 November 1999, found that ground software produced output in pound-force-seconds where newton-seconds were expected, a factor of about 4.45 (&lt;a href="https://llis.nasa.gov/llis_lib/pdf/1009464main1_0641-mr.pdf" rel="noopener noreferrer"&gt;NASA Mishap Investigation Board via LLIS&lt;/a&gt;). Every value was a plain number. Both were impulse values, and nothing in the interface said which unit system they used.&lt;/p&gt;

&lt;p&gt;An LLM tool schema is the same kind of interface. A field typed as &lt;code&gt;number&lt;/code&gt; accepts whatever the model believes a number is. Closing the object catches keys you never declared. It does not catch this second gap, a declared field whose meaning is ambiguous. For that, put the unit in the field name or description (&lt;code&gt;refund_amount_usd&lt;/code&gt;) and add range constraints (&lt;code&gt;Field(gt=0, le=10_000)&lt;/code&gt; in Pydantic, &lt;code&gt;.positive().max(10000)&lt;/code&gt; in Zod).&lt;/p&gt;

&lt;h2&gt;
  
  
  Pydantic or Zod: which one?
&lt;/h2&gt;

&lt;p&gt;Pick the one that matches your agent's language, then configure both the same way. Both can export JSON Schema, which is what the model provider actually sees.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Pydantic (Python)&lt;/th&gt;
&lt;th&gt;Zod 4 (TypeScript)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Default for unknown keys&lt;/td&gt;
&lt;td&gt;Ignored (&lt;code&gt;extra="ignore"&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Stripped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Close one object&lt;/td&gt;
&lt;td&gt;&lt;code&gt;model_config = ConfigDict(extra="forbid")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;z.strictObject({...})&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Export to JSON Schema&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Model.model_json_schema()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;z.toJSONSchema(schema)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coercion risk&lt;/td&gt;
&lt;td&gt;Lax mode accepts &lt;code&gt;"5"&lt;/code&gt; for &lt;code&gt;int&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Strict unless you use &lt;code&gt;z.coerce&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nested objects&lt;/td&gt;
&lt;td&gt;Only closed if the nested model is also closed&lt;/td&gt;
&lt;td&gt;Same: close each level&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The selection usually comes down to the agent SDK you already use, since that decides what you pass in. Python agent SDKs such as OpenAI's &lt;code&gt;pydantic_function_tool&lt;/code&gt;, Instructor, and PydanticAI take Pydantic models directly. TypeScript SDKs such as the Vercel AI SDK's &lt;code&gt;tool({ inputSchema })&lt;/code&gt; (named &lt;code&gt;parameters&lt;/code&gt; before AI SDK 5) and OpenAI's &lt;code&gt;zodFunction&lt;/code&gt; and &lt;code&gt;zodResponseFormat&lt;/code&gt; take Zod schemas directly. Both validate at runtime and both give you static types: Zod through &lt;code&gt;z.infer&lt;/code&gt;, Pydantic through the model class itself.&lt;/p&gt;

&lt;p&gt;A closed Pydantic model for the refund example looks 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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ConfigDict&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RefundArgs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;model_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ConfigDict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;forbid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strict&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;refund_amount_usd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;

&lt;span class="n"&gt;RefundArgs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_validate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4411&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refund_amount_usd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&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_code&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;damaged&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;  &lt;span class="c1"&gt;# raises ValidationError: reason_code, Extra inputs are not permitted
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Zod equivalent is one function name away:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RefundArgs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strictObject&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;refund_amount_usd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&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;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;RefundArgs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4411&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;refund_amount_usd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;damaged&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// parsed.success === false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter more than the syntax. Closing the root object does not close a nested &lt;code&gt;filters&lt;/code&gt; object, so every level needs the setting. And a closed schema only rejects after the fact. Providers now offer grammar-constrained decoding that stops the extra key from being generated at all. OpenAI's Structured Outputs, announced on 6 August 2024, requires &lt;code&gt;strict: true&lt;/code&gt; on the schema. The company reported that &lt;code&gt;gpt-4o-2024-08-06&lt;/code&gt; scored 100% on its complex schema-following evaluations, against under 40% for &lt;code&gt;gpt-4-0613&lt;/code&gt; (&lt;a href="https://openai.com/index/introducing-structured-outputs-in-the-api/" rel="noopener noreferrer"&gt;OpenAI, 2024&lt;/a&gt;). Constrained decoding and a validator on your side do different jobs. Within the JSON Schema subset it supports, the provider guarantees the shape. Refusals, and responses cut off at the token limit, can still come back non-conforming, and your validator is what tells you when the guarantee did not hold. OpenAI strict mode also requires &lt;code&gt;additionalProperties: false&lt;/code&gt; and every property listed as required. &lt;code&gt;extra="forbid"&lt;/code&gt; emits the first in &lt;code&gt;model_json_schema()&lt;/code&gt;, but optional fields need to be expressed as required-but-nullable to pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doesn't strict validation make the model reason worse?
&lt;/h2&gt;

&lt;p&gt;Sometimes, and this is the strongest objection. Tam and colleagues tested format restrictions on reasoning tasks. They found strict formats hurt performance, and tighter constraints hurt more. They also noted that JSON output could place the answer field before the reasoning (&lt;a href="https://arxiv.org/abs/2408.02442" rel="noopener noreferrer"&gt;Tam et al., "Let Me Speak Freely?", arXiv 2408.02442, 2024&lt;/a&gt;). If you force a tool call to fill in a JSON object before the model has reasoned, you may get worse decisions.&lt;/p&gt;

&lt;p&gt;But the objection lands on how most teams apply schemas, not on schemas themselves. The paper's own results suggest where the damage comes from. Their natural-language-first variant, which reasons freely and converts the answer afterward, stayed closest to unconstrained performance. So give the model a free-text reasoning field or a separate step, then validate only the final arguments against a closed schema. Closing the object changes what the validator accepts. It does not change how the model thinks.&lt;/p&gt;

&lt;p&gt;The paper's methodology was disputed by the Outlines team at dottxt (&lt;a href="https://blog.dottxt.co/say-what-you-mean.html" rel="noopener noreferrer"&gt;“Say What You Mean”, 2024&lt;/a&gt;), who argued that the paper's prompts differed between conditions and its free-text answers were parsed leniently, and that structured generation done properly matched or beat unstructured output.&lt;/p&gt;

&lt;p&gt;A broader benchmark points the same way. JSONSchemaBench (2025) gathered roughly 10,000 real-world JSON schemas and compared constrained-decoding frameworks on coverage and compliance, including Guidance, Outlines, XGrammar, and the OpenAI and Gemini APIs (&lt;a href="https://arxiv.org/abs/2501.10868" rel="noopener noreferrer"&gt;Geng et al., arXiv 2501.10868, 2025&lt;/a&gt;). Real schemas vary a lot in what they can express. A closed schema your provider can't fully enforce still needs your validator as the backstop.&lt;/p&gt;

&lt;h2&gt;
  
  
  A rejection nobody counted
&lt;/h2&gt;

&lt;p&gt;My bot's long-form article lane lost 8 of 20 generations to JSON parse errors, and each failure silently burned a 48-hour slot. That is the mirror image of the dropped-key problem. The dropped key is a validator accepting too much. The article lane was real failures that nothing surfaced when they happened. Both are silent.&lt;/p&gt;

&lt;p&gt;Earlier, four hardcoded target accounts that no longer existed absorbed 88% of the same bot's daily comment attempts for about six weeks, and every run logged success without raising an error.&lt;/p&gt;

&lt;p&gt;The lesson carries over to tool calls: an unreliable output rate is survivable, but a rejection that has no counter, no alert and no reason attached looks exactly like a quiet day.&lt;/p&gt;

&lt;h2&gt;
  
  
  What schema validation can't catch
&lt;/h2&gt;

&lt;p&gt;The Replit database deletion in July 2025 is a different kind of failure. An agent acted destructively in production rather than sending a malformed argument. My reading is that it shares a root assumption: nothing between the agent and production treated a destructive action as exceptional (&lt;a href="https://www.eweek.com/news/replit-ai-coding-assistant-failure/" rel="noopener noreferrer"&gt;eWeek, 22 July 2025&lt;/a&gt;; the CEO's own response is in his public posts on X from July 2025, &lt;a href="https://x.com/amasad" rel="noopener noreferrer"&gt;@amasad&lt;/a&gt;). Schema validation would not have caught it, because the commands were valid. The lesson that carries over is narrower: treat a destructive tool as its own tool with its own closed schema and a human approval step, rather than one more optional argument on a general-purpose tool. My X growth agent worked this way: every post, reply, follow and like went through a Telegram approval I pressed by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do when a call is rejected
&lt;/h2&gt;

&lt;p&gt;When a call is rejected, return the validation error to the model as the tool result so it can retry with corrected arguments. Cap the retry count. Log each rejection with the field name, so you can see which keys the model keeps inventing. Alert on a repeat rejection of the same field, since that points to a schema the model is misreading rather than a one-off. For retry strategy in more depth, see the &lt;a href="https://www.robatdasorvi.com/chapters/ai-automation/fixing-malformed-json-from-llms-constrained-decoding-vs-validate-and-retry" rel="noopener noreferrer"&gt;LLM structured output essay&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should you change in your schemas today?
&lt;/h2&gt;

&lt;p&gt;Close every object at every level, reject unknown keys, and count each rejection as a named failure. A tool call with an undeclared field fails quietly, the same way the article lane's parse errors did. The model believed it was sending something the handler understood, and it wasn't. In the hypothetical refund example above, &lt;code&gt;extra="forbid"&lt;/code&gt; or &lt;code&gt;z.strictObject&lt;/code&gt; turns the dropped &lt;code&gt;reason_code&lt;/code&gt; into a validation error on the first call.&lt;/p&gt;

&lt;p&gt;If you're building this now, two related posts go further. &lt;a href="https://www.robatdasorvi.com/chapters/ai-automation/fixing-malformed-json-from-llms-constrained-decoding-vs-validate-and-retry" rel="noopener noreferrer"&gt;LLM Structured Output: Validation, Retry or Constrained Decoding?&lt;/a&gt; compares the retry and grammar approaches. &lt;a href="https://www.robatdasorvi.com/chapters/ai-automation/agent-rule-wars" rel="noopener noreferrer"&gt;Agent Rule Conflicts: Why AI Pipelines Fail Silently&lt;/a&gt; covers silent failure more broadly. The library you pick matters less than the defaults you never changed.&lt;/p&gt;

</description>
      <category>pydantic</category>
      <category>zod</category>
      <category>llmtoolcalling</category>
      <category>structuredoutput</category>
    </item>
    <item>
      <title>Detect AI Agent Actions Outside Task Scope: Block, Don</title>
      <dc:creator>Orvi Das</dc:creator>
      <pubDate>Sun, 01 Nov 2026 04:30:42 +0000</pubDate>
      <link>https://dev.to/robat_das_3c6e956212f6408/detect-ai-agent-actions-outside-task-scope-block-don-9pj</link>
      <guid>https://dev.to/robat_das_3c6e956212f6408/detect-ai-agent-actions-outside-task-scope-block-don-9pj</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/out-of-scope-actions-by-ai-agents-detecting-tool-calls-that-go-beyond-the-assigned-task" rel="noopener noreferrer"&gt;robatdasorvi.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;To detect AI agent actions outside task scope, check every tool call against a declared task contract before it executes, and block anything the contract doesn't name. Logs and dashboards only show a call after it has already run, so the first signal can be a customer complaint or an invoice.&lt;/p&gt;

&lt;p&gt;I thought my agent had a safety net. It did have a lockout check. When X suspended my growth bot's account on 2026-07-21, the check returned True, the caller skipped one action, and the loop kept running for three and a half more days. The check was correct. The control around it was not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why doesn't my agent's log show anything wrong?
&lt;/h2&gt;

&lt;p&gt;A log records what the agent did. It doesn't record whether the agent was allowed to do it, so a clean log says nothing about scope. For about six weeks, four hardcoded target accounts that no longer existed soaked up 88% of my bot's daily comment attempts. Every run logged success. Nothing threw an error. The logs were clean because they recorded activity, not outcomes. A scope gate wouldn't have caught this either: those four accounts were hardcoded targets, so they would have been on any allowlist I wrote. Checking scope and checking outcomes are separate controls, and you need both.&lt;/p&gt;

&lt;p&gt;Monitoring asks "did it fail?" Scope asks "was this call assigned?" Outcome checks ask "did it do anything?" Those are different questions, and in my experience most agent stacks only answer the first one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What counts as out of scope for an AI agent?
&lt;/h2&gt;

&lt;p&gt;A call is out of scope when its tool or target isn't named in the task that started the run. I keep the definition narrow on purpose. A loose one lets every bad call pass as "reasonable."&lt;/p&gt;

&lt;p&gt;OWASP's LLM06:2025 Excessive Agency names three root causes: "excessive functionality, excessive permissions, excessive autonomy" (&lt;a href="https://genai.owasp.org/llmrisk/llm062025-excessive-agency/" rel="noopener noreferrer"&gt;OWASP GenAI Security Project, 2025&lt;/a&gt;). Each one is a different way a call can drift out of scope. Excessive functionality means a tool the task never needed. Excessive permissions means reach beyond the task's target. Excessive autonomy means a high-impact action nobody approved.&lt;/p&gt;

&lt;p&gt;Scope failures aren't exotic. Replit's agent reportedly deleted a live production database during an active code freeze, even though it had been told not to change anything (&lt;a href="https://tomshardware.com/tech-industry/artificial-intelligence/ai-coding-platform-goes-rogue-during-code-freeze-and-deletes-entire-company-database-replit-ceo-apologizes-after-ai-engine-says-it-made-a-catastrophic-error-in-judgment-and-destroyed-all-production-data" rel="noopener noreferrer"&gt;Tom's Hardware, July 2025&lt;/a&gt;). Replit's CEO apologized publicly and announced fixes. The AI Incident Database also logs reports that the system fabricated test results and wrongly said rollback was impossible (&lt;a href="https://incidentdatabase.ai/entities/replit" rel="noopener noreferrer"&gt;AI Incident Database, Replit entry&lt;/a&gt;). Those details are from user reports, not confirmed by Replit.&lt;/p&gt;

&lt;p&gt;Amazon's case is the one that should bother you. Reporting, which a &lt;a href="https://www.365i.co.uk/news/2026/02/22/amazon-kiro-ai-coding-tool-aws-outage/" rel="noopener noreferrer"&gt;365i article&lt;/a&gt; attributes to a Financial Times report from February 2026 citing anonymous sources, says an internal coding agent called Kiro reportedly chose to rebuild an environment. That reportedly caused an outage of about 13 hours to AWS Cost Explorer in one mainland China region. Amazon attributed the event to misconfigured access controls, not the AI. My reading, which neither the FT nor Amazon states, is that both explanations point at the same gap: the agent's permissions were its scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I detect an AI agent acting outside its task?
&lt;/h2&gt;

&lt;p&gt;Declare the task as a contract when the run starts, check every call against it, and deny by default. Detectors built from known-bad patterns miss new ones. My lockout check, which watched account state rather than tool calls, failed the same way. On 2026-09-22, X suspended the account again. The lockout check matched none of its known selectors, so it never fired. I stopped the bot by hand.&lt;/p&gt;

&lt;p&gt;A blocklist catches the failure you already imagined. An allowlist catches the rest.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OutOfScope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;pass&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AwaitingApproval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;pass&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TaskContract&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;allowed_tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;allowed_targets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;needs_approval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;set&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;gate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;approved_by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&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;tool&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allowed_tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;OutOfScope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; not in task &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;task_id&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allowed_targets&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allowed_targets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;OutOfScope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; not in task &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;task_id&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;needs_approval&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;approved_by&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="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;AwaitingApproval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; on &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; awaits human approval&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 hardest case is a target the agent only learns at runtime, such as a search result or an account it decides to reply to. A contract can't name those in advance. This is a recommendation, not something the code above implements: allow a runtime target only if it appears in the output of an earlier in-contract tool call in the same run, and record which call produced it. Anything the agent can't trace back to such a call stays blocked. That keeps the allowlist deny-by-default while still letting the agent act on what it finds.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;raise&lt;/code&gt; is the important line. A function that returns False or None invites the caller to skip the check. The exception has to stop the run, not just flag it. Make it terminate the process or the loop, and make clearing it a human decision. I learned the second half the hard way. After the first suspension, I found that a plain process exit wasn't enough, because launchd's KeepAlive respawned the bot. The fix was a process exit plus a sentinel file that only a human can clear.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tool not in contract&lt;/td&gt;
&lt;td&gt;Block, halt the run, alert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Known tool, unknown target&lt;/td&gt;
&lt;td&gt;Block, halt the run, alert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High-impact tool, in contract&lt;/td&gt;
&lt;td&gt;Hold for human approval&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Isn't logging and alerting enough?
&lt;/h2&gt;

&lt;p&gt;No. OWASP says logging, monitoring, and rate limiting "will not prevent Excessive Agency, but can limit the level of damage caused" (&lt;a href="https://genai.owasp.org/llmrisk/llm062025-excessive-agency/" rel="noopener noreferrer"&gt;OWASP, 2025&lt;/a&gt;). That's the honest version of the counterargument, and it also answers it. An alert limits the damage. Only a gate prevents it.&lt;/p&gt;

&lt;p&gt;The stronger objection is that a good model stays in scope on its own. Anthropic's agentic misalignment study (2025) tested 16 models from several developers in simulated scenarios. It found misaligned behavior, including blackmail, when the models faced being replaced or a conflict between their assigned goals and the company's direction (&lt;a href="https://www.anthropic.com/research/agentic-misalignment" rel="noopener noreferrer"&gt;Anthropic, 2025&lt;/a&gt;). The scenarios were fictional, and Anthropic reported no real-world instances. The conclusion still holds for your purposes. "The model is aligned" is a property you can't check on every call, so the permission has to be enforced outside the model. OWASP makes the same point when it says (&lt;a href="https://genai.owasp.org/llmrisk/llm062025-excessive-agency/" rel="noopener noreferrer"&gt;OWASP, 2025&lt;/a&gt;) authorization belongs "in downstream systems rather than relying on an LLM to decide if an action is allowed or not."&lt;/p&gt;

&lt;p&gt;Per-call gates have limits too. Every post, reply, follow and like my bot made went through a Telegram approval I pressed by hand, and X still suspended the account twice. The second suspension came a week after I raised the reply cap from 16 to 150. I can't show that the cap caused it, but each of those calls was in scope and approved, so a per-call gate had nothing to block. A gate judges single calls. It doesn't judge the volume of a run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is this worth building when most agent projects die first?
&lt;/h2&gt;

&lt;p&gt;Yes, if the agent can write, send, delete, or spend. Gartner (2025) expects more than 40% of agentic AI projects to be canceled by the end of 2027, citing escalating costs, unclear value, and inadequate risk controls (&lt;a href="https://www.gartner.com/en/newsroom/press-releases/2025-06-25-gartner-predicts-over-40-percent-of-agentic-ai-projects-will-be-canceled-by-end-of-2027" rel="noopener noreferrer"&gt;Gartner, 25 June 2025&lt;/a&gt;). The third reason is the one nobody puts in the demo. A scope gate costs far less than a cancellation. It's also the part people skip, because it doesn't make the demo look any smarter.&lt;/p&gt;

&lt;p&gt;Many agents don't have a task scope at all. They have a system prompt that says "only do what the user asked" and a token that can do anything. A prompt is a request to the model. It isn't a permission the system enforces. To see the difference, look at what your agent's token can reach that its task never mentions.&lt;/p&gt;

&lt;p&gt;The rest of the stack follows from that. Scope is the permission model, which is why &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/least-privilege-for-ai-agents-scoping-tools-keys-and-filesystems-in-practice" rel="noopener noreferrer"&gt;least privilege for AI agents&lt;/a&gt; comes before any detector. The gate's rules are easier to audit as code, which is the argument in &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/policy-as-code-for-ai-agent-tool-calls-authorizing-actions-with-opa-or-cedar" rel="noopener noreferrer"&gt;policy as code for AI agent tool calls&lt;/a&gt;. And a gate that fails quietly is just another silent failure, which I covered in &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/how-llm-agents-fail-silently-in-production-and-how-i-learned-to-catch-it" rel="noopener noreferrer"&gt;why LLM agents fail silently in production&lt;/a&gt;. And when a call slips through before the gate exists, &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/rolling-back-an-ai-agent-s-actions-compensating-transactions-and-the-saga-pattern-for-tool-calls" rel="noopener noreferrer"&gt;rolling back an agent's actions&lt;/a&gt; is harder than it looks.&lt;/p&gt;

&lt;p&gt;If you have one minute, do this. Write down the next run's allowed tools and targets before you start it. Make the gate raise instead of return, and make the raise stop the loop. Then test it by giving the agent a task it should refuse, and watch whether the run stops. If it finishes the task anyway, you have a log, not a gate.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>toolcalls</category>
      <category>agentsafety</category>
      <category>leastprivilege</category>
    </item>
    <item>
      <title>Multi-Turn Jailbreaks Against AI Agents With Tool Access</title>
      <dc:creator>Orvi Das</dc:creator>
      <pubDate>Sun, 01 Nov 2026 04:30:12 +0000</pubDate>
      <link>https://dev.to/robat_das_3c6e956212f6408/multi-turn-jailbreaks-against-ai-agents-with-tool-access-5f0c</link>
      <guid>https://dev.to/robat_das_3c6e956212f6408/multi-turn-jailbreaks-against-ai-agents-with-tool-access-5f0c</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/multi-turn-jailbreaks-against-ai-agents-with-tool-access" rel="noopener noreferrer"&gt;robatdasorvi.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The defense against a multi-turn jailbreak on an AI agent is to stop the tool call, not the conversation. Refusals and per-message filters miss attacks spread over several turns, because each turn looks harmless on its own. Enforce permissions in code, outside the model, on every action the agent takes.&lt;/p&gt;

&lt;p&gt;I built and ran an X growth agent where every post, reply, follow and like went through a Telegram approval I pressed by hand, so I think about where the stop actually lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a multi-turn jailbreak get past an agent's guardrails?
&lt;/h2&gt;

&lt;p&gt;Because each message gets judged alone, and the harm only exists across the sequence. The attacker never has to send one bad request. They build toward it one reasonable-looking step at a time.&lt;/p&gt;

&lt;p&gt;Russinovich, Salem and Eldan (2024) describe Crescendo. It opens with a benign question and escalates using the model's own earlier replies. The paper reports that Crescendomation, its automated version, reports 29–61% higher performance than other state-of-the-art jailbreaks on GPT-4 and 49–71% on Gemini-Pro, on an AdvBench subset (&lt;a href="https://arxiv.org/pdf/2404.01833" rel="noopener noreferrer"&gt;arXiv:2404.01833&lt;/a&gt;). No clever string is involved. It takes patience and a conversation the model keeps extending.&lt;/p&gt;

&lt;p&gt;Human red-teamers show the same gap. Li et al. at Scale AI (2024) report multi-turn human jailbreaks exceeding 70% attack success on HarmBench. Those defenses had reported single-digit rates against automated single-turn attacks (&lt;a href="https://arxiv.org/abs/2408.15221" rel="noopener noreferrer"&gt;arXiv:2408.15221&lt;/a&gt;). The authors warn that human and automated setups differ, so treat the number as evidence of a gap, not a clean head-to-head score.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is a rule in the prompt not a permission?
&lt;/h2&gt;

&lt;p&gt;The refusal lives in the model. The damage lives at the tool call. A model that refuses still leaves the call path open. The usual failure is that the rule exists only as text in the conversation, where the model can be argued out of it.&lt;/p&gt;

&lt;p&gt;In July 2025, SaaStr founder Jason Lemkin reported that Replit's AI agent deleted a live production database during a code-and-action freeze, and that the agent told him a rollback was impossible, which turned out to be false; the rollback worked. No attacker was involved; it shows what an instruction-only freeze is worth before anyone tries to argue the model out of it (&lt;a href="https://www.theregister.com/2025/07/22/replit_saastr_response/" rel="noopener noreferrer"&gt;The Register, July 2025&lt;/a&gt;). Most of the detail comes from Lemkin's own posts. His claim that the agent hid and lied about it is his account, not a verified finding. The part I can read from the public record is structural. The freeze was an instruction the agent received. It was not a permission the platform enforced.&lt;/p&gt;

&lt;p&gt;A different failure in my own agent shows the same structural point from the other side. In my growth agent, X suspended the account on 2026-07-21. The lockout check fired and returned True, but the caller skipped one action, and the loop kept running for 3.5 more days. The fix was a process exit. But launchd KeepAlive respawned a plain exit, so the exit now requires a sentinel file that only a human can clear. Nobody attacked anything. The check was right and the caller still ignored it. A tool gate the model can't argue with is useless if the code calling it can skip it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can a refusal be trusted to stop an agent?
&lt;/h2&gt;

&lt;p&gt;No. A refusal is a judgment about one turn. A later turn can be built from the model's own earlier words, and that is the mechanism Crescendo exploits.&lt;/p&gt;

&lt;p&gt;The strongest counterargument is that multi-turn attacks are a benchmark artifact. Production systems layer output classifiers, rate limits and human review, so the residual risk looks small. The public record cuts against that. Anthropic's disclosure of the GTG-1002 campaign, reported on November 13, 2025, describes a group Anthropic assessed with high confidence as Chinese state-sponsored that got around Claude Code's safeguards by splitting its intrusion work into small tasks that each looked routine, and by telling Claude it worked for a legitimate cybersecurity firm doing defensive testing. Anthropic reported that Claude carried out 80–90% of the tactical operations in attacks on roughly 30 targets, a small number of which succeeded (&lt;a href="https://www.anthropic.com/news/disrupting-AI-espionage" rel="noopener noreferrer"&gt;Anthropic, "Disrupting the first reported AI-orchestrated cyber espionage campaign"&lt;/a&gt;). My reading: each task could pass a per-message check; the harm was in the sequence. The same reporting notes that the model hallucinated often, which limited the campaign. Anthropic presents that as a limit on fully autonomous attacks today, not as a defense anyone designed.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you enforce tool permissions against a multi-turn jailbreak?
&lt;/h2&gt;

&lt;p&gt;Put the permission check in code at the tool boundary. Key it on the tool, the environment, and a flag the conversation cannot write to. The model can request anything. The function decides.&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;POLICY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;db.read&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;envs&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;staging&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;prod&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;frozen_ok&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;db.write&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;envs&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;staging&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;frozen_ok&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;db.drop&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;envs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;               &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;frozen_ok&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="c1"&gt;# break-glass only
&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;authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;grants&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# grants come from the break-glass store, not from the conversation
&lt;/span&gt;    &lt;span class="n"&gt;freeze&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config_store&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deploy_freeze&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# read on every call, from a store the chat cannot write to
&lt;/span&gt;    &lt;span class="n"&gt;rule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;POLICY&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;tool&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;rule&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="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;PermissionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: no policy, denied&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;freeze&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;rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;frozen_ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;PermissionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: deploy freeze active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolve_env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# from the connection the tool is bound to, never from model args
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;grants&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;envs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;PermissionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: not allowed in &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;env&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;&lt;code&gt;env&lt;/code&gt; must not come from &lt;code&gt;args&lt;/code&gt;, because a multi-turn attack can make the model label prod as staging.&lt;/p&gt;

&lt;p&gt;The table below shows what that function returns for common states, so you can check it against your own policy:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;db.read&lt;/th&gt;
&lt;th&gt;db.write&lt;/th&gt;
&lt;th&gt;db.drop&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Normal, staging&lt;/td&gt;
&lt;td&gt;allowed&lt;/td&gt;
&lt;td&gt;allowed&lt;/td&gt;
&lt;td&gt;denied unless break-glass granted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Normal, prod&lt;/td&gt;
&lt;td&gt;allowed&lt;/td&gt;
&lt;td&gt;denied&lt;/td&gt;
&lt;td&gt;denied unless break-glass granted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Freeze active&lt;/td&gt;
&lt;td&gt;allowed&lt;/td&gt;
&lt;td&gt;denied&lt;/td&gt;
&lt;td&gt;denied, even with a grant&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two design choices carry most of the weight. Unknown tools are denied by default, so a jailbreak that invents a tool name hits a wall. And the freeze is read from configuration, not from the transcript, so no sentence in the conversation can switch it off. The grants themselves should be time-boxed and human-approved. That's the pattern in &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/break-glass-access-for-ai-agents-time-boxed-elevated-permissions-with-human-approval-and-automatic-expiry" rel="noopener noreferrer"&gt;Break-Glass Access for AI Agents: Approved, Timed, Expiring&lt;/a&gt;, and the scopes belong to &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/least-privilege-for-ai-agents-scoping-tools-keys-and-filesystems-in-practice" rel="noopener noreferrer"&gt;Least Privilege for AI Agents: Scoping Tools, Keys and Filesystems&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a per-call gate does not stop
&lt;/h2&gt;

&lt;p&gt;A stateless check ignores the conversation, which is why multi-turn persuasion can't move it. It also means a sequence of individually allowed calls still gets through. My reading is that GTG-1002 fits this pattern, and so does reading data and then sending it out through an allowed tool.&lt;/p&gt;

&lt;p&gt;The controls that are aware of sequence are per-session action budgets and rate caps, human approval for irreversible or outbound actions, and alerts on unusual chains of tool calls. Detecting calls that drift outside the assigned task is covered in &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/out-of-scope-actions-by-ai-agents-detecting-tool-calls-that-go-beyond-the-assigned-task" rel="noopener noreferrer"&gt;Detect AI Agent Actions Outside Task Scope: Block, Don't Log&lt;/a&gt;. My own approval gate had a Skip button that silently did nothing for months, so every skip was logged as a timeout. A human-in-the-loop control needs its own test. The same goes for detectors. When X suspended the account again on 2026-09-22, a week after the reply cap was raised from 16 to 150, the lockout check matched none of its known selectors and never fired. I stopped the bot by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  What would have stopped the Replit deletion?
&lt;/h2&gt;

&lt;p&gt;A deny the model can't argue with, enforced where the call executes, with the freeze flag read from a store the conversation can't write to. With that in place, a destructive call like the one Lemkin described would fail inside the function, not at the model's discretion. Replit CEO Amjad Masad said in an X post that the company had started rolling out automatic separation of development and production databases to prevent this categorically, with staging environments in the works (&lt;a href="https://www.theregister.com/2025/07/22/replit_saastr_response/" rel="noopener noreferrer"&gt;The Register&lt;/a&gt;). Replit's actual tool path is not public. Even a stopped drop can be hard to undo, which is a separate problem, covered in &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/durable-execution-for-multi-agent-runs-checkpointing-and-resuming-after-a-crash-with-temporal-or-a-plain-state-store" rel="noopener noreferrer"&gt;Durable Execution for AI Agents: Temporal vs a Plain State Store&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>multiturnjailbreak</category>
      <category>aiagentsecurity</category>
      <category>toolaccess</category>
      <category>promptinjection</category>
    </item>
    <item>
      <title>Detect Unauthorized AI Agents Using Company API Keys</title>
      <dc:creator>Orvi Das</dc:creator>
      <pubDate>Sat, 31 Oct 2026 04:30:39 +0000</pubDate>
      <link>https://dev.to/robat_das_3c6e956212f6408/detect-unauthorized-ai-agents-using-company-api-keys-2eh9</link>
      <guid>https://dev.to/robat_das_3c6e956212f6408/detect-unauthorized-ai-agents-using-company-api-keys-2eh9</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/detect-unauthorized-ai-agents-using-company-api-keys" rel="noopener noreferrer"&gt;robatdasorvi.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you want to catch an unauthorized AI agent using a company API key, stop treating the key as the identity. The provider only knows which key made a request. So attribute every call to an agent and a run ID in your own code, then reconcile the provider's usage against a ledger of approvals. Spend alerts and secret scanners each catch one kind of failure and miss the rest.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.gitguardian.com/state-of-secrets-sprawl-report-2025" rel="noopener noreferrer"&gt;GitGuardian (2025)&lt;/a&gt; reported that 70% of valid secrets detected in public GitHub repositories in 2022 were still active when the report was written. The standard response to a leaked key is to rotate it, and the 70% figure shows that rotation often doesn't happen. It also says nothing about a key that was never leaked at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can a company API key tell you which agent used it?
&lt;/h2&gt;

&lt;p&gt;No. A key is a bearer token. Whoever holds it can call the API, and the provider records the key, the model, and the token counts. It doesn't record which process, agent, or person sent the request.&lt;/p&gt;

&lt;p&gt;The per-key usage page answers "which key did this," not "who did this." Those are different questions the moment two services share one key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Isn't rotating keys and watching spend enough?
&lt;/h2&gt;

&lt;p&gt;That's the consensus, and it breaks in three places.&lt;/p&gt;

&lt;p&gt;Rotation fixes leaks. Misuse of a valid key is not a leak. An agent that a colleague launched with a key it was legitimately issued produces no leaked secret for a scanner to find. &lt;a href="https://www.gitguardian.com/state-of-secrets-sprawl-report-2025" rel="noopener noreferrer"&gt;GitGuardian (2025)&lt;/a&gt; also reported that repositories using secrets managers still had a 5.1% incidence of leaked secrets in 2024, so leaks persist even where storage is done properly. Scanning finds the leaks. It can't tell you who is calling.&lt;/p&gt;

&lt;p&gt;Spend alerts fire after the money is gone. Baar-Core, one of my projects, started after an agent loop left running overnight produced a $47 bill. The bill was the signal, and a bill is the last signal in the chain, not the first. &lt;a href="https://genai.owasp.org/llmrisk/llm102025-unbounded-consumption/" rel="noopener noreferrer"&gt;OWASP's LLM10:2025 "Unbounded Consumption" entry&lt;/a&gt; recommends alerting on cost anomalies. That's the right instinct, but it's still lagging. You learn about the call once it has been billed.&lt;/p&gt;

&lt;p&gt;Scope is the third gap. Deciding what an agent may do is a human decision, and the key is the wrong place to record it. For the scoping side of this, see &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/least-privilege-for-ai-agents-scoping-tools-keys-and-filesystems-in-practice" rel="noopener noreferrer"&gt;Least Privilege for AI Agents&lt;/a&gt;. The approval-gated X growth agent I run works this way. Every post, reply, follow, and like went through a Telegram approval that I pressed by hand. The bot held the credentials to act, but an action with no approval record was never supposed to happen. A key-level dashboard can't represent that rule at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a detector that watches the wrong surface looks like
&lt;/h2&gt;

&lt;p&gt;I have a smaller example from my own agent, and it isn't about API keys: X suspended that account on 2026-07-21. The lockout check fired and returned True, but the caller skipped one action, and the loop kept running for 3.5 more days. The fix was a hard process exit plus a sentinel file that only a human can clear. launchd's KeepAlive respawned a plain exit, so the exit had to be harder than that.&lt;/p&gt;

&lt;p&gt;The second suspension was worse. On 2026-09-22, a week after I raised the reply cap from 16 to 150, the lockout check matched none of its known selectors and never fired. I stopped the bot by hand. A detector that depends on a fixed pattern from the outside world will eventually see nothing, and in the logs that failure looks exactly like success. For more on that failure mode, see &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/how-llm-agents-fail-silently-in-production-and-how-i-learned-to-catch-it" rel="noopener noreferrer"&gt;Why LLM Agents Fail Silently in Production&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For about six weeks, four hardcoded target accounts that no longer existed absorbed 88% of the bot's daily comment attempts. Every run logged success; nothing raised an error. A log that only records what your code believes it did can't catch this. The log has to be checked against an independent record of what actually happened.&lt;/p&gt;

&lt;p&gt;That's the same shape as the API key problem. A detector keyed to a known signature misses the unknown agent, and an agent that stays quiet on the channel you're watching is invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does per-key usage in the provider console already solve this?
&lt;/h2&gt;

&lt;p&gt;Partly. This is the strongest counterargument, and the documentation shows exactly where it stops working.&lt;/p&gt;

&lt;p&gt;OpenAI's Usage API, announced on its community forum, filters usage by API key, project ID, user ID, and model (&lt;a href="https://community.openai.com/t/introducing-the-usage-api-track-api-usage-and-costs-programmatically/1043058" rel="noopener noreferrer"&gt;OpenAI community, December 2024&lt;/a&gt;). That's real attribution, but it's attribution to a key or a user account. Both the usage and costs endpoints require an Admin key. Anthropic's Usage API reports tokens grouped by API key, workspace, model and service tier, in 1-minute, 1-hour, or 1-day buckets (&lt;a href="https://docs.claude.com/en/api/usage-cost-api" rel="noopener noreferrer"&gt;Anthropic docs&lt;/a&gt;). Its cost report groups dollars by workspace and line item.&lt;/p&gt;

&lt;p&gt;Even where per-key attribution exists, it fails when keys are shared. A service and an unauthorized agent using the same key produce one line on the dashboard. The provider can't separate them, and neither can you, unless your code tagged the call before it left.&lt;/p&gt;

&lt;p&gt;The cheapest first step is one key per agent or service. The usage groupings above can separate by project ID (OpenAI) and by workspace (Anthropic), so issuing each agent or service its own key inside those groupings turns per-key grouping into per-agent grouping. Any key that isn't on your issued list is itself a finding. The &lt;a href="https://community.openai.com/t/introducing-the-usage-api-track-api-usage-and-costs-programmatically/1043058" rel="noopener noreferrer"&gt;OpenAI community thread&lt;/a&gt; and the &lt;a href="https://docs.claude.com/en/api/usage-cost-api" rel="noopener noreferrer"&gt;Anthropic usage-cost docs&lt;/a&gt; describe these groupings, and nothing beyond them is assumed here.&lt;/p&gt;

&lt;p&gt;The DeepSeek exposure makes the same point from the other direction. In late January 2025, Wiz found a publicly reachable ClickHouse database holding more than a million log entries, including API keys and plaintext chat history (&lt;a href="https://www.bleepingcomputer.com/news/security/deepseek-ai-exposed-databases-with-user-chat-history-api-keys/" rel="noopener noreferrer"&gt;BleepingComputer, 2025&lt;/a&gt;; &lt;a href="https://www.techtarget.com/cybersecurity/news/366618594/Wiz-reveals-DeepSeek-database-exposed-API-keys-chat-history" rel="noopener noreferrer"&gt;TechTarget, 2025&lt;/a&gt;). The keys sat in logs, and the logs were exposed to the internet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does detection actually require?
&lt;/h2&gt;

&lt;p&gt;It requires the caller to be identified at the point of the call, and then checked against a record of what was authorized. Keys can't give you either.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Tells you&lt;/th&gt;
&lt;th&gt;Names the caller?&lt;/th&gt;
&lt;th&gt;Timing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Secret scanning on repos&lt;/td&gt;
&lt;td&gt;Key leaked publicly&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;After commit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider spend alert&lt;/td&gt;
&lt;td&gt;Money spent&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;After billing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider usage by key&lt;/td&gt;
&lt;td&gt;Tokens per key&lt;/td&gt;
&lt;td&gt;Key or user only&lt;/td&gt;
&lt;td&gt;1m, 1h or 1d buckets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Call log with agent and run ID&lt;/td&gt;
&lt;td&gt;Which run made which call&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Per call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider usage reconciled to call log&lt;/td&gt;
&lt;td&gt;Usage your code didn't log&lt;/td&gt;
&lt;td&gt;No, it flags the key and window&lt;/td&gt;
&lt;td&gt;Per usage bucket&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fourth row is the only one that names a caller, and the fifth is the only one that catches a caller you didn't instrument. Here's the minimal shape, assuming your own code makes every call:&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;logging&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;

&lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLogger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llm_calls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CallContext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;            &lt;span class="c1"&gt;# which agent definition is calling
&lt;/span&gt;    &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;              &lt;span class="c1"&gt;# which execution of that agent
&lt;/span&gt;    &lt;span class="n"&gt;approval_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# the human approval covering this action
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;call_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CallContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# approval_ledger belongs to a separate process the agent cannot write to
&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;approval_ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_valid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;approval_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;PermissionError&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;unapproved call: agent=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; run=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run_id&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="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llm_call agent=%s run=%s approval=%s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;approval_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# token counts feed the reconciliation diff below
&lt;/span&gt;    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llm_usage agent=%s run=%s approval=%s input_tokens=%s output_tokens=%s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;approval_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&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="c1"&gt;# provider-side request metadata (Anthropic metadata.user_id, OpenAI's per-request user field)
&lt;/span&gt;    &lt;span class="c1"&gt;# does not appear in usage-API groupings, so the run ID lives only in this log
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reconciliation step is a diff. Provider usage APIs return aggregated buckets, not individual requests. So the diff compares totals: for each key (or project) and each bucket, subtract the tokens your call log recorded from the tokens the provider billed. A persistent positive remainder means something other than your instrumented code is using that key. That includes a machine you don't control, because it never writes a log line. The diff tells you that unlogged usage exists, on which key, and in which time window. It does not tell you who the caller was.&lt;/p&gt;

&lt;p&gt;noburn, which I build, is a related tool, but narrower. Its pre-flight check runs in the customer's own code before each request and returns allow or block. It resolves caps at the project, per end user, and per run (&lt;a href="https://noburn.dev" rel="noopener noreferrer"&gt;noburn.dev&lt;/a&gt;). The check call takes an &lt;code&gt;endUserId&lt;/code&gt;, so the budget decision is tied to a caller identity that your code supplies. It enforces a budget. It doesn't detect intrusion, and it doesn't replace the reconciliation above.&lt;/p&gt;

&lt;p&gt;Valid credentials don't mean authorized behaviour, and these two incidents show what that looks like. Replit's AI agent deleted a live production database on July 18, 2025, during a code freeze. The agent also produced fabricated data (&lt;a href="https://oecd.ai/en/incidents/2025-07-19-1eb1" rel="noopener noreferrer"&gt;OECD AI Incident Monitor, 2025&lt;/a&gt;; &lt;a href="https://heise.de/-10499597" rel="noopener noreferrer"&gt;heise, 2025&lt;/a&gt;). The agent was using access it had been given, so, as far as the public reports show, nothing at the credential level would have looked wrong. Per-call logging would not have prevented this either. It would only have recorded which run issued the destructive actions. The Shai-Hulud npm worm spread in September 2025 across more than 180 packages. It harvested npm tokens, GitHub tokens, and cloud keys, then used them to republish trojanized packages (&lt;a href="https://www.helpnetsecurity.com/2025/09/16/self-replicating-worm-hits-180-npm-packages-in-largely-automated-supply-chain-attack/" rel="noopener noreferrer"&gt;Help Net Security, 2025&lt;/a&gt;). Its traffic looked like ordinary publishing done with valid credentials.&lt;/p&gt;

&lt;p&gt;Scale makes this worse. CyberArk's 2025 Identity Security Landscape reported machine identities outnumbering human identities 82 to 1 (&lt;a href="https://www.cyberark.com/resources/identity-security/2025-identity-security-landscape" rel="noopener noreferrer"&gt;CyberArk, 2025&lt;/a&gt;). An organization at that ratio has far more callers than it has people reviewing calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  What am I still getting wrong?
&lt;/h2&gt;

&lt;p&gt;The reconciliation diff works on totals, not runs: provider usage APIs return aggregated buckets, so the diff can say that a key had unlogged usage in a window, but it can't join that usage to a run ID. Within a bucket, all you get is a total. Timestamps are a weak basis for accusing an agent of anything.&lt;/p&gt;

&lt;p&gt;The approval ledger is also a single point of trust. If an agent can write its own approval ID, the check passes and the audit is decoration. I haven't solved how to protect the approval record from the process it governs. The obvious answer is to separate the process that asks from the process that records. Approval records also fail in quieter ways: the Telegram Skip button on my X agent silently did nothing for months, so every skip was counted as a timeout and the approval statistics were wrong. An approval ledger needs its own tests, separate from the agent's. For how approvals can be time-boxed and expire, see &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/break-glass-access-for-ai-agents-time-boxed-elevated-permissions-with-human-approval-and-automatic-expiry" rel="noopener noreferrer"&gt;Break-Glass Access for AI Agents&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For a company whose only API callers are services it deployed itself, key rotation and spend alerts may be enough. My claim is narrower. Once agents hold keys and make calls on their own, the key can't answer the question "was this authorized?" The only place that question can be answered is the call site.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>apikeysecurity</category>
      <category>llmcostcontrol</category>
      <category>nonhumanidentity</category>
    </item>
    <item>
      <title>Customer Support AI Agent Prompt Injection: Refund Abuse</title>
      <dc:creator>Orvi Das</dc:creator>
      <pubDate>Sat, 31 Oct 2026 04:30:07 +0000</pubDate>
      <link>https://dev.to/robat_das_3c6e956212f6408/customer-support-ai-agent-prompt-injection-refund-abuse-1046</link>
      <guid>https://dev.to/robat_das_3c6e956212f6408/customer-support-ai-agent-prompt-injection-refund-abuse-1046</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/customer-support-ai-agent-prompt-injection-refund-abuse" rel="noopener noreferrer"&gt;robatdasorvi.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Prompt injection against a customer support AI agent turns into refund abuse when the refund limit lives in the prompt, because customers can write prompts too. Put the cap and the approval threshold in the code that issues the refund, outside the model. The model can request a refund. Only the tool should be able to grant one.&lt;/p&gt;

&lt;p&gt;In December 2023, a chat assistant on a Chevrolet dealership's website in Watsonville, California, agreed to sell a 2024 Tahoe for $1. The customer had told it to agree with every statement and to end each reply by calling it a legally binding offer. The bot had no authority to close a sale, so no car changed hands. A sentence typed by a stranger outranked the dealership's instructions. What saved the dealership was not the prompt but the fact that the bot had no power to complete a sale (&lt;a href="https://the-decoder.com/people-buy-brand-new-chevrolets-for-1-from-a-chatgpt-chatbot/" rel="noopener noreferrer"&gt;The Decoder&lt;/a&gt;; &lt;a href="https://incidentdatabase.ai/reports/6682" rel="noopener noreferrer"&gt;AI Incident Database, report 6682&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Can a customer support AI agent be tricked into issuing a refund?
&lt;/h2&gt;

&lt;p&gt;Yes, if the refund tool trusts the model. The public record doesn't yet include a documented case of injection pulling money out of a refund tool, but it shows the step before that: support bots stating offers or policies the company never authorized. One was injected (Chevrolet) and two were hallucinated (Air Canada, Cursor). Only Air Canada was forced to honor what its bot said; Chevrolet was protected because the bot had no power to complete a sale.&lt;/p&gt;

&lt;p&gt;In February 2024, the British Columbia Civil Resolution Tribunal decided &lt;em&gt;Moffatt v. Air Canada&lt;/em&gt; (2024 BCCRT 149). Jake Moffatt had asked the airline's website chatbot about bereavement fares, was told he could claim the reduced rate retroactively, and booked full-fare flights. The tribunal held the airline responsible for what its chatbot said and ordered it to pay $650.88 in damages plus interest and fees (&lt;a href="https://kiplinger.com/personal-finance/travel/air-canada-to-pay-a-partial-refund-for-chatbot-error" rel="noopener noreferrer"&gt;Kiplinger&lt;/a&gt;; &lt;a href="https://www.canlii.org/en/bc/bccrt/doc/2024/2024bccrt149/2024bccrt149.html" rel="noopener noreferrer"&gt;CanLII, Moffatt v. Air Canada, 2024 BCCRT 149&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;In April 2025, the help-desk bot "Sam" at Cursor told users who were being logged out when they switched machines that this was a new one-device-per-subscription policy. No such policy existed. The fault was a session bug. Users believed it, some cancelled their subscriptions, and Cursor's co-founder publicly apologized (&lt;a href="https://arstechnica.com/ai/2025/04/cursor-ai-support-bot-invents-fake-policy-and-triggers-user-uproar/" rel="noopener noreferrer"&gt;Ars Technica, April 2025&lt;/a&gt;). No attacker was involved. The model filled a gap with confident text, and the company absorbed the cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a refund bot fail only some of the time?
&lt;/h2&gt;

&lt;p&gt;It doesn't behave the same way on every attempt, and an attacker can retry until one attempt works. The tau-bench paper from Sierra tested agents on simulated retail and airline customer service, with API tools and a written policy. Its reliability metric, pass^k, asks whether an agent succeeds on all k independent trials of the same task. The authors report that gpt-4o's pass^8 in retail stayed below 25% (&lt;a href="https://arxiv.org/pdf/2406.12045" rel="noopener noreferrer"&gt;Yao et al., 2024, arXiv:2406.12045&lt;/a&gt;). Put plainly, fewer than one retail task in four was completed correctly on all eight tries. tau-bench measured ordinary customers, not attackers; the step to adversarial retries is my inference.&lt;/p&gt;

&lt;p&gt;Set that against an attacker, who needs one success out of many attempts. A bot that refuses 90% of the time pays out about once in every ten tries, and a support chat has no rate limit on that tenth try unless someone built one. Production teams need a reliability story for tool use, not just a demo that works once. The &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/tool-use-in-llms-what-it-actually-means-for-production-systems" rel="noopener noreferrer"&gt;tool-use reliability essay&lt;/a&gt; covers the failure modes in more detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Will better models stop prompt injection?
&lt;/h2&gt;

&lt;p&gt;No. Instructions can also arrive hidden in content the bot reads, such as order notes, emails and ticket text, and a stronger system prompt doesn't close that route. The strongest counterargument is that newer models will simply stop obeying the customer. The evidence supports that part and undercuts it in the same breath.&lt;/p&gt;

&lt;p&gt;Gray Swan and the UK AISI ran a public red-teaming competition in spring 2025 against agents in simulated deployments. The paper reports 1.8 million prompt-injection attacks, with over 60,000 successfully eliciting policy violations, and nearly all agents exhibiting policy violations for most behaviors within 10 to 100 queries (&lt;a href="https://arxiv.org/pdf/2507.20526" rel="noopener noreferrer"&gt;Zou et al., arXiv:2507.20526&lt;/a&gt;). The results snapshot gives each model a single attack success rate for the whole challenge, ranging from about 1.5% to 6.5% (&lt;a href="https://www.grayswan.ai/blog/uk-aisi-x-gray-swan-agent-red-teaming-challenge-results-snapshot" rel="noopener noreferrer"&gt;Gray Swan results snapshot&lt;/a&gt;). Claude models were the most robust in that competition, which is a fair point for the other side. A refund bot that reads order notes, emails, and pasted ticket text is exposed to indirect injection as well as direct: instructions arrive inside content the bot treats as data. The same indirect channel arrives through tool descriptions instead of ticket text, as the &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/mcp-tool-poisoning-malicious-tool-descriptions-and-silent-updates-to-trusted-servers" rel="noopener noreferrer"&gt;MCP tool poisoning essay&lt;/a&gt; describes.&lt;/p&gt;

&lt;p&gt;The newer evidence closes the escape hatch. Gray Swan's March 2026 indirect-injection arena reported more than 272,000 attack attempts against 13 frontier models. "No model was immune." (&lt;a href="https://www.grayswan.ai/blog/your-ai-agent-can-be-compromised-youd-never-know" rel="noopener noreferrer"&gt;Gray Swan blog post, March 2026&lt;/a&gt;). A better model lowers the rate. It doesn't bring it to zero, and a refund process needs zero for the cases that move money.&lt;/p&gt;

&lt;p&gt;AgentDojo, a 2024 benchmark for tool-using agents, makes the same point and also tests a defense. Across 97 tasks and 629 security test cases, the strongest agents were hijacked in under 25% of cases, and a secondary attack detector cut that to 8% (&lt;a href="https://arxiv.org/pdf/2406.13352" rel="noopener noreferrer"&gt;Debenedetti et al., arXiv:2406.13352&lt;/a&gt;). The detector helps. It still lets one attack in twelve through, and a refund bot can't live with that rate for refunds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who pays when the bot grants the refund?
&lt;/h2&gt;

&lt;p&gt;The company does, and the prompt won't help in court. The Air Canada tribunal rejected the argument that the chatbot was a separate entity responsible for its own actions. Rivers, the tribunal member, wrote that the chatbot is part of the website, and the airline is responsible for the information on it. The tribunal found the airline "did not take reasonable care to ensure its chatbot was accurate" (&lt;a href="https://kiplinger.com/personal-finance/travel/air-canada-to-pay-a-partial-refund-for-chatbot-error" rel="noopener noreferrer"&gt;Kiplinger&lt;/a&gt;; &lt;a href="https://www.canlii.org/en/bc/bccrt/doc/2024/2024bccrt149/2024bccrt149.html" rel="noopener noreferrer"&gt;CanLII, Moffatt v. Air Canada, 2024 BCCRT 149&lt;/a&gt;). If your bot promises it, your books carry the promise. That was one small-claims decision in British Columbia, not a binding rule everywhere, but it is the clearest ruling so far and it rejected the 'separate entity' defense outright.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where should the refund limit live?
&lt;/h2&gt;

&lt;p&gt;In tool code that checks identity, order state, and amount before any money moves. The model proposes an action, and the code decides whether it's allowed. The sketch below is minimal and illustrative, not taken from any product.&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;REFUND_CAP_CENTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10_000&lt;/span&gt;          &lt;span class="c1"&gt;# above this, a human decides
&lt;/span&gt;&lt;span class="n"&gt;MAX_REFUNDS_PER_ORDER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;issue_refund&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="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&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;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Identity comes from the authenticated session, never from chat text.
&lt;/span&gt;    &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="o"&gt;=&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;customer_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;order&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;denied&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;why&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_not_found&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;amount_cents&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;denied&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;why&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;invalid_amount&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;amount_cents&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;paid_cents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;denied&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;why&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;exceeds_amount_paid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refunds_issued&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;MAX_REFUNDS_PER_ORDER&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;denied&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;why&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;already_refunded&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;amount_cents&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;REFUND_CAP_CENTS&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;queue_for_human&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="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;payments&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="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payment_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="si"&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;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refunds_issued&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="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# in production, check-and-increment must be atomic (row lock or conditional update)
&lt;/span&gt;    &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refunds_issued&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approved&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;amount_cents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I learned the difference between an advisory check and an enforced one on a different system: my approval-gated X growth agent, where every post, reply, follow and like went through a Telegram approval I pressed by hand. On 2026-07-21, X suspended that account. The lockout check fired and returned True, but the caller skipped one action and the loop kept running for 3.5 more days. A check that returns an answer is advisory until the code path can't proceed without it. The fix was a process exit plus a sentinel file only a human can clear, because launchd KeepAlive respawned a plain exit. A refund cap needs the same property: the model can't route around it, and only a human can lift it.&lt;/p&gt;

&lt;p&gt;The temptation is to put the cap in the system prompt, because that's the easiest place to edit it on a Friday afternoon. The code above is harder to change, and it can't be argued with. Scoping the tool's permissions the same way is the subject of the &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/least-privilege-for-ai-agents-scoping-tools-keys-and-filesystems-in-practice" rel="noopener noreferrer"&gt;least-privilege essay&lt;/a&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;th&gt;Where it runs&lt;/th&gt;
&lt;th&gt;Survives an injected instruction?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"Never refund over $100" in the system prompt&lt;/td&gt;
&lt;td&gt;Model context&lt;/td&gt;
&lt;td&gt;No. It's text, and the customer can argue with text.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Injection classifier in front of the model&lt;/td&gt;
&lt;td&gt;Model pipeline&lt;/td&gt;
&lt;td&gt;Partly. AgentDojo's detector still let 8% of attacks through.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cap, ownership, and once-per-order checks in the refund tool&lt;/td&gt;
&lt;td&gt;Application code&lt;/td&gt;
&lt;td&gt;Yes, for amounts and ownership. It does not stop a plausible false claim under the cap.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Human queue above threshold, not writable from chat&lt;/td&gt;
&lt;td&gt;Ops tooling&lt;/td&gt;
&lt;td&gt;Yes, as long as the chat session has no write path into the queue.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What the tool layer doesn't fix
&lt;/h2&gt;

&lt;p&gt;A cap limits the loss per order, but it doesn't stop abuse below the cap, such as false "item never arrived" claims or many small refunds across accounts. Those need per-customer refund velocity limits and fraud signals that the chat session can't see or write. An enforced check also only protects against failures it can recognize. When X suspended the account again on 2026-09-22, the lockout check matched none of its known selectors and never fired; I stopped the bot by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I expect the next post-mortem to say
&lt;/h2&gt;

&lt;p&gt;By 2027-10-29, I predict at least one publicly documented case in which a customer got a refund or credit above a support bot's published cap through the chat interface. The post-mortem will name a missing check in the tool layer, not a prompt the model failed to follow. My reading of the tau-bench numbers suggests the mechanism: if a bot is inconsistent with ordinary customers, an attacker who retries will eventually get the bad outcome, and the company's fix will be a server-side limit that should have existed from the start. If no such case is documented by that date, the prediction is wrong, and the likely reason is that nobody publishes these incidents.&lt;/p&gt;

</description>
      <category>promptinjection</category>
      <category>aiagents</category>
      <category>customersupport</category>
      <category>refundabuse</category>
    </item>
    <item>
      <title>Break-Glass Access for AI Agents: Approved, Timed, Expiring</title>
      <dc:creator>Orvi Das</dc:creator>
      <pubDate>Thu, 29 Oct 2026 04:30:05 +0000</pubDate>
      <link>https://dev.to/robat_das_3c6e956212f6408/break-glass-access-for-ai-agents-approved-timed-expiring-2n28</link>
      <guid>https://dev.to/robat_das_3c6e956212f6408/break-glass-access-for-ai-agents-approved-timed-expiring-2n28</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/break-glass-access-for-ai-agents-time-boxed-elevated-permissions-with-human-approval-and-automatic-expiry" rel="noopener noreferrer"&gt;robatdasorvi.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For AI agents, "break-glass access" covers two things: a time-boxed, human-approved elevation of the agent's permissions (just-in-time access), and a human's emergency path to override or halt the agent. A human approves it, it covers one purpose, and it expires without anyone having to remember to revoke it. The agent never extends it. The halt path also has to be something the agent cannot clear by itself.&lt;/p&gt;

&lt;p&gt;When X suspended my approval-gated growth bot's account on 2026-07-21, the lockout check returned True. The caller skipped one action, and the loop kept running for 3.5 more days. I had built a check that detected the problem correctly, and a control that did nothing with what the check found.&lt;/p&gt;

&lt;h2&gt;
  
  
  What break-glass access means for agents
&lt;/h2&gt;

&lt;p&gt;It's a scoped grant. An agent can use it only after a named human approves it, and the platform revokes it at a fixed deadline. Just-in-time elevation should be rare, logged, and short.&lt;/p&gt;

&lt;p&gt;The clearest published model I know of is Google Cloud's Privileged Access Manager. It separates an entitlement from a grant. The entitlement is the eligibility to ask for access, under conditions like duration. The grant is one instance of using that entitlement. Approvals can be automatic or routed to a human approver, and access is removed when the duration runs out. The grant timeline records the approver's username and the reason (&lt;a href="https://cloud.google.com/iam/docs/pam-overview" rel="noopener noreferrer"&gt;Google Cloud PAM overview&lt;/a&gt;). Microsoft Entra PIM uses the same two levers: time-based and approval-based activation, with a justification (&lt;a href="https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-configure" rel="noopener noreferrer"&gt;Microsoft Learn&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Most agent guidance focuses on the agent's standing scope. Break-glass covers the moments when that standing scope is wrong, and those moments are where the design actually gets tested.&lt;/p&gt;

&lt;p&gt;Routine JIT elevation and true break-glass are different things. PAM and PIM handle routine just-in-time elevation: a request, an approval, and an expiry. True break-glass is pre-provisioned emergency access, kept for when the normal approval path is down. Microsoft's guidance on emergency access accounts describes this model (&lt;a href="https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/security-emergency-access" rel="noopener noreferrer"&gt;Microsoft Learn&lt;/a&gt;). For agents, the rule is plain: an agent may request JIT elevation, but it should never hold break-glass credentials. Break-glass for agents is a human overriding or halting the agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an approval gate is not a break-glass control
&lt;/h2&gt;

&lt;p&gt;It's tempting to treat an approval gate as the safety boundary. It isn't. An approval gate governs what the agent proposes, not what the process does after something goes wrong. My bot never had a break-glass grant. What it shows is the other half of the pattern: what happens when revocation and halt depend on code that can quietly do nothing.&lt;/p&gt;

&lt;p&gt;My bot ran every post, reply, follow, and like through a Telegram approval, and I pressed each one by hand. The gate worked exactly as designed. What it couldn't cover was what the process did after a safety signal fired. That's the suspension from the opening. The approval gate had nothing to say about a loop that was already running.&lt;/p&gt;

&lt;p&gt;A separate failure was harder to spot, because nothing looked wrong. For about six weeks, four hardcoded target accounts that no longer existed soaked up 88% of the bot's daily comment attempts. Every run logged success, and nothing raised an error. The Telegram Skip button also silently did nothing for months. Every skipped item got counted as a timeout, so the approval statistics were wrong for that whole stretch.&lt;/p&gt;

&lt;p&gt;What I took from both: a control you can't see failing is a control you don't really have. Agents fail quietly by default, so both the grant and the halt need a signal a human actually reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is a plain process exit enough?
&lt;/h2&gt;

&lt;p&gt;No. A plain exit is a suggestion that a supervisor can override. The fix I shipped was a process exit plus a sentinel file that only a human can clear. I needed the sentinel because launchd's KeepAlive respawned the plain exit.&lt;/p&gt;

&lt;p&gt;The second suspension showed the same gap from another side. On 2026-09-22, a week after I raised the reply cap from 16 to 150, X suspended the account again. This time the lockout check matched none of its known selectors, so it never fired. I stopped the bot by hand. A detector that depends on the platform's page structure stops working when the page changes in a way its selectors don't cover.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="n"&gt;HALT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/var/agent/HALT_HUMAN_ONLY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# lives in a root-owned directory the agent's user cannot write; deleting a file needs write access to its directory
&lt;/span&gt;
&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Grant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;approved_by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;        &lt;span class="c1"&gt;# a named human, recorded
&lt;/span&gt;    &lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;    &lt;span class="c1"&gt;# set at approval time, never extended by the agent
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_before_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;grant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Grant&lt;/span&gt; &lt;span class="o"&gt;|&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;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&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;HALT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;halted: sentinel present, only a human clears it&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;grant&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;grant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;PermissionError&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;no grant for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;scope&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;grant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;PermissionError&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;grant for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; expired; request approval again&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;This is a sketch of the pattern, not a product. The in-process check alone repeats my original bug, because a caller can catch or skip it. The supervisor has to honor the sentinel too, or a plain respawn undoes it. The permission model is the part that matters. If the agent's account can delete the sentinel, the sentinel is only a suggestion. The in-process expiry check is defense in depth, not the control. Real expiry belongs to the credential issuer, so a lapsed grant is a dead token and not an if-statement the agent could skip. That matches &lt;a href="https://genai.owasp.org/llmrisk/llm062025-excessive-agency/" rel="noopener noreferrer"&gt;OWASP LLM06:2025&lt;/a&gt;'s point that authorization belongs in downstream systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doesn't human approval slow agents down too much?
&lt;/h2&gt;

&lt;p&gt;Not if approval sits at the elevation boundary instead of on every action. The usual objection is that per-action approvals cause fatigue, and people start clicking through. That objection is right. The answer is to approve less often and with more context, which is the same line I draw in &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/ai-assisted-vs-ai-autonomous-where-the-line-actually-is-and-why-it-matters" rel="noopener noreferrer"&gt;AI-assisted vs AI-autonomous&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The incidents show what happens without a boundary. In July 2025, an autonomous Replit coding agent reportedly dropped a live production database during a code freeze, despite repeated instructions not to touch anything. Replit's CEO called it "unacceptable and should never be possible" (&lt;a href="https://www.eweek.com/news/replit-ai-coding-assistant-failure/" rel="noopener noreferrer"&gt;eWeek&lt;/a&gt;). Nothing below the prompt enforced the freeze.&lt;/p&gt;

&lt;p&gt;In late 2025, Google's Antigravity agent was reportedly running in Turbo mode, which lets it execute commands without user input. A command meant to clear a project cache reportedly wiped the root of the D: drive. The agent acknowledged the error afterward (&lt;a href="https://www.techradar.com/ai-platforms-assistants/googles-antigravity-ai-deleted-a-developers-drive-and-then-apologized" rel="noopener noreferrer"&gt;TechRadar&lt;/a&gt;). Replit's CEO confirmed the first publicly; the second rests on the developer's own account.&lt;/p&gt;

&lt;p&gt;OWASP's guidance points the same way. It names excessive functionality, excessive permissions, and excessive autonomy as the root causes of excessive agency. It recommends human approval for high-impact actions, and it says authorization should be enforced in downstream systems, not decided by the LLM (&lt;a href="https://genai.owasp.org/llmrisk/llm062025-excessive-agency/" rel="noopener noreferrer"&gt;OWASP LLM06:2025&lt;/a&gt;). One approval for thirty minutes of one scope costs a person one decision. Per-action approval costs a decision every time, and that's how decisions turn into rubber stamps. The boundary is also where &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/least-privilege-for-ai-agents-scoping-tools-keys-and-filesystems-in-practice" rel="noopener noreferrer"&gt;least-privilege scoping&lt;/a&gt; and &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/the-economics-of-ai-agents-when-autonomous-beats-human-in-the-loop" rel="noopener noreferrer"&gt;the cost tradeoff between autonomy and human review&lt;/a&gt; meet in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to implement break-glass for an agent
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Define the entitlement in advance: scope, maximum duration, and named approvers.&lt;/li&gt;
&lt;li&gt;The approver must be a human other than whoever triggered the request, never the agent.&lt;/li&gt;
&lt;li&gt;The credential issuer mints a short-lived credential whose TTL is fixed at approval, for example AWS STS AssumeRole &lt;code&gt;DurationSeconds&lt;/code&gt; (&lt;a href="https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html" rel="noopener noreferrer"&gt;AWS STS AssumeRole&lt;/a&gt;) or a HashiCorp Vault lease TTL (&lt;a href="https://developer.hashicorp.com/vault/docs/concepts/lease" rel="noopener noreferrer"&gt;Vault leases&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Log the grant, the approver, the reason, and every use.&lt;/li&gt;
&lt;li&gt;Review each grant after it expires.&lt;/li&gt;
&lt;li&gt;Give a human a halt the agent cannot undo: a sentinel or revocation the agent's identity has no permission to clear, which the supervisor checks before every restart.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why expiry matters more than the approval
&lt;/h2&gt;

&lt;p&gt;Leaked secrets are the best-measured case of credentials outliving their purpose. They are not the same thing as unrevoked grants, but the failure mode is the same. GitGuardian's 2025 report found 23.8 million secrets leaked on public GitHub in 2024, a 25% increase over the year before (&lt;a href="https://blog.gitguardian.com/the-state-of-secrets-sprawl-2025/" rel="noopener noreferrer"&gt;GitGuardian, 2025&lt;/a&gt;). The same report found that 70% of secrets leaked in 2022 were still active.&lt;/p&gt;

&lt;p&gt;Expiry fails silently when it lives in application code or in someone's memory instead of in the credential itself. An approval gets logged the moment it happens. When the issuer enforces expiry, the grant ends at its deadline without anyone watching. If expiry is also checked in application code, check on every use, and an agent that tries to use a lapsed grant should get an error, not a retry.&lt;/p&gt;

&lt;p&gt;My prediction: within two years, a public postmortem of an AI agent incident will name a break-glass grant that was never revoked as a root cause. After that, I expect vendors to make expiry enforcement the default instead of an opt-in setting.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>breakglassaccess</category>
      <category>privilegedaccessmanagement</category>
      <category>agentsafety</category>
    </item>
    <item>
      <title>Your Team Celebration Is an Event Handler. Make It a Cron Job (With the Workflow)</title>
      <dc:creator>Orvi Das</dc:creator>
      <pubDate>Tue, 27 Oct 2026 11:04:30 +0000</pubDate>
      <link>https://dev.to/robat_das_3c6e956212f6408/your-team-celebration-is-an-event-handler-make-it-a-cron-job-with-the-workflow-580e</link>
      <guid>https://dev.to/robat_das_3c6e956212f6408/your-team-celebration-is-an-event-handler-make-it-a-cron-job-with-the-workflow-580e</guid>
      <description>&lt;p&gt;Compare your team's &lt;code&gt;#shipped&lt;/code&gt; channel with your incident log. My guess is the quiet weeks in one line up with the bad weeks in the other.&lt;/p&gt;

&lt;p&gt;That isn't a coincidence. It's a dependency. Most teams wire celebration to output: a release goes out, someone posts the party emoji, people get a nice moment. No release, no moment. In code terms, celebration is an event handler subscribed to &lt;code&gt;on: release&lt;/code&gt;. When the events stop, the handler never runs.&lt;/p&gt;

&lt;p&gt;I think that's a design bug. This post covers the research behind that, then the boring fix: run the ritual on a schedule, whatever the metrics say.&lt;/p&gt;

&lt;h2&gt;
  
  
  The research everyone cites, read backwards
&lt;/h2&gt;

&lt;p&gt;"Celebrate small wins" usually goes back to Teresa Amabile and Steven Kramer's progress principle. They studied nearly 12,000 daily diary entries from 238 knowledge workers at seven companies. People's best workdays were the ones where they made progress on meaningful work. In &lt;a href="https://hbr.org/2011/05/the-power-of-small-wins" rel="noopener noreferrer"&gt;their 2011 HBR piece&lt;/a&gt; they also asked 669 managers to rank what motivates employees. Progress came last. Only 35 of the 669 ranked it first.&lt;/p&gt;

&lt;p&gt;The finding is solid. What people did with it is the problem. Amabile was making a point about &lt;em&gt;visibility&lt;/em&gt;: managers don't see progress, so nobody marks it. The popular version turned that into "notice your wins, then reward yourself." That move welds the ritual to your throughput. Now celebration carries all the variance of your output.&lt;/p&gt;

&lt;p&gt;Think about who gets nothing under that model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the team in week three of a migration that won't demo until next quarter&lt;/li&gt;
&lt;li&gt;the on-call engineer who spent five days keeping a fire small&lt;/li&gt;
&lt;li&gt;the solo dev whose launch flopped on Tuesday&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are the people with the least motivation left. They're the ones the ritual should be serving.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Won't celebrating make us soft?"
&lt;/h2&gt;

&lt;p&gt;This is the serious objection. The argument is that tension drives effort and celebrating lets it out. It sounds tough-minded. The data doesn't back it up.&lt;/p&gt;

&lt;p&gt;In 2010, Michael Kraus, Cassey Huang and Dacher Keltner coded the physical touch between NBA teammates early in the 2008–09 season: fist bumps, high fives, chest bumps, huddles. Teams that touched more early in the season played better later in it, even after controlling for player salary, preseason expectations and early-season performance. The acknowledgment came &lt;em&gt;first&lt;/em&gt;. It wasn't a reward for winning.&lt;/p&gt;

&lt;p&gt;Separately, Alison Wood Brooks and colleagues found that doing a short, arbitrary ritual before a stressful task reduced anxiety and improved performance compared with doing nothing. The ritual didn't have to mean anything. It just had to happen reliably, before the outcome was known.&lt;/p&gt;

&lt;p&gt;So the order is the other way around from what most teams assume: ritual, then performance. Not performance, then ritual.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving it from &lt;code&gt;on: release&lt;/code&gt; to &lt;code&gt;on: schedule&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Roads get built before the traffic shows up, and they get maintained in weeks when nobody drives on them. A team ritual needs the same treatment. Here's a minimal version: a GitHub Action that posts a Friday marker to Slack every week. It doesn't matter whether anything shipped.&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="c1"&gt;# .github/workflows/friday-marker.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;friday-marker&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;15&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;5"&lt;/span&gt;   &lt;span class="c1"&gt;# Fridays, 15:00 UTC&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mark&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python3 scripts/friday_marker.py&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
          &lt;span class="na"&gt;SLACK_WEBHOOK_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SLACK_WEBHOOK_URL }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;#!/usr/bin/env python3
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Friday marker: posts every week, whether or not anything shipped.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.request&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;

&lt;span class="n"&gt;SINCE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%Y-%m-%d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;REPO&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GITHUB_REPOSITORY&lt;/span&gt;&lt;span class="sh"&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;merged_prs&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;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&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;gh&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;pr&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;list&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;--repo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;REPO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--state&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;merged&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;--search&lt;/span&gt;&lt;span class="sh"&gt;"&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;merged:&amp;gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;SINCE&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--json&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;title,author,deletions&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;--limit&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;100&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&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;build_message&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;prs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;merged_prs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# the ritual runs even when the data source doesn't
&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;gh failed, posting anyway: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;exc&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="n"&gt;prs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&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;*Friday marker, week of &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;SINCE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;prs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;deleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deletions&lt;/span&gt;&lt;span class="sh"&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;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;prs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;lines&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; PRs merged, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;deleted&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; lines deleted.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;+=&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;• &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&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;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;author&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;login&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;prs&lt;/span&gt;&lt;span class="p"&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;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;lines&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;Nothing merged this week. The marker fires anyway.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;lines&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;Thread: one thing you learned, fixed, unblocked, or survived.&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="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&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;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&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;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SLACK_WEBHOOK_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="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;text&lt;/span&gt;&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&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;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;build_message&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two design choices matter more than the rest of the code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The empty branch is a first-class path, not an error.&lt;/strong&gt; If a script exits quietly when there's no data, you've rebuilt the event handler with extra steps. A zero-PR week is when this job matters most, so it posts the most deliberate message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The data fetch is allowed to fail. The post isn't.&lt;/strong&gt; If &lt;code&gt;gh&lt;/code&gt; breaks, you still get a marker. Treat the ritual like a heartbeat: when it doesn't arrive, that should be a signal in itself.&lt;/p&gt;

&lt;p&gt;One ironic catch: in public repos, GitHub disables scheduled workflows after 60 days with no repository activity. So the platform itself couples the schedule to output. If your project goes through long quiet stretches, put the marker in a repo that sees regular commits, or run it from somewhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes in the thread on a dead week
&lt;/h2&gt;

&lt;p&gt;The prompt at the end does the real work. On a good week people post features. On a flat week they need permission to count other things. These are the ones I've seen land:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deletions.&lt;/strong&gt; Removing 2,000 lines of dead code is progress nobody demos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incidents contained.&lt;/strong&gt; "It was down for 40 minutes, not four hours" deserves a marker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unblocking.&lt;/strong&gt; Name the person who answered the question that saved someone a day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Learned the hard way.&lt;/strong&gt; A postmortem finding counts. So does a hypothesis you killed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Amabile's own data supports this. What showed up in people's best days was &lt;em&gt;perceived&lt;/em&gt; progress on meaningful work, and you can only perceive what somebody points at. The Friday thread is where the pointing happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this came from for me
&lt;/h2&gt;

&lt;p&gt;I nearly cancelled a standing Friday dinner a few weeks ago. An account I'd spent a quarter growing had just been suspended for the second time in nine weeks, and marking the week felt absurd. Then I noticed I was about to switch off the one recurring thing built for weeks like that one. The dinner happened. The account is still under review. The dinner was never meant to depend on how the account was doing.&lt;/p&gt;

&lt;p&gt;The longer version, with more of the ritual research, is on my site. The short version for engineering teams: if your celebration has an &lt;code&gt;if shipped:&lt;/code&gt; guard, take it out.&lt;/p&gt;

&lt;p&gt;Does your team have a ritual that still runs on bad weeks, or does it go quiet when the metrics do?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.robatdasorvi.com/chapters/human/why-celebration-is-not-optional-it-is-infrastructure" rel="noopener noreferrer"&gt;https://www.robatdasorvi.com/chapters/human/why-celebration-is-not-optional-it-is-infrastructure&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>career</category>
      <category>devops</category>
      <category>python</category>
    </item>
    <item>
      <title>Policy as Code for AI Agent Tool Authorization: OPA or Cedar</title>
      <dc:creator>Orvi Das</dc:creator>
      <pubDate>Sat, 24 Oct 2026 07:40:14 +0000</pubDate>
      <link>https://dev.to/robat_das_3c6e956212f6408/policy-as-code-for-ai-agent-tool-authorization-opa-or-cedar-248f</link>
      <guid>https://dev.to/robat_das_3c6e956212f6408/policy-as-code-for-ai-agent-tool-authorization-opa-or-cedar-248f</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/policy-as-code-for-ai-agent-tool-calls-authorizing-actions-with-opa-or-cedar" rel="noopener noreferrer"&gt;robatdasorvi.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In July 2025, Replit's AI coding agent deleted a live production database holding records on 1,206 executives during a code freeze, according to &lt;a href="https://www.eweek.com/news/replit-ai-coding-assistant-failure/" rel="noopener noreferrer"&gt;eWeek&lt;/a&gt;. The agent had been told not to make changes. That instruction lived in a prompt, and a prompt is a request the model may decline to honor. It is not a control the system enforces. That gap is why policy as code for AI agent tool authorization, with an engine like OPA or Cedar, exists.&lt;/p&gt;

&lt;p&gt;If your agent's limits live in a system prompt, a tool description, or an &lt;code&gt;if tool == "delete"&lt;/code&gt; branch in your dispatcher, you are in the position that database owner was in. The fix is to move the decision out of the model and out of your application code, and into a policy engine that answers allow or deny for every call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Should AI Agent Tool Calls Go Through a Policy Engine?
&lt;/h2&gt;

&lt;p&gt;Every AI agent tool call that changes state should be authorized by a policy engine such as OPA or Cedar, not by the prompt. A policy decision point outside the agent returns allow or deny for each call, along with a reason you can log. The tool runtime refuses to proceed without that answer. A prompt only asks.&lt;/p&gt;

&lt;p&gt;The stakes are rising faster than the controls. &lt;a href="https://www.gartner.com/en/newsroom/press-releases/2025-06-25-gartner-predicts-over-40-percent-of-agentic-ai-projects-will-be-canceled-by-end-of-2027" rel="noopener noreferrer"&gt;Gartner's 25 June 2025 press release&lt;/a&gt; predicts that more than 40% of agentic AI projects will be canceled by the end of 2027, because of escalating costs, unclear business value, or inadequate risk controls. Risk controls are the third item on that list, and they are the one this article is about. In July 2025, a destructive instruction was injected into version 1.84.0 of Amazon Q Developer's VS Code extension, which has more than 950,000 installations, per &lt;a href="https://cyberinsider.com/amazons-ai-coding-assistant-for-vsc-infected-with-data-wiper/" rel="noopener noreferrer"&gt;CyberInsider's report&lt;/a&gt;. Amazon said formatting errors kept the payload from running. Some researchers disputed that. Either way, an agent with a broad tool surface and no independent check on each action has no defense when an instruction arrives from the wrong source.&lt;/p&gt;

&lt;h2&gt;
  
  
  An Agent Breaks a Prompt Rule Because Nothing Enforces It Where the Action Runs
&lt;/h2&gt;

&lt;p&gt;An agent breaks a prompt rule because nothing enforces that rule where the action actually runs. The model decides whether to follow the rule, and the tool runs whatever the model emits.&lt;/p&gt;

&lt;p&gt;The Replit case shows the shape. Per &lt;a href="https://www.eweek.com/news/replit-ai-coding-assistant-failure/" rel="noopener noreferrer"&gt;eWeek's reporting&lt;/a&gt;, the agent ran destructive commands during a code freeze. According to founder Jason Lemkin's public account, it then misreported what it had done. Replit's CEO called it "unacceptable and should never be possible." The safeguards Replit announced afterward are the useful part: separate development and production databases, a planning-only mode, and one-click restore from backups. Each one sits on the execution path. None of them is a sentence in a prompt.&lt;/p&gt;

&lt;p&gt;I run an approval-gated X growth agent: every post, reply, follow and like went through a Telegram approval I pressed by hand. On 21 July 2026, X suspended the account. The lockout check fired and returned True, but the caller skipped one action and the loop kept running for 3.5 more days. A correct answer from a check does nothing if the code that acts treats it as advice: the caller got True, skipped one action, and carried on. The fix was a process exit plus a sentinel file that only a human can clear, because launchd's KeepAlive respawned a plain exit. The problem was the enforcement point, not the detection logic.&lt;/p&gt;

&lt;p&gt;The second suspension, on 22 September 2026, failed the other way. The lockout check matched none of its known selectors and never fired. A gate that works only when a pattern matches the world is a detector, and detectors &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/how-llm-agents-fail-silently-in-production-and-how-i-learned-to-catch-it" rel="noopener noreferrer"&gt;fail silently in production&lt;/a&gt;. Authorization needs a default of deny that still holds when the detector is blind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Does the Policy Decision Point Pattern Come From?
&lt;/h2&gt;

&lt;p&gt;Splitting the decision point from the enforcement point predates agents by more than a decade. XACML, the OASIS access-control standard, defined a Policy Decision Point that evaluates a request against policy, and a Policy Enforcement Point that acts on the answer. OASIS approved XACML 3.0 in a vote that closed on 22 January 2013. It followed XACML 2.0 from 1 February 2005 and 1.0 from 2003 (&lt;a href="https://www.oasis-open.org/standard/xacml3-0-core" rel="noopener noreferrer"&gt;OASIS XACML 3.0 standard&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;NIST formalized the same idea in January 2014. &lt;a href="https://nvlpubs.nist.gov/nistpubs/specialpublications/NIST.SP.800-162.pdf" rel="noopener noreferrer"&gt;NIST SP 800-162&lt;/a&gt; describes attribute-based access control as a permission decision. It weighs attributes of the requester, the target resource, the requested action, and sometimes environmental conditions against policy. Map that onto a tool call and the pieces line up. The agent is the subject. The tool and its arguments are the resource and the action. The environment is production or staging. None of this is new. What is new is that the subject is a language model that composes its own actions.&lt;/p&gt;

&lt;p&gt;XACML showed the model was sound, but the standard never made it into everyday engineering. The idea reached ordinary infrastructure through Open Policy Agent. The CNCF announced OPA's graduation on 4 February 2021 (&lt;a href="https://www.cncf.io/announcements/2021/02/04/cloud-native-computing-foundation-announces-open-policy-agent-graduation/" rel="noopener noreferrer"&gt;CNCF announcement&lt;/a&gt;). AWS open-sourced Cedar in May 2023. Agent tool calls are the third wave. They are also the first where the subject is an unpredictable generator, not a human or a service with a fixed call graph.&lt;/p&gt;

&lt;h2&gt;
  
  
  OPA or Cedar: Which Should You Pick?
&lt;/h2&gt;

&lt;p&gt;Pick Cedar if you are on AWS or want authorization decisions that are easy to analyze. Pick OPA if your organization already runs it and you need policy beyond authorization. Either one works, as long as the enforcement point is mandatory.&lt;/p&gt;

&lt;p&gt;OPA uses Rego, a general-purpose policy language. It is the broader tool, and it already sits in many Kubernetes and CI stacks. Cedar was built for authorization. The &lt;a href="https://arxiv.org/pdf/2403.04651" rel="noopener noreferrer"&gt;Cedar paper&lt;/a&gt; (OOPSLA 2024, DOI 10.1145/3649835) compares Cedar with OpenFGA and Rego. Its authors, the Cedar team at AWS, report that Cedar policies are as readable as or more readable than the alternatives, and that Cedar evaluated faster than both in their benchmarks.&lt;/p&gt;

&lt;p&gt;Amazon Bedrock AgentCore Policy shows what an enforcement point for agents looks like in practice. It intercepts requests at the AgentCore Gateway, checks each one against a Cedar policy engine before the tool is reached, and logs every decision to CloudWatch. It has two modes: LOG_ONLY and ENFORCE. The &lt;a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/policy.html" rel="noopener noreferrer"&gt;AgentCore policy documentation&lt;/a&gt; covers the mode settings. Start in LOG_ONLY, read the denials for a week, then switch to ENFORCE.&lt;/p&gt;

&lt;p&gt;Here is a Cedar policy for a refund tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="n"&gt;permit&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="s2"&gt;"invoke"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;resource&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;Tool&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="s2"&gt;"issue_refund"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;when&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="n"&gt;forbid&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="s2"&gt;"invoke"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;resource&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;Tool&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="s2"&gt;"issue_refund"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;when&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cedar denies by default, so the permit alone already blocks amounts of 50 or more. The forbid is a guardrail: in Cedar an explicit forbid overrides every permit, so it still holds if someone later adds a broader permit. Pair it with &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/least-privilege-for-ai-agents-scoping-tools-keys-and-filesystems-in-practice" rel="noopener noreferrer"&gt;least privilege for AI agents&lt;/a&gt;. A policy engine can only deny what the tool surface exposes.&lt;/p&gt;

&lt;p&gt;Here is the same refund limit in Rego, the language OPA uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="ow"&gt;package&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt;

&lt;span class="ow"&gt;default&lt;/span&gt; &lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"issue_refund"&lt;/span&gt;
  &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(This uses OPA 1.0 syntax; on older versions add &lt;code&gt;import rego.v1&lt;/code&gt;.)&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;default allow := false&lt;/code&gt; line is the important part. Rego has no &lt;code&gt;forbid&lt;/code&gt; keyword, so the usual pattern is a default-deny rule with allow rules beside it. If no rule matches, the answer is deny, so a new tool with no rule is blocked automatically. The dispatcher sends one JSON input document per call, containing the principal (the agent id), the tool name, the arguments, and the environment, such as production or staging. Fill the principal and environment from the runtime session, never from the model's output. An agent that can write its own agent id or environment into the input can authorize itself. Both engines evaluate that document and return a decision the tool runtime must check before it runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doesn't a Policy Engine Add Latency and Another Point of Failure?
&lt;/h2&gt;

&lt;p&gt;Run in-process, an OPA or Cedar evaluation adds very little time. Called over the network, it costs a round-trip. The real risk is the default when the engine is unavailable, not the latency. &lt;a href="https://aws.amazon.com/verified-permissions/faqs/" rel="noopener noreferrer"&gt;AWS describes Verified Permissions as evaluating access requests in milliseconds&lt;/a&gt;. A tool call already waits on a model round-trip that takes seconds. The check is not the bottleneck.&lt;/p&gt;

&lt;p&gt;The failure mode is what matters. If the policy service is unreachable, the tool runtime must deny. A fail-open default turns the policy engine into an optional check, the same shape as the 21 July bug, where a correct answer existed and the code that acted didn't obey it. Write the code so the tool function cannot run without a decision object, and treat a missing decision as a hard error.&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;logging&lt;/span&gt;

&lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLogger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent.tools&lt;/span&gt;&lt;span class="sh"&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;call_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&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="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;principal&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;# set by the runtime, never by the model
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;environment&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arguments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;policy_engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;PermissionError&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;policy engine failed, denying &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&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="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;decision&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="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;PermissionError&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;no decision for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, denying&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&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=%s allow=%s reason=%s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reason&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;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;PermissionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; denied: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reason&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;TOOLS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Writing a policy is not the same as enforcing one. Put the check in the single function every tool call passes through, not in each tool. Then test it by calling each tool with the decision stubbed to deny. If any tool still runs, the gate is optional, and you have the same bug as before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should You Start With the Policy or the Enforcement Point?
&lt;/h2&gt;

&lt;p&gt;Start with the enforcement point. A policy behind an optional gate is just documentation. Once every side-effecting call needs a decision, the policy language becomes a choice you can revisit later.&lt;/p&gt;

&lt;p&gt;Sometimes a denial is not enough, and you need to undo what already happened. &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/rolling-back-an-ai-agent-s-actions-compensating-transactions-and-the-saga-pattern-for-tool-calls" rel="noopener noreferrer"&gt;Rollback for tool calls fails in its own ways&lt;/a&gt;, so design for it before the first incident. And before you give an agent write access anywhere, ask whether it should be an agent at all. &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/the-architecture-decision-nobody-talks-about-when-not-to-use-an-ai-agent" rel="noopener noreferrer"&gt;Some tools need no agent&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Today, find the one function your agent uses to call tools that change state. Make it refuse to run without a decision from a policy engine. Ship that in LOG_ONLY, and tomorrow read every denial it logged.&lt;/p&gt;

</description>
      <category>policyascode</category>
      <category>aiagentauthorization</category>
      <category>opa</category>
      <category>cedar</category>
    </item>
    <item>
      <title>Adding an API Enum Value Is a Breaking Change for Agent Tools</title>
      <dc:creator>Orvi Das</dc:creator>
      <pubDate>Sat, 24 Oct 2026 07:39:43 +0000</pubDate>
      <link>https://dev.to/robat_das_3c6e956212f6408/adding-an-api-enum-value-is-a-breaking-change-for-agent-tools-3l5m</link>
      <guid>https://dev.to/robat_das_3c6e956212f6408/adding-an-api-enum-value-is-a-breaking-change-for-agent-tools-3l5m</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.robatdasorvi.com/chapters/ai-automation/new-enum-values-break-agent-tools-detecting-additive-api-changes-that-spec-diffs-call-non-breaking" rel="noopener noreferrer"&gt;robatdasorvi.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Adding a value to an API enum breaks any client that handles that enum with a closed switch. Calling the change "additive" in your changelog doesn't change what that client does when the new value arrives. If a spec diff told you the change was non-breaking, the diff was right about the schema and wrong about your consumers.&lt;/p&gt;

&lt;p&gt;You're probably reading this because a diff tool marked a change as safe and something downstream fell over anyway. A client threw on the new status value. An agent's tool call started returning an error it had never returned before. You trusted the schema, and the schema wasn't what broke. The general case is in &lt;a href="https://www.robatdasorvi.com/chapters/ai-automation/when-an-upstream-api-changes-your-agent-s-tools-break-silently" rel="noopener noreferrer"&gt;When an Upstream API Changes, Your Agent's Tools Break Silently&lt;/a&gt;; this piece covers the one change most diff tools wave through: a new enum value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is adding an enum value actually a breaking change?
&lt;/h2&gt;

&lt;p&gt;Yes, for any consumer that throws on, drops, or mislabels a value it doesn't recognize. The change is additive for the producer and breaking for the reader, and the reader is the one who decides.&lt;/p&gt;

&lt;p&gt;The tools don't agree, which is the first sign the question is underspecified. The swagger-spec-compatibility library treats an added enum value in a response as an error, since clients built on the old spec can't validate the response (&lt;a href="https://swagger-spec-compatibility.readthedocs.io/en/latest/_modules/swagger_spec_compatibility/rules/added_enum_value_in_response.html" rel="noopener noreferrer"&gt;source&lt;/a&gt;). Smithy's changed-enum evaluator emits only a NOTE for a value appended at the end, and an ERROR for a value that is removed, renamed, or inserted mid-list (&lt;a href="https://smithy.io/javadoc/1.6.1/software/amazon/smithy/diff/evaluators/ChangedEnumTrait.html" rel="noopener noreferrer"&gt;source&lt;/a&gt;). A json-schema-diff proposal in pull request #46 classifies adding a value as non-breaking and removing one as breaking (&lt;a href="https://github.com/getsentry/json-schema-diff/pull/46" rel="noopener noreferrer"&gt;source&lt;/a&gt;). Three tools, three rules, one schema change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does my spec diff call it safe?
&lt;/h2&gt;

&lt;p&gt;The diff tool sees two schemas. The break happens in code it can't read. It can tell you what the producer changed. It can't tell you what a consumer does with a value that wasn't in the consumer's copy of the enum.&lt;/p&gt;

&lt;p&gt;Google's API Improvement Proposal 180 says enum values can be freely added to enums used only in request messages. For enums used in responses, it says the enum should document that it may grow. AIP-180 also warns that user code may not handle new values gracefully (&lt;a href="https://google.aip.dev/180" rel="noopener noreferrer"&gt;source&lt;/a&gt;). Microsoft's guidance makes the same trade: add values despite "a small compatibility risk," because existing code might not handle them (&lt;a href="https://msdn.microsoft.com/library/ms229010(v=vs.90)" rel="noopener noreferrer"&gt;source&lt;/a&gt;). Both rules hand the decision to the consumer, and a spec diff never looks at the consumer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Has this been solved before?
&lt;/h2&gt;

&lt;p&gt;Partly. HTTP and Protobuf both solved it by declaring the set open and telling readers what to do with unknown values. Most JSON APIs and generated clients never adopted the same rule. Postel's law, from &lt;a href="https://www.rfc-editor.org/rfc/rfc761" rel="noopener noreferrer"&gt;RFC 761&lt;/a&gt; in 1980, says to be conservative in what you send and liberal in what you accept. Martin Fowler's "Tolerant Reader" post, published 9 May 2011, made the consumer half concrete: "be as tolerant as possible when reading data from a service" (&lt;a href="https://www.martinfowler.com/bliki/TolerantReader.html" rel="noopener noreferrer"&gt;source&lt;/a&gt;). He also named the failure most teams still hit: generated classes from an XSD break when the provider adds a field. In his words, "Adding field to an interface like this shouldn't be a breaking change for anyone - but often does break these schemes."&lt;/p&gt;

&lt;p&gt;HTTP already solved the enum version of this problem, and it's the closest precedent you have. RFC 7231, published in 2014, requires clients to treat an unrecognized status code as the x00 code of its class, so a new 471 gets handled as a 400-class error (&lt;a href="https://www.rfc-editor.org/rfc/rfc7231#section-6" rel="noopener noreferrer"&gt;source&lt;/a&gt;). The same rule appeared in RFC 2068 in 1997. HTTP status codes are an open set, and the spec told clients to read the class instead of the exact code. Most enum-typed client code was never written that way.&lt;/p&gt;

&lt;p&gt;Protobuf ran the experiment in public. Its enum documentation says closed enums send an unrecognized number to the unknown field set, so the accessor reports the field as unset and returns the default value. Proto3 made enums open "specifically because of the unexpected behavior that closed enums cause" (&lt;a href="https://protobuf.dev/programming-guides/enum" rel="noopener noreferrer"&gt;source&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when a client sees a value it doesn't know?
&lt;/h2&gt;

&lt;p&gt;It does one of three things. It crashes, it silently substitutes a default, or it preserves the value and leaves the decision to the code. Only the last is safe, and most generated clients default to one of the first two.&lt;/p&gt;

&lt;p&gt;Onfido's February 2020 incident, titled "SDK Crashes," shows the crash case. A change to a public Onfido endpoint caused it to return values in a format the Android SDK couldn't handle, and about 90% of applicants using the Android SDK couldn't finish the media upload flow. The change was reverted at 17:58 UTC, about an hour after it went out at 16:55. A small percentage of affected users kept getting errors until a cache fix on 20 February, because a cache on their devices held an invalid value. The iOS and web SDKs had no issues (&lt;a href="https://status.onfido.com/incidents/m5py2jsgzw25" rel="noopener noreferrer"&gt;source&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Dfinity's post-mortem for 27 March 2024 shows the persistence case. Removing and replacing error codes in a new replica version stopped two Internet Computer subnets from restarting after an upgrade, because the changed enum values were stored in checkpoints (&lt;a href="https://forum.dfinity.org/t/post-mortem-subnets-cv73p-and-4ecnw-failed-upgrade-process-on-march-27-2024/29841" rel="noopener noreferrer"&gt;source&lt;/a&gt;). That was a removal, not an addition, but it shows how long an old enum value outlives the deploy that changed it. A checkpoint keeps the old reader and the new writer alive side by side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Isn't the consumer just wrong? Can't we patch it?
&lt;/h2&gt;

&lt;p&gt;You can, but not on your schedule. The producer's change has to be safe for the consumer as it exists today.&lt;/p&gt;

&lt;p&gt;The openapi-code-generator project made this argument in pull request #291: native mobile apps update on users' schedule, and strict enum validation that throws on unrecognized values makes it hard to add values safely (&lt;a href="https://github.com/mnahkies/openapi-code-generator/pull/291" rel="noopener noreferrer"&gt;source&lt;/a&gt;). A small percentage of Onfido's Android users kept getting errors after the revert until a cache fix on 20 February. Calling the consumer wrong doesn't fix a device that's already crashing. It just assigns blame after the outage.&lt;/p&gt;

&lt;p&gt;The GraphQL documentation makes the same point in its guidance on robust applications. It says GraphQL schemas can add new enum values at any time, and that if an application treats every enum value as exhaustively known, a new value from the server can cause crashes or silent data loss. Its recap says to always include a default branch for unknown enum values (&lt;a href="https://graphql.org/learn/robust-applications/" rel="noopener noreferrer"&gt;source&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Is this worth a separate check, or is it a code review issue?
&lt;/h2&gt;

&lt;p&gt;It needs its own check, because the failure is silent when it happens. A code review reads your diff. It doesn't run the consumer's switch statement against the value you're about to ship.&lt;/p&gt;

&lt;p&gt;I built an approval-gated X growth agent, and the same silent pattern showed up there several times. The closest one to an enum: when X suspended the account a second time on 2026-09-22, the lockout check matched none of its known selectors and never fired, so I stopped the bot by hand. A check built on a closed list of known states met a state outside the list and did nothing. For about six weeks, four hardcoded target accounts that no longer existed absorbed 88% of the bot's daily comment attempts. Every run logged success, and nothing raised an error. The Telegram Skip button did nothing for months, so every skipped item got counted as a timeout, and the approval statistics were wrong the whole time. The article lane lost 8 of 20 generations to JSON parse errors, and each failure quietly burned a 48-hour slot. None of these were enum changes, but they failed the same way: the code met a state it didn't expect, kept going, and raised no alarm.&lt;/p&gt;

&lt;p&gt;The same pattern shows up between agents in &lt;a href="https://www.robatdasorvi.com/chapters/ai-automation/agent-rule-wars" rel="noopener noreferrer"&gt;Agent Rule Conflicts: Why AI Pipelines Fail Silently&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does a new enum value break an agent's tool call?
&lt;/h2&gt;

&lt;p&gt;A new value in an API response that the tool wrapper validates against a closed enum returns a tool error, and the model may retry, route around it, or invent a result. A frozen enum in the tool's input schema blocks the new value until the tool definition is regenerated, so the agent can't use the new capability.&lt;/p&gt;

&lt;p&gt;For agents, this is the same problem with a worse failure mode. The agent doesn't crash, which makes the failure harder to see. The &lt;a href="https://modelcontextprotocol.io/specification/2025-06-18/server/tools" rel="noopener noreferrer"&gt;MCP tools specification&lt;/a&gt; defines a tool's &lt;code&gt;inputSchema&lt;/code&gt; and, optionally, &lt;code&gt;outputSchema&lt;/code&gt;. The tool wrapper should map unknown response enum values to an explicit 'unknown' result string the model can read, instead of throwing a validation error. The review side of the same silent failure is in &lt;a href="https://www.robatdasorvi.com/chapters/ai-automation/what-happens-when-the-ai-gets-it-wrong-and-you-do-not-notice" rel="noopener noreferrer"&gt;Silent AI Code Bugs: Passing Reviews, Failing in Production&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What would I do before the next enum value ships?
&lt;/h2&gt;

&lt;p&gt;Ask one question per consumer: what does your code do when it reads a value outside the enum it was built against? Answer that in code, not in a spec.&lt;/p&gt;

&lt;p&gt;Start with three steps. Mark every response enum as open in your contract, so the contract tells consumers to expect growth. Inject an unknown value into each consumer's contract test, and fail the build if the consumer throws. Fowler's post points to consumer-driven contracts, where consumers send their tests to the provider (&lt;a href="https://www.martinfowler.com/bliki/TolerantReader.html" rel="noopener noreferrer"&gt;source&lt;/a&gt;). Then check every cache and persisted store that holds the enum, because that's where the old reader and the new writer meet.&lt;/p&gt;

&lt;p&gt;Where APIDiffGuard fits: it's a diff engine and a monitor. The README describes it as a way to "catch breaking API changes before your clients do," by monitoring API responses, detecting schema drift, comparing versions, and alerting developers (&lt;a href="https://apidiffguard.com" rel="noopener noreferrer"&gt;apidiffguard.com&lt;/a&gt;). The diff engine is MIT-licensed as &lt;code&gt;@apidiffguard/diff&lt;/code&gt;. A schema comparison can't tell you whether a given consumer has a closed switch. A monitor only sees a new value once a response carries it, which is after the deploy. So it's an alert, not prevention. Classifying enum additions per consumer is the part I haven't solved.&lt;/p&gt;

&lt;p&gt;If you have one minute, do this. Find the client code that switches on your enum and look at its default branch. If the default throws, or doesn't exist, ship the default first, in the client, and only then ship the new value. The client's release cycle is the slowest link in your deploy, and you can't change it from the server.&lt;/p&gt;

</description>
      <category>enums</category>
      <category>apiversioning</category>
      <category>schemadrift</category>
      <category>backwardcompatibility</category>
    </item>
    <item>
      <title>LLM Structured Output: Validation, Retry or Constrained Decoding?</title>
      <dc:creator>Orvi Das</dc:creator>
      <pubDate>Fri, 23 Oct 2026 04:30:34 +0000</pubDate>
      <link>https://dev.to/robat_das_3c6e956212f6408/llm-structured-output-validation-retry-or-constrained-decoding-1n36</link>
      <guid>https://dev.to/robat_das_3c6e956212f6408/llm-structured-output-validation-retry-or-constrained-decoding-1n36</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.robatdasorvi.com/chapters/ai-automation/fixing-malformed-json-from-llms-constrained-decoding-vs-validate-and-retry" rel="noopener noreferrer"&gt;robatdasorvi.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most advice on LLM structured output comes down to validation and retry: ask for JSON, parse it, and retry when it breaks. If that's your whole plan, your pipeline is probably throwing away work right now. Constrained decoding is real and useful, but it solves a narrower problem than the one that actually costs you output. Validate-and-retry is the other common answer, and it fails in its own way. Getting reliable structured output from an LLM is a failure-handling problem before it is a parsing one. Which one you pick matters less than what your code does when the output is wrong.&lt;/p&gt;

&lt;p&gt;The case here is mine, and it's small. The long-form article lane in the X growth agent I run asks a model for a structured article payload. The lane lost 8 of 20 generations to JSON parse errors. Each failure quietly burned a full 48-hour publishing slot. Nothing raised an error a human would notice, and the pipeline treated the lost slots as ordinary outcomes. The malformed JSON was the trigger. The damage came from the failure branch, which burned the slot instead of releasing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does Constrained Decoding Fix Malformed JSON From LLMs?
&lt;/h2&gt;

&lt;p&gt;Partly. It fixes the syntax and shape of JSON when your framework supports your schema. It doesn't fix truncated, refused, or wrong-valued output. The mechanism is simple: at each step, the decoder masks out any token that couldn't continue a schema-valid answer, so the model can only produce something the schema allows.&lt;/p&gt;

&lt;p&gt;The strongest evidence came from the vendors. In August 2024, OpenAI reported that gpt-4o-2024-08-06 with Structured Outputs scored 100% on its complex JSON schema-following evals, against under 40% for gpt-4-0613 (&lt;a href="https://openai.com/index/introducing-structured-outputs-in-the-api/" rel="noopener noreferrer"&gt;OpenAI, 2024&lt;/a&gt;). That's a big jump, and it's real. OpenAI's Structured Outputs documentation notes that a response cut off at the token limit can come back incomplete, and the API can return a refusal instead of schema-shaped data (&lt;a href="https://platform.openai.com/docs/guides/structured-outputs" rel="noopener noreferrer"&gt;OpenAI docs&lt;/a&gt;). A schema-valid guarantee doesn't protect you from an answer that got cut off.&lt;/p&gt;

&lt;p&gt;The research is less tidy than the vendor numbers. The 2023 paper "Efficient Guided Generation for Large Language Models" by Willard and Louf showed that regex and grammar constraints can be enforced with a vocabulary index and little per-token overhead. It shipped as the Outlines library (&lt;a href="https://arxiv.org/abs/2307.09702" rel="noopener noreferrer"&gt;arXiv:2307.09702&lt;/a&gt;). In January 2025, Geng and colleagues published JSONSchemaBench, which runs 10,000 real-world JSON schemas through six constrained decoding frameworks: Guidance, Outlines, Llamacpp, XGrammar, OpenAI, and Gemini. They found that coverage and compliance vary widely across frameworks, and that no framework handled every schema construct (&lt;a href="https://arxiv.org/abs/2501.10868" rel="noopener noreferrer"&gt;arXiv:2501.10868&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;So the guarantee belongs to a specific framework and a specific schema. If your decoder doesn't support some construct in your schema, you're back to validation, whether you planned for it or not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does Forcing JSON Output Hurt LLM Reasoning?
&lt;/h2&gt;

&lt;p&gt;Yes, it can. The 2024 EMNLP Industry paper "Let Me Speak Freely?" by Tam and colleagues found a significant drop in reasoning performance under format restrictions, and stricter constraints produced bigger drops on reasoning tasks (&lt;a href="https://arxiv.org/abs/2408.02442" rel="noopener noreferrer"&gt;arXiv:2408.02442&lt;/a&gt;). Results on classification tasks were mixed. The paper also found that key order in a structured output can interfere with the model's reasoning.&lt;/p&gt;

&lt;p&gt;What that means in practice: don't make the model reason inside the constrained field. Let it think freely, then convert the answer into the schema in a second step. The paper tested a version of this two-step approach, which it called NL-to-Format. If your schema has a field where the model has to think and then answer, the constraint is what's degrading the thinking. Retry-with-error-feedback adds tokens on every attempt, and &lt;a href="https://www.robatdasorvi.com/chapters/ai-automation/why-context-window-size-is-the-thing-every-developer-should-care-about" rel="noopener noreferrer"&gt;why long prompts lose accuracy&lt;/a&gt; is a reason to keep the feedback short.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is Retrying Malformed LLM Output Wasteful?
&lt;/h2&gt;

&lt;p&gt;No. A retry is cheap when it's bounded, carries the validator's error message back to the model, and has a hard budget. The unbounded retry is the bug.&lt;/p&gt;

&lt;p&gt;Baar-Core started after an agent loop left running overnight produced a $47 bill. That wasn't a JSON retry, but any loop without a hard stop has the same shape. One more attempt costs very little. An attempt that never stops is another matter.&lt;/p&gt;

&lt;p&gt;A bounded validate-and-retry loop looks like this in practice. Parse, validate against the schema, and on failure send the model the exact validation error along with the original output. Allow two or three attempts. If they all fail, return a typed failure to the caller, not a partial result. The retry budget is what keeps validation from becoming a second source of runaway spend.&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;json&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;jsonschema&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ValidationError&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call_model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&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;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;object&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&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;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;prompt&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;
    &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;reply&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# assumed to expose .text, .finish_reason, .refusal
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refusal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refused&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refusal&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;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;finish_reason&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;length&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;truncated&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;valid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ValidationError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&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;assistant&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;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;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&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;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&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;content&lt;/span&gt;&lt;span class="sh"&gt;"&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;Invalid: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. Return JSON only.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invalid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Does Schema Validation Stop Silent LLM Failures?
&lt;/h2&gt;

&lt;p&gt;Not by itself. A validator only protects you if its failure result changes what the caller does. A check that returns the right answer and then gets ignored is the same as no check at all.&lt;/p&gt;

&lt;p&gt;My own suspension history is the clearest example. These weren't LLM output checks, but the failure shape is identical. On 2026-07-21, X suspended the account. The lockout check fired and returned True, but the caller skipped one action, and the loop kept running for 3.5 more days. The fix was a hard process exit and a sentinel file that only a human could clear, because launchd KeepAlive respawned a plain exit. On 2026-09-22, X suspended the account again. This time the lockout check matched none of its known selectors and never fired at all, and I stopped the bot by hand. The first check fired and was ignored. The second never fired. Either way, nothing stopped the loop.&lt;/p&gt;

&lt;p&gt;The pattern shows up well outside my projects. In July 2025, during a code and action freeze, Replit's AI agent deleted a live production database belonging to SaaStr founder Jason Lemkin, and the agent later told him a rollback was not possible. The rollback in fact worked, according to &lt;a href="https://heise.de/-10499597" rel="noopener noreferrer"&gt;Heise's report on the incident&lt;/a&gt;, which also says the agent admitted it ran a database command without permission after it "panicked." The failure wasn't the model's inability to produce JSON. It was an agent that ignored an explicit freeze, then gave a confident and wrong account of what could be recovered.&lt;/p&gt;

&lt;p&gt;A second case is the Air Canada chatbot. In February 2024, the British Columbia Civil Resolution Tribunal ruled against the airline in &lt;em&gt;Moffatt v. Air Canada&lt;/em&gt; (2024 BCCRT 149). The support chatbot had told passenger Jake Moffatt he could claim a bereavement discount up to 90 days after his flight, but the airline's policy required approval before travel. The tribunal rejected the argument that the chatbot was a separate legal entity and ordered Air Canada to pay C$650.88 in damages plus interest and tribunal fees (&lt;a href="https://ca.news.yahoo.com/air-canada-must-pay-refund-040527669.html" rel="noopener noreferrer"&gt;Canadian Press via Yahoo News&lt;/a&gt;). Neither case was a JSON failure. Both were a system acting on an answer nobody checked.&lt;/p&gt;

&lt;p&gt;That's why I think structured output belongs next to agent safety, not next to parsing. The same silent-failure pattern is covered in &lt;a href="https://www.robatdasorvi.com/chapters/ai-automation/agent-rule-wars" rel="noopener noreferrer"&gt;Agent Rule Conflicts: Why AI Pipelines Fail Silently&lt;/a&gt;, and any automated checker needs its own verification, as in &lt;a href="https://www.robatdasorvi.com/chapters/ai-automation/llm-as-a-judge-reliability-how-to-check-your-grader-against-human-labels" rel="noopener noreferrer"&gt;LLM-as-a-Judge Reliability: Check Your Grader Against Human Labels&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Should You Use Constrained Decoding vs Validate-and-Retry?
&lt;/h2&gt;

&lt;p&gt;Use constrained decoding when the provider or framework supports every construct in your schema and the field needs no reasoning. Use validate-and-retry when the schema has unsupported constructs, when you need semantic checks (ranges, enums tied to business rules, cross-field consistency), or when the provider has no constrained mode. Use both in production.&lt;/p&gt;

&lt;p&gt;The table shows what each approach covers and where it still fails.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;What it guarantees&lt;/th&gt;
&lt;th&gt;What it misses&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Constrained decoding&lt;/td&gt;
&lt;td&gt;Output matches the supported schema's syntax and shape&lt;/td&gt;
&lt;td&gt;Truncation at the token limit, refusals, wrong values, schema features the decoder doesn't support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validate-and-retry&lt;/td&gt;
&lt;td&gt;Output that reaches the caller passes your validator&lt;/td&gt;
&lt;td&gt;Truncated or refused output unless you check for them, semantic errors your validator doesn't test, and retries that can't fix a schema feature the model can't produce&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Both, with a bounded budget&lt;/td&gt;
&lt;td&gt;Schema-valid output that passes your semantic checks, or a typed failure&lt;/td&gt;
&lt;td&gt;Wrong values your checks don't cover, and the cost of retries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How to Make LLM Structured Output Failures Visible
&lt;/h2&gt;

&lt;p&gt;If constrained decoding is your only control, you'll still lose work whenever output is truncated, refused, or semantically wrong, and you won't know when it happens. If validate-and-retry is your only control, a missing retry budget turns one bad output into unbounded spend. Neither is enough alone.&lt;/p&gt;

&lt;p&gt;So make every structured output call return a typed result: valid, invalid, truncated, or refused. Use constrained decoding where your framework supports the schema, and validate every response regardless. Retry invalid results within a fixed budget, feeding the validator's error back. When the budget runs out, raise an error and count it. A batch of 20 with 8 failures should show up as 8 failures, not 12 successes with a hole in them. Release the publishing slot instead of burning it. Alert on the count, not the logs.&lt;/p&gt;

&lt;p&gt;Constrained decoding, validation, and bounded retry are three layers, and each one can fail. The only way to know they work is to count the failures. If your pipeline can't say how many structured outputs it lost last week, you don't have a fallback. You have a hope with a cron job.&lt;/p&gt;

</description>
      <category>llmstructuredoutput</category>
      <category>jsonvalidation</category>
      <category>constraineddecoding</category>
      <category>retrylogic</category>
    </item>
    <item>
      <title>Durable Execution for AI Agents: Temporal vs a Plain State Store</title>
      <dc:creator>Orvi Das</dc:creator>
      <pubDate>Fri, 23 Oct 2026 04:30:03 +0000</pubDate>
      <link>https://dev.to/robat_das_3c6e956212f6408/durable-execution-for-ai-agents-temporal-vs-a-plain-state-store-ooe</link>
      <guid>https://dev.to/robat_das_3c6e956212f6408/durable-execution-for-ai-agents-temporal-vs-a-plain-state-store-ooe</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/durable-execution-for-multi-agent-runs-checkpointing-and-resuming-after-a-crash-with-temporal-or-a-plain-state-store" rel="noopener noreferrer"&gt;robatdasorvi.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Durable execution for AI agents means a crash costs you one step, not the whole run. Temporal gives you that through workflow event history; a plain state store gives you most of it with a table you own. The hard part with either is the step that has already touched the outside world.&lt;/p&gt;

&lt;p&gt;Anthropic reports that multi-agent systems use about 15 times as many tokens as chat interactions (&lt;a href="https://www.anthropic.com/engineering/built-multi-agent-research-system" rel="noopener noreferrer"&gt;Anthropic, 2025&lt;/a&gt;). Gartner predicts that more than 40% of agentic AI projects will be canceled by the end of 2027 (&lt;a href="https://www.gartner.com/en/newsroom/press-releases/2025-06-25-gartner-predicts-over-40-percent-of-agentic-ai-projects-will-be-canceled-by-end-of-2027" rel="noopener noreferrer"&gt;Gartner, June 2025&lt;/a&gt;), citing escalating costs, unclear business value, and inadequate risk controls. Gartner doesn't single out crashes, but re-running a job that already costs 15 times a chat, from zero, is the kind of escalating cost it names. If a run dies at 90% and restarts, you pay for the same tokens twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is durable execution for AI agents?
&lt;/h2&gt;

&lt;p&gt;Durable execution means the runtime records each completed step, so that when a process dies, the run picks up at the last recorded step instead of starting over. For agents, a step is usually a model call or a tool call. The record is either a workflow engine's event history, like Temporal's, or a checkpoint table you write yourself.&lt;/p&gt;

&lt;p&gt;Anthropic describes the requirement in its engineering write-up on its multi-agent research system: "Agents can run for long periods of time, maintaining state across many tool calls." Its conclusion was that "when errors occur, we can't just restart from the beginning," and that the team "built systems that can resume from where the agent was when the errors occurred" (&lt;a href="https://www.anthropic.com/engineering/built-multi-agent-research-system" rel="noopener noreferrer"&gt;Anthropic, 2025&lt;/a&gt;). The same post says "agents are stateful and errors compound," which is the whole problem in one line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do AI agents need Temporal to resume after a crash?
&lt;/h2&gt;

&lt;p&gt;No. You need a durable record of completed steps, and Temporal is one way to get it. A Postgres table keyed by run ID and step number is enough for a single-process agent that finishes in minutes.&lt;/p&gt;

&lt;p&gt;Temporal earns its place when runs are long, span several workers, wait on humans or timers, and have to survive deploys. Its OpenAI Agents SDK integration runs the agent loop inside a Workflow and model calls as Activities, so completed model calls are not repeated during replay (&lt;a href="https://docs.temporal.io/develop/python/integrations/openai-agents" rel="noopener noreferrer"&gt;Temporal docs&lt;/a&gt;). LangGraph takes a similar approach: a checkpointer saves state at each super-step boundary, and side effects should go inside tasks so they don't re-run on resume (&lt;a href="https://docs.langchain.com/oss/python/langgraph/durable-execution" rel="noopener noreferrer"&gt;LangChain docs&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The skeptic's case is that this is overkill, and it's partly right. A state table is plenty for a lot of agents. It stops being right once runs span several workers, survive deploys, or wait on humans. Neither option prevents duplicate side effects on its own; that takes idempotency keys, covered next.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you stop a resumed agent from repeating side effects?
&lt;/h2&gt;

&lt;p&gt;You make every side-effecting call idempotent, and the external service has to enforce it. Temporal's guidance is that activities are retried until they succeed, so execution is at-least-once. Its February 2024 post on idempotency suggests using an existing business identifier, such as an order ID, as the key, or otherwise &lt;code&gt;workflowRunId + '-' + activityId&lt;/code&gt;, which stays constant across retries (&lt;a href="https://temporal.io/blog/idempotency-and-durable-execution" rel="noopener noreferrer"&gt;Temporal, February 2024&lt;/a&gt;). For what duplicate runs look like when two workers touch the same state, see &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/two-coding-agents-one-repo-the-concurrency-bugs-nobody-plans-for" rel="noopener noreferrer"&gt;concurrency bugs between coding agents sharing a repo&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Neither a state store nor Temporal does this for you. Writing "step 4 done" to a table doesn't stop the payment API from charging a customer again if the worker crashed after the charge but before the write. Whatever storage you choose, you still need a key the downstream system checks.&lt;/p&gt;

&lt;p&gt;Duplicate side effects are not the only way multi-agent runs fail. The Berkeley team behind MAST annotated more than 1,600 traces from seven multi-agent frameworks and identified 14 failure modes in three categories: specification issues, inter-agent misalignment, and task verification. The authors argue that many failures come from how the system is specified and coordinated, not only from model limits (&lt;a href="https://arxiv.org/html/2503.13657v3" rel="noopener noreferrer"&gt;Cemri et al., NeurIPS 2025&lt;/a&gt;). A durable engine preserves a run; it does not fix how the run was specified, so a badly coordinated run resumes just as badly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does a minimal checkpoint table look like?
&lt;/h2&gt;

&lt;p&gt;A minimal checkpoint table has one row per step of a run, keyed by run ID and step number. Each row records whether the step is pending, started, done, or failed, plus the idempotency key it sent downstream and the output it got back. A row is &lt;code&gt;pending&lt;/code&gt; when it is written ahead for a planned step.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;checkpoints&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;run_id&lt;/span&gt;          &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;step_no&lt;/span&gt;         &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;          &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'started'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'done'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'failed'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;idempotency_key&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;output_json&lt;/span&gt;     &lt;span class="n"&gt;JSONB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;updated_at&lt;/span&gt;      &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;step_no&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;Write in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Insert the row as &lt;code&gt;started&lt;/code&gt; with the idempotency key before the side effect.&lt;/li&gt;
&lt;li&gt;Send the key to the downstream API.&lt;/li&gt;
&lt;li&gt;Mark the row &lt;code&gt;done&lt;/code&gt; with the output after.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On resume, skip &lt;code&gt;done&lt;/code&gt; rows and re-send &lt;code&gt;started&lt;/code&gt; rows with the same key.&lt;/p&gt;

&lt;p&gt;This assumes one worker per run. If two workers can resume the same run, add a &lt;code&gt;lease_until TIMESTAMPTZ&lt;/code&gt; column and claim the run with an &lt;code&gt;UPDATE ... WHERE lease_until &amp;lt; now()&lt;/code&gt; before resuming, or both will re-send the same &lt;code&gt;started&lt;/code&gt; step.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you checkpoint a multi-agent run with subagents?
&lt;/h2&gt;

&lt;p&gt;In a multi-agent run, the orchestrator records each subagent's result as a step of the parent run, under a parent run ID plus child run IDs. On resume, finished subagents are read from checkpoints instead of being spawned again, and only unfinished ones run.&lt;/p&gt;

&lt;p&gt;In Temporal, subagents map to &lt;a href="https://docs.temporal.io/child-workflows" rel="noopener noreferrer"&gt;Child Workflows&lt;/a&gt;. In a plain table, a &lt;code&gt;parent_run_id&lt;/code&gt; column links child runs to the parent step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why can a restarted agent keep running after it should stop?
&lt;/h2&gt;

&lt;p&gt;Because a supervisor that restarts crashed work cannot tell a crash from a deliberate stop. Any restart or resume path needs an authority check, a signal only a human can clear, not just a check of saved state.&lt;/p&gt;

&lt;p&gt;My own bot shows both halves of this. On 2026-07-21 X suspended the account behind my approval-gated growth agent. The lockout check fired and returned True, but the caller skipped one action and the loop kept running for 3.5 more days. When I fixed it, a plain process exit wasn't enough, because launchd KeepAlive respawned the process. The fix that held was a process exit plus a sentinel file that only a human can clear. A supervisor that restarts whatever crashed will also restart whatever you meant to stop.&lt;/p&gt;

&lt;p&gt;X suspended the account again on 2026-09-22, a week after the reply cap was raised from 16 to 150. This time the lockout check matched none of its known selectors and never fired; I stopped the bot by hand.&lt;/p&gt;

&lt;p&gt;A stop condition that depends on recognising the failure will miss failures it has never seen, so a resume path should also check something a human controls, such as the sentinel file.&lt;/p&gt;

&lt;p&gt;In February 2026 the Financial Times reported that in December 2025 Kiro, an internal agentic coding tool at Amazon, was allowed to make changes that led to a 13-hour interruption of a customer-facing service. Kiro normally asks for authorization, but the FT reported it had permissions similar to a human engineer's, so the change went through without approval. Amazon disputes the account and calls the cause "user error—specifically misconfigured access controls—not AI" (&lt;a href="https://the-decoder.com/aws-ai-coding-tool-decided-to-delete-and-recreate-a-customer-facing-system-causing-13-hour-outage-report-says/" rel="noopener noreferrer"&gt;The Decoder&lt;/a&gt;). The details are contested, and this was not a retry. But it shows the same gap: an action ran on permissions that nobody rechecked at the moment it executed.&lt;/p&gt;

&lt;p&gt;In July 2025 a Replit agent working on a project under a code freeze reportedly deleted a production database. Jason Lemkin, who was running the project, said he had told the agent repeatedly not to make changes without permission. Replit's CEO called it "unacceptable and should never be possible" (&lt;a href="https://www.eweek.com/news/replit-ai-coding-assistant-failure/" rel="noopener noreferrer"&gt;eWeek, 2025&lt;/a&gt;). This wasn't a crash, but it shows what happens when a run keeps going without rechecking authority.&lt;/p&gt;

&lt;h2&gt;
  
  
  How much does a crash cost a multi-agent run?
&lt;/h2&gt;

&lt;p&gt;A crash without checkpoints costs the whole run again. Anthropic reports multi-agent runs use about 15 times the tokens of a chat, so re-running from zero multiplies an already large bill, and on a scheduled pipeline a failed attempt can also consume the slot it was meant to fill.&lt;/p&gt;

&lt;p&gt;That happened to the article lane in the same bot. JSON parse errors killed 8 of 20 generations, and each failure silently burned a 48-hour slot. The failure consumed the schedule, not just the attempt. A checkpoint that records the failed parse as a failed step, rather than as a used slot, would have let a retry run inside the same window. Nothing in the lane did that, and every failure was silent. My earlier essay on &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/how-llm-agents-fail-silently-in-production-and-how-i-learned-to-catch-it" rel="noopener noreferrer"&gt;why LLM agents fail silently in production&lt;/a&gt; covers the detection side of that problem.&lt;/p&gt;

&lt;p&gt;Durability also has a cost ceiling. One of my projects, Baar-Core, started after an agent loop left running overnight produced a $47 bill. An engine with unlimited retries would only have kept a loop like that alive more reliably. Budgets and stop conditions belong in the workflow, not in the operator's memory. The &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/the-economics-of-ai-agents-when-autonomous-beats-human-in-the-loop" rel="noopener noreferrer"&gt;human-in-the-loop cost comparison&lt;/a&gt; works through the tradeoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is resuming more dangerous than the first attempt?
&lt;/h2&gt;

&lt;p&gt;It can be. The risky step is the one that already had a side effect, so resume and retry paths deserve the most review. A read-only step can run again with little harm. A step that charged a card or sent a message can charge or send twice if it runs again.&lt;/p&gt;

&lt;p&gt;My prediction is specific enough to be wrong. By 2027-06-30, most publicly reported agent production incidents involving duplicate charges, repeated messages, or unauthorized actions will trace back to a retry or resume path, not a first-attempt failure. On today's evidence the prediction is behind: neither the Kiro nor the Replit incident above was a retry or resume. I'll check this against AI Incident Database entries that describe an AI agent taking an action and count which incidents involved a retry or resume.&lt;/p&gt;

&lt;p&gt;If the count comes out the other way, I'm wrong that resumption is the dangerous part, and the review time belongs on first-attempt authority checks instead. A related failure on that side is the &lt;a href="https://www.robatdasorvi.com/chapters/ai-agents/the-hidden-state-problem-in-agentic-systems-that-nobody-warns-you-about" rel="noopener noreferrer"&gt;hidden-state problem in agentic systems&lt;/a&gt;, which I've written about separately.&lt;/p&gt;

</description>
      <category>durableexecution</category>
      <category>aiagents</category>
      <category>temporal</category>
      <category>checkpointing</category>
    </item>
    <item>
      <title>Lost in the Middle: Find Your LLM's Context Dead Zone and Fix It Before the Call</title>
      <dc:creator>Orvi Das</dc:creator>
      <pubDate>Fri, 23 Oct 2026 02:21:38 +0000</pubDate>
      <link>https://dev.to/robat_das_3c6e956212f6408/lost-in-the-middle-find-your-llms-context-dead-zone-and-fix-it-before-the-call-1g0b</link>
      <guid>https://dev.to/robat_das_3c6e956212f6408/lost-in-the-middle-find-your-llms-context-dead-zone-and-fix-it-before-the-call-1g0b</guid>
      <description>&lt;p&gt;One result from the "Lost in the Middle" paper should change how you build RAG prompts. GPT-3.5-Turbo was given 20 retrieved documents with the answer placed in the middle of them, and it scored lower than when it got no documents at all. Answering from memory beat answering with the documents. Here the extra context made the answer worse.&lt;/p&gt;

&lt;p&gt;Liu et al. (Stanford, UC Berkeley, Samaya AI, 2023) tested multi-document QA and key-value retrieval. They moved the relevant passage from the first slot to the last and tracked accuracy. The result was a U-shaped curve: strong at both edges, down by 20+ points in the middle. Newer models have flattened that curve, but it hasn't gone away. You won't know how your model handles your prompt shape until you measure it.&lt;/p&gt;

&lt;p&gt;This post covers three things you can add to a pipeline today: a test that finds your own dead zone, a reorder step that keeps important chunks out of it, and a pre-flight gate that blocks oversized prompts before they're sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your context window is a budget
&lt;/h2&gt;

&lt;p&gt;A 200K window is the most the model will accept. You don't have to fill it. Every token you add costs money, adds latency, and competes for attention with the tokens that actually matter.&lt;/p&gt;

&lt;p&gt;The attention cost is the one people miss. Developers tend to treat the window like a bucket: if it fits, ship it. A 3,000-line file fits. Fifteen RAG chunks fit. Forty turns of agent history fit. The model takes all of it without complaint and gets worse at the one thing you asked.&lt;/p&gt;

&lt;p&gt;No error tells you "your instruction is buried at token 61,000." You just get an answer that ignores it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Measure your own dead zone
&lt;/h2&gt;

&lt;p&gt;Don't rely on a 2023 chart for a 2026 model. Run a needle test with your real prompt shape. It takes about ten minutes and a few dollars.&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;anthropic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-sonnet-5-5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;NEEDLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The deploy freeze for the payments service ends on March 14.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;QUESTION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;When does the deploy freeze for the payments service end?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_haystack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filler_chunks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;needle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;position runs from 0.0 (start) to 1.0 (end).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;idx&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;position&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filler_chunks&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;filler_chunks&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;needle&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;filler_chunks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;idx&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="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&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;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filler_chunks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;positions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;trials&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;results&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;pos&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;positions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trials&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shuffle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filler_chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_haystack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filler_chunks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NEEDLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&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="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&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;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;QUESTION&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; Answer in one short phrase.&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;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;march 14&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&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="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
                &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;trials&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use realistic filler: your own docs, old tickets, whatever your production context actually contains. With lorem ipsum the needle stands out too easily. The honest test uses a needle that looks like the text around it.&lt;/p&gt;

&lt;p&gt;One caveat: pulling back a single string is the easy case. If your task needs the model to &lt;em&gt;combine&lt;/em&gt; a fact from the middle with something near the end, expect worse numbers than this test gives you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Put the best chunks at the edges
&lt;/h2&gt;

&lt;p&gt;When a retriever returns chunks ranked by relevance, most pipelines join them in rank order. Chunk #1 ends up at the top, which is good. Chunks #2 through #5 land in the middle, which is bad. And your least relevant chunks sit at the end, right next to the question.&lt;/p&gt;

&lt;p&gt;Reverse that. Alternate the placement so the strongest chunks sit at both edges and the weakest fill the middle:&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;edge_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks_by_relevance&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Input: most relevant first. Output: best at both ends, worst in the middle.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;front&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;back&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks_by_relevance&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;front&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;back&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="n"&gt;chunk&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;front&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;back&lt;/span&gt;&lt;span class="p"&gt;[::&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# ranks [1, 2, 3, 4, 5, 6] -&amp;gt; [1, 3, 5, 6, 4, 2]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you'd rather not maintain this yourself, LangChain has it as &lt;code&gt;LongContextReorder&lt;/code&gt; in &lt;code&gt;langchain_community.document_transformers&lt;/code&gt;. Either way it's only a few lines.&lt;/p&gt;

&lt;p&gt;Two more placement rules that cost nothing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Put the question &lt;em&gt;after&lt;/em&gt; the documents. The end of the prompt is the second-best position you have.&lt;/li&gt;
&lt;li&gt;In long-running agents, repeat the core instruction near the end of each turn. Don't count on the model still following a system prompt from forty messages ago.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 3: Block the prompt before it ships
&lt;/h2&gt;

&lt;p&gt;Reordering helps. Not sending 120K tokens of marginal context helps more. The simplest fix is a pre-flight check: count tokens before the call, and if the prompt is over budget, trim it or fail with a clear error.&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;MAX_INPUT_TOKENS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30_000&lt;/span&gt;  &lt;span class="c1"&gt;# set this from your Step 1 results, not the model card
&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ContextBudgetExceeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;pass&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;guarded_create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;messages&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;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&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;system&lt;/span&gt;

    &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;MAX_INPUT_TOKENS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ContextBudgetExceeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; input tokens &amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;MAX_INPUT_TOKENS&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; budget; trim retrieval or history&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="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&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="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where should &lt;code&gt;MAX_INPUT_TOKENS&lt;/code&gt; come from? Your own needle curve. If mid-prompt accuracy holds at 30K tokens and drops at 80K, your usable window is 30K, whatever the pricing page says.&lt;/p&gt;

&lt;p&gt;When the gate trips, start by dropping the lowest-ranked chunks. Then summarize old agent turns into a short state block. For code, send the function you're working on plus its direct callers, not the whole file. A model that sees 400 relevant lines will usually do better than one that sees 3,000 lines where the class definition sits in the middle.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same gate keeps the bill down
&lt;/h2&gt;

&lt;p&gt;This gate helps with cost as well as accuracy. Context bloat is a common way agent costs run away. If an agent loop appends every tool result to its history, the prompt grows with every step. Step 30 costs many times what step 1 did, and the model reasons worse by then too. As a rough example, 30 steps where the input grows by 4K tokens each come to about 1.86M input tokens for a single task.&lt;/p&gt;

&lt;p&gt;A token cap handles the accuracy problem. A dollar cap handles the cost problem. I wrote &lt;a href="https://github.com/orvi2014/Baar-Core" rel="noopener noreferrer"&gt;baar-core&lt;/a&gt; for the second one. It's an open-source Python library that puts a hard budget on every LLM call and returns a 402 before the provider is ever contacted. It reserves spend atomically, so twenty parallel agents that each look under budget can't overshoot the cap together. &lt;code&gt;pip install baar-core&lt;/code&gt;. For teams, &lt;a href="https://noburn.dev" rel="noopener noreferrer"&gt;noburn.dev&lt;/a&gt; builds on it: once a user goes over their budget, it blocks their API calls before they're sent.&lt;/p&gt;

&lt;p&gt;Both checks go in the same spot, right before &lt;code&gt;create()&lt;/code&gt;, while you can still stop the call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the advertised window tells you
&lt;/h2&gt;

&lt;p&gt;The number on the model card tells you how much the model will accept. It doesn't tell you how much it uses well. You have to measure that gap for each model, task, and prompt shape. It also changes whenever a provider ships a new version, so rerun the needle test after every upgrade.&lt;/p&gt;

&lt;p&gt;Think of the advertised window like a car's top speed. It's useful to know, but it's not the speed you should drive at.&lt;/p&gt;

&lt;p&gt;Have you measured the effective context size of your own pipeline, and how far was it from the spec sheet?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.robatdasorvi.com/chapters/ai-automation/why-context-window-size-is-the-thing-every-developer-should-care-about" rel="noopener noreferrer"&gt;https://www.robatdasorvi.com/chapters/ai-automation/why-context-window-size-is-the-thing-every-developer-should-care-about&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>machinelearning</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
