Authorize policies
By default, POST /v1/intent/verify enforces two rules: a $500 hardware threshold and an opt-in biometric requirement. The Custom Policy Engine lets you replace those with your own stored, declarative rules — change the threshold, require biometrics for specific action types or block stale tokens, without a code change, and manage them from the dashboard or the API.
Policies are per account, versioned, and evaluated automatically inside verify. Accounts with no policy use a built-in default that reproduces today's behavior exactly, so this is fully backward compatible.
How evaluation works
A policy is an ordered list of rules. Each rule has a when (conditions, all ANDed) and a require. Rules run top-to-bottom; the first rule whose when matches and whose require is not satisfied fails the verification with that rule's reason. If no rule fails, verification proceeds.
Cryptographic checks (action_hash, payload_hash) always run before policy — an invalid token is rejected regardless of policy.
Facts
Conditions and requirements evaluate against the verify-time fact object:
| Fact | Type | Source |
| --- | --- | --- |
| transaction_amount | number | request context |
| action_type, currency, … | scalar | request context (any scalars you pass) |
| action_description | string | request body |
| hardware_attested | boolean | token (native hardware SDK) |
| biometric_bound | boolean | token — true only from a verified biometric |
| device_trust_tier | string | token (hardware tier: simulated … sep) |
| payload_bound | boolean | verify |
| intent_token_age_seconds | number | verify |
| require_biometric | boolean | request context |
A condition that references a missing fact is treated as non-matching (the rule simply doesn't apply).
Operators
eq, ne, gt, gte, lt, lte, in (value is an array), matches (a safe glob — * and ?; regex metacharacters are literal, so there is no ReDoS surface). Numeric operators against a non-numeric fact are non-matching, never an error.
Requirements
biometric: true— fails unlessbiometric_boundis true →policy_biometric_requiredhardware: true— fails unlesshardware_attestedis true →policy_hardware_requiredmin_tier: "<tier>"— fails unlessdevice_trust_tier≥ tier →policy_tier_requireddeny: true— always fails →policy_deniedapproval: true— requires out-of-band human step-up →approval_pending(see below)
Set a rule's reason to override the derived code. The default policy keeps the legacy hardware_required_above_500 and biometric_required strings so existing integrations keep working. (The approval reason codes are never overridden — they drive the poll-and-approve flow.)
Step-up approval
require: { approval: true } doesn't deny — it asks a human. The verify returns { valid:false, reason:"approval_pending", approval_id } and a request appears in the account's dashboard Approvals inbox. Once approved, re-verify returns valid:true. Compose it with biometric: true to demand the approver clear Face ID / Touch ID as they approve (not the token's own biometric). approval and deny are mutually exclusive. See the full sequence in agent authorization → step-up approval.
Example
Raise the threshold to $1,000 and require a real biometric for large wires:
{
"rules": [
{
"id": "wire-over-1k-needs-biometric",
"when": [
{ "fact": "action_type", "op": "eq", "value": "wire_transfer" },
{ "fact": "transaction_amount", "op": "gt", "value": 1000 }
],
"require": { "biometric": true }
},
{
"id": "any-over-1k-needs-hardware",
"when": [{ "fact": "transaction_amount", "op": "gt", "value": 1000 }],
"require": { "hardware": true }
}
]
}
Managing policies
| Method | Path | Purpose |
| --- | --- | --- |
| GET | /v1/policy | the active document (or the built-in default, is_default: true) |
| PUT | /v1/policy | validate + create a new version and activate it ({ document, note? }) |
| DELETE | /v1/policy | revert to the built-in default (history kept) |
| GET | /v1/policy/versions | version history, newest first |
| GET | /v1/policy/versions/:version | one historical document |
| POST | /v1/policy/versions/:version/activate | roll back / forward |
| POST | /v1/policy/simulate | dry-run { document?, facts } → { allowed, reason?, matched_rule_id? } |
Versions are immutable — editing inserts version N+1 and atomically activates it; rollback re-activates an older version. simulate never mutates state, so you can test an unsaved draft against hypothetical facts before committing. Management requires a self-serve account key; CLI/enterprise keys always use the default policy.
With the SDK: prov.policy.get(), .set(document, { note }), .reset(), .listVersions(), .getVersion(v), .activateVersion(v), .simulate({ document, facts }).
Policy Engine v2
Beyond static facts, v2 adds history, time, lists, an org-defined escape hatch, and multi-person approvals. Velocity, policy webhooks, and multi-sig are enterprise-only at write time — a policy PUT that uses them below the enterprise plan is rejected with a 402 naming the offending rule(s). v1 rules stay available to all plans.
Velocity facts
Rate-limit from the decision ledger: { fact: "velocity", metric, window, per, op, value }.
metric:verify_count|amount_sum(sumscontext.transaction_amount)window:1h|24hper:device|session|account
// "block the 4th+ wire in an hour from this agent session"
{ "id": "burst", "when": [
{ "fact": "velocity", "metric": "verify_count", "window": "1h", "per": "session", "op": "gte", "value": 4 }],
"require": { "deny": true }, "reason": "VELOCITY_EXCEEDED" }
Velocity counts prior valid verifies in the window (the current one isn't counted yet).
Time-of-day conditions
hour_utc (0–23) and weekday_utc (0=Sun … 6=Sat), evaluated from the verify timestamp — UTC only. For local-time policy, pass your own offset/flags in context and reference those; we never guess a timezone.
Counterparty lists
Named, account-scoped allow/deny lists (up to 10k entries), referenced by { fact: "<context key>", op: "in_list" | "not_in_list", value: "<list name>" }.
curl -s -X PUT "$API/v1/policy/lists/partners" -H "Authorization: Bearer $ACCT" \
-H "Content-Type: application/json" -d '{"kind":"allow","entries":["acct-1","acct-2"]}'
# rule: { "when":[{ "fact":"counterparty","op":"not_in_list","value":"partners" }], "require":{ "deny":true } }
Lists are replace-on-write (a PUT replaces the whole list). Manage them at /v1/policy/lists (GET/PUT/DELETE :name).
Policy webhook escape hatch
Delegate the decision to your own service: require: { webhook: "<endpoint name>" }. Register the HTTPS endpoint first (POST /v1/policy/webhooks → { name, url }, returns a signing secret once). At verify, Provenance POSTs { facts } (signed with the Provenance-Signature HMAC, same scheme as outbound webhooks), waits up to 3 s, and expects:
{ "decision": "allow" | "deny" | "require_approval", "reason": "..." }
Fail-closed: a timeout, network error, non-2xx, or malformed body denies with policy_webhook_unavailable. A deny → policy_webhook_denied; require_approval funnels into the step-up flow. The same SSRF guard as outbound delivery applies (https + non-private in live). simulate never calls the webhook — it reports would_call_webhook instead.
Multi-sig (quorum) approvals
require: { approval: true, approvers: N } — N distinct credentials (sessions/keys) must approve before the request resolves; a deny by any one is terminal. The pending verify surfaces approvals_needed / approvals_received so a UI can show progress.
Canary conditions (Trace)
{ "fact": "canary", "set": "prompt_injection", "op": "gte", "value": 1, "best_effort"?: true } reads a canary family score (0–1) measured on the agent's own model — from the traced inference the agent presents (trace_id at authorize or verify) or a re-read of inference_input. A deny from such a rule is named canary_tripped. No reading ⇒ canary_unavailable (fail-closed) unless every condition on that family is best_effort. Canary rules need the Growth plan at write time, and the family must exist on a registered model. See Canary policies.
Template gallery
The dashboard editor offers starting drafts (consumer payments, enterprise treasury, canary guardrails and agent sandbox) that seed the editor in the browser. Load one, tweak, and save.
Domains
Everything above is the intent domain — the Authorize verify policy. The same versioned machinery also hosts a second domain, claims_risk: an Attest risk policy that tunes status thresholds, factor weights, and required-signal rules.
Every /v1/policy* endpoint takes an optional ?domain= query param (intent, the default, or claims_risk). Versioning and the exactly-one-active invariant are per (account, domain): your intent and claims-risk policies have independent version histories and don't interfere. The dashboard's Policies section shows a tab per domain (the Claims risk tab appears for Attest-entitled accounts). The claims_risk documents follow the risk-policy schema, and simulate?domain=claims_risk returns a { score, status, factors } preview instead of a verify evaluation.
Limits
Max 50 rules per policy, 10 conditions per rule, string values ≤ 512 chars, in arrays ≤ 100 elements. Only the fixed operator set is allowed — no arbitrary code, ever. Invalid documents are rejected at write time (HTTP 400) with per-rule details so the editor can highlight the offending rule. Claims-risk thresholds must satisfy 0 < verified < partial < 100, and each factor weight is clamped to [0, 2×default].
Shadow mode — test on live traffic before you enforce
Rolling out a stricter policy is scary: will it block legitimate traffic? Shadow mode answers that on your real traffic, with zero risk. Mark any version as the shadow:
curl -X POST $PROVENANCE_API/v1/policy/versions/7/shadow -H "Authorization: Bearer $PRV_KEY"
On every verify, the engine now ALSO evaluates the shadow document on the same facts —
but never enforces it. The response, metering, approvals, and notifications are
completely unchanged; the shadow verdict is only recorded (sealed onto the decision ledger,
present-only so non-shadow decisions hash byte-identically). Growth+ (Developer: a monthly Developer allowance of shadow runs; the report and stopping a shadow are free); one shadow per
(account, domain).
Read the delta whenever you like:
curl "$PROVENANCE_API/v1/policy/shadow-report?from=2026-07-01T00:00:00Z" -H "Authorization: Bearer $PRV_KEY"
# → { totals: { evaluated, both_allowed, both_denied, divergent, would_have_denied, … },
# divergence_rate, per_rule: [...], would_have_denied: [ { decision_id, shadow_reason, shadow_rule_id } ] }
When the would-have-denied list looks right, promote — which is just the existing
activate: POST /v1/policy/versions/7/activate. No separate machinery, no
surprises.