<?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: Chris Sellers</title>
    <description>The latest articles on DEV Community by Chris Sellers (@chrissellers).</description>
    <link>https://dev.to/chrissellers</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%2F4170058%2F99c6e6ef-2e1c-410f-a86f-ba691fd1b948.jpg</url>
      <title>DEV Community: Chris Sellers</title>
      <link>https://dev.to/chrissellers</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chrissellers"/>
    <language>en</language>
    <item>
      <title>A timeout does not tell you whether the tool ran</title>
      <dc:creator>Chris Sellers</dc:creator>
      <pubDate>Tue, 27 Oct 2026 05:30:11 +0000</pubDate>
      <link>https://dev.to/chrissellers/a-timeout-does-not-tell-you-whether-the-tool-ran-367j</link>
      <guid>https://dev.to/chrissellers/a-timeout-does-not-tell-you-whether-the-tool-ran-367j</guid>
      <description>&lt;p&gt;When an agent calls a tool and the response never arrives, you know one thing: you stopped waiting. You do not know whether the tool did the work.&lt;/p&gt;

&lt;p&gt;That gap is where duplicate emails, duplicate tickets and duplicate charges come from. The retry is reasonable. The missing information is the problem.&lt;/p&gt;

&lt;p&gt;In AMW (Agent Middleware, Preview), which I am building, a lost response after the send is recorded as delivery_uncertain. AMW never sends that call again on its own. It keeps the state visible so a person or the tool owner can decide.&lt;/p&gt;

&lt;p&gt;A same key retry returns the original receipt instead of dispatching again. A new key is a new request. That is the whole contract, and it is narrower than exactly once on purpose.&lt;/p&gt;

&lt;p&gt;If you run agents against MCP tools, I would like you to try to break it: github.com/agentmiddleware/agent-middleware&lt;/p&gt;

&lt;p&gt;Chris&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>agents</category>
      <category>reliability</category>
    </item>
    <item>
      <title>Try a safe agent retry in two minutes</title>
      <dc:creator>Chris Sellers</dc:creator>
      <pubDate>Thu, 22 Oct 2026 03:08:37 +0000</pubDate>
      <link>https://dev.to/chrissellers/try-a-safe-agent-retry-in-two-minutes-31oh</link>
      <guid>https://dev.to/chrissellers/try-a-safe-agent-retry-in-two-minutes-31oh</guid>
      <description>&lt;p&gt;Earlier posts in this series argued about where idempotency keys should come from. This one is hands-on: get a free sandbox key (no account), issue a permit, call a tool, retry with the same key and watch the original receipt come back instead of a new dispatch, then verify the receipt offline. Code: &lt;a href="https://github.com/agentmiddleware/amw-demo" rel="noopener noreferrer"&gt;https://github.com/agentmiddleware/amw-demo&lt;/a&gt; . If you try it, tell me in the comments which real tool you'd put behind it.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>code</category>
      <category>github</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Which agent tool errors are safe to retry? Only one of four</title>
      <dc:creator>Chris Sellers</dc:creator>
      <pubDate>Mon, 19 Oct 2026 15:23:33 +0000</pubDate>
      <link>https://dev.to/chrissellers/which-agent-tool-errors-are-safe-to-retry-only-one-of-four-52j4</link>
      <guid>https://dev.to/chrissellers/which-agent-tool-errors-are-safe-to-retry-only-one-of-four-52j4</guid>
      <description>&lt;p&gt;An agent gets an error back from a tool call. Should it try again?&lt;/p&gt;

&lt;p&gt;Most agent loops answer that with one rule: on error, retry a few times with backoff. I think that rule is wrong for any tool that moves money or changes state, because "error" covers at least four very different situations, and only one of them is safe to retry blindly.&lt;/p&gt;

&lt;p&gt;This post is the error map I use in Agent Middleware (AMW), the permit and receipt gateway I am building for agent tool calls. The code below is generic, not lifted from my repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask one question first: did anything happen?
&lt;/h2&gt;

&lt;p&gt;Before deciding whether to retry, the agent needs to know whether the call reached the point where a side effect could exist. I sort every error by that question.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Refused before anything was reserved.&lt;/strong&gt; The permit does not allow the call: wrong tool, expired permit, or a constraint the backend cannot enforce. Nothing was written, charged or sent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lost a race before anything was written.&lt;/strong&gt; Two calls tried to update the same permit at the same moment and this one lost. Nothing was written, charged or sent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refused because a limit was reached.&lt;/strong&gt; The permit set a per tool call cap and it is used up. Nothing new was charged or sent, but the earlier calls did happen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sent, and the answer never came back.&lt;/strong&gt; The request left the gateway and the response was lost. The side effect may or may not exist.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Only the second one is a "just try again" error.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each case should do
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Refused before reservation: do not retry.&lt;/strong&gt; Retrying an identical request gets the identical refusal. The fix lives with whoever issues the permit. In AMW this comes back as a signed denied receipt, so the agent has a record of why it stopped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lost a race: retry, with the same idempotency key.&lt;/strong&gt; AMW answers this with HTTP 409 (or JSON RPC error -32005 on the MCP endpoint). The transaction rolled back, the idempotency record for that attempt was released, and no credits moved. Reusing the same key matters here: if the first attempt had somehow finished, the same key returns its stored receipt instead of dispatching again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limit reached: do not retry, and definitely not with a fresh key.&lt;/strong&gt; A fresh key is a new call, not a repeat. When the permit sets a per tool cap and the cap is used up, a new key gets refused with a cap exceeded error, with nothing dispatched or charged. When the permit sets no cap, a new key simply runs again within the permit's budget and expiry. So the cap only protects you if you set it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sent with no answer: park it.&lt;/strong&gt; I wrote a whole post on this one. The short version: AMW records the attempt as delivery uncertain, keeps the charge, and never resends it on its own. A human or a reconciliation job checks the provider before anything else happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  A sketch of the agent side
&lt;/h2&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;handle_tool_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call&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;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&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="c1"&gt;# Permit does not allow it. Retrying changes nothing.
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;receipt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contended&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Nothing was written. Safe to retry with the SAME key.
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&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="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                     &lt;span class="n"&gt;backoff&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;jittered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_ms&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;max_tries&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cap_exceeded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Earlier calls happened. A new key would be a new call.
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cap reached&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;receipt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delivery_uncertain&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Side effect may exist. Do not retry at all.
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;park&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;for_review&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="c1"&gt;# Unknown error class: treat it as uncertain, not as safe.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;park&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;for_review&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last line is the part I care most about. When the agent cannot classify an error, the safe default is to stop and ask, not to loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not give you
&lt;/h2&gt;

