Skip to main content
A refund agent rarely fails by refunding one enormous amount. It fails by refunding 90againsta90 against a 40 order, quietly, on an order it misread, because the ceiling it was given was a number and the order total is a different number on every request.

Before you start

The agent and budget below come from the quickstart. Export the two ids it printed, and scope the agent for refund.create so its key may ask about refunds at all:
Identifiers

Why a cap cannot express this

per_transaction_cap_cents and cost_cap compare cost_cents to a number you wrote when you saved the policy. That number cannot be “whatever this order was worth”, so the tightest cap you can write is the largest order you ever expect, which is far too loose for the smallest one. A condition predicate can compare a field to another field on the same request instead of to a literal, using value_field:
Both sides resolve the same way, so value_field accepts exactly the names the left side accepts: action_type, resource, cost_cents, currency, agent_id, agent_mode, budget_id, or any metadata.* dot path. The condition language covers the rest of the grammar.

1. Write the ceiling

201 Created
action_types is an exact list, not a prefix. To cover a family of refund types, drop it and match action_type with starts_with inside the expression instead.

2. Refuse a refund that arrives without an order total

A field that is not on the request resolves to nil, and nil does not compare. It does not fall back to zero. So a refund that omits metadata.order_total_cents makes the predicate false, the rule does not fire, and the refund goes through.
A value_field rule is silent when the fact is missing, which is the direction that costs you money. Pair it with an exists test whenever the fact is mandatory.
Add the guard by sending the full rules array back. An update replaces the array rather than merging into it, and each rule keeps its id only if you send the id back with it:
200 OK
A rule you resend without its id is assigned a fresh one, and a decision recorded last week then names a rule id that no longer exists in the policy. Keep the ids.

3. Send the order total with the refund

The agent asks before it refunds, and carries the order total it is refunding against:
201 Created
A refund is money leaving, so it reserves budget like any other spend. Report it with complete or fail once your rail has answered.

4. Watch it refuse

Same order, an amount above what the order was worth:
403 Forbidden
detail.matched is the predicate that fired, echoed back with the value_field intact, so the record of the denial says which two numbers were compared rather than just naming a rule.

What write time catches

Both mistakes are refused when you save the policy, not when an agent is waiting on an answer:
422 Unprocessable Entity
Book refunds on /control/v1/agent/actions. The payment endpoint always evaluates as payment.create, whatever you send it, so it cannot carry a refund.create action type.

Next steps

Pass facts from your own service

Where metadata.order_total_cents comes from, and what else belongs there.

Roll a policy out safely

Preview this rule before it ever decides anything real.

Writing a policy

Every rule kind, operator, and combinator.

Errors

Every rule that can appear in decision.rule.