Skip to main content

Policies (contract engine)

A policy is a typed, versioned rule set you author as JSON, attach to a subject (wallet / card / agent / treasury account), and evaluate. It is the deterministic core of the platform: every decision is reproducible and content-addressed by a sha256 of its compiled rules — no model, no guesswork.

Deterministic by design

Evaluation reuses the same engine that gates live card authorizations, so a dry-run matches real enforcement exactly. A natural-language authoring layer ("ap-bot can't spend at gambling merchants") can sit on top later — it would compile English into this same document format — but it never sits in the decision path. Money decisions stay deterministic and auditable.

The policy document (schemaVersion 1)

Only rules the engine actually enforces exist in the schema — a document can never claim a control it can't apply. Unknown fields are rejected, never silently dropped.

{
"schemaVersion": 1,
"limits": {
"perTransactionUsd": "250.00",
"dailyUsd": "1000.00",
"weeklyUsd": "5000.00",
"monthlyUsd": "20000.00"
},
"mcc": {
"blocked": ["7995"],
"allowed": ["5411", "5812"]
},
"counterparties": {
"blocked": ["0x1111111111111111111111111111111111111111"],
"allowed": ["0x2222222222222222222222222222222222222222"]
}
}

Both mcc and counterparties take a blocked denylist and an allowed allowlist. A counterparty allowlist is default-deny: with it set, only the listed addresses may be paid — the control machine and agent spend (x402-style per-call payments) needs to say "this agent may only pay these addresses." An address on both lists is rejected at authoring time.

Compilation normalizes the document — dedupes and sorts lists, lowercases addresses, collapses an empty allowlist (MCC or counterparty) to "no allowlist" — so identical rule sets always produce the same IR and the same sha256, regardless of authoring order. The counterparty allowlist is additive: a document that omits it compiles to the byte-identical IR (and sha256) it always did.

Author + version

curl -X POST https://api.sandbox.safebank.ai/v1/policies \
-H "X-SafeBank-Api-Key: $SAFEBANK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "AP bot guardrails", "document": { "schemaVersion": 1, "limits": { "perTransactionUsd": "50.00" } } }'

Versions are immutable; committing a new one just moves the active pointer. History is never rewritten.

Attach

Bind a policy to a subject — one active policy per subject.

sb policies attach <policyId> --to card:<cardId>
sb policies attach <policyId> --to agent:<agentId>
sb policies detach <attachmentId>

Live enforcement (rollout flag)

Attached policies are enforced on two live money paths — payments (the source wallet's policy) and card authorizations (the card's policy) — behind a staged rollout flag, POLICY_ENFORCEMENT:

ModeBehavior
offGating skipped entirely (no evaluation, no overhead).
log (default)Shadow mode — every gated transaction is evaluated and recorded to DecisionLog, and a would-be decline is logged, but the transaction proceeds.
enforceA policy deny blocks the transaction (POLICY_DENIED on payments; a Stripe decline on card auths).

The default is log so the first rollout can never decline live traffic — you watch the shadow DecisionLog rows first, then flip to enforce. Legacy cards with no tenant are never gated. Payments carry no MCC, so MCC rules are card-only.

Error posture (deliberate): if the policy subsystem itself errors mid-check, log mode fails open (the transaction proceeds; the error is logged — shadow mode can never touch live traffic) and enforce mode fails closed (a POLICY_UNAVAILABLE decline — caps fail closed once you've opted into enforcement).

Interval caps: cumulative on cards, per-transaction on wallets

dailyUsd / weeklyUsd / monthlyUsd interval caps behave differently by subject on the live gate:

  • Card authorizations — cumulative. The gate reads the card's tracked rolling spend and evaluates spent + amount > cap, so a monthlyUsd: "500" policy blocks the transaction that pushes the card's month past $500 (not just any one transaction over $500). This shares the same live spend accumulator as card limits (PUT /v1/cards/{id}/limits); when both are set, the most restrictive wins. Reversed authorizations credit the spend back to the window they were authorized in.
  • Payments (wallet policies) — per-transaction. Wallet interval caps are still evaluated against a zero-spend window: a monthlyUsd: "500" policy blocks any single payment over $500 but does not yet accumulate live spend across the month. Payments settle asynchronously through a multisig, so a hard cumulative cap needs reserve-at-create + settlement reconciliation (tracked work); until then, treat a wallet interval cap as a per-transaction ceiling.

Evaluate (dry-run)

Test a hypothetical transaction and get an authoritative verdict, a per-rule trace, and a DecisionLog audit row.

sb policies evaluate --policy <policyId> --amount 120 --mcc 5411
sb policies evaluate --subject card:<cardId> --amount 300 \
--counterparty 0x1111111111111111111111111111111111111111

The response carries allow, a decline reason (e.g. PER_TXN_LIMIT, MCC_BLOCKED, MCC_NOT_ALLOWED, COUNTERPARTY_BLOCKED, COUNTERPARTY_NOT_ALLOWED), the sha256 the decision was made against, and a ruleTrace[] showing every rule's pass / fail / skip. MCC and counterparty rules are only applied when you supply that field.

Dry-run is zero-spend by design: interval caps test the single hypothetical transaction against an empty window (a pure, deterministic rule check — "does this one transaction violate a rule?"). It deliberately does not read live tracked spend, so a repeated evaluate call always returns the same verdict. Live card gating additionally sums the card's cumulative spend against interval caps (see the note above), so a live card decision can differ from a zero-spend dry-run once the window has real spend in it.

Each list has its own trace rule: COUNTERPARTY_BLOCKED (fails on the denylist) and COUNTERPARTY_NOT_ALLOWED (fails when the supplied counterparty is absent from a set allowlist), mirroring MCC_BLOCKED / MCC_NOT_ALLOWED. Precedence mirrors MCC too: the denylist wins over the allowlist, and both counterparty rules are checked ahead of the per-transaction and interval caps.

Key endpoints

MethodPathScope
POST/v1/policiespolicies:write
GET/v1/policiespolicies:read
GET/v1/policies/{id}policies:read
POST/v1/policies/{id}/versionspolicies:write
GET/v1/policies/{id}/versionspolicies:read
DELETE/v1/policies/{id}policies:write
POST/v1/policies/{id}/attachmentspolicies:write
GET/v1/policies/{id}/attachmentspolicies:read
DELETE/v1/policies/attachments/{attachmentId}policies:write
POST/v1/policies/evaluatepolicies:evaluate

Agent keys may read and evaluate policies but never write them — standing rule authoring outlives the agent's own caps, so policies:write is agent-forbidden.

See the REST reference for full request/response schemas.