#Core Concepts

Anvil is the execution-correctness layer for risky actions. Put it in front of a payment, a refund, a webhook, an email, an external mutation, or an agent tool call, and that action becomes idempotent, policy-checked, and provable. Every call is blocked, observed, or explicitly accounted for.

This page is the dictionary the rest of the docs assume. It defines the words once so the guides can stay short. You do not need to read it top to bottom — land here from another page, find the term, and go back.

Two entrypoints run through everything below. The safe namespace is the ergonomic layer you write in app code — it returns your provider's result and throws on a block. The anvil namespace is the lower-level layer for when you want the structured verdict instead of an exception. Lead with safe; reach for anvil when you need the detail.


#Contents

  • The protected call — guard, safe.protect, safe.wrap, the verdict, holds, risky actions
  • Ghost and enforce — the adoption arc, dryRun, default mode, the control plane
  • Idempotency and the guarantee — durable keys, hashing, receipts, exactly-once-or-known-unknown
  • The unhappy path — unknown outcomes, closing one on hosted, reconciliation, compensation
  • Reliability — fail modes, persistence, the durability requirement
  • Scope — workspaces, run boundaries, workflow grain, workflows
  • Policies and rules — policies, rules, primitives, conflicts, the floor, versioning
  • The operator view — the decision log, verdicts, unavailable, risk context, tamper-evident receipts

Here for the SDK surface? It lives in this page rather than a separate reference: guard() · safe.protect / safe.wrap · key · anvil.hash() · anvil.workflow() · the verdict it returns.


#The protected call

#Risky action

Anything with an external or stateful side effect: moving money (payment.charge, payment.refund, payment.payout, payment.transfer), changing billing (billing.subscription.cancel), delivering a webhook or an email, mutating a third-party system (external.mutation), an agent tool call (api.call), or PII leaving your walls (data.egress). These are the calls where "oops" has a real cost, so these are the calls Anvil protects.

Reads, GETs, and idempotent lookups are not risky and are not meant to be guarded.

Action names are lowercase. Letters, digits, dots and underscores — payment.refund, s3.upload, or a single word like charge. A capital or a hyphen throws AnvilKeyError at the call site, because the durable key is built on the action name and the two have to agree for as long as the receipts live. Hyphens are fine in the id half of a key, which is where they usually belong: payment.refund:order-1024.

#guard() — the protected call

The core primitive. guard() resolves the durable key, evaluates the policy, claims the key so the side effect runs at most once, runs your execute(), and returns a structured verdict.

ts
const res = await anvil.guard({ action: 'payment.charge', amount: 4999, key, execute });
if (res.status !== 'executed') {
  // blocked, duplicate, or unknown — handle it; execute() may never have run
}

guard() returns a verdict; it does not throw when a policy blocks the call. A block is status: 'blocked', a deduped repeat is status: 'duplicate', an uncertain result is status: 'unknown'. It throws only for genuine infrastructure and setup failures (Redis unavailable in fail-closed mode, a policy that fails to load, a money action with no durable store, a money action guarded in enforce with no amount, an invalid key).

#safe.protect(...) and safe.wrap(...)

The ergonomic layer. Both run guard() under the hood and then unwrap the verdict: on success you get your provider's raw result back; on a block, an unavailable duplicate, or an unknown outcome, they throw a typed error — so a blocked call can never be mistaken for a completed one.

  • safe.protect wraps one inline risky effect. Return-or-throw around a single call.
  • safe.wrap returns a drop-in replacement for an existing function, preserving its this, arguments, return value, and thrown errors. Every business option — key, action, amount, and the rest — can be a value or a factory of the call, so the key and amount can come from the wrapped call's own arguments.
ts
// Drop-in: call charge(order) exactly as before — now idempotent and policy-gated.
const charge = safe.wrap({
  fn: chargeOrder,
  orderId: (ctx) => ctx.args[0].orderId,
  amount: (ctx) => ctx.args[0].amountCents,
});

Product-shaped helpers sit on top of the same machinery and return your provider result: safe.payments (charge, refund, confirm, capture), safe.subscriptions, safe.external.mutation, and safe.agent.toolCall. Amounts are always integer cents (4999 = $49.99).

#The verdict (AnvilResponse)

What guard() returns. Exactly one status per call:

statusmeaningresultnextAction
executedallowed and ranyour resultnone
duplicatethis key already ran; the recorded result is replayedrecorded result, or nullnone
conflicta concurrent caller won the race and already ran itrecorded result, or nullnone
blockeda policy rule stopped it; execute() never rannulldo_not_retry
unknownthe outcome is uncertain; it may or may not have runnullreconcile · review · retry · do_not_retry

