Control Arm · generated walkthrough

It wanted to charge. The gate said no. It got paid anyway.

A revenue-recovery agent for Indian payment failures that ships with its own counterfactual. Every frame below is captured from the same renderer the CLI calls, at the same 80 columns. Nothing here is a screenshot and nothing is typed in.

generated 2026-08-31 21:46 IST · git 9418359 · 14 exhibits · RECOVERY_LLM=replay · 0 network calls at view time

Beat 1

It wanted to charge. The gate said no. It got paid anyway.

The whole thesis in one frame: a calibrated prior said retry, a named RBI clause said not yet, and the rebooked debit was paid. Zero model calls, by design -- this frame survives every cut on the cut list.

$ make demo-frame80 cols
$ RECOVERY_LLM=replay python -m recovery replay evt_10427 --explain

evt_10427  cust_3391  sub_88213  "Vaayu Fitness"  Rs 4,999  method=upi_autopay
  payment.failed   error_source=customer   error_step=payment_debit_response
                   error_reason=insufficient_funds
  platform_retries=0   notification object present on the order ->
                       Razorpay does not retry; the sequencer is ours
  tier0    INSUFFICIENT_FUNDS     deterministic, 0 tokens
  prior    p(recover | retry today) = 0.71   calibration.yaml:34  [source: url]
  propose  RETRY_DEBIT Rs 4,999   today 20:40 IST      proposer=tier0.v3

  policy   17 rules evaluated, 2 fired:
    UPI_AUTOPAY_EXECUTION_WINDOW  MODIFY  sev 25  20:40 in blocked 17:00-21:30
                                  NPCI UPI OC-215, 21 May 2025 (as reported)
    PREDEBIT_NOTICE_NOT_MATURE    DENY    sev 88  notice 4h old, 24h required
                                  RBI/DPSS/2026-27/396 cl.6(a), 21 Apr 2026
                                  binds=issuer -> reaches us via cl.10(c)
  VERDICT  DENY   rule=PREDEBIT_NOTICE_NOT_MATURE   (strictest of 2)
  EXECUTOR refused.    0 debits.    0 messages.    audit=rec_00918

  REPLAN   SEND_PRE_DEBIT_NOTICE  20:41  -> matures 2026-09-01 06:12
           RETRY_DEBIT Rs 4,999   2026-09-01 13:40 IST  (permitted 13:00-17:00)
  -> rec_01204  2026-09-01 13:40   subscription.charged   Rs 4,999 RECOVERED

frame: 23/30 rows, max col 79/80  fits

Beat 3 · the counterfactual

What it recovered, against Razorpay's own documented behaviour

Read the rails, not the pool. Arm B is a different incumbent on every rail, and pooling averages over four of them. Pooled, this system is slightly behind — that row stays on the page.

Ground truth is a function of the seed alone, so every arm faces the same customer with the same true failure cause on the same day. Every interval is a paired bootstrap on the per-seed difference, never three independent CIs on shared populations.
raillift95% CI on the delta (Rs/seed)seeds favouring Csign testcontacts
card_subscription-46.0%[-268,674, -235,741]0/30p=0.00000.27x
emandate+17.8%[+25,201, +45,499]26/30p=0.00010.41x
payment_link+18.5%[+16,781, +28,907]27/30p=0.00000.22x
upi_autopay+37.9%[+141,116, +174,466]30/30p=0.00000.70x
pooled-2.8%[-65,685, -6,950]10/30p=0.09870.40x
rule breachedper seed
------:
COOLDOWN_ACTIVE2,880
OUTREACH_BUDGET2,655
CONSENT_REVOKED_90D258
TCCCPR_BLOCKED_PREFERENCE241
PROMISE_ACTIVE220
DISPUTE_HOLD60
LADDER_MONOTONE46
ISSUER_DOWN12

Read from docs/RESULTS.md, which src/eval/headline.py generates from a real 30-seed run and tests/unit/test_results_doc.py regenerates and diffs. No number on this page was typed by hand.

