ADR-105: The charge idempotency key is seeded by an attempt counter, not `updated_at`
Status: Accepted (amended 2026-10-19, 2026-10-20)
Date: 2026-07-30
Supersedes: the hardcount_inv_<id>_<UpdatedAt> key shape, and the
money-path playbook’s gate #2 wording that blessed it.
Builds on: ADR-029 (disjoint wall/clock charge flows), ADR-049
(payment reconciler), ADR-059 (in-flight invoice guards).
Summary
Section titled “Summary”Each Stripe charge sends the idempotency key hardcount_inv_<invoice_id>_<charge_attempt_seq>, with a _dunning_retry suffix on dunning retries. Every write that records a PaymentIntent id on the invoice bumps the counter charge_attempt_seq. So does the stale-hold sweep when Stripe has no payment for a lost charge (amendment 2026-10-19) or, 24 hours after the attempt, for a parked one (amendment 2026-10-20). No other write does. The old seed, updated_at, also moved on unrelated writes such as a tax commit, so a retry after a lost response could create a second PaymentIntent and charge twice. A Stripe idempotency conflict now counts as an unknown outcome, not a decline, because it proves an earlier attempt reached Stripe. Status: active; the case it left open, an unclear result with no PaymentIntent id, is now handled by ADR-107 and ADR-127.
Context
Section titled “Context”Every Stripe charge carries an idempotency key. Stripe’s contract:
- same key + same params → returns the original PaymentIntent, no second charge;
- same key + different params →
409, the call is refused.
So the key answers exactly one question: is this call the same charge attempt as before, or a new one? Two requirements pull against each other, and both are money-critical:
| Requirement | Cost of violating it | |
|---|---|---|
| R1 | A call that reached Stripe but whose outcome was never recorded (crash, timeout, lost response) must RETRY UNDER THE SAME KEY | Double charge. PI_A charges, PI_B charges |
| R2 | A RECORDED decline must retry under a FRESH key | Liveness sink. Stripe replays the declined PI forever; the customer fixes their card and still cannot pay, with no error raised |
The key must therefore move exactly when an attempt outcome is recorded, and at no other time.
The implementation used inv.UpdatedAt as the seed. The reasoning was
sound on its face: a recorded failure runs UpdatePayment, which writes
updated_at (R2 ✓); a crash writes nothing at all, so updated_at is
unchanged and Stripe dedups (R1 ✓). No extra column, no extra write.
The problem
Section titled “The problem”updated_at does not answer “was an attempt outcome recorded?” It
answers “did anything touch this row?” — a broad proxy for a narrow
question. That makes every other writer on the invoice an accidental
participant in the payment protocol:
tick N auto-charge calls Stripe, PI_A created ($100), charging the card process dies before the outcome is stamped invoice: payment_status='pending', updated_at unchanged ← R1 still holds
tick N+1 tax_commit lands its transaction id → updated_at MOVES auto-charge retries, different key → PI_B created ($100)
both settle. $200 collected on a $100 invoice.tax_commit is not a hypothetical: it is the reconciler that runs
immediately before the auto-charge sweep in the same scheduler tick.
Credit application, the sweep’s own no-PM email marker, and operator
actions sit in the same window.
The drift was observed, not theorised (#677):
hardcount_inv_<id>_1785420781393005000 → hardcount_inv_<id>_1785420781397042000The deeper problem is that the invariant could not be stated, so it could
not be followed. It is not “never touch updated_at” — most writers
should. The true rule was roughly “don’t touch updated_at on a
finalized invoice with auto_charge_pending=true while a charge may be
in flight, unless you are recording an attempt outcome, in which case you
must.” The codebase already carried one hand-made exception for it
(ClaimAutoCharge skips updated_at and explains why, #396). Every
future writer needed the same reasoning and nothing forced it.
Decision
Section titled “Decision”Seed the key from an explicit counter, invoices.charge_attempt_seq
(migration 0165):
hardcount_inv_<invoice_id>_<charge_attempt_seq>[_<purpose>]The counter is bumped by exactly the three writes that record a charge
attempt outcome — the three that stamp stripe_payment_intent_id:
UpdatePayment, MarkPaymentFailedReportingTransition,
markPaidReportingTransition. Nothing else touches it.
The invariant is now statable in one line, and therefore enforceable:
Every UPDATE on
invoicesthat setsstripe_payment_intent_idbumpscharge_attempt_seq. Nothing else does.
TestChargeAttemptSeqBumpedByEveryPIStamp scans the store’s SQL and
fails the build in both directions — a stamp without a bump (R2 broken:
liveness sink) and a bump without a stamp (R1 broken: re-seeding from an
unrelated write). This is a class-C2 hazard (a guard spread across
several writers), so the mechanised gate is the point, not a nicety.
Corollary: an idempotency conflict is ambiguous, not a rejection
Section titled “Corollary: an idempotency conflict is ambiguous, not a rejection”classifyStripeError grouped ErrorTypeIdempotency with card declines
and invalid requests under “Stripe explicitly rejected the request; no
charge occurred”. That is backwards. Stripe raises it when the key was
already used with different parameters, so the one thing it proves is
that an attempt under this key reached Stripe — a PaymentIntent may be
live and charging. Treating it as a definite failure stamped
payment_status='failed', started dunning inline, told the customer
their card had failed, and freed the next retry to open a second
PaymentIntent beside the live one.
It is now classified Unknown, routing to the payment reconciler — the
component whose entire job is resolving “an attempt may exist at Stripe”.
Consequences
Section titled “Consequences”The drift class is gone by construction. Unrelated writers cannot reach the seed. This is the substance of the fix.
409s become relatively more likely, and that is the correct trade. A param change (a credit lands, the customer swaps cards) inside the crash window used to produce a new key and a silent second charge; it now produces a 409 and a loud failure. Losing money silently is strictly worse than refusing loudly.
One residual, pre-existing and unchanged by this ADR. If a 409 (or
any ambiguous error) carries no PaymentIntent id, the reconciler cannot
query what it cannot name: reconcileOne settles the invoice failed, and
a later retry — now with a bumped seq — may open a second PI beside a
live one. This is the “ambiguous charge with no PI id” class, which
predates this change and is untouched by it. Mitigation shipped here: the
charge path logs the idempotency key at ERROR for that exact case, and
Stripe’s dashboard is searchable by idempotency key, so an operator has a
route to the live PaymentIntent.
The complete fix is to record the attempt before calling Stripe, so recovery never depends on the response. That belongs with ADR-062’s obligation queue rather than a bespoke table, and is deferred with an explicit trigger: before production cutover, or the first report of a duplicate charge, whichever comes first.
Backfill is a no-op by design. Existing rows default to 0. Pre-migration keys were nanosecond timestamps, which never equal 0, so no in-flight key can collide with the new shape — and Stripe expires idempotency keys after 24h regardless.
Alternatives considered
Section titled “Alternatives considered”Keep updated_at, forbid unrelated writes in the charge window.
Rejected: the rule cannot be stated simply (see above), so it cannot be
followed or mechanised. The existing hand-made carve-out in
ClaimAutoCharge was evidence of the maintenance burden, not a
counter-example.
Include the mutable params (amount, payment method) in the key. Then a param change yields a different key and no 409 — but PI_A is still live and unrecorded, so it converts a loud 409 into a silent double charge. Strictly worse.
Bump the counter at claim time instead of at outcome time. Breaks R1 directly: a crash after the bump but before the outcome means the retry presents a different key and opens a second PI. Recovering that would need two counters (attempted vs resolved) — a state machine to avoid one column.
Search Stripe by metadata.hardcount_invoice_id on 409. Viable (the
metadata is already stamped) and it would close the residual above, but
it adds a Stripe client method and a network round-trip on a rare path.
Deferred with the ADR-062 work rather than bolted on here.
Amendment 2026-10-19 — the counter also moves when Stripe has nothing
Section titled “Amendment 2026-10-19 — the counter also moves when Stripe has nothing”ADR-127 leaves a
charge’s hold set when its result was lost, and a sweep asks Stripe what the
charge did. When Stripe has no PaymentIntent for the invoice and the invoice is
still open, ClearStaleChargeHold clears the hold and moves
charge_attempt_seq, in one compare-and-set on the hold and the counter it
read. “No payment was created” is that attempt’s recorded outcome, so the rule
above holds: the key moves when an attempt’s outcome is recorded. Reusing the
old key could be handed a 500 Stripe saved under it, or a 409 once credits
changed the amount. It is the one write that moves the counter without
stamping a PaymentIntent id; TestChargeAttemptSeqBumpedByEveryPIStamp admits
exactly that statement, and now scans every file of the store, not one.
The key’s job also narrows. It still dedupes the same initiator resending the same request, but a lost result no longer relies on it. Another charge kind, a changed card, a pruned key or the hosted page all go around the key (#976); the hold is what stops them now.
Amendment 2026-10-20 — a second write moves the counter without a PaymentIntent
Section titled “Amendment 2026-10-20 — a second write moves the counter without a PaymentIntent”ADR-127’s
2026-10-20 amendment finishes parked invoices. When Stripe shows no payment
for a parked charge 24 hours after the attempt, UnparkAfterNoPayment puts
the invoice back to pending or failed. In the same compare-and-set it
clears the hold and moves charge_attempt_seq. “No payment was created” is the
parked attempt’s recorded outcome, the same reason as the stale-hold clear.
The next charge must not reuse the parked attempt’s key. Within 24 hours
Stripe returns the saved 500 under it, or a 409 once the card or amount
changed. Once Stripe prunes the key, it starts a new request anyway.
TestChargeAttemptSeqBumpedByEveryPIStamp now admits exactly these two
hold-clearing writes.
A processing payment the sweep finds for a parked invoice is put on it with
StampChargeOutcome, which stamps the PaymentIntent and moves the counter in
one statement. ADR-108’s AdoptPaymentIntentIfParked is deleted.