One Brain, Two Front-Ends: Refunded Twice on Sale Day

Yaqin Hei··12 min read
One Brain, Two Front-Ends: Refunded Twice on Sale Day

The 16th piece in the Agentic AI in Practice series. Earlier pieces covered how to design and grade a single agent; this one is about where the money leaks when several agents are wired together — one brain, two front-ends, and no idempotency key on the seam. 中文版:一个大脑,两个前端:大促那天,用户被退了两次款.

On sale day, a user got refunded twice

Day one of the big sale, a complaint pops up in the support console: a user filed one refund and received two payouts. Reconciling the books, finance finds that the same refund request was executed twice by the refund service — the money genuinely went out twice. Not a display bug; real money, out the door, twice.

Finance caught it first — during reconciliation, the same refund order number had two payout records under it, identical amounts, less than a second apart. Support pulled the conversation: the user filed once, and clicked "confirm refund" exactly once on screen. Everyone's first instinct was "the user double-clicked," but the front-end log shows that click was clean — exactly one. The real second time was fired by a machine.

Swap the instinct to "the refund logic is wrong," go read that code — the logic is right, one request maps to one refund, and it's tested. Yet in production it refunded twice. Trace up the chain and the problem is inside no single agent — it's on the unwatched seam between two agents.

This system is "one brain, two front-ends": a public-channel front-end (the marketplace's built-in support entry) and a private-channel front-end (the brand's own mini-program / community entry) are two independent front-ends, but they share one agent brain behind them, and a write operation like a refund goes through the same L2 adapter layer down to the refund service. The one that broke that day was the private-channel front-end: it called the adapter to issue a refund, a network hiccup timed the response out, and by default the front-end retried once. But the first request had already reached the refund service and already executed — the retry was a second refund. On the refund service's side, it saw two requests that "looked different" (different timestamps, different request IDs) and honored both. Two refunds.

That's the seam this piece is about, in one line: one brain wired to two front-ends — the moment the shared write layer has no idempotency key, one timeout-retry is one duplicate refund. And the real bottleneck of multi-agent delivery is organizational integration, not model quality.

Below, that seam broken into four real pitfalls — each one happening between two agents, not inside any one of them — closing with a checklist you can carry into an architecture review.

One brain, two front-ends: the seam is the shared L2 adapter

A public front-end + a private front-end share one agent brain; refund writes both go through the same L2 adapter down to the refund service. The seam is inside no front-end — it's the layer where the two converge, and that layer has no idempotency key, so a retry becomes a duplicate refund.

The double refund isn't a model error — the seam is missing an idempotency key

Verdict first: the double refund has nothing to do with the model or the refund logic. The fault is that the adapter where the two front-ends converge has no idempotency key — it treated "a retry of the same refund" as "two different refunds."

Lay the two raw requests side by side at the review and it's clear: semantically they're the same refund — same user, same order, same amount — but technically the two requests' timestamps are 800 ms apart, and the request ID is freshly generated by the front-end on each call. The refund service only knows request IDs; two different IDs means two refunds, so it pays twice.

The root isn't that the refund service is "too obedient" — it's that no layer owns the question "is this the same piece of business." The front-end only cares "did I send it, did it time out," and on timeout it retries — that's the front-end's job. The refund service only cares "one request in, one refund out" — that's its job. The L2 adapter in the middle, which should be the one place that knows "public and private both funnel to me in the end," only passed things through — it did no dedup. It was the only one with standing to block the duplicate, and it didn't.

The fix isn't touching the refund logic — it's adding an idempotency gate at the adapter: use the (request_id, member_id) combination as the idempotency key, and within a time window (5 minutes here) let the same combination execute only once; a retry that comes in gets the first result back, triggering no refund. Why the combination and not just the request ID? Because the request ID changes on every retry, so it can't stop a retry; and the real unit to dedup on is "the same person, the same operation" — a member dimension plus a business request identifier is what fences off "this is the same one."

Five minutes isn't arbitrary either: too short, and a slightly slow retry lands outside the window and slips through; too long, and a user's genuine second refund that day (bought and returned another order) gets falsely blocked. Five minutes is the balance of "covers the vast majority of retry intervals without falsely hitting a real second action" — tune the number to your own retry policy and business rhythm, but someone must own it rather than leave it blank.

And this pitfall is almost impossible to catch in tests, because it only happens at the moment of a timeout — the normal path succeeds once and never retries. Only production sale-day traffic pushes the timeout probability up, retries fire densely, and double refunds surface at scale. This is the nature of seam bugs: they fall outside any unit test's coverage, because what they need is a timing mismatch between two components, and unit tests test one component. A green suite doesn't stop it.

Double-refund timeline: timeout → retry → executed twice, the idempotency key is the only gate

