#Policies & Rules

You don't build safety from scratch. Anvil ships fifteen enforcement primitives — thirteen that fire on any guarded call, plus two PII rules that fire only on the safe.data.egress path. Battle-ready building blocks for the things that actually go wrong: a double charge, a runaway agent, a payment to the wrong place, a secret leaving in a payload. You pick the ones you need, tune them, and combine them into a policy. This page is that catalog — plus how rules combine, and how to read a block when one fires.

It serves two readers. If you're setting rules up, jump to the router and then the rule reference. If you're an operator staring at a blocked call, go straight to block reasons. Everything here is the exact, enforceable definition behind the vocabulary in Core Concepts — this is where the promises get their fine print.

Most people set these in the dashboard: you pick a primitive, fill its fields, and the rule writes itself as a plain-English sentence you can read back. Every card below leads with that same sentence, so the docs and the product say the same words.


#1. First principles

Anvil begins with one idea: every risky action deserves a verdict. Put a policy in front of a payment, a webhook, or an agent's tool call, and that call is always blocked, observed, or explicitly accounted for — never run unexamined. Everything below is a consequence of that.

  • You compose guarantees, not logic. A policy is a set of small, named primitives — a cap, a dedup, an approval — each doing one thing well. You pick and tune them; Anvil enforces. There's no procedure to get subtly wrong.
  • You observe before you enforce. Every rule can run in ghost first — watching and logging, blocking nothing — so a policy is adopted from evidence, not hope. That is the whole arc of Ghost → Enforce.
  • Protection only compounds. Verdicts combine as all must pass, strictest wins, so adding a rule can only tighten a policy, never quietly weaken it. Layering is always safe.
  • Safe by default, honest about limits. Bypasses stay off until you turn them on, and a rule that can't actually fire says so instead of pretending. Enforce fails closed — with three deliberate exceptions, where a rule whose counter has gone unreadable lets the call through rather than turning a brief outage into a worse one: see when a store goes down. A safety layer that lies is worse than none.
  • You're never at zero. Even an empty policy hard-blocks a leaked API key on the egress path and absorbs retry storms on any call carrying a durable key — the floor is always under you.

Every rule on this page is one guarantee: composed, observed, and only then enforced.


#2. What do you want to prevent?

Start from the bad outcome, not the primitive. Find your fear; the rule that stops it is one click away.

The bad thing you're afraid ofThe rule that stops it
A retry charged the customer twicedurable_key_dedup
A stuck job retried forevermax_retries · retry_rate_guard
One call moved more than it ever shouldamount_cap
A call went out missing the fields you audit onmetadata_required
An agent looped and burned the budgetworkflow_spend_cap · tool_call_cap
An agent planned itself into an infinite loopstep_depth_limit
It did something you can't undoconfirmation_gate
It paid someone you've never paid beforeconfirmation_gate
A secret or customer PII left in a payloadthe floor · pii_hard_block · pii_redact_and_allow
It called a vendor it should never touchservice_allowlist · service_blocklist
A forged webhook triggered a real actionwebhook_signature
One record got hammered in a loopresource_mutation_rate
Something no primitive coversCustom-code rules

#3. How a rule works

A rule is one instance of a primitive with its own settings, living in your policy's rules array. In JSON it's small:

json
{ "primitive": "amount_cap", "enabled": true, "params": { "max_amount": { "amount": 100000, "currency": "USD" } } }

Four things govern how rules behave together. You rarely touch them by hand — the dashboard enforces them for you — but knowing them explains every surprise.

Scope (applies_to). By default a rule applies to every action in the policy. Set applies_to to a subset and it only evaluates for those actions.

Cardinality. Each primitive is either singleton (exactly one per action — the default) or composable (may stack; verdicts compose as all must pass, strictest wins). It is a property the primitive declares, not a pattern you can infer — each card below states its own.

Conflicts. A duplicate pair of singletons on an overlapping scope, or a contradictory pair (service_allowlist ↔ service_blocklist), is a blocking conflict — rejected when you save and when the policy loads. If one ever slips through, the runtime fails loud with policy_rule_conflict rather than silently picking one.

#The floor