nextAction is one of five values — none, retry, do_not_retry, reconcile, review.

On unknown it is derived from what Anvil actually knows, not from the action type alone. A provider failure it can prove terminal reads do_not_retry, a timeout it is confident never landed reads retry, a payment. / billing.subscription. / external.mutation call reads reconcile, anything else reads review. Branch on nextAction, not on your own reading of the reason string.

blockedBy is the stable, machine-readable _reason_ code for the block that the dashboard's reason filter keys off (the specific rule instance is a separate policyRuleId). result is null for anything that did not run.

There is no allow status — an allowed-and-ran call is executed. And response.ghost (the "what it _would_ have done" shadow, described next) is never the authoritative outcome: read status, not ghost.

#Held for a human

A confirmation_gate doesn't refuse a call — it holds it for a person. But a hold reaches your code as a block: status: 'blocked', blockedBy: 'confirmation_required', and under safe. a thrown AnvilPolicyBlockedError. That is deliberate. Code that treats every non-executed verdict as "do not proceed"* is already correct for a hold, so a pending decision can never be mistaken for a completed one — the same reasoning that makes a block a return value and not a silent pass.

What separates a hold from a refusal is what happens next. The key is deliberately left unclaimed, so once a human approves, retrying the same guarded call with the same key goes through. The shape is block-and-retry, not hold-and-resume — nothing re-fires the held action for you.

ts
catch (err) {
  if (err instanceof AnvilPolicyBlockedError && err.response.blockedBy === 'confirmation_required') {
    // Held, not refused. Surface it to a human, then retry this exact call once approved.
    const { approvalId } = err.response.policyDetails?.context ?? {};
  }
}

Approve or reject in the dashboard's Actions tab. An approval authorizes one act, once: it is bound to that call's amount, currency, and counterparty, and redeemed by a compare-and-swap that leaves a tombstone behind — so a changed amount no longer matches, and two concurrent retries can never both spend it. A human's no survives the next retry instead of being quietly re-asked.


#Ghost and enforce

Ghost then enforce is the whole adoption arc. You observe against real traffic, you watch the proof, then you turn enforcement on. These are the two runtime modes a protected call can resolve to.

#Ghost mode

Observe-only. guard() still runs your real execute() — the charge is charged, the email is sent — and then records what the policy _would_ have decided as a shadow observation (response.ghost.decision: would_allow, would_block, would_dedup, would_redact, would_unknown). Ghost blocks nothing, holds nothing, redacts nothing.

Because ghost never enforces, it also never mutates durable protection state. Counterparty trust is the clearest case: it accrues only from an enforced call that actually executed, and only when you have opted in with ANVIL_AUTO_TRUST_COUNTERPARTY. A ghost rollout pre-warms it instead with anvil counterparty backfill, so flipping to enforce doesn't hold every recipient you already pay.

#Enforce mode

Active protection. When the policy verdict is _block_, execute() is not called and the verdict is blocked with a reason and a blockedBy code. Amount caps, dedup, PII egress blocks, and confirmation gates are real here. Allowed calls run normally and are audited.

#dryRun — the true simulation

Pass dryRun: true to guard() to evaluate the policy without running the side effect and without any durable change: no key claim, no receipt, no audit record, no spend-cap reservation at all — the real counter is read, never written — and no approval spent, so a human decision waiting on that key is still there for the call that actually runs. The verdict comes back { simulated: true } with the predicted status.

A dry run also leaves the approval queue alone. A call the policy would hold writes no pending hold and fires no approval.held webhook, so a preview never pages anyone. If a person has already approved that key, the verdict's humanApproval still names them — the approval is shown, not spent. What a dry run does still count is the retry windows (the always-on retry-storm floor and retry_rate_guard), so a burst of previews on one key can trip them; see the floor.

dryRun is refused on the hosted-API path and the egress path (it throws), so a simulation can never accidentally execute.

#Default mode

It depends on which engine you are on, and the two default opposite ways.

  • Self-hosted: a fresh install enforces. With nothing configured, ANVIL_MODE unset, and no control-plane override, a local call resolves to enforce. Ghost is opt-in. You cannot accidentally ship a policy that silently observes but never protects — though a too-strict policy blocks real calls immediately, which is exactly why you set an action to ghost first.
  • Hosted: a fresh workspace observes. Until you flip an action to enforce, the hosted plane resolves it to ghost — an un-flipped action never blocks. That is the arc anvil protect is built around, and it is why ANVIL_MODE=ghost in your .env is a local default rather than the authority.

Only ghost and enforce are runtime modes; any other value throws. (off appears in the mode-change _history_ log to mean "un-protected," but it is never a value the runtime resolves to.)