Private front-end issues a refund → times out (but the request already reached the refund service and executed) → front-end retries → the refund service sees a new request ID and executes again = double refund. Fix: the adapter uses (request_id, member_id) 5-minute idempotency; a retry returns the first result, triggers nothing.

refundType is defined in reverse across the two systems — one mistranslation is one wrong refund

A second class of leak on the seam, subtler than the double refund: two systems define the same enum in reverse. When the adapter mistranslates once, the refund type is wrong once — a direct loss.

Here's the setup: the refund service has its own refundType enum, and the order system has one too. The two should map one-to-one, but look closely and — their value orders are reversed. What 1 means in the refund service maps to a different value in the order system; when the adapter translates between them, mapping "number to number" turns "refund-only" into "return-and-refund," turns type A into type B. What does a wrong refund type mean? It means a case that should refund 30 yuan refunds the full amount under another type, or a case that should go through the return flow becomes refund-without-return — every mistranslation is money computed wrong.

Concretely: a user files "refund-only" (no return, money back only), and on the refund service side refundType should be some value; but the adapter, mapping by the order system's order, translates it to "return-and-refund" — so the system waits for the user to ship the goods back before releasing the money, the user clearly chose not to return, and the refund sits stuck, the user thinks it wasn't processed, another round of complaints. The reverse mistake costs more: what should be "return-and-refund" gets translated to "refund-only," the goods never come back, the money goes out first — a clean loss of one item.

The nastiest part: it doesn't error. The translation succeeds (the numbers are all in range), the refund succeeds (the refund service honors it), and only reconciliation, or the user's complaint, reveals the wrong type. It hides between two enums maintained by two teams; each looks fine on its own, and only crossing the seam to compare reveals the mismatch.

Why are the two enums reversed? Because they were defined by two teams, at two points in time, independently — the refund service ordered by its own business logic, the order system by another historical order, and neither is wrong on its own; the fault is that no one owns "these two must be aligned." Most seam pitfalls are this shape: not that someone wrote it wrong, but that no one owns the contract between the two. Which is also why this kind of cross-team enum mismatch is the hardest tier on the whole project's blocker list — fixing it isn't editing a line of code, it's two teams sitting down to agree "whose definition governs."

refundType defined in reverse; number-to-number mapping flips it

The refund service and the order system each define refundType, and the value orders are reversed; the adapter mapping "number to number" turns "refund-only" into "return-and-refund" — no error, refund honored, exposed only at reconciliation or complaint. Fix: a value-by-value mapping table + both teams sign off.

The router's enum is a security boundary — internal intents shouldn't leak to the C-end

A third seam problem: an intent used inside one agent leaks into an action another front-end can trigger. The router's enum isn't just a classification table — it's a security boundary.

This system's router has an action-intent enum ActionIntent, 15 values to start. But look closely and several of them are internal / agent-desk-only intents — operations meant for the support console, for human agents: certain back-office state transitions, certain actions only a desk agent should initiate. They sit in the same enum as the user-facing intents.

The problem: C-end user input also goes through this router. When one enum holds both "what users can trigger" and "what only desk agents can trigger," the LLM, classifying by semantic similarity, can absolutely route a user's line to an internal intent only a desk agent should touch — a user-facing entry accidentally gains the ability to trigger an internal operation. This isn't a feature bug, it's a leak of the permission boundary: two classes of operation that should be isolated, crammed into one enum, lose their isolation.

Concretely: the support console has a back-office state-transition action, normally only a human agent clicks it on the console. But it shares the same ActionIntent with C-end intents, and when a user types something semantically adjacent, the router may well route it to that desk action — an ordinary user accidentally triggers a state change only a desk agent should touch. The user needs no malice; they just said something, and the boundary collapsed on its own.

The fix is to narrow the enum: cut ActionIntent from 15 down to 10, move the internal / desk-only intents out of the C-end router's enum entirely, so the C-end router only recognizes actions the C-end should have. The principle: a router's enum defines the boundary of "what this entry is allowed to do," and whatever this entry shouldn't do shouldn't appear in its enum. One enum serving two subjects (users + desk agents) welds two permission classes together.

For the architecture owner, the detection signal is one line: go count the C-end-facing router, and check whether its intent enum has "internal / desk-only" values mixed in. If it does, that's a boundary standing open — you don't have to wait for it to be exploited; the mixing itself is the risk.

Who can create a ticket is an architecture decision, not an interface tweaked on a whim

The last seam problem is on the data side: several agents share one "source of truth" system, and who can write to it and how the writes relate must be pinned down as an architecture decision from the start.