&lt;p&gt;None of this makes a remote side effect happen exactly once. A cap of 1 means the gateway dispatches at most once for that permit and tool. Whether the provider itself applies the effect once still depends on the provider honoring the idempotency key. The error map just keeps the agent from turning one uncertain call into two certain ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I am
&lt;/h2&gt;

&lt;p&gt;AMW is early and I am building it alone. I am looking for people building agents that touch payments or other irreversible tools who want to break it.&lt;/p&gt;

&lt;p&gt;How does your agent loop decide which errors are safe to retry? I would like to hear where my map is wrong.&lt;/p&gt;

&lt;p&gt;More at &lt;a href="https://www.thisisatest.tech" rel="noopener noreferrer"&gt;https://www.thisisatest.tech&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>architecture</category>
      <category>agents</category>
    </item>
    <item>
      <title>When an agent's tool call times out, park it instead of retrying</title>
      <dc:creator>Chris Sellers</dc:creator>
      <pubDate>Tue, 13 Oct 2026 19:06:14 +0000</pubDate>
      <link>https://dev.to/chrissellers/when-an-agents-tool-call-times-out-park-it-instead-of-retrying-4i18</link>
      <guid>https://dev.to/chrissellers/when-an-agents-tool-call-times-out-park-it-instead-of-retrying-4i18</guid>
      <description>&lt;p&gt;An agent calls a tool that moves money or sends a message. The request goes out. Then nothing comes back: a gateway timeout, a dropped connection, a worker that died mid response.&lt;/p&gt;

&lt;p&gt;From the agent's seat, that looks like a failure. Most agent loops treat a failure as a reason to try again. That is the step that charges a customer twice.&lt;/p&gt;

&lt;p&gt;This post is about the third state that sits between "it worked" and "it failed", and what an agent should do with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A timeout is not a failure
&lt;/h2&gt;

&lt;p&gt;A client timeout tells you one thing: the client stopped waiting. It says nothing about whether the provider did the work.&lt;/p&gt;

&lt;p&gt;So every effectful call really ends in one of three states:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Succeeded.&lt;/strong&gt; You have a response and a receipt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failed before send.&lt;/strong&gt; The request never left your side (validation, auth, a connection that never opened). Safe to retry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unknown.&lt;/strong&gt; The request was sent and the response never arrived. The side effect may or may not have happened.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most code has branches for the first two and quietly folds the third into "failed". That fold is the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "unknown" must not trigger a retry
&lt;/h2&gt;

&lt;p&gt;If you retry an unknown under a &lt;strong&gt;new&lt;/strong&gt; idempotency key, you have asked for a second intent. The provider has no way to know it is the same payment. If the first one landed, you now have two.&lt;/p&gt;

&lt;p&gt;If you retry under the &lt;strong&gt;same&lt;/strong&gt; key, you are safer, but only as far as the provider actually de-duplicates on that key. Many do. Some do it only for a limited window. Some tools an agent reaches through MCP never look at the key at all.&lt;/p&gt;

&lt;p&gt;Either way, the agent itself is the wrong place to resolve this. It does not have the provider's state, it is under pressure to finish the task, and its default move is to try again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Park it
&lt;/h2&gt;

&lt;p&gt;The pattern several builders described to me recently is simple: when the outcome is unknown, &lt;strong&gt;park&lt;/strong&gt; the call.&lt;/p&gt;

&lt;p&gt;Parking means:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Record the attempt as pending, not failed.&lt;/strong&gt; Keep the idempotency key, the exact arguments, the tool, the time of send, and whatever partial response you got.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the hold.&lt;/strong&gt; If you reserved budget or a call slot for this attempt, leave it reserved. Releasing it is how the next attempt gets room to spend again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Block new keys for the same intent.&lt;/strong&gt; While an intent is pending, the agent should not be able to issue a fresh key for it. A same key retry can answer "still pending" instead of sending.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tell the agent something useful.&lt;/strong&gt; Return a status the loop can act on, not a generic error, so it knows to wait or move on rather than retry.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One reply put it well, paraphrased: treating pending as failed is the expensive move, because failed implies retry or refund, and the charge may well have gone through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reconcile outside the agent
&lt;/h2&gt;

&lt;p&gt;A parked call needs something to close it. That is a reconciler, and it should live outside the agent loop.&lt;/p&gt;

&lt;p&gt;What it does:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ask the provider.&lt;/strong&gt; Many payment and messaging APIs let you look up a request by a client reference you sent with it, and some by the idempotency key itself. That lookup is the real answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Back off.&lt;/strong&gt; Poll on a backoff until the provider gives a final status. Do not hammer it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Close the record.&lt;/strong&gt; If the provider says it happened, mark it succeeded and attach what you learned. If the provider says it never arrived, mark it failed, release the hold, and only then let a retry go out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escalate when the provider cannot say.&lt;/strong&gt; Some tools have no lookup at all. Those go to a person with the key, the arguments, and the time of send, so they can check by hand.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rule underneath: only the reconciler, with evidence from the provider, moves a call from unknown to failed. The agent never does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to measure
&lt;/h2&gt;

&lt;p&gt;If you run agents against effectful tools, three numbers tell you whether this is working:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How many calls end in unknown per day. If it is zero, check whether unknowns are being folded into failures.&lt;/li&gt;
&lt;li&gt;How long a call stays parked before something closes it.&lt;/li&gt;
&lt;li&gt;How many parked calls closed as "it actually happened". That is the number of double charges a naive retry would have caused.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I handle this in AMW
&lt;/h2&gt;

&lt;p&gt;I'm building Agent Middleware (AMW), a gateway that puts a permit and receipt boundary in front of MCP tool calls. Agents act under a permit; a retry with the same key returns the original receipt instead of dispatching again; a new key is a new request.&lt;/p&gt;

&lt;p&gt;For the unknown case, AMW records a lost response after send as &lt;strong&gt;delivery uncertain&lt;/strong&gt;: the call is charged, the caller gets an error rather than a success, and AMW never re-sends it. When a permit sets a per tool call cap, a delivery uncertain call keeps its slot, so a new key past the cap is still denied.&lt;/p&gt;

&lt;p&gt;What it does not do: it does not reconcile with the provider for you, and it does not make the remote effect happen exactly once. Someone, or something you run, still has to check the provider and close the record. I'm biased, since this is the product I'm building, and it is pre-revenue. The parking pattern is older than AMW and works without it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclosure
&lt;/h2&gt;