#Control plane

The single config document — mode plus policy — that the SDK reads on the hot path of every guarded call, stored in Redis and cached in-process for ~3 seconds (ANVIL_CONTROL_CACHE_TTL_MS). The dashboard and CLI write it; the SDK reads it and overlays it on your local env defaults, so you can flip a mode at runtime without redeploying. Writes are atomic compare-and-swap, so two operators can't clobber each other.

How long "propagates within a few seconds" actually is depends on which engine you're on, and it's worth knowing before an incident rather than during one. Self-hosted, a flip crosses one cache: the SDK's ~3 s. Hosted, it crosses two in series — the plane's own per-workspace snapshot (ANVIL_HOSTED_POLICY_CACHE_TTL_MS, 5 s by default, which is the number anvil control reports back to you) and then each SDK process's ~3 s control cache. Budget up to ~8 seconds for a hosted flip to be in force everywhere. Nothing is lost in that window — calls keep running under the previous mode and every one is still recorded — but a flip you check after four seconds can look like it didn't take.

If the control plane is unreachable, resolution falls back to the last mode the control plane itself gave this process, and only then to your local env mode (default enforce) — toward the operator's actual setting first, local config second, and never toward open. That order is load-bearing: ANVIL_MODE is a local default that no dashboard flip ever rewrites, so treating it as the authority would mean an outage silently un-enforced every action you had flipped on. When nothing is known at all — a process that has never once reached the control plane — ANVIL_FAIL_MODE decides, and it defaults to closed.

Mode is resolved per call by picking the most specific setting: a per-workspace enforcement ceiling beats a kill switch beats a per-action override beats a per-service default beats the global default. So you can run payment.refund in enforce while payment.charge stays in ghost, under one service.

#Enforcement ceiling

The one setting above your own. Anvil can pause enforcement for an entire workspace: while a ceiling is in force every action resolves to ghost regardless of its own mode — calls run, nothing blocks, everything is still recorded. It only ever reduces enforcement, never raises it, and only Anvil can lift it. See One case where enforce does not block.

#Kill switch

The panic lever. Set it and every guarded call drops to ghost within the cache TTL — blocking stops everywhere at once, without touching a single policy. It disables _blocking_, not the side effects: calls still run (money still moves), they're just observed. Turn it off and per-action modes resume.


#Idempotency and the guarantee

#Durable key (AnvilKey)

An opaque, validated string that names the logical action, so retries of the same logical call collapse to one side effect. Anvil dedups on the key — not on your code — so two guard() calls carrying the same key are the same action. Build keys through the helpers, never by hand:

ts
key.payment(orderId)                                   // 'payment.charge:<orderId>'
key.refund(orderId, reason)
key.webhookReceived('stripe', eventId)                 // stable across the provider's retries
key.externalMutation(provider, resourceType, id, op)   // business identity, not a request id

A key identifies one logical action forever: pass the same durable business identity and every retry produces the same key.

Key-construction failures throw AnvilKeyError immediately, with an actionable message — a loud failure at build time instead of a silent double-charge later. Don't catch and swallow it.

#anvil.hash() / payloadHash

A deterministic SHA-256 fingerprint of durable business identifiers. Same inputs → same output, across processes and machines; different value or order → different output; it depends only on what you pass, never on time or randomness.

ts
const payloadHash = anvil.hash(orderId, amountCents); // 'sha256:...'

Pass a payloadHash and omit key, and guard() derives the key for you as <action>:auto-<hash>. Anvil only ever auto-derives from your payloadHash — never from the execute() closure, because a closure like () => stripe.charge(customerId, amount) stringifies identically for every customer and would fold distinct charges into one receipt. Supply neither a key nor a payloadHash and guard() refuses rather than guess. As with keys: hashing requestId or Date.now() produces a new fingerprint per retry and defeats dedup.

#Receipt

The durable, per-execution record of a guarded action — the correctness artifact Anvil leaves behind. It captures the decision (executed / duplicate / blocked / unknown), the identity (key, action, amount, payloadHash, provider reference), and the recovery posture (resolution, reconciliation, a derived recovery state). It's what a retry, an operator, and an audit all read from.

For money-moving and enforce paths the receipt is written to an authoritative durable store (Postgres), so the executed state survives a Redis outage; a durable-write failure is thrown, not swallowed. A local JSONL file (artifacts/anvil-receipts.jsonl) is also appended best-effort, but it is not authoritative and is wiped on an ephemeral filesystem redeploy.

#The guarantee — exactly-once-or-known-unknown