armeventsrecovered nrecoveredcontacts/recchannel spendbreaches
A no intervention (control)1,200189Rs 4,13,029.360.00Rs 00
B Razorpay default (per rail)1,200600Rs 12,85,520.333.22Rs 333.550
B' volume-matched to C1,200460Rs 9,81,532.631.50Rs 123.020
C Control Arm1,200586Rs 12,49,644.731.31Rs 310.220
D gate removed1,200783Rs 16,64,478.164.28Rs 43,363.436,371
raillift95% CI on the delta (Rs/seed)seeds favouring Csign testcontacts
------:------:---:---:
card_subscription-46.0%[-268,674, -235,741]0/30p=0.00000.27x
emandate+17.8%[+25,201, +45,499]26/30p=0.00010.41x
payment_link+18.5%[+16,781, +28,907]27/30p=0.00000.22x
upi_autopay+37.9%[+141,116, +174,466]30/30p=0.00000.70x
pooled-2.8%[-65,685, -6,950]10/30p=0.09870.40x
rule breachedper seed
------:
COOLDOWN_ACTIVE2,880
OUTREACH_BUDGET2,655
CONSENT_REVOKED_90D258
TCCCPR_BLOCKED_PREFERENCE241
PROMISE_ACTIVE220
DISPUTE_HOLD60
LADDER_MONOTONE46
ISSUER_DOWN12

Arm D is this same agent with the gate removed. Its last column counts breaches of encoded policy rules — my predicates on my population, not violations of law. That is the price of the gate, measured rather than asserted.

Beat 2

The rule ladder, with citations

Seventeen rules evaluated against one deliberately aggressive proposal. Every rule that fires prints its basis and its citation, and the verdict is the strictest one -- DENY over MODIFY over ALLOW.

$ make explain80 cols
$ recovery explain --proposal tests/fixtures/aggressive_retry.json

evt_10427  cust_3391  "Vaayu Fitness"  Rs 4,999  method=upi_autopay
  propose  RETRY_DEBIT Rs 4,999   2026-09-01 20:40 IST      proposer=tier0.v3

  policy   17 rules evaluated, 2 fired:
    UPI_AUTOPAY_EXECUTION_WINDOW  MODIFY  sev 25  20:40 in blocked 17:00-21:30
                                  NPCI UPI circular OC-215 (as reported)
    PREDEBIT_NOTICE_NOT_MATURE    DENY    sev 88  notice 4h old, 24h required
                                  RBI/DPSS/2026-27/396 cl.6(a), 21 Apr 2026
                                  binds=issuer -> reaches us via cl.10(c)

  VERDICT  DENY   rule=PREDEBIT_NOTICE_NOT_MATURE   (strictest of 2)
  EXECUTOR refused.    0 debits.    0 messages.

One webhook in, the whole chain out

The case pack

Seven real Razorpay payment.failed envelopes, parsed by the same ingest the harness uses. A webhook carries no consent, no preference and none of our history, so every frame prints what it assumed before it prints a verdict.

insufficient funds upi

Tier 0 proposes the NOTICE, not the retry. The cl.6(a) pre-debit notice needs 24 hours to mature, so on this event a retry is not yet an action the proposer is allowed to want.

$ recovery try --event tests/fixtures/cases/01_insufficient_funds_upi.json80 cols
$ recovery try --event tests/fixtures/cases/01_insufficient_funds_upi.json

  INGEST     payment.failed  pay_QxK1insuff01  Rs 3,081
             signature   not checked -- pass --signature to verify
             rail        upi_autopay  surface=MANDATE_DEBIT_FAILURE
             error       BAD_REQUEST_ERROR / customer / payment_debit_response
                         / insufficient_funds

  ASSUMED    a webhook carries none of this; --context replaces it
             consent=INFERRED  preference=UNREGISTERED   (default)
             contract=SUBSISTING  lender=false   gates the 08:00-19:00 window
             history     0 contacts, 0 debits, pre-debit notice: none
             now         2026-08-31 12:10 IST  (the webhook created_at)

  CLASSIFY   tier0.v3  INSUFFICIENT_FUNDS
             keyed on the 4-tuple, never the reason alone:
             upi_autopay / customer / payment_debit_response /
             insufficient_funds

  PROPOSE    1 proposal

