> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ctrlrun.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# A refund agent with amount tiers

> A support agent refunds customers: small refunds run on their own, larger ones wait for a human, anything above a ceiling is refused.

A support agent issues refunds at Stripe from customer conversations. Small ones should run
without a person, larger ones should wait for one, and nothing above a ceiling should run at
all, whatever the customer said.

## The policy

Amounts are integer minor units. Both ends of each band are bound, because an upper bound alone
lets a negative amount through, and a refund of a negative amount is a charge.

```yaml runnable theme={null}
schema: ctrlrun.policy/v2

actions:
  stripe.refund:
    effect: "refund:{payment_id}"
    resource: "payment:{payment_id}"
    rules:
      - when: { amount_gte: 0, amount_lte: 50000 }       # up to €500.00: autonomous
        decision: allow
      - when: { amount_gte: 0, amount_lte: 500000 }      # up to €5,000.00: a human
        decision: approve
      - decision: deny
```

## The code

```python runnable file=main.py theme={null}
from pathlib import Path

import ctrlrun
from ctrlrun import Control, Policy, SQLiteStateStore

HERE = Path(__file__).resolve().parent
STATE = HERE / ".ctrlrun"
STATE.mkdir(exist_ok=True)
for name in ("state.db", "state.db-wal", "state.db-shm"):  # so the recipe repeats
    (STATE / name).unlink(missing_ok=True)


calls: list[tuple[str, int]] = []


class FakeStripe:
    def refund(self, payment_id: str, amount: int) -> dict:
        calls.append((payment_id, amount))
        return {"id": f"re_{payment_id}", "status": "succeeded"}


stripe = FakeStripe()
store = SQLiteStateStore(STATE / "state.db")
control = Control(Policy.from_file(HERE / "ctrlrun.yaml"), store)


@ctrlrun.protect("stripe.refund", effect="refund:{payment_id}", control=control)
def refund(payment_id: str, amount: int) -> dict:
    return stripe.refund(payment_id, amount)


with ctrlrun.context(agent="support-agent"):
    print("€120 refund:", refund(payment_id="txn_1", amount=12000)["status"])

    try:
        refund(payment_id="txn_2", amount=250000)
    except ctrlrun.ApprovalRequired as pending:
        print("€2,500 refund: a human decides:", pending.request_id)
        store.grant_approval(pending.request_id, "human:ops")  # what `ctrlrun approve` does
        with ctrlrun.with_approval(pending.request_id):
            print("€2,500 refund, approved:", refund(payment_id="txn_2", amount=250000)["status"])
    else:
        raise SystemExit("a €2,500 refund ran without a human")

    try:
        refund(payment_id="txn_3", amount=2000000)
    except ctrlrun.ActionDenied as refused:
        print("€20,000 refund: refused,", refused.reason)
    else:
        raise SystemExit("a €20,000 refund ran")

    try:
        refund(payment_id="txn_1", amount=12000)
    except ctrlrun.DuplicateEffect:
        print("€120 refund again: refused as a duplicate")
    else:
        raise SystemExit("the same refund ran twice")

print("remote refund calls:", len(calls))
```

## What the agent sees

```text theme={null}
€120 refund: succeeded
€2,500 refund: a human decides: apr_…
€2,500 refund, approved: succeeded
€20,000 refund: refused, rule[2]
€120 refund again: refused as a duplicate
remote refund calls: 2
```

Two calls reached Stripe. The refusal for €20,000 names the rule that decided it, and the
duplicate names nothing but the effect key, `refund:txn_1`, which had already committed.

## The receipt

```bash runnable theme={null}
ctrlrun receipts --last 4
```

Four receipts: `allow/committed`, `approve/committed` with the approver, `deny/blocked`, and
`allow/blocked` for the duplicate.

## When an AMBIGUOUS appears

A refund whose reply was lost is `AMBIGUOUS` and a retry is refused. Look up the payment in the
Stripe dashboard, then `ctrlrun resolve refund:txn_N --committed` or `--failed`. A `reconcile`
hook that queries `stripe.Refund.list(payment_intent=...)` does the same automatically:
[Reconcile against the remote](/cookbook/reconcile-against-the-remote).

## Next

* [A payout agent with maker/checker](/cookbook/payout-maker-checker): the same money, two people.
* [Approval binding](/concepts/approval-binding) · [Get started](/get-started/quickstart) · [Why](/why).


## Related topics

- [Cookbook](/cookbook/index.md)
- [60-second quickstart](/get-started/quickstart.md)
- [Policy YAML reference](/reference/policy-yaml.md)
- [LangGraph with interrupt()](/cookbook/langgraph-interrupt.md)
- [CTRLRun and guardrail libraries](/compare/guardrail-libraries.md)
