Skip to content
CommonLayer
Documentation menu
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 it unknown; the obligation stays scheduled, 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): paid needs the provider reference and posts once; failed returns the obligation to pending. 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 alert PayoutOutcomeUnknown; PayoutsStuckScheduled warns 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 with timeout.
  • 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).