The promise underneath everything: a retried action runs its side effect at most once.

  • When Anvil can durably prove the prior outcome, a repeat of the key returns the recorded result (duplicate) — charged once.
  • When it genuinely cannot prove the outcome (Redis dropped after the side effect but before it was recorded), it returns unknown with nextAction: 'reconcile'. It never reports a false executed.

What this does not promise: unconditional exactly-once _completion_. Under unknown the side effect may or may not have run — branch on nextAction: reconcile and review mean confirm at the provider first, while retry means Anvil is confident the side effect never landed. Dedup holds only when you pass a stable key or payloadHash; a money deployment with no durable store is designed to refuse to run rather than offer a weak guarantee. When in doubt, Anvil reconciles — it does not claim success.


#The unhappy path

The failure modes a safety product has to document honestly. This is where Anvil earns trust.

#Unknown outcome

When a guarded side effect's result is ambiguous — the provider call timed out, or the process crashed after the side effect may have run but before Anvil recorded a terminal state — the verdict is status: 'unknown'. What reaches your code depends on which engine you are on, and the difference is worth stating plainly.

Self-hosted (local engine). Anvil surfaces a typed AnvilOutcomeUnknownError — not a plain error, not a guess. It carries the key, the full response, an autoReconciling flag, and a resolveAs() resolver.

ts
catch (err) {
  if (err instanceof AnvilOutcomeUnknownError) {
    // The side effect MAY have run. Do NOT blindly retry — that double-charges.
    // Reconcile first: an auto-worker, or confirm at the provider then err.resolveAs('executed').
  }
}

resolveAs() writes a durable record so the next guard() with that key returns duplicate instead of re-running. It takes 'executed' or 'duplicate', and both do the same thing — mark the key executed; the word only reaches the reason string. There is no resolveAs('not_executed'). The "it never happened" exit is anvil receipts resolve <receipt-id> safe_to_retry. It reopens the key's durable claim too, but only the claim this receipt's own attempt took: a retry that has re-taken the key since is a newer attempt, and an old receipt's answer does not speak for it.

Hosted. You never receive AnvilOutcomeUnknownError and there is no resolveAs closure. On an ambiguous execution the SDK reports the outcome to Anvil and then re-throws your provider's own error, so what surfaces is the failure your code already knows how to read. The exit is POST /v1/resolve, below.

When the failure is a Redis outage specifically, the class you caught already answers "did it run?" Under the default ANVIL_FAIL_MODE=closed, an outage before the side effect throws AnvilSetupError — Redis was unreachable, nothing ran, the retry is safe. An AnvilRedisUnavailableError under that default is therefore always the post-execution case.

The phase it carries is what pins that down, and it still matters on the other fail modes: claim/wait means the side effect did not run; mark_executed means it did run but couldn't be recorded, so reconcile before you retry. Under graceful, a money call held pre-execution throws AnvilRedisUnavailableError with a claim/wait phase — the one shape where the class alone isn't enough, and the phase is the answer.

#Closing an unknown on hosted

POST /v1/resolve is the hosted exit from an ambiguous outcome. There is no CLI command and no SDK method for it — call it with your own HTTP client, authenticated with your workspace API key.

sh
curl -X POST https://api.anvilcontrol.ai/v1/resolve \
  -H "Authorization: Bearer $ANVIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"key":"payment.charge:ord_1001","resolution":"executed","note":"confirmed in Stripe"}'

The two resolutions are not symmetric, and picking the wrong one has opposite costs.

  • executed — you checked the provider and it did happen. The key is settled forever; every future call on it dedups.
  • not_executed — you checked and it did not. The key re-opens, so the caller can legitimately retry.
ResponseWhat it means
200 {ok, key, resolution, reclaimable}Done. reclaimable is true only for not_executed.
400 key_required · invalid_resolutionresolution must be exactly executed or not_executed.
409 already_executedAlready settled as executed; there is nothing to resolve.
409 not_resolvableNever went ambiguous, already resolved, or belongs to another workspace.
503 state_unavailableThe resolution was recorded; the idempotency cache could not be updated yet.

Resolving an already-resolved key returns 409 rather than quietly succeeding — "this key was already closed, by someone, at some time" is exactly what the second caller needs to know before they go looking at the provider again.

This is why refusing to re-claim an unknown is safe rather than hostile. Without an exit, avoiding a double-charge would just trade it for a key nobody can ever use again — and a retry that silently never runs is quieter than a double-charge, but not better.

#The claim lease and the reclaim sweep

A durable idempotency claim is what stops two retries of the same call both executing. A process that dies mid-call would hold its claim forever, so every claim carries a 15-minute lease. An SDK process renews it while the call runs. A claim taken through the hosted plane is not renewed, so its lease simply runs out. A claim still pending past its lease is stuck, and every retry of its key is refused.