A baseline ships in every policy regardless of configuration: a 24-hour dedup TTL floor, an always-on retry-storm guard, and an auth_secret egress block (leaked API keys can't leave, even with zero rules). A rule of the same primitive overrides the floor for its actions — except the dedup TTL, which is a minimum rather than a default: a shorter ttl_ms is silently raised back to 24 hours. Ghost evaluates the floor too — against its own separate counters, so a retry storm shows up in the ghost log before it can cost you anything. It still never refuses: a floor verdict in ghost is recorded as a would_block and your call runs.

For what these terms mean rather than how they behave, see Core Concepts.

#When a store goes down

Three rules keep their counters outside your process, and they do not all fail the same way. §1 says enforce fails closed; these are the exceptions, and they are deliberate.

RuleWhen its store is unreachableWhat you see
durable_key_dedupFails closed — the call is refuseddedup_store_unavailable, an availability event
retry_rate_guardFails open — the call runsA warning on the verdict, no block
resource_mutation_rateFails open — the call runsA warning on the verdict, no block
confirmation_gate's new-counterparty axisFails open — that axis doesn't fireThe gate's other axes still apply

The split is the point. Dedup exists to stop a second charge, so refusing is the safe direction when it cannot check. A rate guard exists to absorb a storm, and refusing every call because a counter is briefly unreadable would turn a Redis blip into an outage worse than the storm — so it lets the call through and says so on the verdict. Your other rules are unaffected: a policy is all must pass, and a rule that stood down simply isn't one of the votes.


#4. Build a policy from primitives

The primitive set is powerful on its own — most teams' first enforce is a couple of primitives, no custom code. You build up from there.

  1. Pick a primitive. In the dashboard picker, choose the one that matches your fear (§2). The rule appears as a plain-English sentence.
  2. Fill its fields. Each field is a typed pill — an amount, a duration, a list of services. The sentence updates as you type, so you always read exactly what will enforce.
  3. Scope and stack. Point a rule at specific actions with applies_to, or stack composable rules (two amount caps for tiered limits). The floor is already under everything.
  4. When nothing fits — custom code (below).
  5. Edit & confirm as JSON. Every rule is editable as raw JSON for engineers who'd rather type it; the dashboard validates it against the same schema before it saves.

#Custom-code rules (when nothing fits)

For logic no primitive covers, custom_code_rules are sandboxed TypeScript files under policies/ that compose alongside the built-in rules. They run in a real sandbox and fail closed in enforce mode — if one can't load, the call is blocked, not waved through. Reach for a primitive first; reach for custom code only when your rule is genuinely bespoke.

Where your rule code runs. In your process. Always — hosted or self-hosted.

Every other rule type is evaluated by whichever runtime is enforcing for you. Custom rules are the exception, on purpose: they're code, and we will not execute one customer's TypeScript inside the multi-tenant service that guards everyone's money. No sandbox makes that a good trade.

So on the hosted path the SDK evaluates your custom rules before it calls Anvil — the same engine, the same sandbox, the same fail-closed behavior, running against the files already in your repo. If a rule blocks, the call stops locally and no network request is made at all. You lose nothing on hosted: all fifteen runtime primitives are evaluated by the Anvil runtime, and your custom-code rules are evaluated by yours, before the call ever leaves your process. Your rule code, and anything it reads, never leaves that process.

Three consequences worth knowing:

  • The pre-flight runs under the mode the plane gives this action — the same one every other rule in the policy follows, so a custom rule and the amount cap beside it can never be in different modes. Only when the plane has never answered does it fall back to your process's ANVIL_MODE. Ghost observes and records a would-block without stopping anything; enforce blocks for real.
  • Your policy file is what declares the rules, so keep it in the repo alongside them. If that file declares custom rules and can't be loaded, enforce mode holds the call rather than proceeding unchecked — same polarity as a rule that fails to load.
  • ANVIL_CUSTOM_RULE_PREFLIGHT=off turns it off if you ever need a one-line rollback. It says so loudly in your logs while it's off, because with it off nothing evaluates your custom rules.

#5. Rule reference

Fifteen primitives, grouped by what they guard. Each card leads with the sentence the dashboard shows, then the fields, what the rule needs from your code to actually fire, how it stacks, and the block reason it emits.

Idempotency — stop repeats and runaways.

#durable_key_dedup

Idempotency · runs each call once; a repeat of the same key replays the first result instead of re-running.

Reach for it when a retry could charge — or do anything — twice.

FieldWhat it meansDefaultRange
Window (ttl_ms)How long a repeat of the same key counts as a duplicate24h24h – 7d (shorter values are raised to 24h)

Needs to fire: an idempotency key on the call (context.key, or a key.for(...) on the wrap). Stacking: singleton — one per action. Blocks with: dedup_replay (the original receipt was returned) · dedup_store_unavailable (store unreachable — fail-mode decides allow vs block).

json
{ "primitive": "durable_key_dedup", "params": { "ttl_ms": 86400000 } }

#retry_rate_guard

Idempotency · caps how fast one key can be retried, so a storm can't pile up.

Reach for it when a single call might get hammered faster than anything downstream can absorb.

FieldWhat it meansDefaultMax
Max rate (max_rate_per_sec)Sliding-window ceiling against one durable key100 / sec10,000 / sec

Needs to fire: an idempotency key. Without one the window falls back to the action name, which turns a per-key retry guard into a per-action rate limit every caller shares — still a guard, but not the one you configured. The verdict says which: keyedOnAction is true when it fell back. Counts per process: the sliding window lives in the process that evaluated the call, so across N replicas the effective ceiling is N × this number. Size it as a runaway-loop floor, not a distributed rate limiter. Stacking: composable — tighter guards can layer. Blocks with: retry_rate_exceeded. (The always-on floor also emits floor_retry_storm for the baseline storm guard.)

json
{ "primitive": "retry_rate_guard", "params": { "max_rate_per_sec": { "count": 100, "per_ms": 1000 } } }

#max_retries

Idempotency · caps how many times one call can be retried, so a stuck job can't retry forever.

Reach for it when you want a hard ceiling on attempts, not just a rate.

FieldWhat it meansDefaultRange
Max (max)Retries allowed before blocking51 – 1000

Needs to fire: an attempt counter — set automatically when the call carries a key. Stacking: composable. Blocks with: max_retries_rule_exceeded.

json
{ "primitive": "max_retries", "params": { "max": 5 } }

Financial — cap what one call can move, and demand the paper trail.

#amount_cap

Financial · blocks any single call over a hard ceiling.

Reach for it when one call could move more than it ever should.

FieldWhat it meansDefaultRange
Max amount (max_amount)The ceiling. Entered in dollars in the picker; stored as cents$1,000.00$0.01 – $10,000,000
Bypass field (metadata_field)Optional metadata that waves a call past the cap— (hard cap)—

Needs to fire: an amount on the call (context.amount, in minor units). Stacking: composable — stack caps for tiered limits. Blocks with: amount_cap_exceeded.

json
{ "primitive": "amount_cap", "params": { "max_amount": { "amount": 100000, "currency": "USD" } } }

#metadata_required

Financial · blocks any call missing the fields you name.

Reach for it when every call must carry an audit key like customer_id or approved_by.

FieldWhat it meansDefaultMin
Required fields (required_fields)Metadata keys every call must includecustomer_id1 field

Needs to fire: the named fields on context.metadata. Stacking: singleton. Blocks with: metadata_required_rule.

json
{ "primitive": "metadata_required", "params": { "required_fields": ["customer_id"] } }

Agent & workflow — keep one run inside its budget.

These three rules don't govern a single call — they govern a whole agent run: one logical unit of work that fans out into many calls, tools, and steps. To enforce them, Anvil has to know where a run begins and ends, so it can total spend, count tool calls, and measure nesting across the run instead of per call. You mark that boundary once, and every call inside shares one run context:

  • anvil.run(fn) / anvil.middleware() — wrap the run; everything inside belongs to it.
  • anvil.workflow(id) — name a multi-step run explicitly.
  • Or the dashboard's one-tap "what counts as one run?" chooser, which infers the boundary from live traffic.

Until that boundary exists these rules are dormant — saved and visible, but enforcing nothing, because there is no run to measure. The dashboard shows them as needs setup until real runs flow, then greens them on its own. Spend, tool calls, and depth each accumulate independently against the same run context, so all three can guard one run at once.

#workflow_spend_cap

Agent · one budget for a whole run; Anvil sums spend across every step and blocks at the cap.

Reach for it when an agent could rack up many small calls that add up.

FieldWhat it meansDefaultRange
Max spend (max_spend)Ceiling on accumulated spend within one run, as an { amount, currency } value{ amount: 100000, currency: "USD" } — $1,000.001 – 1,000,000,000

Needs to fire: a run boundary (see above) — without one, this rule is dormant. Stacking: composable. Blocks with: workflow_spend_cap_exceeded · workflow_spend_cap_currency_mismatch.

json
{ "primitive": "workflow_spend_cap", "params": { "max_spend": { "amount": 100000, "currency": "USD" } } }

#tool_call_cap

Agent · caps how many tool calls one run can make, then stops it.

Reach for it when a runaway loop could fire the same tool hundreds of times.

FieldWhat it meansDefaultRange
Max (max)Tool/function calls allowed in one run501 – 100,000

Needs to fire: a run boundary (see above). Stacking: composable. Blocks with: tool_call_cap_exceeded.

json
{ "primitive": "tool_call_cap", "params": { "max": 50 } }

#step_depth_limit

Agent · stops a run that keeps nesting sub-steps past a depth.

Reach for it when a plan-and-execute agent might recurse without exiting.

FieldWhat it meansDefaultRange
Max (max)Levels of nesting allowed before blocking121 – 1000

Needs to fire: a run boundary (see above). Stacking: composable. Blocks with: step_depth_exceeded.

json
{ "primitive": "step_depth_limit", "params": { "max": 12 } }

#confirmation_gate

Agent · pauses a risky action and waits for a person to approve or reject it in the Actions tab.

Reach for it when some calls should never run unattended — the ones you can't undo, the big ones, or the first payment to a stranger.

It fires when any of these conditions match (you can turn on as many as you like):

ConditionWhat it does
Trigger words (trigger)Hold when the action's name contains one of these words
Amount over (amount_over)Hold any call at or above this amount. Entered in dollars in the picker; stored in the currency's smallest unit — 50000 = $500.00 in USD, the same contract as max_amount and max_spend
Amount scope (amount_scope)any (default): hold the trigger action and anything over the amount. trigger: only "the trigger action over the amount" (e.g. refunds over $1,000)
Irreversible (irreversible)Also hold actions you can't take back — deletes, purges, revokes. Anvil knows these by name
New counterparty (new_counterparty)Also hold the first call to a recipient Anvil hasn't seen — stores only a salted hash, never the raw value
All actions (all_actions)Hold every call this policy covers, with no other test
Counterparty field (counterparty_field)Which metadata field names the recipient. Leave it blank and Anvil auto-detects from the usual names — recipient, destination, destination_account, payee, vendor_id, customer_id, account_id, to_address, to, email. No field on the call, no counterparty: the axis stays quiet rather than holding everything
Approval field (approval_field)Optional metadata that pre-clears a call your code already vetted

Needs to fire: an idempotency key (so the same held call resumes on approval). Stacking: singleton. Blocks with: confirmation_required (the call is held, then resolved in the Actions tab).

json
{ "primitive": "confirmation_gate", "params": { "trigger": "refund|payout", "irreversible": true } }

Service & egress — control where calls can go, and what can leave.

#service_allowlist

Service · lets calls reach only the services on your list; everything else is blocked.

Reach for it when an action should only ever talk to a known, short list of vendors.

FieldWhat it meansDefaultMin
Allowed (allowed)Services calls may reach. Wildcards: .example.com, stripe.—1 service

Needs to fire: a service name Anvil can see. context.service is ANVIL_SERVICE — the name of the app doing the guarding, not the host the call goes out to — so an allowlist written against vendor names matches nothing. Set ANVIL_SERVICE to the name you want matched. Left unset on the SDK path a call carries no service at all, and the rule stays dormant rather than blocking. Stacking: singleton. Conflicts with: service_blocklist. Blocks with: service_not_in_allowlist.

json
{ "primitive": "service_allowlist", "params": { "allowed": ["stripe", "sendgrid"] } }

#service_blocklist

Service · blocks calls to the services you name; everything else still goes through.

Reach for it when you need to quarantine a vendor fast after an incident.

FieldWhat it meansDefaultMin
Blocked (blocked)Services to block. Wildcards supported—1 service
Require service (require_service)Also block any call that doesn't declare where it's goingoff—

Needs to fire: a service on the call. Stacking: singleton. Conflicts with: service_allowlist. Blocks with: service_in_blocklist.

json
{ "primitive": "service_blocklist", "params": { "blocked": ["legacy-billing"] } }

#webhook_signature

Service · verifies the signature on inbound webhooks before you act on them.

Reach for it when a webhook triggers a real action and a forged one would be costly.

FieldWhat it meansDefault
Scheme (scheme)stripe, github, shopify, or generic_hmac_sha256stripe
Secret (secret_ref)Name of the env var holding the signing secret— (required)
Tolerance (tolerance_seconds)Max age of the webhook timestamp300s
Header / encoding / timestamp headerFor generic_hmac_sha256 — which header carries the signature, its encoding, and (optionally) the timestamp header—

Needs to fire: a webhook context on the call — guard({ action, webhook: { rawBody, headers } }), with the raw body. Mount express.raw({ type: '/' }) before your JSON parser: a body that has been parsed and re-stringified will not verify, because the signature covers the original bytes. Without the context the rule is dormant — it never blocks and says so on the verdict, rather than failing a webhook you meant to check. Stacking: singleton. Blocks with: webhook_signature_missing · webhook_signature_invalid · webhook_timestamp_stale · webhook_secret_unavailable · webhook_scheme_unsupported.

json
{ "primitive": "webhook_signature", "params": { "scheme": "stripe", "secret_ref": "STRIPE_WEBHOOK_SECRET" } }

PII — stop sensitive data from leaving.

#pii_hard_block

PII · blocks any outbound call carrying the data classes you pick — it never reaches the destination.

Reach for it when certain data must never leave, full stop.

FieldWhat it meansDefaultMin
Classes (classes)auth_secret, government_id, financial, contact, ip_address, phi—1 class

Needs to fire: the call must run on the safe.data.egress path (see fine print). Stacking: singleton. Blocks with: data_egress — the reason names the classes it found.

json
{ "primitive": "pii_hard_block", "params": { "classes": ["government_id", "financial"] } }

#pii_redact_and_allow

PII · strips the data classes you pick from the payload, then lets the call through.

Reach for it when the call is legitimate but shouldn't carry the sensitive fields.

FieldWhat it meansDefaultMin
Classes (classes)Classes to strip before the call proceedscontact1 class

Needs to fire: the safe.data.egress path (same as above). Stacking: singleton. Blocks with: — (it redacts and allows; redacted fields are logged for review). Off the egress path it emits the same data_egress soft warning as pii_hard_block.

json
{ "primitive": "pii_redact_and_allow", "params": { "classes": ["contact"] } }

Rate — stop one thing from being changed too often.

#resource_mutation_rate

Rate · limits how often one resource — a single user, order, or document — can be changed.

Reach for it when a loop could keep updating the same record.

FieldWhat it meansDefaultMax
Rate (rate)Per-resource ceiling, sliding window10 / min100,000
Resource field (resource_field)Metadata key carrying the resource idresource_id—

Needs to fire: the resource id in context.metadata[resource_field]. Counts per process: same in-process window as retry_rate_guard above — N replicas means N × this rate. Stacking: composable. Blocks with: resource_mutation_rate_exceeded.

json
{ "primitive": "resource_mutation_rate", "params": { "rate": { "count": 10, "per_ms": 60000 }, "resource_field": "resource_id" } }

#6. Block reasons

Every blocked call carries a machine-readable blockedBy code on its receipt, its audit record, and the dashboard's reason filter. blockedBy is null on an allow. This is the operator's decoder ring — code → what happened → what to do.

Most codes below mean a rule of yours fired. A handful mean Anvil could not evaluate the call — its own dependency was degraded — and those are never counted as money a policy saved. Both kinds appear here, and each row says which it is.

Idempotency & retries

CodeWhat it meansWhat to do
dedup_replayA repeat of a key was caught; the caller got the original receiptNothing — this is dedup working
dedup_store_unavailableThe dedup store was unreachableCheck Redis / the control plane; fail-mode decided allow vs block. An availability event, not a policy decision — never counted as money saved
retry_rate_exceededRetries on one key crossed the rule's rateExpected under a storm; raise the rate if legitimate
max_retries_rule_exceededA key hit its retry ceilingInvestigate why the job keeps retrying
floor_retry_stormThe always-on baseline storm guard firedSame as above; tune the rule if the baseline is too tight

Financial

CodeWhat it meansWhat to do
amount_cap_exceededA single call was over its capExpected; raise the cap or use the bypass field if legitimate
metadata_required_ruleA call was missing a required fieldHave the caller include the field

Agent & workflow

CodeWhat it meansWhat to do
workflow_spend_cap_exceededA run hit its spend budgetRaise the budget or investigate the run
workflow_spend_cap_currency_mismatchSpend arrived in a currency the cap doesn't compareNormalize currencies before the run
tool_call_cap_exceededA run made too many tool callsUsually a runaway loop — investigate
step_depth_exceededA run nested too deepUsually non-terminating recursion — investigate
confirmation_requiredA call is held for a humanApprove or reject it in the Actions tab

Service, egress & PII

CodeWhat it meansWhat to do
service_not_in_allowlistA call targeted a service not on the allowlistAdd the service if legitimate
service_in_blocklistA call targeted a blocked serviceExpected; remove from the blocklist if intended
data_egressTwo meanings, and the reason string tells them apart. A block: an outbound safe.data.egress payload carried a hard-blocked class and was stopped before it left your process — the reason names the classes found. A soft warning: a PII rule saw a call off the egress path and could not scan it.If it blocked: expected, the payload was stopped. If it's the warning: the rule is dormant on that call — route it through safe.data.egress to make it enforceable

Webhooks

CodeWhat it meansWhat to do
webhook_signature_missingNo signature header on the requestReject — likely not a genuine webhook
webhook_signature_invalidSignature didn't verifyReject — forged or wrong secret
webhook_timestamp_staleTimestamp older than the toleranceReplay or clock skew; check the sender
webhook_secret_unavailableThe signing secret env var isn't setConnect the secret
webhook_scheme_unsupportedThe configured scheme isn't recognizedFix the rule's scheme

Spend caps, rate & invariants

CodeWhat it meansWhat to do
spend_per_user / spend_per_workflowA cumulative spend cap was hitRaise the cap or investigate
spend_store_unavailableThe spend-cap store was unreachable, so Anvil failed closed on a limit it could not readCheck Redis / the control plane; fail-mode decided allow vs block. An availability event, not a policy decision — never counted as money saved
spend_user_key_missingA per-user cap couldn't find the user keySupply the user key on the call
resource_mutation_rate_exceededOne resource was mutated too oftenExpected under a loop; investigate
policy_rule_conflictTwo conflicting singleton rules reached the runtimeFix the policy — the loader normally rejects this; a hand-edited file slipped through

Custom code, and the codes that aren't a rule of yours

CodeWhat it meansWhat to do
custom_ruleOne of your custom_code_rules returned a block — or a policy naming a primitive the runtime doesn't recognise reached evaluationRead the rule's own reason string; it carries whatever your code returned. An unknown primitive means the policy and the SDK version disagree
anvil_unavailableAnvil could not evaluate the call and held it. Not a ruleRetry once the dependency is back. Never counted as money saved
anvil_failed_openAnvil could not evaluate the call and let it run unchecked — failsafe: true on the recordThis call executed with no protection. Reconcile it, then revisit your fail mode
setup_requiredAn enforce-mode money call had no durable store to record itSet ANVIL_POSTGRES_URL — see Do I need a database?

The blockedBy enum is append-only — codes are added, never renamed — so it's safe to build alerting on these strings. (Policies still using the older legacy fields emit older synonyms — e.g. max_amount, allowlist, service_not_allowed, workflow_budget — which mean the same things as their rules-array equivalents above.)


#Where to go next

Esc
↵ to open · ↑↓ to move · Esc to close