Policies
How Veriticity decides what an AI agent may buy. Spending limits, merchant allow and block lists, budget windows, approval thresholds, and why policies are authored by people.
A policy is the set of rules the Trust Engine evaluates a purchase against. Each rule states something a purchase must satisfy, and what happens when it does not. A policy is bound to an organisation, to particular agents, or to both.
Policies are written by people#
There is no API for creating, editing, activating or archiving a policy, and there will not be one. Every credential of every kind is refused with 403 api_key_not_permitted.
If you receive api_key_not_permitted, no other credential will work. Do not go looking for one.
A policy is created and edited in the rule builder. Creating one takes a name, who it applies to and its rules, and produces a draft. A draft affects no purchase until somebody activates it, and activation checks the draft first. Every condition on this page can be chosen in the builder except the ones marked as unavailable, and a rule that names a merchant chooses it from the merchants your organisation has on record.
What a policy contains#
{
"policy": {
"id": "0199a3e0-1f4c-7a11-b3d2-5e8c7a4f2b90",
"name": "Company baseline",
"boundToOrganization": true,
"status": "ACTIVE",
"inForce": true,
"bindings": { "organization": true, "agentIds": [] },
"activeVersion": {
"id": "0199a3e0-2a7b-7c33-9f10-1b2c3d4e5f60",
"versionNumber": 4,
"status": "ACTIVE",
"languageVersion": 1,
"ruleCount": 3,
"activatedAt": "2026-09-01T08:00:00.000Z"
}
}
}| Field | Meaning |
|---|---|
bindings | Who the policy governs. organization: true governs every agent; agentIds names particular ones. A policy bound to nobody governs nobody. |
status | ACTIVE, DISABLED or ARCHIVED. |
inForce | Whether the engine is actually reading it right now. Not the same as having an active version — see below. |
activeVersion | The version the engine evaluates against, or null. |
What a version contains#
{
"version": {
"id": "0199a3e0-2a7b-7c33-9f10-1b2c3d4e5f60",
"versionNumber": 4,
"status": "ACTIVE",
"languageVersion": 1,
"policyId": "0199a3e0-1f4c-7a11-b3d2-5e8c7a4f2b90",
"appliesWhen": null,
"appliesWhenConditions": [],
"rules": [
{
"id": "0199a3e0-…",
"key": "max-purchase",
"name": "Maximum purchase amount",
"when": null,
"require": { "atom": "limit" },
"effect": "BLOCK",
"onUnestablished": "BLOCK",
"conditions": [
{
"atom": "limit",
"condition": "AMOUNT_LIMIT",
"config": { "max": { "amountMinor": "50000", "currency": "GBP" } }
}
]
},
{
"id": "0199a3e0-…",
"key": "daily-budget",
"name": "Daily spending limit",
"when": null,
"require": { "atom": "limit" },
"effect": "BLOCK",
"onUnestablished": "BLOCK",
"conditions": [
{
"atom": "limit",
"condition": "SPEND_LIMIT",
"config": { "max": { "amountMinor": "100000", "currency": "GBP" }, "window": "DAY" }
}
]
},
{
"id": "0199a3e0-…",
"key": "approved-suppliers",
"name": "Approved suppliers",
"when": null,
"require": { "atom": "merchant" },
"effect": "BLOCK",
"onUnestablished": "REQUIRE_APPROVAL",
"conditions": [
{ "atom": "merchant", "condition": "MERCHANT_IN_SET", "config": { "set": "suppliers" } }
]
}
],
"valueSets": [
{
"key": "suppliers",
"kind": "MERCHANT",
"members": [{ "value": "acme cloud", "label": "Acme Cloud" }]
}
]
}
}Rules#
A rule is a requirement. require is an expression over the rule’s conditions: all, any and not, over atom leaves that each name one condition. A rule can only restrict. Nothing in a policy grants a purchase that another rule refuses.
| Field | Meaning |
|---|---|
key | The rule’s identity across versions of its policy. An approval is bound to it. |
when | Which purchases the rule is about. null means every purchase. A rule steps aside only when it is established that the purchase is outside when. |
effect | What happens when the requirement is broken: BLOCK refuses, REQUIRE_APPROVAL asks a person, FLAG records it and changes nothing. A rule that only flags is not shown to the agent it applies to, and never approves a purchase: a policy needs at least one rule that enforces. |
onUnestablished | What happens when the requirement cannot be checked, because a fact it reads is missing or has not been verified. A rule whose |
Conditions#
| Condition | Holds when |
|---|---|
AMOUNT_LIMIT | The purchase amount is at or under config.max. |
AMOUNT_MINIMUM | The purchase amount is at or over config.min. |
SPEND_LIMIT | Spending in config.window, including this purchase, is at or under config.max. |
PURCHASE_COUNT_LIMIT | The purchases in config.window, including this one, number at most config.maxPurchases. |
MERCHANT_IN_SET | Earlier versions only. Never satisfied: a name in the value set named by config.set identifies no merchant. Use MERCHANT_IS. |
CURRENCY_IN | The purchase currency is one of config.currencies. |
MERCHANT_IDENTIFIED | The merchant is one the organisation has on record. |
MERCHANT_VERIFIED | It is, and an independent source currently verifies it. No such source is available yet, so this does not pass. |
MERCHANT_IS | The merchant is one of config.merchants. |
MERCHANT_COUNTRY_IN | The merchant’s country is one of config.countries. |
LEGAL_ENTITY_JURISDICTION_IN | The company behind the merchant is registered in one of config.countries. |
PRODUCT_IDENTIFIED | The product is one the organisation has on record for that merchant. |
PRODUCT_CATEGORY_IN | The product’s category is one of config.categories, or inside one. |
ITEM_CATEGORY_IN | With config.items ANY, an item of the basket is in, or tagged as, one of config.categories; with EVERY, every item and every tag on it is. “Must not include any of” is ANY under not. An uncertain item or an unexplained charge leaves it unknown unless another item settles it. The agent’s categories count in a purchase’s favour only where accept takes AGENT_ASSERTED for fact.state[items.category_id]. |
VARIABLE_COST_LIMIT | Every charge that can vary is of a kind in config.allowances (TAX, DELIVERY or DUTY, each once), its maximum is within the smaller of its fixed amount plus rateBasisPoints of the items and its ceiling, and the maxima together are within config.overallCeiling. Every amount is in the overall ceiling’s currency. Without such a rule, charges that can vary are limited only by their stated maxima, budgets and amount rules. |
PRODUCT_ATTRIBUTE_IN | The product’s config.attribute is one of config.values. |
ATTEMPT_COUNT_LIMIT | The purchase attempts in config.window number at most config.maxAttempts. |
DISTINCT_MERCHANT_LIMIT | The purchases in config.window, including this one, are with at most config.maxMerchants different merchants. |
DISTINCT_CATEGORY_LIMIT | The purchases in config.window, including this one, are in at most config.maxCategories different categories. |
PURCHASE_HISTORY_MINIMUM | At least config.minPurchases purchases were approved in config.window before this one. |
AMOUNT_DEVIATION_LIMIT | The amount is at or under config.maxPercent per cent of the usual amount over config.window. |
The same condition does different jobs under different rules. An AMOUNT_LIMIT under a BLOCK rule is a maximum; under a REQUIRE_APPROVAL rule it is an approval threshold. A rule that requires MERCHANT_IS allows only the merchants it names; one that requires { "not": … } of it rules them out.
Typed merchant names in earlier versions#
A version written before merchants were kept on record may hold a list of typed merchant names and a MERCHANT_IN_SET condition that reads it. You will still see such a version when you read policies. A merchant is identified by its website, never by its name, so no purchase passes such a list. Names can no longer be typed into a policy:
- A new policy or draft cannot add a list, a name or a
MERCHANT_IN_SETcondition. - A draft that still holds one cannot be activated. Each is replaced with merchants on record, using
MERCHANT_IS, or removed. - No typed name is carried across. A person chooses the merchants on record that replace the list.
A list is a value set: a named list of values a version’s conditions refer to. Names on a list are kept without regard to case, so “Acme Cloud” and “acme cloud” are one entry.
A typed name is not a merchant and never identifies one. A name can refuse a purchase and can never allow one, whatever website the purchase states.
- A purchase whose domain or reference is not on record for one merchant, or that states neither, is refused by an allowed list with
MERCHANT_IDENTITY_UNESTABLISHED, whatever name it states. A person cannot approve it. A blocked list applies its rule’sonUnestablishedwith the same code, and refuses where the name is on the list. - A purchase with a merchant on record, under a name missing from an allowed list, is refused with
MERCHANT_NOT_IN_SET. Under a name missing from a blocked list it passes that rule, as before. - A purchase with a merchant on record, under a name on a list, is refused with
MERCHANT_NOT_IN_SETandreasonParams.state, on an allowed list and on a blocked one. A person cannot approve it.
Policies with no merchant rule are unaffected.
An agent can read that a policy has a merchant list and how long it is. It cannot read the names on it.
Merchants, products and categories#
An organisation keeps a record of the merchants it deals with, each under a key it chooses, such as acme-cloud. A purchase never states that key. It states the website it would buy from, and optionally a platform reference, and the merchant is the one those are on record for. The name on a purchase is a label: it is shown in the dashboard and in history, and is never used to match a merchant.
Owners and admins add merchants under Merchants in the dashboard. A website is required and is what purchases are matched on; a platform reference is optional; the name is a label. A merchant on record is one the organisation has chosen to trust. Adding it does not verify its identity, and withdrawing it stops purchases matching it without removing its history. There is no API for merchant records.
That trust lasts 90 days. After that, purchases stop matching the merchant until an owner or admin confirms it again in the dashboard. Nothing a purchase states renews it, and a rule set to go by what the agent states does not match a merchant whose trust has expired or been withdrawn.
- Matching is exact. A host or a reference identifies a merchant only if that exact value is on record for it, and
shop.example.comis notexample.com. example.comandwww.example.comare the same website, whichever of them is on record and whichever the purchase states.- A purchase that states no website, or one that is not a host name, matches no merchant by its name. A matching name never makes up for a missing or different website, and a different name never stops a website matching.
- A website matches its subdomains only when an owner or admin chose to trust them. Then
checkout.example.commatches a merchant on record atexample.com;notexample.comandexample.com.attacker.testnever do. - A public suffix or a shared-hosting domain, such as
co.ukorgithub.io, cannot be put on record as a merchant’s website, and trusting subdomains never reaches a host that belongs to a different registrant. - A domain or a reference that is sent must be on record. A purchase naming a known merchant at an unknown domain is not recognised. Matching a merchant only identifies it: every rule, limit and trust check still applies to the purchase.
- If the domain and the reference point to different merchants, the merchant is not recognised.
- A merchant that is not recognised never satisfies a rule, is never off a block list, and is never outside a rule’s scope. The rule’s
onUnestablishedapplies, withMERCHANT_IDENTITY_UNESTABLISHED.
What is on record about a merchant — that it is verified, its country, the company behind it — has a source and a date, and can expire or be withdrawn. Expired or withdrawn, it no longer counts. Where two sources disagree, neither counts. Only an independent source can say a merchant is verified: a member of the organisation cannot, and adding a merchant never does.
An agent’s statement of which merchant it is buying from is still the agent’s statement. By default a rule about the merchant is satisfied only when a person named the merchant, as on a purchase made from the dashboard; otherwise the purchase waits for a person with FACT_UNVERIFIED. A condition’s accept can allow the agent’s statement of the merchant. Nothing an agent sends can make a merchant verified.
An agent reading its own rules is shown how many merchants a MERCHANT_IS condition lists, as merchantsCount, and not which ones.
A product is identified by productReference, within its merchant. A category is a key, and includes everything inside it. Every organisation has Veriticity’s categories, veriticity:electronics, veriticity:software and the rest, including veriticity:adult and veriticity:alcohol: one for each category a purchase may state. Every purchase states one. A purchase decided before categories were required may have none, and has no category a rule can rely on.
The category a purchase states is the caller’s claim, and Veriticity doesn’t check it. It can make a purchase fail a category rule but never pass one, unless the condition’s accept takes AGENT_ASSERTED for fact.state[product.category_id]. In the dashboard that is “Accept the category the agent gives”. Window selectors and DISTINCT_CATEGORY_LIMIT say the same with acceptStated: true.
Recent activity#
Five conditions look at what surrounds a purchase: how many attempts there have been, across how many merchants or categories, with how much history, and how far the amount is from what is usual. Each takes a window and a scope, written as for a budget. scope is required.
An attempt is a purchase request Veriticity decided on: approved, refused, or waiting on a person. It stays one attempt when a person later approves or rejects it, and when it expires or is cancelled.
- A request refused before a decision is not an attempt. That covers a malformed request, a failed sign-in, a rate limit and a plan allowance.
- Sending the same request again with the same
Idempotency-Keyis the same attempt. Sending it with a new key, or with none, is a new one. - A simulation is not an attempt, and does not check these rules: they are decided when a purchase is requested.
| Condition | Configuration |
|---|---|
ATTEMPT_COUNT_LIMIT | maxAttempts, and counting: ALL counts every attempt including this one; REFUSED counts earlier refusals; PENDING counts purchases waiting on a person. |
DISTINCT_MERCHANT_LIMIT | maxMerchants. Counts purchases that were approved or are waiting on a person. |
DISTINCT_CATEGORY_LIMIT | maxCategories, and level: EXACT counts each category separately, so a category and one inside it are two; TOP counts by top-level category. |
PURCHASE_HISTORY_MINIMUM | minPurchases, a rolling window, and optionally currency. With "selector": { "kind": "EACH_MERCHANT" } it counts earlier purchases at this purchase’s merchant. |
AMOUNT_DEVIATION_LIMIT | baseline (MEAN or MAX), maxPercent, currency, minSample (5 or more) and a rolling window. |
Purchase history and the usual amount come from approved purchases only. A purchase that was refused, or is waiting on a person, is not history.
- With fewer than
minSampleapproved purchases in the window there is no usual amount. The rule’sonUnestablishedapplies, withBASELINE_NOT_ESTABLISHED. PURCHASE_HISTORY_MINIMUMis not met when there are too few approved purchases, withPURCHASE_HISTORY_INSUFFICIENT. The rule’seffectapplies.- A purchase whose merchant or category is not recognised could be anywhere. If the limit holds wherever it was, the rule is met. If it cannot be told, the rule’s
onUnestablishedapplies, withDISTINCT_MERCHANTS_NOT_ESTABLISHEDorDISTINCT_CATEGORIES_NOT_ESTABLISHED. - A merchant the agent named is not a recognised merchant for
DISTINCT_MERCHANT_LIMITunless the condition sets"acceptStated": true.
These conditions cannot be used in a rule’s when. To leave purchases with too little history to other rules, require either too little history or a usual amount:
"require": { "any": [{ "not": { "atom": "history" } }, { "atom": "amount" }] }A usual amount rises as an agent spends more. Keep an AMOUNT_LIMIT or a SPEND_LIMIT alongside it.
A limit on refused attempts stays in force while the agent keeps trying. With a scope of ORGANIZATION or POLICY, one agent’s attempts count against every agent the rule covers; use EACH_AGENT to count each agent’s own.
An agent is told that one of these rules objected, and its reason code. It is not shown the limit, the count, the window or the usual amount, and an agent reading its own rules sees that the rule exists and not its configuration. These rules are not listed as budgets.
Versions and lifecycle#
Policies are versioned, and versions are immutable once active. A decision records which version it was evaluated against, so a purchase from March can be explained against the rules that were in force in March rather than today’s.
DRAFT— editable, not enforced. A policy may have one.ACTIVE— enforced. Exactly one per policy.ARCHIVED— superseded, kept for evidence.
Budget windows#
A SPEND_LIMIT or a PURCHASE_COUNT_LIMIT measures a window. A calendar window — DAY, WEEK, MONTH, QUARTER or YEAR — is a period in the organisation’s timezone, not the server’s or the caller’s. A daily limit resets at midnight where the organisation is. A week starts on Monday unless weekStartsOn names another day.
// A calendar week in the organisation's timezone, from Monday
{ "max": { "amountMinor": "100000", "currency": "GBP" }, "window": "WEEK" }
// The same, from Sunday
{ "max": { "amountMinor": "100000", "currency": "GBP" },
"window": { "kind": "CALENDAR", "unit": "WEEK", "weekStartsOn": "SUNDAY" } }
// No more than three purchases in any ten minutes
{ "maxPurchases": 3, "window": { "kind": "ROLLING", "seconds": 600 } }A rolling window is a length of elapsed time, from 60 seconds to 366 days. It is never a calendar period: 86400 seconds is twenty-four hours, not a day, and 604800 is not a week. A rolling limit holds for every stretch of that length, so no ten minutes can contain more than a ten-minute limit allows.
A window counts purchases that were approved or are waiting on a person. A refused, expired or cancelled purchase is not counted. It includes purchases made before the limit existed: a monthly limit activated on the 20th starts from what was spent since the 1st.
Whose spending a limit measures#
config.scope says whose purchases the window holds. Left out, it is everything the policy governs: a policy bound to the organisation limits the whole organisation, and a policy bound to two agents limits those two together.
scope | The window holds |
|---|---|
omitted, or { "kind": "POLICY" } | The agents the policy binds, together. |
{ "kind": "ORGANIZATION" } | Every agent in the organisation. |
{ "kind": "EACH_AGENT" } | The agent making the purchase. Each agent has its own. |
{ "kind": "AGENT", "agentId": "…" } | One agent. |
{ "kind": "EACH_PRINCIPAL" } | The person the purchase is made for: whoever granted the connection, or asked in the dashboard. Purchases made with an API key share one window. |
{ "kind": "PRINCIPAL", "userId": "…" } | One person. |
An AGENT scope must name one of the organisation’s agents that is not archived, and a PRINCIPAL scope one of its members. A version that names anybody else is refused with SUBJECT_NOT_IN_ORGANIZATION when it is saved and when it is activated. If the agent is archived or the person leaves afterwards, the limit can no longer be applied: purchases under that rule are decided by its onUnestablished, with the reason code SCOPE_SUBJECT_NOT_ESTABLISHED, until the policy is changed.
Limits add up; none overrides another. A purchase is approved only when every limit that applies to it holds, so an organisation-wide limit of £1,000 a day does not raise an agent’s own limit of £100.
Limits for one merchant or one category#
config.selector narrows a window to purchases of one kind. It combines with any scope: £500 a month at one merchant, for each agent, is a selector and EACH_AGENT.
selector | The window holds |
|---|---|
{ "kind": "MERCHANT", "merchant": "acme-cloud" } | Purchases at that merchant. |
{ "kind": "EACH_MERCHANT" } | Purchases at this purchase’s merchant. Each merchant has its own. |
{ "kind": "CATEGORY", "category": "veriticity:electronics" } | Purchases in that category or anything inside it. |
A purchase is outside a selector only when its merchant or category is on record as a different one. A purchase whose merchant is not recognised, or is only the agent’s statement, is tested against the limit and counted in it, so a merchant’s budget cannot be avoided by naming the merchant differently. acceptStated: true on the selector lets the agent’s own statement put a purchase outside. Under EACH_MERCHANT, a purchase whose merchant is not recognised has no window to be measured in, and the rule’s onUnestablished applies.
A selector must name a merchant or category the organisation has, on the same terms as a named agent. Purchases made before a merchant was on record have no recognised merchant, so they count toward every merchant and category limit whose window still includes them.
Currency#
A monetary limit compares amounts in its own currency only. Veriticity performs no conversion. A purchase in another currency cannot be shown to be within the limit, so the rule’s onUnestablished applies, and it is not added to the window. To leave other currencies to other rules, give the rule a when that names its currency. PURCHASE_COUNT_LIMIT has no currency and counts purchases in all of them.
A window tracks two separate quantities:
| Quantity | Meaning |
|---|---|
heldMinor | Reserved by purchases waiting on a person. |
committedMinor | Spent by purchases that were approved. |
A simulation returns both plus what the purchase would add, which is the honest way to answer “how much is left”. Subscribe to budget.threshold_reached and budget.exhausted on webhooks to be told rather than having to poll.
What a version applies to#
A version can say which purchases it is about with appliesWhen, an expression over appliesWhenConditions. A version about sterling only uses a CURRENCY_IN condition. null means every purchase.
Veriticity performs no currency conversion. A limit set in one currency says nothing about a purchase in another, so a purchase in a currency no rule covers is refused rather than converted at a rate nobody agreed to: with CURRENCY_NOT_LISTED when a policy lists the currencies it has limits for, and NO_APPLICABLE_RULE when no rule applied at all.
A simulation lists every policy that governs the agent under policiesConsidered, each with a participation of PARTICIPATING or NOT_APPLICABLE, which is what makes a NO_APPLICABLE_POLICY answer actionable.
Reading policies over the API#
Three read endpoints, all organisation-scoped API keys only:
GET /v1/policies— filter with?statusand?search, page with?pageand?pageSize.GET /v1/policies/{id}GET /v1/policies/{id}/versions/{versionId}
An agent-bound credential is refused. The constraints being drawn up for an agent are the authoring side’s business — an agent learns what it may spend by asking, through a simulation, rather than by reading the rules.
Testing a change before it is active#
Two endpoints help before an activation:
Validate#
Runs activation’s own check as a dry run. It answers 200 even when the draft is unacceptable — you asked a question and got an answer.
curl -X POST https://app.veriticity.com/v1/policies/$POLICY_ID/versions/$VERSION_ID/validate \
-H "Authorization: Bearer $VERITICITY_ORG_KEY"{
"valid": false,
"issues": [
{
"path": "version.rules",
"code": "NO_RULES",
"message": "A policy version needs at least one rule. A policy that requires nothing grants nothing."
}
],
"warnings": [],
"budgetImpact": [],
"version": { "versionNumber": 5, "status": "DRAFT" }
}budgetImpact is empty in the ordinary case. A non-empty one lists each window where a limit in this version would start from spending already made, as spentMinor — worth looking at before you activate.
warnings never stop an activation. Each describes purchases the version refuses that its author may expect to pass. Those about one rule have a path of version.rules[n]:
HISTORY_NEVER_BUILT: a rule refuses purchases until enough have been approved, so with no approved history it refuses every purchase it applies to.ZERO_MAXIMUM: a rule allows nothing above zero.AMOUNT_IN_OTHER_CURRENCY: a rule sets amounts in one currency and the version accepts another. Amounts aren’t converted, so a purchase in the other currency can’t be checked against that rule.EVERY_RULE_CONDITIONAL: every rule applies only to some purchases, so a purchase outside all of them is refused.
Simulate#
POST /v1/simulations/draft-policy asks what a draft would decide about a specific purchase. POST /v1/simulations/policy-version asks the same of an active or archived version, exactly as it was frozen — which is how you explain a decision that was made months ago.

