Payments2026
Control Arm
A Razorpay hackathon agent that recovers failed Indian payments: every retry passes a cited policy gate, and the lift is measured against Razorpay's own.

- Type
- Hackathon project (Razorpay, Track 03)
- Year
- 2026
- Platform
- Python CLI · static report site
- Role
- Sole author
- Collaboration
- Solo, written with Claude Code
- Timeline
- Aug — Sep 2026
- Stack
- Python · Pydantic · pytest · Hypothesis · import-linter · matplotlib
The problem
A failed recurring payment in India is not one problem, it is four. A card subscription gets four automatic attempts and then stops. An eMandate debit waits on the previous outcome and shifts around bank holidays. A UPI AutoPay debit whose order carried a notification object gets no retry at all; Razorpay's own documentation tells the merchant to retry it manually. A payment link gets at most three reminders inside two fixed windows.
So the money a merchant loses is concentrated where the platform does least, and every action that might win it back (a retry, an SMS, a WhatsApp message) is constrained by rules that bind different parties: TCCCPR's time bands, RBI's e-mandate framework, NPCI's execution windows, DPDP's consent basis. Most dunning tools treat those as a checklist. Control Arm treats them as a gate that can refuse a money action, and measures what each refusal costs.
What it does
It reads a stream of Razorpay-shaped webhook events and, for each failure:
- Classifies it deterministically into one of a fixed set of reasons, with no model call.
- Proposes one bounded action: retry the debit, send a pre-debit notice, a message or a payment link, hand off to a human, or write the debt off.
- Submits it to a policy gate of 17 rules, each carrying its citation, the party it binds and its severity. The gate answers ALLOW, MODIFY (for example, move a UPI debit out of NPCI's blocked 17:00–21:30 band) or DENY.
- Executes only what the gate allowed, through a single idempotent executor
that writes every decision to an append-only audit log.
recovery replay <event>rebuilds any decision from that log alone.
The number, and the rail where it loses
The claim is not "it recovered money". It is how much more it recovered than Razorpay's documented behaviour, per payment rail, with a paired 95% interval. Over 30 simulated seeds of 400 customers and 45 days each:
- UPI AutoPay +37.9%, payment links +18.5%, eMandate +17.8%, while contacting customers less often than the incumbent on every rail.
- Cards −46.0%, and pooled a small loss of −2.8%. Every compliant retry needs its own 24-hour pre-transaction notice, so the agent can retry about every four days on a rail where Razorpay's documented ladder retries daily. The incumbent collects first. A test pins that loss in both directions, so a future change that "wins" the card rail has to explain itself.
- Against Razorpay's cadence held to the agent's own contact budget, the agent still recovers +27.3% more on 30 of 30 seeds. At matched volume, what is left is the quality of each decision.
- With the gate removed, the same agent recovers 33.2% more and breaks the encoded rules 6,371 times per seed. That is the price of the gate, measured.
Every test green, and a customer who had opted out got a call
The worst bug was found by a checker written for something else. A human collections handoff reached a customer 26 days after they had opted out, while all nine invariant tests, including 500 generated cases for consent, were green.
The policy had two sets describing "reaching the customer", and they disagreed. A handoff was on the escalation ladder but not in the set of contact actions, so the ladder governed it and the consent rule never saw it. The property tests could not catch it at any number of examples: they generated proposals from the same list the production code branched on, so the test and the bug shared a premise.
What caught it was a second verifier that re-derives every invariant from the audit log, using only the recorded actions and timestamps rather than asking the policy engine. Its first run on a supposedly clean simulation reported 101 violations. The fix was a single predicate for "outreach" used by every rule that governs contact, and the incident is written up in full in the repository's post-mortem.
Rules that never fire prove nothing
A gate with zero breaches is only a result if a breach was possible. So the ablation run removes one rule at a time, replays the identical world, and counts the breaches the log-based checker then reports. Removing the UPI execution-window rule left the executed actions byte-identical and still put 3,063 debits inside NPCI's blocked band, which a simple diff of actions would have called harmless. Seven rules never fire in the simulation at all, and the report says so plainly: for those, a pair of boundary unit tests is the only evidence there is.
How it is checked
- Nine invariants, each a test: authority (nothing executes without a matching verdict), consent that never expires, time windows, debit eligibility, bounded escalation, exactly-once under duplicated and reordered webhooks and crash-and-resume, messages that cannot contain a number the data did not supply, content-class integrity, and an import-graph firewall that keeps the agent from reading the simulator's ground truth.
- Every threshold lives in config with its basis. A regulatory rule must carry a citation and the party it binds; a business rule must not carry one, so a preference cannot pass itself off as law.
- Deterministic end to end. The harness owns the only clock and the only random source, and two runs at the same seed produce identical results.
What it does not prove
It runs on a simulator I wrote, so the lift is a property of my failure model, not of the Indian payments market, and it is not a claim of real-world recovery. The breaches it counts are breaches of the rules I encoded, not legal findings. The NPCI AutoPay windows come from secondary sources, and the code marks them "as reported". What it does show holds regardless of the model: across duplicated, reordered and crashed runs it never double-charged, never contacted an opted-out customer and never contacted anyone outside the permitted hours.