Skip to content
PROVE ITExperiment 1Transaction Integrity

The Fix That Charged Twice

A payment timeout can occur after the remote service has committed the operation. Retrying without preserving the identity of the logical purchase can therefore create a second effect.

Supported within the tested scope
Published Tested

Watch the demonstration

2 min

The experiment is also written out in full below.

The experiment

The claim under test

A plain retry after a lost payment response charges the order twice; a stable operation key, claimed in the same transaction as the charge, keeps it to one charge without blocking new purchases.

The question

If a payment’s confirmation is lost after the money has moved, does the retry charge the customer again?

Scope

A purpose-built simulation: a demo store called Field Supply, a network hop with a fault injector, a payment service and a PostgreSQL ledger, connected by real network calls on one machine. The same code runs as an unsafe build and a corrected build with identical retry rules. The result covers this checkout workflow and the scenarios below. It does not cover a real payment provider, real money, other retry policies, or exactly-once delivery across a distributed system.

How it was run

Both builds use the same retry rules: the store’s payment client waits 1 second for an answer, then retries, up to 3 attempts with a backoff starting at 250 ms. The unsafe build retries with no operation ID. The corrected build saves a stable operation ID with the order before the first attempt and sends it on every attempt; the payment service claims that ID, writes the charge and stores its answer in one database transaction, and the ledger also allows only one charge per order. A scripted harness ran ten scenario runs: the unsafe baseline, five scenarios on the corrected build, and four of those again on the unsafe build for comparison. The filmed run is the same lost-response case, started by hand from the store page with the fault injector armed, once on each build. Every charge count comes from the ledger rows for the order, never from what the store was told, and each ledger row is chained to the one before it by hash, so a row cannot be quietly edited or removed.

The challenge introduced

A fault injector on the network hop lets the first payment attempt reach the payment service and commit its charge, then throws away the 201 response. The store waits out its 1 second timeout with the money already taken and no way to tell whether it was.

Pass and fail criteria

Fixed before the run, so the experiment could not be rewritten once the results arrived.

  1. Unsafe baseline: the charge commits, its response is lost and the store retries, leaving two charges for one order. If that does not happen, the run has not reproduced the failure and cannot test the fix.

  2. Corrected retry: under the same fault, the retry carries the same operation ID, receives the original result, and the order has one charge.

  3. Concurrent retries: five simultaneous requests under one operation ID produce one charge, and the other four receive the original result.

  4. Changed amount: a retry under the same operation ID that asks for $99 instead of $49 is refused with 422, the order is flagged for review, and the ledger keeps one $49 charge.

  5. Separate purchase: two genuine orders produce two charges, so the fix does not block real purchases.

  6. Key lifetime: a redelivery one second inside 24 hours is answered from the stored result without a new charge; one second past 24 hours the operation ID has expired and the ledger’s one charge per order rule refuses it with 409.

  7. Unsafe comparisons: without the fix, the simultaneous requests, the changed amount and the redeliveries each add charges, while two genuine orders are still two charges.

What the run showed

In the filmed run on 9 October, the unsafe build charged order ord_af95de1f411755f9 twice: ledger rows #496 and #497, $49.00 each, written 1.2 seconds apart, while the store received a single success response. Under the same lost response, the corrected build wrote one charge, row #225 for order ord_c6d81c6f581d1d74, and answered the retry with the original result. In the scripted run that followed, all ten scenario runs matched their expectations and all 40 checks passed. The corrected build held each order to one charge: after the lost response, across five simultaneous requests (four answered with the replayed original), when the retry changed the amount to $99 (refused with 422), and across redeliveries one second either side of 24 hours (replayed, then refused with 409). Two genuine orders were each charged once. Given the same cases, the unsafe build charged $98.00 for the baseline, five times ($245.00) for the simultaneous requests, $49.00 plus $99.00 for the changed amount, and three times ($147.00) for the redeliveries. A repeat run five minutes later and an earlier run on 8 October produced the same counts, every ledger hash chain verified, and all three packaged runs are listed in the evidence below.

Verdict

Supported within the tested scope

Supported for this workflow: the lost response produced a duplicate charge only in the unsafe build, and the corrected build held every tested retry to one charge while separate purchases still went through.

The experiment behaved as the claim predicted, for the systems, versions and conditions stated in the scope. It is not a general guarantee.

Authority result: Not applicable. Recorded separately from the verdict, because permission and correctness are different properties.

What was running