What happens to a stuck claim is the part worth reading. The reclaim sweep settles it as unknown unless the ledger proves otherwise. unknown ends the pending state and keeps the key closed. The obvious implementation, which reopens the key so the caller can retry, is the dangerous one. An expired lease is equally consistent with "the process died before the charge" and "the process died mid-charge, and the money moved," and reopening a key asserts the call did not happen. An execution receipt settles it executed, and only a failure receipt settles it failed, which reopens it. Trading a stuck key for a double charge is not a trade Anvil makes.

The sweep does not run on its own. anvil doctor counts stuck claims, and an operator settles one with anvil db settle-claim, which shows the evidence first, records who did it, and also refuses a failure receipt written before that attempt was claimed. The unknown it leaves is then closed like any other: check the provider, then POST /v1/resolve, which also accepts a claim still pending.

#Reconciliation

The mechanism that resolves an unknown-outcome receipt by asking the real provider what happened. A per-action adapter queries the provider (e.g. Stripe search by metadata['anvil_key']) and concludes one of four states:

  • confirmed_applied — the provider confirms it landed.
  • failed_after_attempt — the provider confirms it failed or no longer exists.
  • provider_mismatch — the provider's record disagrees (wrong key, amount, customer). This is a correctness alarm; it is parked for a human, never auto-healed.
  • pending_verification — still indeterminate; keep holding.

Reconciliation degrades safely: no adapter configured, or the adapter throws, or STRIPE_SECRET_KEY is missing → pending_verification with a note, never a crash or a fabricated result. It only _verifies_ what happened; it does not itself retry your business action. A background worker drains the queue (with an immediate startup scan, so a receipt left unknown by a crashed process is picked up), a single-writer lease stops two workers from both writing a result, and it self-limits to three attempts. After the third, the receipt is parked as pending_verification and the onReconciliationExhausted callback fires.

Anvil ships ten Stripe adapters, and you register your own with registerReconciliationAdapter.

#Compensation / rollback

Undoing already-executed steps — a saga-style rollback. executeRollback(workflowId) walks the executed steps that registered a compensation, in reverse order, and runs each one under its own idempotent guard key.

ts
// charge → book inventory → email; step 3 fails:
await executeRollback(workflowId); // release inventory, then refund the charge — in reverse

Rollback is idempotent and best-effort-complete: an already-compensated step is skipped; a step whose compensation throws is marked failed and rollback continues to the rest.

A compensation that _started_ but never reached a terminal state is not re-run automatically — Anvil demands manual review instead, precisely to prevent a double-refund.


#Reliability

#Fail mode

Your chosen policy for what a sustained outage does to a call Anvil could not evaluate, once the retry layer has already absorbed the common sub-second blip. It applies on both transports — on Anvil Cloud the unreachable dependency is the guard API, self-hosted it is your Redis — and it is one decision, applied identically to the idempotency claim and the spend-cap reservation. If you are on Anvil Cloud and own no Redis, this is still your setting, and it is the one that decides what happens to your money while Anvil is down.

ANVIL_FAIL_MODEbehavior on a sustained outage, after retries
closed (default)block every call — safe, but an Anvil outage becomes an availability outage
gracefulblock money-moving calls above ANVIL_GRACEFUL_MAX_UNCHECKED_CENTS, allow the rest
openallow every call unprotected — the spend cap can leak; the double-charge guarantee is off

ANVIL_GRACEFUL_MAX_UNCHECKED_CENTS is the largest spend Anvil lets through unverified in graceful mode, and it defaults to 0 — so out of the box graceful holds any money call during an outage while letting non-money calls through. That is the behaviour you get by choosing graceful and nothing else; raise the ceiling only once you have decided some amount is small enough to run unverified.

Which one to set, and what each looks like in your logs, is on Running in Production.

#Persistence model — Redis coordinates, Postgres remembers

Two roles, and the distinction matters:

  • Redis coordinates concurrent work — the idempotency claim, the spend-cap reservation, the workflow counters. It is coordination, not the system of record.
  • Postgres is the durable truth operators recover from later — receipts, audit records, durable commands, and workflow state. All writes are idempotent upserts, so a retry on a recovered connection is safe.

With no Postgres configured the default adapter is a no-op: there is no durable truth at all.

#The durability requirement — money needs Postgres

A database is for money, not for saying no. Refusing a call needs nothing durable: the call never ran, so there is nothing to remember. Knowing a refund already went out is the part that has to survive a crash — otherwise a retry after a restart sends it again.

