Build1 publisher2 min readPublished
Refunding TX-18491 instead of TX-18419 passes every check the endpoint can run
In a dev.to walkthrough an agent refunds the wrong one of two identical charges, and because the refund service only sees an ID and an amount, the author's fix keys idempotency to the customer's intent instead of the HTTP request.
The Engineer · Build desk

What happened
- A dev.to post walks through an agent-issued refund that is authenticated, authorised, processed and answered with 200 OK, leaves every technical dashboard green, and still refunds the wrong transaction.
- In the worked example the agent is handed two charges of 2500 at merchant ABC Store, TX-18419 and TX-18491, and posts a refund for TX-18491 when the customer meant the other one.
- The post splits agent success into three layers: the request completing, the backend performing the operation, and the system acting on the right resource for the right user under the right conditions.
- A second scenario has the refund commit while the response is lost, after which the agent reasons the refund probably failed and calls again, and one customer intent produces two real refunds.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint An endpoint that receives only a transaction ID and an amount can check that both exist and agree, so whether the row was the one the customer meant has to be settled before the call or re-derived by the service itself.
- decision Teams handing a model write tools have to choose where the idempotency key sits, because a key scoped to one HTTP request does not stop a model that decides on its own to issue the refund a second time.
- exposure Five of the seven tools in the post's list change something outside the model, including sendEmail and disableAccount, and a sent notification is beyond the reach of any database rollback.
- cost Intent-scoped idempotency needs a durable record each repeatable step reads before acting, so the key has to be plumbed through the CRM update and the customer notification as well as the refund call.
The request body in the post's example has two fields, transactionId and amount, and the value sent is TX-18491 with 2500 [3]. Both candidate rows carry 2500 and the same merchant, ABC Store, so an amount check passes on either [3]. The two identifiers are the same five digits reordered, TX-18419 and TX-18491, which is a pair a human reviewer also misreads [16]. The endpoint can confirm the row exists and the figure agrees; it cannot know the customer meant the other row [8].
Set that payload against the post's own list of what a correct refund requires: correct customer, correct transaction, correct amount, correct reason, correct approval, exactly once [6]. Four of the six have no field in the body [17]. Reason and approval are never sent. Exactly-once needs an idempotency key, and the request does not include one [17]. The author wrote, "API correctness does not guarantee semantic correctness" [7].
The retry path is the half I would fix first. In the post's second scenario the refund commits, the response is lost, the agent sees a timeout, concludes the refund probably failed and calls again, and the second call also succeeds [9]. That retry does not come from a library with a backoff and a budget. The post has the model reasoning, "Let me try that again" [10].
request_id, trace_id and span_id identify technical execution, so the post adds an identifier for the business objective, intent_id = REFUND_DUPLICATE_CHARGE_8472 [11]. One intent covers five steps: transaction lookup, eligibility check, refund creation, CRM update, customer notification [12]. After a timeout the system asks whether that intent has already produced a successful refund [13]. The supplied text breaks off mid-sentence at that check, so the argument as published does not reach reversal, or an assertion that the executed refund matched what was asked for [13].
In my view the intent key is worth doing on its own: one durable record, written before the side effect, readable by every step that can repeat. It removes the duplicate refund but does nothing about TX-18491. For the boundary to catch that one, the caller would have to send the selection rule next to the resolved ID, so the service can re-derive "the most recent duplicate charge" from its own rows and disagree, or the service would have to return the row it is about to touch and wait to be told to proceed. The post's tool list already gives the agent lookupTransaction(), so the service holds the same rows the model read [14].
What to watch
- Whether the continuation of the post specifies where the intent record lives, which step writes it, and how long it is retained.
- Whether tool schemas start carrying the selection rule next to the resolved ID, so the service can re-derive the agent's choice.
- Whether agent frameworks expose an intent-level idempotency key by default instead of per-request trace IDs.