Tested on
2026-10-09
Application revision
prove-it-charged-twice 1.0.0 (unsafe and corrected builds)
Environment
a local lab on one macOS machine
Run identifiers
exp_20261009T042341Z_f9bb, exp_20261009T042900Z_4af9
Evidence reviewed by
Kevin Kissi

Figures

Illustration, not measured data

A response lost after the effect was already committedThree lanes: caller, service, and durable record. The caller sends a request, the service accepts it, and the durable record commits the effect. The response back to the caller is then lost, shown as a crossed line. The caller sees a timeout and cannot tell whether the effect happened, so retrying without preserving the identity of the operation can commit a second effect.CallerServiceDurable recordRequest sentEffect committedResponse lostThe caller now sees a timeoutA timeout is uncertainty, not evidence that nothing happened.
A response lost after the effect was already committed. A timeout is uncertainty, not absence.

Captured from the run

Zof console during the filmed unsafe run. Store attempts: 2, one lost its response. Retry handling: charged again, no operation ID. Ledger rows for order ord_af95de1f411755f9: row 496 for $49.00 and row 497 for $49.00, marked 2 charges.
Before the fix. The filmed unsafe run on 9 October: two attempts, one lost response, and two $49.00 charges in the ledger for one order.

Captured from the run

Zof console during the filmed corrected run. Store attempts: 2, one lost its response. Retry handling: replayed, operation ID beginning op_2810494ef7f. Ledger rows for order ord_c6d81c6f581d1d74: row 225 for $49.00, marked 1 charge.
After the fix. The same lost response on the corrected build: the retry is answered from the stored result, and the ledger holds one $49.00 charge.

Evidence

Every artifact below was reviewed and cleared for release. Material that was examined but cannot be published is listed without a download.

  • Evidence package from the scripted run after the film (opens in a new tab)

    Dataset from run exp_20261009T042341Z_f9bb

    All ten scenario runs: each check’s expected and observed values, every request and response, each order’s four-lane timeline and ledger rows, structured logs, and a SHA-256 manifest of every file. Its ledger exports cover every order in the lab database at the time, including the filmed rows #496, #497 and #225, with both hash chains verified.

    Download dataset135 KB

    Cleared by Kevin Kissi on 2026-10-13.

  • Evidence package from the repeat run (opens in a new tab)

    Dataset from run exp_20261009T042900Z_4af9

    The same ten scenario runs five minutes later, with the same counts in every scenario and the same package layout.

    Download dataset137 KB

    Cleared by Kevin Kissi on 2026-10-13.

  • Evidence package from the earliest run, 8 October

    Dataset from run exp_20261008T223217Z_1701

    The earliest packaged run, against a fresh database, with the same 40 of 40 result. Reviewed and held back, because its test report names the machine the lab ran on.

    Reviewed, not published.

    Cleared by Kevin Kissi on 2026-10-13.

Limitations and reproduction

What this result does not establish

  • It is a simulation. No payment provider and no real money were involved; a charge is a row in the lab’s own PostgreSQL ledger.
  • The timings come from one machine and are not performance claims.
  • The 24 hour scenario shifts the lab’s clock per request instead of waiting a day. The shift is recorded in every trace and exists only in the lab.
  • One retry policy was tested: a 1 second timeout and up to 3 attempts. Other timeouts and backoff schedules, and a client that creates a new key for every attempt, were not tested.
  • The 24 hour key lifetime is the lab’s own choice. The result does not show that any payment provider’s idempotency contract behaves the same way, and it does not establish exactly-once delivery across a distributed system.
  • The film shows Zof’s Idempotency Testing Agent and verification engine checking the corrected build in the Zof console. Their records are not part of the published evidence, so this verdict rests on the lab’s own requests, traces and ledgers.

Reproducing this

The lab runs as one script that creates a fresh database, starts both builds, runs the automated tests and every scenario, then writes a new evidence package and re-hashes it. Identifiers, hashes and millisecond timings change from run to run; charge counts and amounts do not, because those are what the checks assert. A published package can be checked without the lab:

  1. Download an evidence package below and unzip it.
  2. Recompute the SHA-256 fingerprint of every file and compare it with manifest.json. A changed byte shows up as a mismatch.
  3. Read results.json for each scenario’s expected and observed values, and ledger/unsafe.json and ledger/corrected.json for the hash-chained ledger rows.

The lab’s source code is not published, so the run cannot be repeated from public materials. The evidence packages can still be checked on their own.

