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.
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
- TypeScript
- CLI
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" } } }'
const policy = await sb.policies.create({
name: 'AP bot guardrails',
document: { schemaVersion: 1, limits: { perTransactionUsd: '50.00' } },
})
// Commit a new immutable version (becomes active):
await sb.policies.addVersion(policy.policyId, {
schemaVersion: 1,
limits: { perTransactionUsd: '75.00' },
})
sb policies create --name "AP bot guardrails" \
--doc '{"schemaVersion":1,"limits":{"perTransactionUsd":"50.00"}}'
sb policies add-version <policyId> --file ./guardrails-v2.json
sb policies versions <policyId>
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:
| Mode | Behavior |
|---|---|
off | Gating 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. |
enforce | A 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).
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 amonthlyUsd: "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.
- CLI
- TypeScript
sb policies evaluate --policy <policyId> --amount 120 --mcc 5411
sb policies evaluate --subject card:<cardId> --amount 300 \
--counterparty 0x1111111111111111111111111111111111111111
const result = await sb.policies.evaluate({
subjectId: cardId,
subjectType: 'card',
amountUsd: '300.00',
})
console.log(result.allow, result.reason, result.ruleTrace)
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
| Method | Path | Scope |
|---|---|---|
POST | /v1/policies | policies:write |
GET | /v1/policies | policies:read |
GET | /v1/policies/{id} | policies:read |
POST | /v1/policies/{id}/versions | policies:write |
GET | /v1/policies/{id}/versions | policies:read |
DELETE | /v1/policies/{id} | policies:write |
POST | /v1/policies/{id}/attachments | policies:write |
GET | /v1/policies/{id}/attachments | policies:read |
DELETE | /v1/policies/attachments/{attachmentId} | policies:write |
POST | /v1/policies/evaluate | policies: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.