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

# Stop an agent over-refunding

> Cap a refund at the order it belongs to, with one condition rule.

A refund agent rarely fails by refunding one enormous amount. It fails by refunding $90 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

<Snippet file="env-agents.mdx" />

The agent and budget below come from the [quickstart](/agents/quickstart). Export the two ids
it printed, and scope the agent for `refund.create` so its key may ask about refunds at all:

```bash Identifiers theme={"dark"}
export KORDIO_AGENT_ID="7d2a6c15-3f89-4b02-9e64-8a15d7c0b3f2"
export KORDIO_BUDGET_ID="3f1c8a92-5d41-4c8e-9f2b-7a6e1d0c4b83"
```

## 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`:

```json theme={"dark"}
{ "field": "cost_cents", "operator": "gt", "value_field": "metadata.order_total_cents" }
```

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](/agents/policies#the-condition-language) covers the rest of the grammar.

## 1. Write the ceiling

```bash theme={"dark"}
POLICY=$(curl -s -X POST https://api.kordio.io/control/v1/workspaces/$KORDIO_WORKSPACE/policies \
  -H "Authorization: Bearer $KORDIO_DASHBOARD_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"agent_id\": \"$KORDIO_AGENT_ID\",
    \"rules\": [
      {
        \"kind\": \"condition\",
        \"effect\": \"deny\",
        \"rule_name\": \"refund_exceeds_order\",
        \"action_types\": [\"refund.create\"],
        \"when\": {
          \"field\": \"cost_cents\",
          \"operator\": \"gt\",
          \"value_field\": \"metadata.order_total_cents\"
        }
      }
    ]
  }")

export KORDIO_POLICY_ID=$(echo "$POLICY" | jq -r '.data.id')
```

```json 201 Created theme={"dark"}
{
  "data": {
    "id": "c41d7b28-9e05-4a63-b7f1-6d2c8e30a915",
    "agent_id": "7d2a6c15-3f89-4b02-9e64-8a15d7c0b3f2",
    "status": "active",
    "mode": "blocklist",
    "imports": [],
    "rules": [
      {
        "kind": "condition",
        "effect": "deny",
        "rule_name": "refund_exceeds_order",
        "action_types": ["refund.create"],
        "when": {
          "field": "cost_cents",
          "operator": "gt",
          "value_field": "metadata.order_total_cents"
        },
        "id": "b8e4f072-3c19-4d5a-8e26-0f7a91c4b3d8"
      }
    ],
    "per_transaction_cap_cents": null,
    "counterparty_allowlist": [],
    "created_at": "2026-08-22T10:04:18Z",
    "updated_at": "2026-08-22T10:04:18Z"
  }
}
```

`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.

<Warning>
  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.
</Warning>

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:

```bash theme={"dark"}
curl -X PATCH https://api.kordio.io/control/v1/workspaces/$KORDIO_WORKSPACE/policies/$KORDIO_POLICY_ID \
  -H "Authorization: Bearer $KORDIO_DASHBOARD_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "rules": [
      {
        "id": "b8e4f072-3c19-4d5a-8e26-0f7a91c4b3d8",
        "kind": "condition",
        "effect": "deny",
        "rule_name": "refund_exceeds_order",
        "action_types": ["refund.create"],
        "when": {
          "field": "cost_cents",
          "operator": "gt",
          "value_field": "metadata.order_total_cents"
        }
      },
      {
        "kind": "condition",
        "effect": "deny",
        "rule_name": "refund_missing_order_total",
        "action_types": ["refund.create"],
        "when": {
          "field": "metadata.order_total_cents",
          "operator": "exists",
          "value": false
        }
      }
    ]
  }'
