Policy Manager
Policies are how you tell the Inference Engine what a decision must respect. A policy is structured JSON: an ordered list of rules, each with a condition (when) and an action (then), plus optional cost controls. Policies are validated before publish, versioned, evaluated deterministically on every decision, and never involve an LLM at decision time.
What policies can define#
- Routing preferences (prefer or avoid specific providers)
- Risk thresholds (step up or block above a score or band)
- Retry limits and no-retry lists
- Step-up requirements
- Hold rules (pause for review or approval)
- Block rules
- Provider fallback rules
- Minimum provider health requirements
- Spending limits by amount, useful as agent guardrails
- Cost and margin controls for cost-aware routing (
deterministic-v2)
Anatomy of a policy#
{
"merchant_id": "m_123",
"name": "EU card routing",
"default_provider": "stripe",
"publish": true,
"rules": [
{
"rule_id": "prefer_adyen_eu",
"name": "Prefer Adyen for EU cards",
"priority": 10,
"enabled": true,
"when": { "region_in": ["EU"], "payment_method_in": ["card"] },
"then": { "type": "prefer_provider", "provider": "adyen", "min_health_score": 70 }
},
{
"rule_id": "step_up_high_value",
"name": "Step up new customers above 500",
"priority": 20,
"enabled": true,
"when": { "amount_gte": 50000, "customer_type_in": ["new", "guest"] },
"then": { "type": "step_up" }
},
{
"rule_id": "never_retry_hard_declines",
"name": "Never retry hard declines",
"priority": 30,
"enabled": true,
"when": {},
"then": { "type": "no_retry", "provider_reason_codes": ["stolen_card", "do_not_honor"] }
}
]
}
Rules are evaluated by ascending priority. An empty when matches every transaction. A policy holds up to 100 rules.
Conditions (when)#
| Field | Type | Matches |
|---|---|---|
amount_gte / amount_lte |
integer | Amount thresholds in minor units |
currency_in |
string[] | ISO 4217 currencies |
country_in |
string[] | ISO 3166-1 alpha-2 countries |
region_in |
enum[] | EU, US, AFRICA, GLOBAL |
payment_method_in |
enum[] | card, wallet, bank_transfer, mobile_money |
card_network_in |
enum[] | visa, mastercard, amex, discover, verve, other |
customer_type_in |
enum[] | new, returning, guest |
transaction_type_in |
enum[] | authorization, sale, capture |
risk_level_in |
enum[] | low, medium, high |
risk_score_gte / risk_score_lte |
integer | Risk score thresholds, 0 to 100 |
risk_level_in and risk_score_gte / risk_score_lte evaluate the risk_level and risk_score you supply in the decision request's transaction, which is how your existing fraud tooling drives step_up and block instructions. A condition on a field you did not send does not match (an absent risk_score is treated as 0 for threshold checks).
Previous attempt#
These match the decision request's previous_attempt, so a rule can route around whatever just failed rather than only reacting to timeouts through the retry actions.
| Field | Type | Matches |
|---|---|---|
previous_outcome_in |
enum[] | timeout, declined, error |
previous_provider_in |
enum[] | Provider the failed attempt ran on |
previous_reason_code_in |
string[] | Provider reason code, compared case-insensitively |
A request with no previous_attempt never matches any of these, so a retry rule stays inert on a first attempt instead of firing on every transaction.
{
"rule_id": "r_route_around_decline",
"name": "avoid_the_provider_that_just_declined",
"priority": 5,
"when": { "previous_outcome_in": ["declined"], "previous_provider_in": ["stripe"] },
"then": { "type": "avoid_provider", "provider": "stripe" }
}
Naming a provider you have since disabled produces a publish-time warning, not an error, because a prior attempt can legitimately have run on it. The far more common cause is a typo, which would make the rule silently unreachable.
Risk signals#
These match the decision request's risk_signals object, for intents the derived risk_score cannot express.
| Field | Type | Matches |
|---|---|---|
velocity_count_10m_gte |
integer | Attempts in the last 10 minutes |
velocity_count_1h_gte |
integer | Attempts in the last hour |
velocity_amount_1h_gte |
integer | Amount in the last hour, minor units |
previous_decline_count_gte |
integer | Declines already seen for this order |
device_risk_band_in |
enum[] | low, medium, high |
ip_country_match |
boolean | Whether the IP country matched |
Two things to know before using these. They read your own numbers, and the risk engine also folds the same signals into risk_score, so pairing a velocity rule with a risk-score rule counts the same evidence twice. And unlike risk_score_gte, an absent signal never matches — velocity_count_10m_gte: 0 does not fire for a caller that omits velocity data, because treating a missing count as zero would silently match every request.
Actions (then)#
| Type | Effect | Extra fields |
|---|---|---|
prefer_provider |
Bias routing toward a provider | provider, optional min_health_score |
avoid_provider |
Exclude a provider | provider |
set_fallback |
Set the fallback provider on the route | provider |
avoid_health_states |
Exclude providers in the given health states | states, for example ["degraded", "critical"] |
require_min_health |
Exclude providers below a health score | score |
block |
Return the block instruction |
|
step_up |
Return the step_up instruction |
|
hold |
Return the hold instruction (pause for review) |
|
retry_on_timeout |
Allow retries after timeouts | max_retries (0 to 3), use_different_provider |
no_retry |
Suppress retries for specific provider reason codes | provider_reason_codes |
A matched block rule is terminal: evaluation stops and the decision is block with reason code RISK_SCORE_ABOVE_BLOCK_THRESHOLD, whatever the rule's condition was. A matched hold rule sets the instruction to hold (reason code HOLD_REQUIRED_BY_POLICY) and outranks step_up; a matched step_up rule sets step_up (reason code STEP_UP_REQUIRED_BY_POLICY). In both cases routing is still computed, so the response carries the provider to use once the pause or control resolves.
Cost controls#
Declaring any of these top-level cost controls (or having resolved pricing cached) switches the merchant's decisions to cost-aware routing (deterministic-v2). Without them, decisions run deterministic-v1 unchanged.
| Field | Meaning |
|---|---|
optimization_mode |
approval (ignore cost), cost (minimize cost behind an approval guardrail), balanced (rank by expected value, default), margin (maximize expected margin) |
margin_bps |
Your gross margin in basis points, used by margin mode |
retry_cost_minor |
What a failed attempt costs you, in minor units |
max_cost_bps |
Hard ceiling on route cost |
require_real_pricing_for_cost_optimization |
Forbid optimizing on estimated_fallback or unknown pricing |
Lifecycle#
- Draft.
POST /v1/policieswithpublish: false. Drafts can be saved with warnings. - Validate. Static validation plus conflict detection runs on every create. Errors always reject; warnings reject only on publish.
- Simulate.
POST /v1/policies/simulateruns a draft (or the published policy) against a sample transaction with the same engine and cached health the live path uses. Nothing is persisted. - Publish.
publish: truemakes the policy live and archives the previously published one. One published policy per merchant. Publishing invalidates the policy cache and is audit-logged.
Every decision response reports which policy shaped it (policy.policy_id, policy.policy_version) and which rules matched (policy.matched_rules), so you can trace any decision back to the exact rule set.
Drafting from plain English#
POST /v1/policies/translate turns text like "Prefer Adyen for EU cards. Require 3DS above 500 EUR. Never retry hard declines." into a structured draft. The translator is rule-based, runs only at authoring time, and its output must pass the same strict validation as hand-written policies before it can be saved or published. It never runs during a decision.
It also understands previous-attempt and risk-signal phrasing:
| Phrase | Condition produced |
|---|---|
| "Avoid PayPal when Stripe declines" | previous_outcome_in: ["declined"], previous_provider_in: ["stripe"] |
| "after a charge declined on Stripe" | same, from the declined on <provider> form |
| "when the previous attempt timed out" | previous_outcome_in: ["timeout"] |
| "more than 5 attempts in 10 minutes" | velocity_count_10m_gte: 5 |
| "over 20 charges in the last hour" | velocity_count_1h_gte: 20 |
| "spends over $2,500 in the last hour" | velocity_amount_1h_gte: 250000 |
| "after 3 previous declines" | previous_decline_count_gte: 3 |
| "for high-risk devices" | device_risk_band_in: ["high"] |
| "when the IP country does not match" | ip_country_match: false |
The translator binds a provider from its position in the sentence, so "avoid PayPal when Stripe declines" avoids PayPal and conditions on Stripe rather than confusing the two. It stays conservative by design: a sentence it cannot parse becomes a warning rather than a guessed rule, and "for high-risk devices" sets the device band without also setting the transaction risk_level_in.
Practical guidance#
- Start with a small policy: one health guard (
avoid_health_states: ["critical", "unavailable"]), one step-up rule for your risk threshold, one no-retry list for your known-fatal decline codes. - Use
simulatebefore every publish. It shows matched rules, excluded providers, and the resulting instruction for a sample transaction. - Keep rule names meaningful; they appear verbatim in
policy.matched_ruleson every decision and in the decision log. - Amount thresholds are minor units.
amount_gte: 50000is 500.00 in a two-decimal currency.