In this system, the ticketing system is the cross-agent system-of-record — whichever front-end or agent handled a conversation, whatever needs a human handoff, an archive, or an audit trail lands in the ticketing system. It's the one place where every agent's actions ultimately converge.

Precisely because it's the source of truth, two decisions can't be vague. First, who can create a ticket. If every agent and front-end can write to the ticketing system at will, the source of truth is quickly polluted — the same user issue gets a ticket from the public agent, one from the private agent, one from the human handoff, three tickets describing one thing, and the desk agent has no idea which to read. Second, how the multiple tickets from one conversation relate. One user session may trigger several actions and generate several tickets, and without a shared key stringing them together, you can't reconstruct "what actually happened in this one conversation." The key used here is session_id: every ticket spawned from the same conversation carries the same session_id, and you join back a full conversation by it.

The mess I've actually seen is the former: one user session, the public agent decides on a handoff and creates a ticket, midway the user switches to the private channel and keeps asking, the private agent creates another, and when a human steps in there are two tickets describing one thing on the desk — the agent that was supposed to save labor instead handed the desk a judgment call. Joined by session_id, what the desk sees is "one conversation, one user, a chain of actions" rather than a pile of disconnected tickets.

The verdict is blunt: a source-of-truth system's write permissions and join key are architecture decisions, and the moment several agents start writing their own way, unrelated, the source of truth degrades into shards that don't line up. This isn't some agent's implementation detail — it's the foundation of the whole multi-agent system; a bad foundation, and however right each agent is on top, together they're a muddled account.

Four seams, what leaks at each: money / money / permission / truth — every fix is cross-team alignment

Four seams — the shared write layer leaks money, the cross-system enum leaks money, the C-end router leaks permission, the shared source-of-truth leaks truth — none inside a single agent; each fix is not editing code but two teams sitting down to align a contract. The bottleneck of multi-agent delivery is which team nods.

Detection toolkit: one seam checklist + 5 questions to press in the architecture review

Flip the four pitfalls over and you get a seam checklist — its core admission: where a multi-agent system leaks money, permission, and truth is almost never inside a single agent, but on the unwatched seam between two of them.

Reviewing a multi-agent architecture, check off whether each of these seams has an owner:

SeamLeaks whatConfirm in review
The write layer two front-ends shareMoney (duplicate execution)Is there an idempotency key, keyed on the business dimension (member + request identifier)
Cross-system enum mappingMoney (wrong refund type)A value-by-value table + both teams sign off, don't trust "number to number"
The C-end-facing router enumPermission (internal intent leaks out)Are desk-only / internal intents mixed into the enum
The shared source-of-truth systemTruth (tickets don't line up)Who can write, what key joins the multiple tickets
Each capability's degrade pathFallbackWhen the seam breaks, can it hand off to a human cleanly

Then five questions to press, on the spot, in the architecture review:

  1. Does the write layer the two front-ends share have an idempotency key? Is it keyed on the business dimension (member + request identifier) rather than the request ID that changes on every retry? Without it, one timeout-retry is one duplicate charge / refund.

  2. Does every cross-system enum mapping have a value-by-value table? For an enum like refundType that two systems each define, don't trust "number to number" — get both teams to align in black and white and sign off.

  3. Does the C-end-facing router have internal / desk-only intents in its enum? One enum serving both users and desk agents welds two permission classes together — go count, and whatever's mixed in is an open boundary.

  4. Who can write to the source-of-truth system, and what key joins the multiple records? Every agent writing its own way, unrelated, and the source of truth degrades into shards that don't line up.

  5. What's the degrade when a seam breaks? Idempotency blocked the retry, the translation failed, the downstream timed out — every seam needs a clean "hand off to a human / refuse to execute" exit, not a silent retry or a silent pass.

What these five share is one shift of view: don't just review whether each agent does its own job right — review whether money, permission, and truth leak on the seam between two agents. And fixing these seam problems is almost never editing code; it's getting two teams to sit down and align a contract — which is why the real bottleneck of multi-agent delivery is which team nods, not model quality.

Grab this piece's checklist

If you want to put this seam review straight to work on your next architecture review — without re-reading this piece each time — I've put together a kit for readers who got this far. Reply with the keyword "SEAM-KIT" and I'll send you Multi-Agent Seam Checklist + 5 questions to press in the review: a one-page seam checklist (idempotency / enum mapping / router boundary / source-of-truth / degrade, checkbox by checkbox), a card version of the 5 pressing questions, and a quick-reference of the four symptoms of "the problem is on the seam, not inside a single agent." All judgment tools worn in over a year of multi-agent deployment projects.

Reply channels are in the footer (WeChat / X). If replying isn't convenient, drop your email in the comments.

Subscribe for updates

Get the latest AI engineering posts delivered to your inbox.

评论