pay_QxK1insuff01  cust_3391  "Vaayu Fitness"  Rs 3,081  method=upi_autopay
  propose  SEND_PRE_DEBIT_NOTICE Rs 3,081   2026-08-31 12:10 IST
           proposer=tier0.v3

  policy   17 rules evaluated, 0 fired:

  VERDICT  ALLOW   rule=-   (strictest of 0)

  EXECUTOR SEND_PRE_DEBIT_NOTICE  channel=sms  rung=SMS
    Namaste, Vaayu Fitness ka payment Rs 3,081 complete nahi ho paya kyunki
    account mein balance kam tha. Kripya apne account se dobara try karein.
    Reply STOP to opt out.
    I7 ok   I8 SERVICE_INFERRED

  SUMMARY    1 executed, 0 refused, 0 debit(s), 1 message(s)
             2 audit records, in memory only
             dry-run port: nothing left this machine. This frame shows what
             the system decides, never whether the customer pays -- that
             number needs a counterfactual, which is `make eval`.
             fixtures/llm_cache is empty; drafting is the template path.

Beat 6

Five messages that cannot invent a number

Slot-constrained generation: every numeral in every body traces to a validated slot value. I7 rejects any numeral -- ASCII or Devanagari -- that is not in the slots.

$ make outreach80 cols
$ make outreach
  RECOVERY_LLM=replay · drafter=template.v1 · 0 tokens · 0 network calls

  RCV_FAIL_01  ISSUER_DOWN   ok
    Namaste, Neelam Grocers ka payment Rs 2,741 complete nahi ho paya kyunki 
bank ki taraf se technical dikkat thi. Kripya apne account se dobara try karein.
Reply STOP to opt out.
    slots: merchant=Neelam Grocers, amount=Rs 2,741, reason=bank ki taraf se 
technical dikkat thi

  RCV_FAIL_01  INSUFFICIENT_FUNDS   ok
    Namaste, Vaayu Fitness ka payment Rs 3,081 complete nahi ho paya kyunki 
account mein balance kam tha. Kripya apne account se dobara try karein. Reply 
STOP to opt out.
    slots: merchant=Vaayu Fitness, amount=Rs 3,081, reason=account mein balance 
kam tha

  RCV_FAIL_01  PLATFORM_ERROR   ok
    Namaste, Kadak Coffee Club ka payment Rs 946 complete nahi ho paya kyunki ek
technical error aa gaya tha. Kripya apne account se dobara try karein. Reply 
STOP to opt out.
    slots: merchant=Kadak Coffee Club, amount=Rs 946, reason=ek technical error 
aa gaya tha

  RCV_MANDATE_02  MANDATE_DEAD   ok
    Namaste, Tarang Music ke liye aapka auto-pay Rs 3,844 nahi ho paya kyunki 
aapka mandate ab active nahi hai. Mandate resume karke dobara try karein. Reply 
STOP to opt out.
    slots: merchant=Tarang Music, amount=Rs 3,844, reason=aapka mandate ab 
active nahi hai

  RCV_FAIL_01  INSTRUMENT_BLOCKED   ok
    Namaste, Setu Learning ka payment Rs 872 complete nahi ho paya kyunki aapka 
card ya account block hai. Kripya apne account se dobara try karein. Reply STOP 
to opt out.
    slots: merchant=Setu Learning, amount=Rs 872, reason=aapka card ya account 
block hai

  5 messages · every numeral traced to a slot · 0 validation failures

Beat 7

The word 'off' flips the verdict

A promotional token reclassifies a Service message as Promotional, which costs it its inferred-consent basis. The model is overruled and made to regenerate.

$ make outreach-adversarial80 cols
$ make outreach --adversarial

  draft    "Namaste, Neelam Grocers ka payment Rs 2,741 complete…
            …. Aur aaj hi pay karein to 10% off bhi milega!"
           drafter=injected.fixture.v1  model=None
  I7 GROUNDED_LANGUAGE  FAIL  ['10'] not in slot values
  I8 CONTENT_CLASS      Service -> PROMOTIONAL   (TCCCPR cl.(au) second proviso)
                        consent_basis=inferred · recipient=fully_blocked
  VERDICT  DENY  rule=TCCCPR_PROMO_CONTAMINATION      0 messages sent

  regenerate -> clean draft -> ALLOW -> sent 11:04 IST
    Namaste, Neelam Grocers ka payment Rs 2,741 complete…Reply STOP to opt out.

  the word 'off' flipped the verdict. Not a silent strip:
  a DENY, with the clause.

Beat 8

Every rung, and the silence after a promise

The ladder is monotone and VOICE is disabled by config, so it never executes. Then she names a date -- and the system does nothing at all for three days. That is the feature.