```

```json 200 OK theme={"dark"}
{
  "data": {
    "id": "c41d7b28-9e05-4a63-b7f1-6d2c8e30a915",
    "status": "active",
    "rules": [
      { "rule_name": "refund_exceeds_order", "id": "b8e4f072-3c19-4d5a-8e26-0f7a91c4b3d8" },
      { "rule_name": "refund_missing_order_total", "id": "1a7c5d93-8b04-4e21-9f6d-3c0b8a52e7f1" }
    ],
    "updated_at": "2026-08-22T10:06:41Z"
  }
}
```

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:

```bash theme={"dark"}
curl -s -X POST https://api.kordio.io/control/v1/agent/actions \
  -H "Authorization: Bearer $KORDIO_AGENT_KEY" \
  -H "Idempotency-Key: refund-4471-attempt-1" \
  -H "Content-Type: application/json" \
  -d "{
    \"budget_id\": \"$KORDIO_BUDGET_ID\",
    \"action_type\": \"refund.create\",
    \"resource\": \"order-4471\",
    \"cost_cents\": 2500,
    \"metadata\": { \"order_id\": \"4471\", \"order_total_cents\": 4000 }
  }"
```

```json 201 Created theme={"dark"}
{
  "data": {
    "id": "9b2ef0c1-6a3d-4e75-8c19-2f4b7d5a0e63",
    "budget_id": "3f1c8a92-5d41-4c8e-9f2b-7a6e1d0c4b83",
    "action_type": "refund.create",
    "resource": "order-4471",
    "cost_cents": 2500,
    "currency": "USD",
    "state": "pending",
    "metadata": { "order_id": "4471", "order_total_cents": 4000 },
    "trace_id": "5e8c1a47-2b93-4d06-a7f1-9c3e05b8d24a"
  },
  "decision": {
    "outcome": "allowed",
    "rule": null,
    "detail": {},
    "headroom": { "session_remaining_cents": 38000 }
  },
  "cosignature": "eyJhbGciOiJFUzI1NiIsImtpZCI6ImtleV8xIn0..."
}
```

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:

```bash theme={"dark"}
curl -s -X POST https://api.kordio.io/control/v1/agent/actions \
  -H "Authorization: Bearer $KORDIO_AGENT_KEY" \
  -H "Idempotency-Key: refund-4471-attempt-2" \
  -H "Content-Type: application/json" \
  -d "{
    \"budget_id\": \"$KORDIO_BUDGET_ID\",
    \"action_type\": \"refund.create\",
    \"resource\": \"order-4471\",
    \"cost_cents\": 9000,
    \"metadata\": { \"order_id\": \"4471\", \"order_total_cents\": 4000 }
  }"
```

```json 403 Forbidden theme={"dark"}
{
  "data": {
    "id": "2c7f4b81-0d63-4a19-8e05-1b9c6f3a7d24",
    "action_type": "refund.create",
    "cost_cents": 9000,
    "state": "denied",
    "metadata": { "order_id": "4471", "order_total_cents": 4000 }
  },
  "decision": {
    "outcome": "denied",
    "rule": "refund_exceeds_order",
    "detail": {
      "rule_id": "b8e4f072-3c19-4d5a-8e26-0f7a91c4b3d8",
      "matched": {
        "field": "cost_cents",
        "operator": "gt",
        "value_field": "metadata.order_total_cents"
      }
    },
    "headroom": { "session_remaining_cents": 35500 }
  }
}
```

`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:

| You wrote                      | You get                                                             |
| ------------------------------ | ------------------------------------------------------------------- |
| `"value_field": "order_total"` | `422`, `Unknown field "order_total"`                                |
| both `value` and `value_field` | `422`, `Expression compares to a value and a value_field, pick one` |

```json 422 Unprocessable Entity theme={"dark"}
{
  "error": {
    "message": "Validation failed: Rules Expression compares to a value and a value_field, pick one",
    "code": null
  }
}
```

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

<CardGroup cols={2}>
  <Card title="Pass facts from your own service" icon="braces" href="/agents/guides/metadata-facts">
    Where `metadata.order_total_cents` comes from, and what else belongs there.
  </Card>

  <Card title="Roll a policy out safely" icon="flask-conical" href="/agents/guides/policy-rollout">
    Preview this rule before it ever decides anything real.
  </Card>

  <Card title="Writing a policy" icon="scale" href="/agents/policies">
    Every rule kind, operator, and combinator.
  </Card>

  <Card title="Errors" icon="triangle-alert" href="/agents/errors">
    Every rule that can appear in `decision.rule`.
  </Card>
</CardGroup>