So the requirement is scoped to money. A money-moving call in enforce mode requires durable (Postgres) idempotency; a non-money action — a webhook, a delete, an ordinary API call — enforces with no database at all, which is the day-one path. A call counts as money when it declares an amount or when the action name is one Anvil reads as a payment action: a full refund carries no amount and still moves the whole captured sum, so it fails closed exactly like a partial one. Which names count is a short list you can check your own action strings against.

When the requirement isn't met, guard() throws AnvilSetupError at call time, naming ANVIL_POSTGRES_URL, rather than running half-protected. In ghost the same call still runs — that is ghost's contract — and the observation is recorded as non-durable rather than silently counted as clean. The ANVIL_SKIP_DURABILITY_CHECK bypass is deliberately ignored when NODE_ENV=production, so a real money deploy cannot quietly run without durable storage.


#Scope

Three nested scopes, outermost first: the workspace you belong to, the run a call happens inside, and the workflow a run is grouped under.

#Workspace

Your tenant on Anvil Cloud, and the outermost scope. It is created the first time you sign in with a verified email, and every hosted read and write is scoped to it — actions, policies, receipts, approvals, keys. Your app resolves its workspace from the ANVIL_API_KEY it holds; each machine joins the same one with anvil login, which is why your laptop, your editor and your dashboard see the same actions. A workspace also carries the one setting above your own: the enforcement ceiling.

#Run boundary

An ambient scope, opened by anvil.run(...) / anvil.middleware(), during which every guarded call — including nested async continuations — shares one runId. It's built on Node AsyncLocalStorage (the same mechanism OpenTelemetry and Sentry use), so no id has to be threaded through your call sites. The boundary is what lets a per-run cap total a metric — spend, tool calls, step depth — across the separate guard() calls inside it.

ts
await anvil.run(orderId, async () => {
  await safe.payments.charge(...);     // both calls share orderId as their runId,
  await safe.external.mutation(...);   // so a workflow spend cap sums across them
});

#Workflow grain

The operator-facing answer to "what counts as one run?" for a policy. The default, auto, means "runs = one execution" (a per-execution boundary). A declared / live grain is a chosen cross-call business id — a sessionId, an orderId. An in-process cap is happy with auto; a cross-call per_workflow cap needs a declared id to mean anything. The dashboard nudges you to pick one — it does not block you, and the cap saves and activates either way. What you give up is the totalling: a call that arrives with no workflow id is allowed without being measured at all, and under auto the totals only ever cover one execution. So a cap that saved cleanly and has never once fired is usually this, rather than a policy that isn't loading.

#anvil.workflow(workflowId)

The strongest, most explicit form of run scope. Bind a stable business id once and every step inherits it, and Anvil accumulates the per-workflow tool-call count, retry count, and spend server-side, keyed on that id — so the totals survive a process restart, and a misbehaving agent can't "vote" itself past a cap with a client-side counter.

ts
const wf = anvil.workflow(sessionId);
await wf.guard({ action: 'payment.charge', amount: 5000, payloadHash, execute });
const { spendCents, toolCallCount } = await wf.state(); // a pure read; not counted as a tool call

#Policies and rules

#Policy

The top-level config Anvil evaluates on every call. It names a set of allowed_actions plus the enforcement that acts on them: a rules array, cumulative spend_caps, egress PII config, and optional custom-code rules. A policy that loads has passed full structural validation (AnvilPolicyError otherwise), and every rule's scope is guaranteed to reference an allowed action. Loading does not guarantee a rule _fires_ — a rule can validate yet stay dormant if the runtime context it needs (a key, an amount, a workflow) is never supplied.

#Rule

One entry in policy.rules: an instance of a named primitive with its own params, an enabled flag, an optional applies_to action scope, and a stable id. A rule is the row the dashboard renders and the unit the runtime dispatches on. Adding a behavior means adding a rule — not editing the policy's shape.

#Rule primitive

The catalog of enforcement building blocks — one canonical, typed table that the dashboard picker, the natural-language importer, and the runtime evaluator all read from. There are fifteen primitives. Thirteen enforce on any guarded call; the two PII primitives are the exception, noted under the table:

PrimitiveWhat it doesCategory
durable_key_dedupreplay a repeated key instead of re-runningidempotency
max_retriesblock once attempts on a key exceed maxidempotency
retry_rate_guardblock a retry storm on one key (sliding window)idempotency
resource_mutation_ratelimit how often one resource id is mutatedrate
amount_capblock a single call over a max amountfinancial
metadata_requiredblock a call missing named metadata fieldsfinancial
workflow_spend_capcap cumulative spend across one runagent
tool_call_capcap tool/function calls in one runagent
step_depth_limitblock a run that nests past a depthagent
confirmation_gatehold a risky call for a human decisionagent
service_allowlistpermit calls only to listed servicesservice
service_blocklistblock calls to listed servicesservice
webhook_signatureverify an inbound webhook's signatureservice
pii_hard_blockblock a payload containing chosen PII classesPII
pii_redact_and_allowstrip chosen PII classes, then allowPII

