Write your first policy
A real refund policy, built one rule at a time, with the reasoning behind each rule and the tests that keep it honest.
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
bashveefore 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.
#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.
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": [ … ]
}
`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.
#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 |
bashveefore 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
bashcurl -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
bashveefore policies deploy pol_01ABC --version 4 --reason "Refund policy reviewed in CHG-4411"
Three requirements, none optional:
policy:activate— high impact, cannot come from a wildcard grant- Step-up authentication, regardless of session age
- 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.
bashveefore 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.