# Policy rules

> Every rule id decide() can record, with its effect, the vault code it mirrors, the exact condition in the code, and what to do next.

The off-chain rules decide() applies before the SDK sends a spend, one row per rule id. Each row gives the rule's effect, the condition in the code that fires it, and what to do.

Status: Live · Network: Testnet and Mainnet · Reviewed: 2026-11-11 · Built from: 4ab41ca1436589c22073fa842ee46f970415e3d5

Canonical page: https://probarion.xyz/docs/build/policy-rules

`decide()` in `@probarion/core` is Probarion's policy engine. The SDK runs it before it sends a spend, and it records one result for each rule it applies: `pass`, `hold` or `refuse`. These rules run off chain, in the process that runs the SDK. They are separate from the 14 spend checks the vault runs on chain, which have the last word: see [Payment checks](https://probarion.xyz/docs/concepts/payment-checks.md). A code hold sends nothing: the outcome is `held` with `spendId` null. Each rule has its own anchor on this page, for example [`#policy-mandate-over_total`](#policy-mandate-over_total).

## How to read the tables

- **Rule id** is the `id` of one `RuleResult` in a decision's `rules`. It is also the `code` of a `refused_by_code` outcome, the first rule that refused, and of a code hold, the first rule that held.
- **Effect** is what the rule does when it fires. A rule that runs and does not fire records `pass`, with a detail that says what it checked. [Which rules a decision records](#which-rules-a-decision-records) says when a rule does not run. Hold or refuse means the rule can do either. Note means the rule always passes: it only records how the decision was made.
- **Vault code it mirrors** is the contract code the same condition raises in `spend`. Each code has its row on [Errors](https://probarion.xyz/docs/build/errors.md), such as [`OverPerPayment`](https://probarion.xyz/docs/build/errors.md#OverPerPayment). "None: off-chain only" means the vault cannot check it.
- **When it fires** is the condition in `packages/core/src/policy.ts`, with the code's own comparison. Amounts are integer strings in the token's smallest units.
- **What to do** is the next step for whoever runs the agent.

Each rule's `detail` says what it saw, such as the amount and the cap. The tables give the conditions, not the detail texts.

## The verdict

One refusing rule makes the verdict `refuse`. Otherwise one holding rule makes it `hold`, and otherwise it is `allow`:

`packages/core/src/policy.ts`

```ts
const verdict = rules.some((r) => r.outcome === "refuse")
  ? "refuse"
  : rules.some((r) => r.outcome === "hold")
    ? "hold"
    : "allow";
```

No rule can undo another, so a custom rule can only tighten the verdict. `decided_by` is `model+rules` only when a model proposal passed its attestation checks, which the caller reports as `attestationOk` true, and `rules-only` otherwise. `decide()` is pure: the clock, the mandates, the spends and the balances all come in through its `DecisionInput`, and the same input gives the same decision.

## Built-in rules

The rows are in the order a decision records them. The rules that mirror a vault check keep the contract's order and comparisons, so the first mirrored rule that refuses names the code `spend` would panic with. The off-chain rules sit between them.

| Rule id | Effect | Vault code it mirrors | When it fires | What to do |
| - | - | - | - | - |
| `contract.check_failed` | Refuse | `InvalidInput`, in part | `invoice.invoice_hash` is not 64 lowercase hex characters, or `ref` is not 1 to 64 bytes in UTF-8. | Fix the hash or the ref. The SDK throws `InvalidInput` for both before it decides, so through the SDK you meet this id in payment entries instead. |
| `vault.frozen` | Refuse | `Frozen` | The input's `frozen` is true. The SDK reads it from the vault's config. | Find out why the owner froze the vault. Only the owner can `unfreeze` it, and then the cycle can run again. |
| `model.attestation_invalid` | Note | None: off-chain only | A `proposal` came with `attestationOk` false, because its evidence failed the checks, or null, because it had none. The proposal is ignored and the decision is rules-only. `process()` never records it: only `requestSpend()` with a proposal of your own. | Nothing, for this decision. For the model to count, pass evidence made for this spend that passes the attestation checks. |
| `model.unavailable` | Note | None: off-chain only | `proposal` is null and `attestationOk` is false: a model was asked and gave no answer the SDK could use. In `process()` that is a model error, a response over 64 KiB, or evidence that fails its checks at this ledger position. | Nothing, for this decision. The SDK's warning names the cause. With `model: null` the SDK decides rules-only without asking a model. |
| `mandate.none` | Refuse | `NoMandate` | No mandate was chosen: a trusted proposal's `mandate_id` names no mandate in the input, or no mandate has the invoice's `purpose_hash`, or that hash is null. | Check the invoice's PO reference and the `purpose_hash` made from it. If no mandate covers this purpose, the owner creates one. |
| `mandate.inactive` | Refuse | `MandateInactive` | The chosen mandate's `status` is `revoked`. A mandate that reads `exhausted` passes here and is refused by `mandate.uses_exhausted` or `mandate.over_total`. | A revoked mandate stays revoked. If the payment is still wanted, the owner creates a new mandate. |
| `mandate.expired` | Refuse | `MandateExpired` | `now`, in nanoseconds, is at or past the mandate's `expires_at_ns`, or the clock or the expiry cannot be read. The status is not read: an expired mandate still reads `active`. | Mandates cannot be edited. If the payment is still wanted, the owner creates a new mandate with a later expiry. |
| `mandate.agent_mismatch` | Refuse | `NotMandateAgent` | The mandate's `agent_pk` is not the input's `agentPk`, the public key of the agent key that signs the spend. | Sign with the agent key the mandate names, or have the owner create a mandate for this key. |
| `invoice.po_mismatch` | Refuse | None: off-chain only | The invoice's `purpose_hash` is null, differs from the chosen mandate's, or selects another mandate than the one a trusted proposal chose. | Check the PO reference and its `purpose_hash`. When a model chose the mandate, the purpose wins. |
| `mandate.payee_mismatch` | Refuse | None: off-chain only | The invoice's `payee_hint`, or the trusted proposal's, contradicts the mandate's payee. For a NEAR payee: a `near_account` or an `address` other than the mandate's own account, compared exactly, or a `chain` with no `near_account` and no `address`; the mandate's own account names no other destination, in either field and whatever the `chain`, unless it is also a Base or Solana address (an ETH-implicit `0x` account, for example), which in `address` still contradicts. For a Base or Solana payee: any `near_account`, another `chain`, or another `address`, compared without case on Base. A `name` never contradicts. | Do not pay. The invoice names a destination the mandate does not pay, and a mandate's payee never changes. If the payee really changed, the owner creates a new mandate. |
| `invoice.amount_invalid` | Refuse | `ZeroAmount`, in part | `invoice.amount` is not a whole number string from 1 up to the u128 maximum, with no sign, point or leading zero, or a trusted proposal's `amount` is set and differs from it, character for character. | Write the amount in the token's smallest units: `10000000` for 10 USDC. If the model read another amount, check the invoice by hand. |
| `mandate.over_per_payment` | Refuse | `OverPerPayment` | The amount is above the mandate's `max_per_payment`, or that cap cannot be read. An amount equal to the cap passes. | Do not pay it under this mandate. If the amount is right, the owner creates a mandate with a higher cap. |
| `mandate.uses_exhausted` | Refuse | `UsesExhausted` | The mandate's `uses` is not below its `max_uses`, which includes a count that is not a number. | Wait for a held spend to be rejected or to expire, which gives its use back, or have the owner create a new mandate. |
| `mandate.over_total` | Refuse | `OverMandateTotal` | `spent` plus `reserved` plus the amount is above `max_total`, or one of the three cannot be read. The sum stops at the u128 maximum, as the contract's does. | Wait for a held spend to be rejected or to expire, which frees its reservation, or have the owner create a new mandate. |
| `invoice.duplicate` | Refuse | `DuplicateInvoice`, `DuplicateRef` | The invoice hash is in `paidInvoiceHashes`, checked first, or the ref is in `usedRefs`. The SDK fills them from the vault's spends: the invoices of held, pending, paid and burned spends, and the ref of every spend. | Read the detail. When it says the invoice "was already paid or is pending", do not pay it again. When it says the ref "was already used", run the cycle again under a new ref. |
| `vault.daily_backstop` | Refuse | `OverDailyBackstop` | `daily.spent_today` plus the amount is above `daily.backstop`, or either cannot be read. The SDK passes the vault's backstop and the total of its live spends created on the UTC day of `now`. | Wait for the next UTC day. The backstop is one limit for the whole vault, set when the vault is created. |
| `intents.balance_low` | Hold | None: off-chain only | The mandate pays a Base or Solana address through NEAR Intents, and `intentsBalance` is null or below the amount. The SDK reads the vault's balance of the mandate's asset inside NEAR Intents, and a failed read gives null. | The owner tops up the vault's balance of that asset inside NEAR Intents, then the cycle runs again. A NEAR payee never holds here. |
| `model.refused` | Hold or refuse | None: off-chain only | A trusted proposal's `action` is `hold`, which holds, or `refuse` or any value other than `pay`, which refuses. `pay` passes. | Check the invoice yourself. For `hold` and `refuse` the detail quotes the model's reasoning, cut at 200 characters. For another action it names that action. |
| `model.low_confidence` | Hold | None: off-chain only | A trusted proposal's `confidence` is not at or above `confidenceFloor`: 0.7 by default (`DEFAULT_CONFIDENCE_FLOOR`), or the SDK's `confidenceFloor` option, from 0 to 1. A confidence that is not a number holds. | Check the invoice yourself before you run the cycle again, which asks the model again. |
| `custom.<name>` | Hold or refuse | None: off-chain only | Your rule's `check(input, chosen)` returned `hold` or `refuse`. It also refuses when its id does not start with `custom.`, its outcome is not `pass`, `hold` or `refuse`, or `check` throws. The result is recorded under the rule's own `id`, whatever id `check` returns. | Your own code decided: read its detail. |

> **Owner signs**
>
> Some fixes in the What to do column are the owner's own transactions, such as `unfreeze` and a new mandate with `create_mandate`. The owner signs each one from their own wallet. The CLI's `vault unfreeze` and `mandate create` print the transaction and never sign it.
>
> _The owner signs this transaction. Probarion never signs it._

## Which rules a decision records

A decision does not record every rule every time:

- `contract.check_failed` is recorded only when it refuses.
- At most one of `model.attestation_invalid` and `model.unavailable` is recorded. `model.refused` and `model.low_confidence` are recorded only for a proposal that passed its attestation checks.
- With no mandate chosen, `mandate.inactive`, `mandate.expired`, `mandate.agent_mismatch`, `invoice.po_mismatch`, `mandate.payee_mismatch`, the three limit rules and `intents.balance_low` are left out. The three limit rules are `mandate.over_per_payment`, `mandate.uses_exhausted` and `mandate.over_total`.
- With an invalid amount, the three limit rules and `intents.balance_low` are left out, and `vault.daily_backstop` passes as not evaluated.
- With `daily` null, `vault.daily_backstop` passes as not evaluated. Through the SDK `daily` is never null, so there the backstop is evaluated whenever the amount is valid.
- Custom rules come last, in the order they were added.

## How decide() picks the mandate

The decision is about one mandate, chosen before the mandate rules run:

1. A trusted proposal's `mandate_id` names it. A proposal is trusted only when `attestationOk` is true.
2. Otherwise `purposeMatch()` takes the mandates whose `purpose_hash` equals the invoice's, lowest id first, and picks the first one the vault could still take a spend from at `now`: not revoked, not expired, bound to this agent's key, with a use left and room under `max_total` for the smallest amount, 1.
3. If none of them qualifies, it picks the lowest-id match anyway, so the decision refuses with the precise reason, such as `mandate.expired`, instead of `mandate.none`.

The [`invoice.po_mismatch`](#policy-invoice-po_mismatch) row says when the chosen mandate and the purpose disagree. `purposeMatch()` is exported from `@probarion/core`, and `process()` uses it to pick the one mandate it shows the model. `requestSpend()` decides against the mandate you name and nothing else.

## From a verdict to an outcome

`process()` and `requestSpend()` write the decision entry to the ledger first, then act on the verdict:

- **`refuse`.** Nothing is sent. The outcome is `refused_by_code`, with `spendId` null and `code` set to the first rule that refused.
- **`hold`.** The SDK asks the vault's free `check_spend` dry run. If the vault would pay at once, the SDK sends nothing: the outcome is `held` with `spendId` null, and `code` is the first rule that held. This is a code hold. No spend is on chain, so no approver can act on it and no call releases it: change what caused it, then run the cycle again. If the vault would hold the spend too, the SDK sends it and the vault holds it for an approver, so the outcome is `held` with a `spendId`.
- **`allow`.** The SDK asks `check_spend`, then sends `spend` once.

When `check_spend` refuses a decision that code allowed or held, the outcome is `refused_by_contract` with the bare vault code, and nothing is sent. [Spend outcomes](https://probarion.xyz/docs/build/spend-outcomes.md) covers every status, and [Code hold](https://probarion.xyz/docs/build/spend-outcomes.md#code-hold) what to do about each holding rule.

## Your own rules

`probarion.rules.add(rule)` adds a `CustomRule` to every later decision of that instance: `process()`, and `requestSpend()` unless `skipPreflight` is true. A `CustomRule` is an `id` that starts with `custom.` and a `check(input, chosen)` function, which gets the same `DecisionInput` as the built-in rules and the chosen mandate, or null. `rules.add()` throws `ConfigError` for an id that does not start with `custom.` or is only `custom.`, and for a rule with no `check()`.

The SDK's integration test adds a rule that refuses every invoice:

`packages/sdk/test/probarion.integration.test.ts`

```ts
pb.rules.add({
  id: "custom.no_payments_today",
  check: () => ({
    id: "custom.no_payments_today",
    outcome: "refuse",
    detail: "payments paused",
  }),
});
const out = await pb.process(nearInvoice("Invoice INV-1 for PO-1001", "INV-1"));
expect(out).toMatchObject({ status: "refused_by_code", code: "custom.no_payments_today" });
```

The [`custom.<name>`](#policy-custom) row says how a custom rule's result is recorded, and what turns it into a refusal.

## Rule ids recorded outside decide()

The SDK and Probarion's own hosted cycles record these ids as well. They use the same `RuleResult` shape, so they appear in the same `rules` lists of decision and payment entries.

| Rule id | Effect | Recorded by | When | What to do |
| - | - | - | - | - |
| `ledger.unresolved` | Refuse | The SDK, in the decision entry, after `decide()` | An earlier payment entry of this invoice in the same ledger has the status `unknown`, and none of these holds yet: a later payment entry of that decision knows its status, a spend on chain carries the decision's hash, or 48 hours (`UNKNOWN_SETTLE_MS`) have passed since that entry. It refuses in raw mode too. | Never send it again. Find the spend by its ref in the vault's spends, or wait until the time the detail names. |
| `contract.check_failed` | Refuse | The SDK, in the payment entry | `check_spend` refused, `spend` panicked, or the spend failed without the vault releasing it: a transaction that did not run, or a spend receipt that failed without a vault code. The detail says which. | Read the payment entry's `contract.code` and look it up on the Errors page. |
| `invoice.duplicate` | Note | The SDK, in the payment entry | The vault answered `DuplicateRef` or `DuplicateInvoice` to a re-signed copy of this spend, and the SDK found this decision's own spend by its ref with the status `paid`, `burned` or `held`. The rule passes and names that spend. | Nothing: the outcome reports that spend as it stands. |
| `custom.raw_mode` | Note | The SDK, as the first rule of a raw-mode decision | `requestSpend()` ran with `skipPreflight: true`, which is meant for the Canary only. The code rules and `check_spend` are skipped and `spend` is always sent. Of the code rules, only `ledger.unresolved` can still refuse. | Nothing: the vault alone decides. A panic comes back as `refused_by_contract` with the failed transaction's hash. |
| `custom.invoice_number_duplicate` | Refuse | Payables (Live on testnet), as the first custom rule of the cycle, before `custom.owner_confirms_without_model` | Another invoice to the same payee has the same invoice number, and it may have paid or may still pay: it is processing, sent and not yet confirmed, waiting for the owner, or decided as paid, burned, settled, held on chain with a spend, or refused because its own text was already paid or may still pay (`invoice.duplicate` or `ledger.unresolved` in its decision, or the contract's `DuplicateInvoice` or `DuplicateRef`). The texts differ: the same text is refused at intake as a duplicate, with no cycle. The detail names the earlier invoice by its invoice hash, not by its number, and names one that may have paid before one that waits for the owner. When a built-in rule of `decide()` refuses first, the outcome's code is that rule's, and the refusal is final like any other. | Nothing was sent. Look at the earlier invoice the detail names. If it waits for the owner, upload this one again once the owner rejects it in the console, and any other invoice with this number that waits for them: it runs on the same row and ref. If the console shows this invoice as "Outcome unknown: do not resend", its refusal could not be written, and the number stays blocked whatever happens to the earlier invoice. Otherwise the number stays blocked while that invoice may still pay, and a paid invoice is not paid again, even when an agent paid its text outside the console and the console refused it as already paid. The console does not yet free the number of an invoice held on chain, sent and not confirmed, or with an unknown outcome when its spend fails, is rejected or expires. |
| `custom.owner_confirms_without_model` | Hold or refuse | Payables, the console's reference agent (Live on testnet), on every cycle but the owner's confirmed one | No attested model stated the amount the code read: no proposal, one that is not attested, or a model amount that is null or differs. It holds, or refuses when the amount is above the mandate's `approval_above` or cannot be compared, since a hold would then send the spend for approval. | The vault's owner confirms the amount in the console, on the invoice's "Waiting for you" card. |
| `custom.owner_confirmed` | Refuse | Payables (Live on testnet), in the cycle that runs after the owner confirms | The decision does not pay exactly what the owner signed for: another amount, invoice hash, mandate or payee, or no mandate. When it passes, its detail holds the owner's whole signed confirmation. | Nothing was sent. Compare the confirmed amount, invoice and mandate with the decision's. |
| `custom.canary_no_autopay` | Refuse | The Canary, Probarion's public-challenge vault (Live on testnet), on every cycle | Always: the Canary never pays from its own cycle. It is the outcome's `code` only when no built-in rule refused first. | Nothing: it is the expected refusal there. |

The four SDK rows can appear in the ledger of any agent that uses the SDK. The other three come from Probarion's hosted cycles, so they appear only in the ledger of a vault that runs one. [The 48-hour guard](https://probarion.xyz/docs/build/spend-outcomes.md#the-48-hour-guard) explains `ledger.unresolved` in full.

## What the rules do not check

- `approval_above`. No rule of `decide()` reads it: the vault holds a spend above it, after the SDK sends the spend. Of the rules recorded outside `decide()`, only [`custom.owner_confirms_without_model`](#policy-added-custom-owner_confirms_without_model) reads it.
- The vault's USDC balance. A spend the vault cannot cover passes every rule, and its transfer fails, as [Spend outcomes](https://probarion.xyz/docs/build/spend-outcomes.md) shows.
- Whether the signer is one of the vault's agent keys. `decide()` takes `agentPk` as given, so the contract's [`NotAgent`](https://probarion.xyz/docs/build/errors.md#NotAgent) has no rule.
- The `decision_hash` part of [`InvalidInput`](https://probarion.xyz/docs/build/errors.md#InvalidInput). The decision hash does not exist yet when `decide()` runs.
- A change on chain after the SDK read the vault. The vault runs its own checks on every spend, and its answer stands.

The rules are the same on both networks. Today the vaults run on NEAR testnet, and one small vault, `v0.probarion.near`, runs on mainnet: see [Testnet and mainnet](https://probarion.xyz/docs/start/testnet-and-mainnet.md).

## Related pages

- [Spend outcomes](https://probarion.xyz/docs/build/spend-outcomes.md): what each outcome means and what to do next.
- [Payment checks](https://probarion.xyz/docs/concepts/payment-checks.md): the 14 checks a spend meets inside the vault.
- [Holds and approvals](https://probarion.xyz/docs/concepts/holds-and-approvals.md): a code hold next to a contract hold.
- [Mandates](https://probarion.xyz/docs/concepts/mandates.md): the mandate fields these rules read.
- [Why spend has no payee](https://probarion.xyz/docs/concepts/why-spend-has-no-payee.md): why only an off-chain rule can refuse a contradicting payee.
- [Let an agent pay](https://probarion.xyz/docs/build/let-an-agent-pay.md): run the cycle from your own code.
- [Errors](https://probarion.xyz/docs/build/errors.md): every vault code and SDK error.