&lt;p&gt;I'm Chris, the founder and sole owner of AMW. This post was drafted with help from an AI assistant, and the AMW claims were checked against its code. More at &lt;a href="https://www.thisisatest.tech" rel="noopener noreferrer"&gt;thisisatest.tech&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;My question for you: &lt;strong&gt;when a tool call comes back unknown, who closes it in your setup: the agent, a reconciler job, or a person?&lt;/strong&gt; I'd like to hear what has held up in production.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>architecture</category>
      <category>security</category>
    </item>
    <item>
      <title>A traceability import should never guess a missing lot code</title>
      <dc:creator>Chris Sellers</dc:creator>
      <pubDate>Mon, 12 Oct 2026 17:10:53 +0000</pubDate>
      <link>https://dev.to/chrissellers/a-traceability-import-should-never-guess-a-missing-lot-code-13m7</link>
      <guid>https://dev.to/chrissellers/a-traceability-import-should-never-guess-a-missing-lot-code-13m7</guid>
      <description>&lt;p&gt;A supplier sends a CSV of shipping rows. Row 37 has no traceability lot code. The importer has three choices: reject the row, store it with a warning, or fill the gap with something like &lt;code&gt;UNKNOWN&lt;/code&gt; so the batch goes through. The third option feels friendliest on upload day and does the most damage during a trace request.&lt;/p&gt;

&lt;p&gt;This post is about where to draw that line for FSMA 204 records, the FDA food traceability rule. The pattern applies to any import where a missing field can quietly turn into a wrong answer later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a shipping record has to carry
&lt;/h2&gt;

&lt;p&gt;For foods on the Food Traceability List, the rule names key data elements (KDEs) for each critical tracking event (CTE). For shipping, 21 CFR 1.1340(a) lists eight:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Traceability lot code&lt;/li&gt;
&lt;li&gt;Quantity and unit of measure&lt;/li&gt;
&lt;li&gt;Product description&lt;/li&gt;
&lt;li&gt;Location description for the immediate subsequent recipient&lt;/li&gt;
&lt;li&gt;Location description for the location you shipped from&lt;/li&gt;
&lt;li&gt;Date shipped&lt;/li&gt;
&lt;li&gt;Location description for the traceability lot code source, or a traceability lot code source reference&lt;/li&gt;
&lt;li&gt;Reference document type and number (a bill of lading, for example)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Receiving under 1.1345(a) mirrors this, with the immediate previous source, the receiving location and the receive date in place of the shipping fields.&lt;/p&gt;