Two other names you'll see in the importer — vendor_token_detection and custom_code — are UI tokens, not runtime primitives. For logic no primitive covers, custom_code_rules are sandboxed TypeScript files under policies/ that compose alongside the built-in rules (and fail closed in enforce mode if they can't load).

A few carry honesty worth stating on the concepts page:

  • amount_cap gates one call; spend_caps (per_user / per_workflow) sum across many. A cap's optional metadata_field bypass is agent-writable and unverified — a prompt-injected approved_by can wave an over-cap charge through (audited, but not blocked) unless you set ANVIL_REQUIRE_CAP_NO_BYPASS.
  • confirmation_gate fires when any of four axes match — a trigger word, an amount over a threshold, an irreversible verb, or a new counterparty (a first-time recipient). It is secure by default: metadata is not treated as approval unless you explicitly name an approval_field, and the validator forces at least one axis so a gate can never be a silent no-op.
  • PII rules only enforce on the safe.data.egress path. On an ordinary guard() call a PII rule scans nothing — it emits a loud, audited soft warning rather than a fake "allow." Detection is deterministic (regex and checksums — Luhn, IBAN, SSN — no ML, no network), not a model guess.

#applies_to / scope

The per-rule action filter. Set it and the rule only evaluates for those actions; leave it empty and it applies to every action in the policy. Every entry is validated to be one of the policy's allowed_actions.

#Cardinality and conflicts

Each primitive is singleton (the default — exactly one per action) or composable (may stack, and verdicts compose as _all must pass, strictest wins_). A duplicate pair of singletons on an overlapping scope, or a contradictory pair (service_allowlist ↔ service_blocklist), is a blocking conflict — rejected at save and at load, and if one ever slips through, the runtime fails loud with policy_rule_conflict rather than silently picking one. Composable duplicates are allowed and only produce a "redundant" notice.

#The floor

The always-on baseline that ships in every policy regardless of configuration: a 24-hour dedup TTL, an always-on retry-storm guard, and an auth_secret PII floor (leaked API keys are blocked from egress even with zero rules). A user rule of the same primitive overrides the floor per action — with one asymmetry worth knowing: the dedup TTL is a minimum, not a default. Raise it (up to 7 days) and Anvil honours you; set it shorter and Anvil silently raises it back to 24 hours.

The floor evaluates in ghost too. It does not block there — nothing blocks in ghost — but it is checked, so what you read in the ghost log is what enforce would actually have done. That extends to spend caps: ghost accumulates its own window and reports a cap it would have exceeded, without reserving anything against your real budget. A week of ghost under a blown cap used to read clean; it doesn't now, which is the whole point of watching before you enforce.

The floor is also what an action gets when it has no policy at all. Enforce with an empty policy applies the floor and says so rather than holding every call — an unconfigured action is not the same as a forbidden one. The response and the receipt both carry floorOnly, and the SDK warns once per action that the call ran "protected by the always-on Anvil floor … and by nothing else."

#Policy versioning

Named saves are immutable snapshots under a family, referenced as <family>@v<number> (e.g. payments@v3). Each record keeps the full validated policy plus who created it and when, so a prior version is reproducible for diff and rollback, and the "Activate these policy changes?" screen diffs the incoming policy against the active one.

#blockedBy codes

The stable, append-only enum of machine-readable block reasons (amount_cap_exceeded, confirmation_required, service_not_in_allowlist, spend_per_user, …). The dashboard's reason filter and per-rule fire counters key off these exact strings, so codes are added but never renamed. blockedBy is null on an allow.


#The operator view

Every term above leaves a trace an operator can inspect. This is what they see.

#Decision log (the Actions feed)

The chronological record of every decision Anvil made. Each guard() call writes one terminal audit record — a degraded call writes one extra unavailable record alongside it, and that is the only case where a single call produces two rows. The dashboard projects those into rows carrying the verdict, a human status label, the reason, the amount, the latency, the matched policy, and the risk context. Torn or corrupt log lines are counted, never silently dropped, so aggregates never quietly undercount real traffic. A row is an observation of what happened — it does not cache or replay the original result. The rows and the rollups read the same audit records, and the Decisions feed and the CLI share one pass / block / unknown projection.

#Verdict (pass · block · unknown)