The companion article

Idempotency under ambiguous payment outcomes

A payment timeout can occur after the remote service has committed the operation. Retrying without preserving the identity of the logical purchase can therefore create a second effect.

The experiment uses a synthetic order and a controllable payment service. A fault injector discards the response only after the service records acceptance. Capture that injection point explicitly. A timeout before acceptance tests a different condition and cannot establish the ambiguous-outcome failure shown here.

Count committed payment effects by order identity, not HTTP attempts. Preserve the request sequence, operation key, payment records, and reconciliation query. The healthy expectation is one logical charge for one authorized purchase, while legitimate separate purchases remain possible.

The corrected fixture assigns a stable key to the logical operation and coordinates duplicate handling at the commit boundary. A client-side key alone is insufficient if the receiving service ignores it. Test overlapping retries, changed parameters under the same key, a different purchase with a different key, and behavior after the documented retention window. Provider contracts differ; the lab must state its own semantics rather than assuming indefinite deduplication.

Run the unsafe and corrected variants against the same fault schedule. Add a normal purchase control to reveal fixes that merely reject everything. If reconciliation is asynchronous, define when the test may conclude and what pending means before execution.

The result supports a bounded claim about duplicate effects in this workflow. It does not prove exactly-once delivery across a distributed system. The durable business invariant is the important distinction: repeated transport attempts should not create an additional unauthorized charge. Publish the ledger evidence alongside the film so the viewer can inspect that claim directly.

Transcript

What was said on camera, edited for readability. The argument is unchanged. This is not the companion article.

Read the transcript

One order, two charges

One order, one click, two charges. While testing a checkout platform before production, we uncovered a flaw that could charge a customer twice for the same purchase.

A synthetic $49 purchase

This is a synthetic $49 purchase. The payment service accepts it. Then our fault injector drops the response before the store receives confirmation. From the store’s perspective, the request timed out. From the payment service’s perspective, the money already moved.

The request timeline

On the Zof AI console, we follow the failure through the request timeline. Request sent, payment committed, response lost. The application retries because it wants to finish the job. But this implementation treats the retry as a new payment. The dangerous moment is the gap between what happened and what the caller knows happened.

The durable record decides

Now we inspect the payment ledger using the order identifier. We do not infer the number of charges from the number of requests. Two requests can correctly produce one charge. One success message can conceal two. The durable record decides.

The candidate correction

Our candidate correction gives the logical purchase a stable operation key and enforces duplicate handling where the payment is committed. Reusing a key with different payment details must not silently reuse the wrong result. Concurrent retries also need a defined outcome.

We rerun the lost response, then send overlapping requests, then try a different legitimate purchase. That last case checks that our fix has not stopped all payments.

Resolve the uncertainty first

We bring the request trace, payment records, and limits of the key’s lifetime together for review. A timeout is uncertainty, not evidence that nothing happened. Reliable recovery starts by resolving that uncertainty before repeating an irreversible action. That is the difference between finishing the purchase and charging for it again.

Technical sources

Context for the mechanism under test. These describe intended behaviour; the artifacts above are what establish this episode's result.

Request this experiment against your workflow

We will run the same controlled demonstration on a system you choose, and publish the evidence to you rather than to the internet.

01Zof Console

Une surface pour la posture, les opérations et ce qui nécessite une attention particulière.

Le foyer authentifié que les équipes d'ingénierie, de QA et de SRE ouvrent chaque jour : posture de qualité, exécutions en vol, couverture par module et ce qui requiert de l'attention ensuite.

KPI OPÉRATIONNELS

  • Courses
  • Couverture
  • Risque

Vivez dans tous les environnements dans lesquels vous expédiez.

TRAVAIL DE LA Colonne Vertébrale

  • Spécifications
  • Tests
  • Horaires

De la spécification à la régression planifiée.

GARDE-CORPS

  • RBAC
  • SSO
  • audit

Chaque action attribuable à un humain nommé.

LIVE/console
Centre de commande domestique Zof AI affichant 12 exécutions à 94 % de réussite, 3 problèmes critiques ouverts, une couverture de 84 %, quatre barres de traçabilité des modules, le pipeline de spécifications, les calendriers à venir et les prochaines actions recommandées avec une barre latérale d'exécutions actives.
Vue d'accueil · Service de paiement · Mise en scène · capturé en direct à partir du produit.
Idempotency under ambiguous payment outcomes | PROVE IT | Zof AI