---
title: Write your first policy
description: A real refund policy, built one rule at a time, with the reasoning behind each rule and the tests that keep it honest.
label: Write your first policy
---

We will build a policy for a refund agent. Four rules, added in order of how
much they matter, each one justified. By the end you will have something you can
activate and a test suite that stops a future edit from breaking it.

## Start from a template

```bash
veefore policies list
```

The built-in templates are complete, valid rule sets you create a policy from
and then edit. They are starting points, not managed policies — once created,
the rules are yours and Veefore will not change them underneath you.

`starter-observe-safe-defaults` is the one to begin with. It allows read-style
capabilities, requires approval for anything flagged dangerous, and blocks
destructive database operations. All templates with their full rule sets are in
[Policy templates](/reference/policy-templates).

## Rule 1 — block what must never happen

Start with the absolutes. These are not conditional on amounts or risk; they are
things that are simply not allowed through an agent, ever.

```json
{
  "id": "rule-block-destructive-db",
  "name": "Block destructive database operations",
  "description": "DELETE, DROP and TRUNCATE are never permitted through an agent.",
  "priority": 900,
  "enabled": true,
  "when": {
    "kind": "group",
    "id": "g-destructive",
    "operator": "and",
    "children": [
      {
        "kind": "condition",
        "id": "c-cap-db",
        "field": "capability",
        "operator": "starts_with",
        "value": "database."
      },
      {
        "kind": "condition",
        "id": "c-op-destructive",
        "field": "parameters",
        "path": "operation",
        "operator": "in",
        "value": ["DELETE", "DROP", "TRUNCATE"],
        "ignoreCase": true
      }
    ]
  },
  "then": {
    "decision": "BLOCK",
    "reason": "Destructive database operations are not permitted through an agent."
  }
}
```

Two things to notice. `path: "operation"` reaches into `parameters.operation` —
that is how you test a request parameter rather than a top-level field.
`ignoreCase: true` means `delete` and `DELETE` both match, which matters because
you do not control how the model capitalises things.

:::note Why priority 900 when strictness wins anyway?
A `BLOCK` would win regardless of priority. Priority orders evaluation and the
explanation, so the absolutes appear first in the trace and are the first thing
a reader sees. Convention: absolutes 900+, escalations 500–800, allowances
below.
:::

## Rule 2 — escalate what needs a human

```json
{
  "id": "rule-refund-ceiling",
  "name": "Approval above ₹1,000",
  "description": "Larger refunds get a human decision.",
  "priority": 700,
  "enabled": true,
  "when": {
    "kind": "group",
    "id": "g-large-refund",
    "operator": "and",
    "children": [
      {
        "kind": "condition",
        "id": "c-cap-refund",
        "field": "capability",
        "operator": "eq",
        "value": "refund.create"
      },
      { "kind": "condition", "id": "c-amount", "field": "amount", "operator": "gt", "value": 100000 }
    ]
  },
  "then": {
    "decision": "REQUIRE_APPROVAL",
    "reason": "Refunds above ₹1,000 require a human approver.",
    "approval": {
      "routingMode": "SINGLE",
      "requiredApprovals": 1,
      "approverRoleKeys": ["APPROVER"],
      "ttlSeconds": 900
    }
  }
}
```

`amount` is in **minor units**, so `100000` is ₹1,000.00. Getting this wrong by
a factor of 100 is the most common policy bug there is, in both directions.

`ttlSeconds: 900` means fifteen minutes to respond. Set it to how fast a human
can realistically respond to *this particular thing*, not to a round number. An
approval that always expires is a rule that always blocks.

## Rule 3 — use risk, not just thresholds

A fixed threshold cannot see that a request is unusual. Risk can.

```json
{
  "id": "rule-new-destination-large",
  "name": "Block large amounts to unseen destinations",
  "priority": 820,
  "enabled": true,
  "when": {
    "kind": "group",
    "id": "g-new-dest",
    "operator": "and",
    "children": [
      {
        "kind": "condition",
        "id": "c-signal",
        "field": "risk.signals",
        "operator": "contains",
        "value": "UNUSUAL_DESTINATION"
      },
      { "kind": "condition", "id": "c-amt", "field": "amount", "operator": "gt", "value": 50000 }
    ]
  },
  "then": {
    "decision": "BLOCK",
    "reason": "Large amounts to a destination this agent has never used are refused."
  }
}
```

