On this page
ADR-0041: An ambiguous payout stays unknown and keeps its idempotency key
Status: Accepted (WP-70b; security review finding HIGH-4). Implemented in crates/commonlayer-funding/src/service/payouts.rs, src/adapters/stripe.rs and migrations/0020_funding_hardening.sql.
Context
Payout execution treated every provider error as a failure: a network timeout or a 5xx
after Stripe had already created the transfer marked the payout failed and returned the
obligation to pending. The next run created a new payout with an idempotency key
derived from the new run id, so Stripe saw a different key and paid again.
Decision
- The provider idempotency key is derived from the obligation and an attempt counter,
payout:<obligation>:<attempt>, and the counter increases only after a confirmed failure. Retrying an attempt always reuses its key. - Provider errors are classified. A definitive rejection marks the payout
failed. An ambiguous outcome (timeout, connection reset, unreadable response, 5xx, 409, 429) marks itunknown; the obligation staysscheduled, so no later run can plan it again. - An administrator resolves an unknown payout after checking the provider
(
GET /api/v1/admin/payouts?status=unknown,POST /api/v1/admin/payouts/{id}/resolve, interactive session):paidneeds the provider reference and posts once;failedreturns the obligation topending. A note of at least 10 characters is required. - The payout destination is fixed when the run is planned and shown to the approver
(
destination_hint); execution pays only there (LOW-2). commonlayer_payouts_total{outcome="unknown"}drives the critical alertPayoutOutcomeUnknown;PayoutsStuckScheduledwarns when obligations stay scheduled for 6 hours.
Consequences
- A timeout can no longer cause a second payment through CommonLayer; the cost is a manual resolution step.
- Tests:
hardening::an_ambiguous_payout_stays_unknown_until_resolved_and_keeps_its_key,an_unknown_payout_resolved_as_paid_posts_once,payouts_go_to_the_destination_fixed_at_planning. The simulator produces unknown outcomes for destinations starting withtimeout. - Not exercised: live payouts through Stripe Connect (only a mock), and the resolve
dialog in the portal end to end (no seeded payout is
unknown).