$ make demo-ladder80 cols
$ recovery demo-ladder
  evt_10004  cust_1000  "Neelam Grocers"  Rs 2,741

  rung            verdict  rule
  |       SILENT_RETRY   ALLOW   
  ||      SMS            ALLOW   
  |||     WHATSAPP       ALLOW   
  ||||    VOICE          DENY    LADDER_MONOTONE
                               VOICE is disabled by config
                               channel disabled by config; never executes
  |||||   HUMAN_HANDOFF  DENY    OUTREACH_BUDGET
                               105p spent + 5000p exceeds the 1500p rolling
  ||||||  WRITE_OFF      ALLOW   

  she replies  "parso pay kar dunga"
  day +0   SEND_MESSAGE   DENY    PROMISE_ACTIVE
  day +2   SEND_MESSAGE   DENY    PROMISE_ACTIVE
  day +4   SEND_MESSAGE   ALLOW   
          when she names a date, the system does nothing at all
          for three days. That is the feature.

Chaos

The same webhook three times, one charge

Razorpay retries a webhook for 24 hours, so duplicates are the normal case rather than the fault case. Each duplicate is logged explicitly, never silently dropped.

$ make chaos-duplicate80 cols
$ make chaos-duplicate
  -- Razorpay retries for 24h; duplicates are the normal case

  rec_00001 EXECUTION  PENDING
  rec_00002 OUTCOME    FAILED
  rec_00003 OUTCOME    NO_OP_DUPLICATE
  rec_00004 OUTCOME    NO_OP_DUPLICATE

  NO_OP idempotency_key=d025a36be553d6ef0583aa6b...  charges=1  audit_records=4
  2 duplicate deliveries logged explicitly, never silently dropped

Beat 11

Nine guarantees, each one a test

Every row runs its own test file and prints the generated case count. The property rows are 500 Hypothesis examples each, derandomised -- a repo whose headline claim is determinism cannot have a random suite.

$ make prove100 cols
                           GUARANTEES -- each one is a test, not a claim                            
                                                                                                    
  id                 statement                        proved by                       cases         
 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 
  I1 AUTHORITY       no execution without a           invariants/test_i1_authority.       -   PASS  
                     matching ALLOW verdict           py                                            
  I2 CONSENT         opt-out is absorbing, forever,   invariants/test_i2_consent_ab     500   PASS  
                     every channel                    sorbing.py                                    
  I3 TIME_WINDOW     every contact inside its         invariants/test_i3_time_windo     500   PASS  
                     permitted time window            w.py                                          
  I4 DEBIT_ELIG      never retry a debit that         invariants/test_i4_debit_elig     500   PASS  
                     cannot succeed                   ibility.py                                    
  I5 BOUNDED         contacts capped, cooled,         invariants/test_i5_bounded_es     500   PASS  
                     budgeted, ladder never skips     calation.py                                   
  I6 EXACTLY_ONCE    duplicates and crashes change    invariants/test_i6_exactly_on     500   PASS  
                     no canonical projection          ce.py                                         
  I7 GROUNDED        no number in output absent       invariants/test_i7_grounded_l     500   PASS  
                     from input                       anguage.py                                    
  I8 CONTENT_CLASS   promotional contamination        invariants/test_i8_content_cl       -   PASS  
                     downgrades consent and denies    ass.py                                        
  I9 SIM_FIREWALL    the agent cannot import the      arch/test_i9_sim_firewall.py        -   PASS  
                     world                                                                          
                                                                                                    
                                                                                          
  import firewall     src.agent -> src.sim edges                                    PASS  
  calibration         44/44 parameters carry a source URL                           PASS  
  policy provenance   17/17 rules carry valid provenance                            PASS  
  lender rule         RECOVERY_AGENT_HOURS does not fire for a payment aggregator   PASS  
                                                                                          
  9/9 invariants proved   | rules implemented: 17/17 | RECOVERY_LLM=replay

Reproduce

Every frame on this page, from a clean clone

No API key is needed. Model responses are a committed hash-keyed cache and RECOVERY_LLM=replay is the default, which hard-fails on a cache miss rather than quietly calling out.

$ make
make prove          # the nine guarantees, each one a test
make cases          # the seven-case pack, back to back
make eval SEEDS=30  # the arm table and the per-rail lift
make report         # out/report.html, the measurement report
make walkthrough    # this page

This page is regenerated by src/ui/walkthrough.py and pinned by tests/unit/test_walkthrough.py, which re-renders every exhibit and diffs it against the committed copy. A stale page is a failing test, not a file that still opens.