The tri-state each decision is bucketed into for the operator: pass (allowed and clean), block (blocked or deduplicated — a duplicate is shown as a catch), unknown (uncertain, awaiting reconciliation). In ghost mode the labels read "Would catch" instead of "Caught"; an unknown reads "Self-resolving."

A verdict is history, and it stays that way. Each row is labelled from the mode that was in force when the call happened, not the mode the action is in now. Flip an action to enforce and yesterday's ghost rows still read "Would catch" — they were hypothetical, and relabelling them would rewrite the evidence you flipped on.

#Code map

Where your protected calls live: the repo-relative paths and line numbers Anvil records so the dashboard can point at a call, and so + Protect new can search your project at all. anvil protect and anvil sync publish it; anvil sync --forget deletes it; ANVIL_NO_CODE_MAP=1 means it is never sent. Paths only — never your source, and Anvil tells you the first time it records one rather than leaving you to find it here.

It is a snapshot, not a live view, which is why the dashboard dates it. Move code and the map goes stale until the next anvil sync — that staleness is also why a search can honestly answer unsearchable rather than "not found."

#unavailable — the status that is not a block

One status appears in the ledger that a guard() caller never receives: unavailable. Your code never branches on it — guard() resolves a degradation by throwing (AnvilRedisUnavailableError, AnvilControlPlaneUnavailableError, AnvilSetupError) or, under a fail-open setting, by returning executed with failsafe: true. unavailable is what the ledger writes down about it, which is what makes "how many calls executed money unguarded today?" a question with an answer.

Here's the decoder ring for when you read one. It means Anvil could not evaluate the policy at all, and rather than guess it either held the call or let it through unprotected, according to your fail mode. Either way the record names the dependency that failed.

  • Held at a pre-execution phase (ghost_read / claim / wait) — nothing ran, and a retry is safe. The degradation is usually transient.
  • Held at a post-execution phase (mark_executed) — the side effect DID run; only the recording failed. Reconcile before you retry.
  • Ran unprotected (nextAction: 'review', failsafe: true) — the side effect executed with no claim and no policy. A blind retry here is exactly the double-execution Anvil exists to prevent. A human reconciles it.

It is deliberately not filed under blocked, and the reason is honesty: a block is the product working, and it gets counted as caught damage on the Impact surface. Pooling the two would let Anvil's own outages inflate the number it reports back to you as value. It is also not unknown: unknown names an ambiguous side effect, unavailable names a failure to evaluate. Check the phase before you treat a hold as "nothing ran."

#Rich risk context (why · who · how-bad)

The three rows on an expanded receipt that answer what every reviewer asks:

  • Why it fired — the confirmation-gate axes that tripped (trigger / amount / irreversible / new-counterparty).
  • Who was on the other end — an opaque, salted-hash counterparty handle (never the raw recipient). The row appears only for a first-time recipient, and is always marked new.
  • How bad if wrong — the blast radius derived from the action type.

This is persist-and-display only: it reads the facts the gate already wrote, never re-inferring the decision, and shows an honest "—" for an unknown action type instead of a fabricated verdict.

#Tamper-evident receipts

An HMAC-SHA-256 hash chain over the audit records: each record's hash incorporates the previous record's hash, so any later edit, deletion, insertion, or reorder breaks the chain and is detectable. Only a holder of the deployment's signing secret can forge a valid record.

Verify it the same way from either surface:

bash
anvil verify   # or: GET /api/audit/verify
# → "Chain intact: N record(s) verified from genesis"
# → or the exact broken record, and whether it was altered vs reordered

Records removed from the end are caught too: the head anchor is compared against the chain, and a missing tail reports truncated_tail. So verification has three outcomes, not two: 0 verified, 1 tampered, and 2 could not be proven. That third state is the point — a verifier with only pass and fail has to round "the log is empty" to one of them, and it used to round it to green.

Exit 2 has exactly two causes, which is what you need before wiring this into CI: no records at all, or a chain that does not reach its origin because earlier records are gone — rotation, retention, a stripped head. An unsigned deployment is none of the three: it exits 0 as a supported no-op and only becomes a 2 under --strict, so anvil verify is safe to drop into a nightly job without a shell condition. See anvil verify for the full code table.


#Where to go next

  • Quickstart — wrap your first call, run it in ghost, open the dashboard, see the receipt.
  • Going from ghost to enforce safely — the flagship guide this whole vocabulary builds toward.
  • Policies & Rules — the exhaustive per-primitive reference.
  • Running in Production — persistence, fail modes, health checks, reconciliation, shutdown.
Esc
↵ to open · ↑↓ to move · Esc to close