Testing a named signal says what you mean. `risk.score >= 70` is a number;
`risk.signals contains UNUSUAL_DESTINATION` is a sentence an operator can read
back to you. Prefer the signal when you know which one you care about, and the
score when you want a general safety net.

## Rule 4 — mask what the agent does not need

```json
{
  "id": "rule-mask-pii",
  "name": "Mask customer contact details",
  "priority": 300,
  "enabled": true,
  "when": {
    "kind": "condition",
    "id": "c-order-read",
    "field": "capability",
    "operator": "eq",
    "value": "order.read"
  },
  "then": {
    "decision": "MASK",
    "reason": "Agent does not need customer contact details to process a refund.",
    "maskFields": ["customer.email", "customer.phone", "customer.address"]
  }
}
```

`MASK` lets the action through with those paths redacted — in the forwarded
request **and** in the audit trail. The agent does its job; the PII never
enters its context.

This is the rule people underuse. An agent that does not receive data cannot
leak it, and masking costs nothing in capability.

## Set the default

```json
{
  "schemaVersion": 1,
  "defaultDecision": "ALLOW",
  "defaultReason": "No rule matched; the request is within the refund policy baseline.",
  "rules": [ … ]
}
```

:::danger `defaultDecision: BLOCK` denies everything in scope
Deny-by-default is the right instinct and it interacts badly with broad scope.
Because strictness wins over priority, a `BLOCK` default bound to an entire
environment beats every `ALLOW` from every other policy in that environment.
Unrelated agents stop working and no rule looks relevant.

If you want deny-by-default, bind the policy to the `AGENT` or `TOOL` it
constrains — not to the environment. See [Tenancy](/concepts/tenancy).
:::

## Write the tests

Test cases live on the policy version. Given an input, Then expect a decision.

| Given | Then |
| --- | --- |
| `refund.create`, amount 50000 | `ALLOW` |
| `refund.create`, amount 150000 | `REQUIRE_APPROVAL` |
| `refund.create`, amount 100000 | `ALLOW` — the boundary is `gt`, not `gte` |
| `refund.create`, amount 100001 | `REQUIRE_APPROVAL` |
| `database.query`, `operation: DELETE` | `BLOCK` |
| `database.query`, `operation: delete` | `BLOCK` — case-insensitivity holds |
| `database.query`, `operation: SELECT` | `ALLOW` |
| `order.read` | `MASK` |
| amount 60000 with `UNUSUAL_DESTINATION` | `BLOCK` |

```bash
veefore policies test
```

Test the **boundaries**, not the middle. `100000` and `100001` catch an
off-by-one between `gt` and `gte`; `50000` tells you almost nothing. And test
the case-insensitivity claim explicitly, or you will not notice when someone
removes `ignoreCase`.

## Simulate against real traffic

```bash
curl -X POST https://veefore.example.com/v1/policies/simulate \
  -H "Authorization: Bearer $VEEFORE_API_KEY" \
  -H 'content-type: application/json' \
  -d '{
    "agentId": "agt_01JAV…",
    "tool": "refund_customer",
    "capability": "refund.create",
    "amount": 150000
  }'
```

Returns the decision with its full explanation — the deciding rule, every rule
that matched, the resolved authority, the risk signals. No side effects of any
kind.

`veefore deploy` preflight does this across your recent observed traffic
automatically, which is a much better signal than a handful of cases you thought
of.

## Activate

```bash
veefore policies deploy pol_01ABC --version 4 --reason "Refund policy reviewed in CHG-4411"
```

Three requirements, none optional:

1. `policy:activate` — high impact, cannot come from a wildcard grant
2. Step-up authentication, regardless of session age
3. Every test case passing

There is no override flag for a failing test, because the one time you want an
override is the one time you should not have one.

## Iterate

Policies are versioned and immutable once activated.

```bash
veefore policies diff pol_01ABC --from 4 --to 5
veefore policies deploy pol_01ABC --version 4 --reason "Rolling back CHG-4411"
```

Rollback creates a *new* version with the old content rather than moving a
pointer, so history stays linear and the audit trail reads as a sequence of
decisions.

## Related

:::cards

:::card Policy | /concepts/policy
The IR, operators and precedence in full.
:::

:::card Policy fields | /reference/policy-fields
Every field, type and operator. Generated.
:::

:::card Policy templates | /reference/policy-templates
Every template with its complete rule set.
:::

:::card Gate your pipeline | /build/ci-gate
Run the tests on every commit.
:::

:::