&lt;p&gt;Read that list as a data contract. Every field is either there or it is not. The interesting design question is what your system does with the rows where it is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to handle a missing field
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Reject.&lt;/strong&gt; The row never enters the system. The uploader gets an error naming the row and the field. Nothing downstream ever sees a half record.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Store and flag.&lt;/strong&gt; The row goes in, marked incomplete, with a warning that names the missing element and the section it comes from. Someone can fix it later, and every report that touches it knows it is incomplete.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fill and continue.&lt;/strong&gt; The importer writes a placeholder (&lt;code&gt;TLC-UNKNOWN&lt;/code&gt;, &lt;code&gt;Unnamed Facility&lt;/code&gt;, today's date) and logs a warning nobody reads. The record now looks complete.&lt;/p&gt;

&lt;p&gt;Fill and continue is the trap. The placeholder is a value, so every later check that asks "is this field present?" says yes. Worse, placeholders collide. If ten suppliers each upload a row with no lot code and all ten become &lt;code&gt;TLC-UNKNOWN&lt;/code&gt;, a trace on that "lot" links ten unrelated shipments. During a recall you have just built a false connection, or hidden a real one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to draw the line
&lt;/h2&gt;

&lt;p&gt;The split I have landed on:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reject when the row cannot identify what moved.&lt;/strong&gt; No lot code, no product, no quantity or unit, no date. Without those, the row cannot be joined to anything, so storing it only creates noise. Fail loudly at upload, while the person holding the paperwork is still looking at it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store and flag when the row identifies the lot but is missing context.&lt;/strong&gt; A missing reference document number or location description is a real gap, but the event still happened and the lot is still traceable through it. Keep the row, mark it, cite the section, and make the gap show up in every export.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never fill.&lt;/strong&gt; If a default seems tempting, that is usually a sign the field belongs in the reject group or the flag group, not in a third group of invented data.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The flag has to travel. A warning that lives only in an upload log is gone the next day. Put the incomplete state on the record itself so a sortable spreadsheet, an export and a trace query all carry it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Present is not the same as correct
&lt;/h2&gt;

&lt;p&gt;Even a perfect completeness check only proves a value exists. It does not prove the lot code matches the physical label, that the quantity is right, or that the ship to location is the real recipient. Those need a cross check: the receiving record at the next node should name the same lot code your shipping record did, and the quantities should reconcile within whatever tolerance the business accepts.&lt;/p&gt;

&lt;p&gt;So I think of it as two layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Completeness:&lt;/strong&gt; does each required element have a value? Cheap, deterministic, run on every row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistency:&lt;/strong&gt; do the values agree with the records on either side of the event? Harder, needs both trading partners' data, and is where most real traceability failures hide.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plenty of systems ship layer one and call it done. It is a good start. It is not the same promise.&lt;/p&gt;

&lt;p&gt;A few small things help at the completeness layer: normalize units (&lt;code&gt;cs&lt;/code&gt;, &lt;code&gt;case&lt;/code&gt;, &lt;code&gt;CS&lt;/code&gt;) before checking, warn when a lot code mixes the letter O with zero or the letter I with one, and cap upload size so one bad file cannot bury a reviewer in thousands of warnings.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I am building this in RegEngine
&lt;/h2&gt;

&lt;p&gt;I'm building RegEngine, an FSMA 204 evidence layer. Today it takes CSV in its own column layout for shipping and receiving events and checks each row against the required KDE lists from 1.1340(a) and 1.1345(a). A row missing the lot code, quantity, unit, date or product is rejected rather than stored. Other missing elements come back as warnings that cite the CFR section.&lt;/p&gt;

&lt;p&gt;What it does not do yet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No automatic bill of lading reading.&lt;/strong&gt; No PDF, scan, API, EDI or EPCIS intake. If you have a paper BOL, someone types it into the CSV.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Completeness, not correctness.&lt;/strong&gt; The checks confirm a value is present. They do not prove it is right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not a legal determination.&lt;/strong&gt; A clean check does not mean a record satisfies the rule or any buyer's program. Requirements vary by buyer, so confirm with your retailer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is pre-revenue, and I am biased. The FDA compliance date for the rule is July 20, 2028, but some buyers ask sooner, which is why import rules are worth getting right early.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclosure
&lt;/h2&gt;

&lt;p&gt;I'm Chris, the founder of RegEngine. This post was drafted with help from an AI assistant, and the RegEngine behavior above was checked against a review of its code. More at &lt;a href="https://www.regengine.co" rel="noopener noreferrer"&gt;regengine.co&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;My question for you: &lt;strong&gt;when a supplier file arrives with a missing lot code, does your pipeline reject the row, store it flagged, or fill a placeholder so the batch goes through?&lt;/strong&gt; I would especially like to hear from anyone who has had to untangle placeholder lot codes after the fact.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>api</category>
      <category>python</category>
      <category>supplychain</category>
    </item>
    <item>
      <title>Retries and per-tool call caps are not exactly-once delivery</title>
      <dc:creator>Chris Sellers</dc:creator>
      <pubDate>Sun, 11 Oct 2026 19:25:15 +0000</pubDate>
      <link>https://dev.to/chrissellers/retries-and-per-tool-call-caps-are-not-exactly-once-delivery-2oa5</link>
      <guid>https://dev.to/chrissellers/retries-and-per-tool-call-caps-are-not-exactly-once-delivery-2oa5</guid>
      <description>&lt;p&gt;The payout tool charged the wallet. The HTTP response never came back. The agent timed out, minted a fresh idempotency key, and fired again. Without a per-tool call cap, that second call is a second dispatch. With a cap of 1, the second key is refused. Neither outcome is exactly-once delivery.&lt;/p&gt;

&lt;p&gt;Earlier I wrote about &lt;a href="https://dev.to/chrissellers/same-idempotency-key-different-arguments-refuse-the-mismatch-5ad3"&gt;same-key retries with different arguments&lt;/a&gt;. That post was about binding the request to the key. This one is about what happens when the agent gives up on the key and tries again under a new one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same key means replay. New key means new intent.
&lt;/h2&gt;

&lt;p&gt;An idempotency key is a claim about intent, not a magic "run once forever" flag.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Same key, same request:&lt;/strong&gt; return the original result. No second side effect. No second charge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same key, different request:&lt;/strong&gt; refuse. That is a conflict, not a retry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New key:&lt;/strong&gt; treat it as a new logical action. The server has no duty to notice that the arguments look familiar.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last rule is the one agent loops keep violating. A timeout feels like failure. Failure feels like "try again." "Try again" in many frameworks means a new UUID. The first call may already have charged the remote tool. The second call does not know that.&lt;/p&gt;

&lt;p&gt;Builders who have shipped agent wallets keep hitting the same question: what do you do when the outcome is unknown? Some have not exercised the delivery_uncertain path yet. Peers ask where the call cap should live so a frantic retry cannot spend the budget twice. The pattern below is the boring answer that still gets missed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dangerous path: timeout, then new key
&lt;/h2&gt;

&lt;p&gt;Walk the failure the way it shows up in logs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The agent sends &lt;code&gt;pay_invoice&lt;/code&gt; under key &lt;code&gt;task-4417-pay&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The gateway reserves budget, dispatches to the remote tool, and the tool charges.&lt;/li&gt;
&lt;li&gt;The response is lost. Network blip, client timeout, worker kill. From the agent's seat the call looks failed.&lt;/li&gt;
&lt;li&gt;The agent (or its supervisor) starts a new attempt with key &lt;code&gt;task-4417-pay-retry-2&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If nothing else stops it, the gateway treats that as a fresh call and dispatches again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 5 is not an idempotency bug. Idempotency did its job for key &lt;code&gt;task-4417-pay&lt;/code&gt;. The second key asked for a second intent. The bug is in the control loop: treating silence as "safe to re-issue under a new key."&lt;/p&gt;

&lt;p&gt;A same-key retry would have been fine. If the first call finished and a receipt exists, replay it. If the first call is still in flight, wait or get &lt;code&gt;idempotency_in_progress&lt;/code&gt;. If the first call left the system in an unknown state after send, a same-key retry still should not fire a second dispatch; it should return the same unknown and make a human or a reconciler check the provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put a per-tool call cap on the permit
&lt;/h2&gt;

&lt;p&gt;When you cannot trust every caller to keep the key, bound how many times a given tool may dispatch under one permit.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;max_calls_per_tool&lt;/code&gt; belongs on the permit next to budget, expiry, and tool scope. Session policy dies when the session dies; an agent that starts a new session walks right past it. A permit is the durable authorization, so the counter belongs there.&lt;/p&gt;

&lt;p&gt;What the cap should mean in practice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Count before dispatch.&lt;/strong&gt; Reserve a slot when you authorize the call, not only after a success receipt lands. Otherwise two overlapping calls both pass a cap of 1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New key past the cap: deny.&lt;/strong&gt; Prefer a distinct error such as &lt;code&gt;permit_max_calls_exceeded&lt;/code&gt; with the tool name, the limit, and how many calls were already made. No attempt. No charge. No send.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same key still replays.&lt;/strong&gt; Hitting the cap must not break honest retries. A same-key replay returns the original receipt and does not consume another slot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide what keeps the slot.&lt;/strong&gt; If the tool never dispatched, give the slot back. If the call was sent and the outcome is unknown, keep the slot. Refunding money while returning the count is how you get a second charge on the next attempt.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is a sketch written for this post, not code 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="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;permit&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;key&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;store&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;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="n"&gt;key&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;existing&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&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;# Same key: replay. Do not touch the counter.
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replay&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;receipt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;permit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_calls_per_tool&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="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;permit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_call_counts&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="mi"&gt;0&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;limit&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;limit&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;Denied&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;permit_max_calls_exceeded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="o"&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;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;calls_made&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;used&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Reserve the slot, then dispatch. Slot sticks if send happens.
&lt;/span&gt;    &lt;span class="n"&gt;permit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_call_counts&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;used&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;receipt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dispatch&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;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;receipt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dispatched&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;receipt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A cap of 1 on &lt;code&gt;pay_invoice&lt;/code&gt; turns the dangerous path into a loud denial on the second key. That is the whole point: stop the blind second send before it leaves.&lt;/p&gt;

&lt;h2&gt;
  
  
  At most N is not exactly-once
&lt;/h2&gt;

&lt;p&gt;This is the claim people oversell, so say it plainly.&lt;/p&gt;

&lt;p&gt;A per-tool call cap bounds the gateway to &lt;strong&gt;at most N dispatches&lt;/strong&gt; for that tool under that permit. It does not make the remote side effect happen exactly once.&lt;/p&gt;

&lt;p&gt;Why not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;After send, the outcome can still be unknown.&lt;/strong&gt; If the response is lost, the honest record is delivery uncertain: charged, not re-sent by the gateway, and still needing a check against the provider. The cap kept you from firing a second time. It did not finish the first time for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The remote tool must honor its own key.&lt;/strong&gt; Forwarding an idempotency key only helps if the upstream tool de-duplicates on it. If the tool ignores the key, a single dispatch from your side can still collide with another client talking to the same tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A default "duplicate looking request" log is not a lock.&lt;/strong&gt; Some systems log when a new key carries the same request hash as a recent attempt. Logging is not enforcement. Unless that guard is set to deny, a new key still dispatches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No cap means no bound.&lt;/strong&gt; If the permit never set &lt;code&gt;max_calls_per_tool&lt;/code&gt; for that tool, a new key is simply a new call, limited only by budget and expiry.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the honest ladder is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Same-key replay protects one intent.&lt;/li&gt;
&lt;li&gt;A per-tool cap bounds how many intents can leave for that tool.&lt;/li&gt;
&lt;li&gt;Exactly-once remote effects still require the tool itself to cooperate, plus reconciliation when delivery is uncertain.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If your dashboard says "exactly once" because you set a cap of 1, the dashboard is lying. It means "at most one dispatch from this gateway under this permit." That is still worth having. It is not the stronger promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I am building this in AMW
&lt;/h2&gt;

&lt;p&gt;I build Agent Middleware (AMW), a gateway that puts a permit and receipt boundary in front of MCP tool calls. Agents act under a permit; a retry with the same key returns the original receipt instead of dispatching again; a new key is a new request. When a permit sets &lt;code&gt;max_calls_per_tool&lt;/code&gt;, that cap is checked before dispatch. A new key past the cap is denied as &lt;code&gt;permit_max_calls_exceeded&lt;/code&gt;. A same-key replay still returns the original receipt.&lt;/p&gt;

&lt;p&gt;What it does not do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No exactly-once remote side effect.&lt;/strong&gt; After send, a lost response is recorded as delivery uncertain: charged, not re-sent by AMW. Someone still has to check the provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cap is at most N dispatches&lt;/strong&gt;, not a guarantee the remote tool ran once. The remote tool must honor its own key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The duplicate guard is log-only by default.&lt;/strong&gt; Identical looking new-key requests are logged and still dispatched unless that guard is set to enforce.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'm biased: this is the product I am building, and it is pre-revenue. The retry and cap pattern above is older than AMW. Agent loops just make the new-key-after-timeout path common enough that leaving the bound optional is how wallets get drained.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclosure
&lt;/h2&gt;

&lt;p&gt;I'm Chris, the founder and sole owner of AMW, which is pre-revenue. This post was drafted with help from an AI assistant; the Python is illustrative and is not AMW's source, and the AMW claims were checked against its code. More at &lt;a href="https://www.thisisatest.tech" rel="noopener noreferrer"&gt;thisisatest.tech&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;My question for you: &lt;strong&gt;when a tool call times out after you believe it may have charged, do you block new-key retries behind a per-tool cap, or do you force same-key only until a reconciler clears the unknown?&lt;/strong&gt; I want to hear which control you trust in production.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>python</category>
      <category>security</category>
    </item>
    <item>
      <title>Same idempotency key, different arguments: refuse the mismatch</title>
      <dc:creator>Chris Sellers</dc:creator>
      <pubDate>Fri, 09 Oct 2026 22:26:53 +0000</pubDate>
      <link>https://dev.to/chrissellers/same-idempotency-key-different-arguments-refuse-the-mismatch-5ad3</link>
      <guid>https://dev.to/chrissellers/same-idempotency-key-different-arguments-refuse-the-mismatch-5ad3</guid>
      <description>&lt;p&gt;The agent paid the invoice once. Then the model regenerated the tool call for a retry, kept the same idempotency key, and changed the memo field by a few words. The server saw the familiar key, returned the original receipt, and the caller moved on. In the ledger it looks like the corrected call went through. It never did.&lt;/p&gt;

&lt;p&gt;Yesterday I wrote about &lt;a href="https://dev.to/chrissellers/where-should-an-ai-agents-idempotency-key-come-from-4i2f"&gt;where an AI agent's idempotency key should come from&lt;/a&gt;. That post was about minting the key for the logical action and keeping it across retries. This one is about the other half: what the server must do when that same key shows up with a different request.&lt;/p&gt;

&lt;h2&gt;
  
  
  The key-only matching bug
&lt;/h2&gt;

&lt;p&gt;An idempotency key is not a free pass to replay whatever you stored last time. It is a pointer into a record that also captured what was asked for.&lt;/p&gt;

&lt;p&gt;Key-only matching is the bug. The server stores &lt;code&gt;(key → response)&lt;/code&gt;, looks up by key alone on the next request, and returns the stored response. That is fine when every retry truly repeats the first call. It is a silent lie when the arguments changed.&lt;/p&gt;

&lt;p&gt;With ordinary HTTP clients, the body usually stays the same on retry. With LLM agents, a "retry" often means the model writes the tool call again. Amounts get re-parsed. Memo text gets reworded. A recipient id shifts by one digit after a copy from a PDF. The key can still be the stable task key you carefully persisted, and the payload is no longer the same action.&lt;/p&gt;

&lt;p&gt;If the server only checks the key, the agent gets the old receipt for a call it never made. That is worse than a clean double charge in one important way: the books say the new intent succeeded.&lt;/p&gt;

&lt;p&gt;Builders keep rediscovering this. The usual warning is: if you only check the key, a retry with the same key but different arguments returns the old receipt and the caller thinks the new call went through. Hash the args into the key, or refuse the mismatch loudly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refuse the mismatch (hash the request)
&lt;/h2&gt;

&lt;p&gt;The correct server behavior is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;When you accept a request under a key, store a hash of the effectful request alongside the response.&lt;/li&gt;
&lt;li&gt;On a later request with the same key, compare hashes.&lt;/li&gt;
&lt;li&gt;Same key and same hash: return the original result. Skip a second side effect and a second charge.&lt;/li&gt;
&lt;li&gt;Same key and different hash: refuse. Prefer not to replay or overwrite. Surface a conflict the client has to handle.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is a sketch written for this post, not code 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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;request_hash&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;arguments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Canonicalize so key order does not create false mismatches.
&lt;/span&gt;    &lt;span class="n"&gt;canonical&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="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;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;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;arguments&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;sort_keys&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;separators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&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;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;arguments&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;incoming&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;request_hash&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;arguments&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;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="n"&gt;key&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;existing&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="c1"&gt;# First time: reserve the key, run the call, store hash + result.
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;new&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;incoming&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;request_hash&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;incoming&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replay&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="c1"&gt;# Same key, different request: fail closed.
&lt;/span&gt;    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;Conflict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;idempotency_key_reused&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;Two design notes matter in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hash the fields that change the effect.&lt;/strong&gt; Tool name, amount, currency, recipient, and other business fields belong in the hash. Purely cosmetic headers usually do not. If you hash nothing consequential, you are back to key-only matching in disguise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the conflict loud.&lt;/strong&gt; A soft 200 with a warning header will be ignored by most agent loops. Prefer a distinct error the client must handle, such as &lt;code&gt;idempotency_key_reused&lt;/code&gt; or HTTP 409 with a clear body.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is a second school of thought: bind the arguments into the key itself, so a changed payload naturally becomes a new key. That works too, as long as every caller uses the same recipe. The hash-and-refuse approach is usually safer for a shared API, because clients that already send a stable task key still get protection even when they forget to include the args in the key string.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the client should do on conflict
&lt;/h2&gt;

&lt;p&gt;When the server says the key was reused with a different request, minting a fresh key and firing again is usually the wrong move. That turns a detected conflict into a second real call.&lt;/p&gt;

&lt;p&gt;Treat it as a signal:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stop the automatic retry.&lt;/strong&gt; A conflict is not a timeout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare the two intents.&lt;/strong&gt; Was the change accidental (model reworded a memo) or intentional (amount corrected after review)?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If accidental, resend the original arguments&lt;/strong&gt; under the same key, so you get a true replay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If intentional, that is a new logical action.&lt;/strong&gt; Give it a new key, after you have decided the first action is cancelled, superseded, or left as-is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route unclear cases to a person or a planner step.&lt;/strong&gt; Agent frameworks that retry every non-200 will happily dig a deeper hole unless you catch this error class explicitly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Filling consequential arguments from task state, not from free-form model text, cuts how often this fires. Let the model decide &lt;em&gt;to&lt;/em&gt; pay; let code supply amount, currency, and recipient from the stored task.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I am building this in AMW
&lt;/h2&gt;

&lt;p&gt;I build Agent Middleware (AMW), a gateway that puts a permit and receipt boundary in front of MCP tool calls. Agents act under a permit; a retry with the same key returns the original receipt instead of dispatching again; a new key is a new request. The request is hashed with the call. Same key with a different payload (or a different permit) is refused as &lt;code&gt;idempotency_key_reused&lt;/code&gt;, not replayed. A retry that arrives while the first call is still running gets &lt;code&gt;idempotency_in_progress&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What it does not do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A new key is a new call.&lt;/strong&gt; Nothing silently de-duplicates across keys. If you change the key to "get past" a conflict, you asked for a second dispatch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote side effects are a separate problem.&lt;/strong&gt; AMW bounds its own side: at most one dispatch, one charge, and one receipt per accepted key. Whether the remote tool itself runs once still depends on that tool honoring an idempotency key of its own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'm biased: this is the product I am building, and it is pre-revenue. The pattern above is older than AMW. Stripe-style APIs have taught request binding for years. Agents just make the mismatch case common enough that leaving it soft is no longer optional.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclosure
&lt;/h2&gt;

&lt;p&gt;I'm Chris, the founder and sole owner of AMW, which is pre-revenue. This post was drafted with help from an AI assistant; the Python is illustrative and is not AMW's source, and the AMW claims were checked against its code. More at &lt;a href="https://www.thisisatest.tech" rel="noopener noreferrer"&gt;thisisatest.tech&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;My question for you: &lt;strong&gt;when your agent hits a same-key conflict because the model rewrote the arguments, do you force a resend of the original payload, or do you treat it as a new action with a new key?&lt;/strong&gt; I want to hear how people decide that line in production.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>api</category>
      <category>python</category>
    </item>
    <item>
      <title>Where should an AI agent's idempotency key come from?</title>
      <dc:creator>Chris Sellers</dc:creator>
      <pubDate>Thu, 08 Oct 2026 18:45:40 +0000</pubDate>
      <link>https://dev.to/chrissellers/where-should-an-ai-agents-idempotency-key-come-from-4i2f</link>
      <guid>https://dev.to/chrissellers/where-should-an-ai-agents-idempotency-key-come-from-4i2f</guid>
      <description>&lt;p&gt;Your agent's worker sends a payout request, and then the process gets killed before the response comes back. The supervisor restarts it. The agent reloads its plan, sees that the "pay invoice 4417" step never finished, and runs it again. The payment API supports idempotency keys, and the agent sent one both times. The vendor still got paid twice, because the agent generated a fresh UUID on each run.&lt;/p&gt;

&lt;p&gt;Yesterday I wrote about &lt;a href="https://dev.to/chrissellers/gate-a-risky-ai-agent-tool-call-with-a-permit-and-keep-a-receipt-47a7"&gt;gating a risky tool call with a permit and a receipt&lt;/a&gt; and ended by asking where an agent's key comes from. This post is my answer, and it applies whatever sits behind the tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two terms
&lt;/h2&gt;

&lt;p&gt;An &lt;strong&gt;idempotency key&lt;/strong&gt; is a value the client sends with a request so the server can recognize a repeat and return the first result instead of acting again. It only works if the repeat carries the same key.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;logical action&lt;/strong&gt; is the thing you want to happen once in the business sense: pay invoice 4417, delete the staging database created for run 812, send the renewal email for account 77. Attempts are how you try to make it happen. One logical action can take many attempts.&lt;/p&gt;

&lt;p&gt;The rule that follows: &lt;strong&gt;the key identifies the logical action, not the attempt.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The per request UUID trap
&lt;/h2&gt;

&lt;p&gt;Most retry code mints the key where the request is built. That is the bug.&lt;/p&gt;

&lt;p&gt;This is a simplified sketch written for this post, not code 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="c1"&gt;# Wrong: every attempt looks like a brand new action.
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&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="mi"&gt;3&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;pay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoice&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="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&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;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timeout is exactly the case where the first attempt may have succeeded, and exactly the case where this loop sends a key the server has never seen. Moving &lt;code&gt;uuid4()&lt;/code&gt; above the loop fixes the in process retry, but not the restart in the opening story, because the key dies with the process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the key should come from
&lt;/h2&gt;

&lt;p&gt;Here is the flow I use, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Mint or derive the key when the action is planned, not when it is executed.&lt;/strong&gt; The planner decides "pay invoice 4417 for $250". That decision is the moment the action gets its identity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persist the key with the task state before the first attempt.&lt;/strong&gt; If the step is in your database, queue message, or workflow history, the key goes there too, written before anything is sent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every attempt reads the key, never creates it.&lt;/strong&gt; In process retries, a restarted worker, and a resumed workflow all send the same value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A new key means a new intent.&lt;/strong&gt; If the amount or recipient changes, that is a different action and deserves a different key. A transient error is not a reason for a new key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat a key conflict as a signal, not an obstacle.&lt;/strong&gt; If the server says "this key was used with a different request", stop and look. Do not mint a fresh key to get past it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fixed loop, again a sketch:&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="c1"&gt;# Right: the key was created with the task and stored next to it.
&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;idempotency_key&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&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="mi"&gt;3&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;pay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoice&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="n"&gt;key&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;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Stored or derived?
&lt;/h3&gt;

&lt;p&gt;There are two reasonable ways to get &lt;code&gt;task.idempotency_key&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stored:&lt;/strong&gt; generate a random value when the task row or message is created, and keep it there. Simple, and safe as long as that storage is durable and is the same record every retry reads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Derived:&lt;/strong&gt; compute the key from identifiers that are already stable for this action. Also a sketch:&lt;br&gt;
&lt;/p&gt;

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


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;action_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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;step_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;business_ref&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payout:v1:&lt;/span&gt;&lt;span class="si"&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="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;step_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;business_ref&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deriving survives even if you lose the stored key, as long as the inputs are truly stable. Choose them carefully:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Include&lt;/strong&gt; a workflow or run ID, the step ID, and the business reference (invoice number, account ID).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exclude&lt;/strong&gt; timestamps, attempt counters, hostnames, and any text the model wrote. Each of those changes between attempts and quietly turns your retry into a new action.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not go too coarse.&lt;/strong&gt; A key of just the invoice number blocks a legitimate second, partial payment on the same invoice. Add whatever makes two real actions different.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version the format&lt;/strong&gt; (the &lt;code&gt;v1&lt;/code&gt; above), so changing the recipe later does not collide with old keys.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The agent specific twist
&lt;/h2&gt;

&lt;p&gt;With a normal service, a retry resends the same bytes. With an LLM agent, a retry often means the model generates the tool call again, and it may not produce the same arguments. The memo field gets reworded, or the amount is parsed slightly differently from the invoice text.&lt;/p&gt;

&lt;p&gt;If your key is derived from the task, a properly built server will see the same key with a different request and refuse it. That is the system working: it failed closed instead of paying twice. Two things make this smooth in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fill consequential arguments from task state, not from the model.&lt;/strong&gt; Let the model decide to pay; let code supply the amount, currency and recipient from the stored task.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route conflicts to a person or a planner step.&lt;/strong&gt; The fix is to figure out which version of the action is right, not to retry harder.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How AMW handles keys
&lt;/h2&gt;

&lt;p&gt;I build Agent Middleware (AMW), a gateway that puts a permit and receipt boundary in front of MCP tool calls, so here is how it treats keys today. Agents act under a permit; a retry with the same key returns the original receipt instead of dispatching again; a new key is a new request. Governed calls must carry a key, scoped per wallet. The same key with a different request is refused with &lt;code&gt;idempotency_key_reused&lt;/code&gt;, and a retry that arrives while the first call is still running gets &lt;code&gt;idempotency_in_progress&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What it does not do matters just as much for this topic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A new key is a new call.&lt;/strong&gt; If the permit sets a per tool call limit and it is used up, the retry is refused. Do not count on anything else to catch it. Where your key comes from is your job, which is why this post exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Once only at the remote tool depends on the tool.&lt;/strong&gt; AMW controls its own side: at most one dispatch, one charge and one receipt per accepted key. Whether the side effect itself happens once at the remote tool depends on that tool de-duplicating on an idempotency key of its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A crash in the gateway can make a same-key retry wait.&lt;/strong&gt; After a crash mid call, a same-key retry can see &lt;code&gt;idempotency_in_progress&lt;/code&gt; for about three hours until the stuck call is reconciled. Your agent should treat that as "pending", not as "failed, try a new key".&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Disclosure
&lt;/h2&gt;

&lt;p&gt;I'm Chris, the founder and sole owner of AMW, which is pre-revenue. This post was drafted with help from an AI assistant; the Python is illustrative and is not AMW's source, and the AMW claims were checked against its code. More at &lt;a href="https://www.thisisatest.tech" rel="noopener noreferrer"&gt;thisisatest.tech&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;My question for you: &lt;strong&gt;when an agent's plan changes partway through, say the amount gets corrected after a failed attempt, do you treat it as the same action and resolve the conflict, or as a new action with a new key?&lt;/strong&gt; I go back and forth on where that line belongs.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>python</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Gate a risky AI agent tool call with a permit, and keep a receipt</title>
      <dc:creator>Chris Sellers</dc:creator>
      <pubDate>Thu, 08 Oct 2026 03:00:32 +0000</pubDate>
      <link>https://dev.to/chrissellers/gate-a-risky-ai-agent-tool-call-with-a-permit-and-keep-a-receipt-47a7</link>
      <guid>https://dev.to/chrissellers/gate-a-risky-ai-agent-tool-call-with-a-permit-and-keep-a-receipt-47a7</guid>
      <description>&lt;p&gt;Your agent calls a payout tool. The payout goes through on the other side, but the response never makes it back. The client times out, and the agent does the reasonable thing: it retries. Now the vendor has been paid twice.&lt;/p&gt;

&lt;p&gt;Swap "payout" for "delete the staging database" or "send the customer email" and the story is the same. The problem is that nothing between the agent and the tool knows the second call is the same action as the first.&lt;/p&gt;

&lt;p&gt;This post walks through a pattern for that gap, first in general terms you can build yourself, then how Agent Middleware (AMW), the project I work on, applies it. The Python below is a simplified sketch I wrote for this post, not AMW's source. The one AMW snippet is the request shape from its README.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two terms first
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;permit&lt;/strong&gt; is a signed, scoped grant that says what an agent may do: which tools, which scopes, how much it may spend, and until when. The gateway checks it before anything costs money or touches the outside world.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;receipt&lt;/strong&gt; is a signed record of what happened to one governed call: which permit it ran under, which tool, what was charged, and the outcome. A refusal under a valid permit gets one too, so "denied" leaves evidence just like "succeeded".&lt;/p&gt;

&lt;h2&gt;
  
  
  The general pattern
&lt;/h2&gt;

&lt;p&gt;You can build this in front of any tool that has side effects. The flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The agent picks an idempotency key per logical action, before the first attempt, and keeps it across retries.&lt;/strong&gt; Not per HTTP request. Per "pay invoice 4417".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The gateway hashes the request.&lt;/strong&gt; Tool name, arguments, who is calling, and under which permit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The gateway looks up the key.&lt;/strong&gt; If it already exists with the same hash and a finished result, return the stored response and receipt. Do not call the tool. Do not charge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If the key exists with a different hash, refuse.&lt;/strong&gt; A reused key with a new payload is a bug or an attack, not a retry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If the key exists and the first call is still running, say so.&lt;/strong&gt; Return "in progress" rather than starting a second call.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Only for a new key: check the permit, reserve budget, call the tool once, and store the response and receipt under that key.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The ordering matters. The replay check comes before the permit check, the charge, and the dispatch, so a retry never gets a chance to spend anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  A simplified sketch
&lt;/h3&gt;

&lt;p&gt;This is illustrative code to show the shape of the idea. It is not AMW's implementation, and the &lt;code&gt;store&lt;/code&gt;, &lt;code&gt;permits&lt;/code&gt;, &lt;code&gt;tools&lt;/code&gt; and &lt;code&gt;sign_receipt&lt;/code&gt; pieces are stand ins for whatever your stack uses.&lt;br&gt;
&lt;/p&gt;

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


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;KeyReused&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;InProgress&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;request_hash&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;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;permit_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;canonical&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="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;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;args&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;caller&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;permit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;permit_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;sort_keys&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;separators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;governed_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;permits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&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;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;permit_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;request_hash&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;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;permit_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Look up the key before anything can spend money or touch the tool.
&lt;/span&gt;    &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;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="n"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&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;record&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&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;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request_hash&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;h&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;KeyReused&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# same key, different request
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;response&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;InProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# first call still running
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;          &lt;span class="c1"&gt;# original result and receipt
&lt;/span&gt;
    &lt;span class="c1"&gt;# New key only. The store must enforce uniqueness on (caller, key),
&lt;/span&gt;    &lt;span class="c1"&gt;# so two racing first attempts cannot both get past this line.
&lt;/span&gt;    &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;permit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;permits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;permit_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;caller&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;caller&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="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# raises on deny
&lt;/span&gt;    &lt;span class="n"&gt;permit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reserve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;call&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;idempotency_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;key&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;receipt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;sign_receipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;permit&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;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&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="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;A real version also needs a signed receipt for denials, a budget release when the call fails before it is sent, and an answer for the case where the tool may have acted but you never heard back. That last one is where most of the difficulty lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  How AMW applies it
&lt;/h2&gt;

&lt;p&gt;In AMW, agents act under a permit; a retry with the same key returns the original receipt instead of dispatching again; a new key is a new request.&lt;/p&gt;

&lt;p&gt;A governed call is an MCP &lt;code&gt;tools/call&lt;/code&gt; with the wallet, permit and idempotency key carried in &lt;code&gt;mcpContext&lt;/code&gt;. This is the request shape from the AMW README:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$API_URL&lt;/span&gt;&lt;span class="s2"&gt;/mcp/messages"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-API-Key: &lt;/span&gt;&lt;span class="nv"&gt;$AGENT_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"{
    &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;jsonrpc&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;2.0&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,
    &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;request-1&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,
    &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;method&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;tools/call&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,
    &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;params&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: {
      &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$TOOL_ID&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,
      &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;arguments&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: {&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;input&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;hello&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;},
      &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;mcpContext&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: {
        &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;wallet_id&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$WALLET_ID&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,
        &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;permit_id&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$PERMIT_ID&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,
        &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;idempotency_key&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;invoke-1&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;
      }
    }
  }"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The behavior follows the same steps as the sketch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Governed calls require a key.&lt;/strong&gt; Retry safety is not opt in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The request hash covers the tool name, the arguments, the wallet and the permit.&lt;/strong&gt; The key is scoped per wallet and backed by a database uniqueness constraint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reusing a key with a different payload fails closed&lt;/strong&gt; with &lt;code&gt;idempotency_key_reused&lt;/code&gt;, and no new receipt. A retry while the first call is still running gets &lt;code&gt;idempotency_in_progress&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A replay returns the stored response before permit validation, the charge, or the dispatch happen.&lt;/strong&gt; AMW's own test suite sends the same governed call twice and asserts the same receipt ID and a single ledger debit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the permit side, each governed call is checked against a signed permit: which wallet and key may use it, which tools and scopes it covers, a credit budget, an expiry, and optional limits such as per tool call counts, an aggregate value cap, forbidden argument fields, and a required recipient domain. Calls outside the permit are refused before any charge.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the receipt binds
&lt;/h3&gt;

&lt;p&gt;Every governed call, whether it succeeds, is denied, or fails, produces an Ed25519 signed receipt. The receipt binds the permit, wallet, tool, credits authorized and charged, outcome, ledger entry and audit event, plus SHA-256 hashes of the request and the tool's response. A receipt can be exported as a portable bundle and checked by anyone who holds it, with no account, using our published public key and any Ed25519 library, or our SDK verifier (function or CLI), shared on request. Verification trusts the key AMW publishes; there's no transparency log yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A new key is a new call.&lt;/strong&gt; A retry sent under a new idempotency key is treated as a new call, not a repeat, and it does not get the original receipt back. If the permit sets a per tool call limit and that limit is used up, the retry is refused. Do not count on anything else to catch it. An agent that restarts and mints a fresh key for the same action can still pay twice. Keep the key with the task, not the process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not exactly once at the remote tool.&lt;/strong&gt; AMW makes each accepted key produce at most one call to the tool, one charge and one receipt. Whether the remote side effect itself happens only once depends on the tool honoring the forwarded idempotency key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lost responses stay uncertain.&lt;/strong&gt; For remote tools, AMW records a durable checkpoint right before it calls the tool. If it crashes before that point, the charge is refunded and the budget released. If it crashes or loses the connection after that point, the call is kept as charged and marked &lt;code&gt;delivery_uncertain&lt;/code&gt; (the gateway sent it, but cannot say what the tool did) with a signed receipt. AMW never re-sends the call automatically. Someone still has to reconcile it with the tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Receipts sign hashes, not content.&lt;/strong&gt; They do not prove the tool's output was correct, and the signing key is a single injected key with no KMS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Narrow scope today.&lt;/strong&gt; MCP is the only governed adapter, upstream execution is limited to one configured tool per deployment, and the supported deployment is vendor managed and single tenant. I am not promising dates for anything beyond that.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Disclosure
&lt;/h2&gt;

&lt;p&gt;I'm Chris, the founder and sole owner of AMW. It is pre-revenue. This post was drafted with help from an AI assistant; the Python sketches are illustrative and are not AMW's source, the curl call is from AMW's README, and every product claim was checked against the AMW code. A sample signed receipt and the public API docs are linked from &lt;a href="https://www.thisisatest.tech" rel="noopener noreferrer"&gt;thisisatest.tech&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The pattern above works without AMW. The hard part in my experience is step 1, so here is my question: &lt;strong&gt;when your agent retries a tool call, where does its idempotency key come from, and does it survive a process restart?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>api</category>
      <category>python</category>
    </item>
  </channel>
</rss>
