On this page
Policy rules
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.
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. 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.
How to read the tables#
- Rule id is the
idof oneRuleResultin a decision'srules. It is also thecodeof arefused_by_codeoutcome, 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 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, such asOverPerPayment. "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:
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.
Which rules a decision records#
A decision does not record every rule every time:
contract.check_failedis recorded only when it refuses.- At most one of
model.attestation_invalidandmodel.unavailableis recorded.model.refusedandmodel.low_confidenceare 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 andintents.balance_loware left out. The three limit rules aremandate.over_per_payment,mandate.uses_exhaustedandmandate.over_total. - With an invalid amount, the three limit rules and
intents.balance_loware left out, andvault.daily_backstoppasses as not evaluated. - With
dailynull,vault.daily_backstoppasses as not evaluated. Through the SDKdailyis 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:
- A trusted proposal's
mandate_idnames it. A proposal is trusted only whenattestationOkis true. - Otherwise
purposeMatch()takes the mandates whosepurpose_hashequals the invoice's, lowest id first, and picks the first one the vault could still take a spend from atnow: not revoked, not expired, bound to this agent's key, with a use left and room undermax_totalfor the smallest amount, 1. - 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 ofmandate.none.
The 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 isrefused_by_code, withspendIdnull andcodeset to the first rule that refused.hold. The SDK asks the vault's freecheck_spenddry run. If the vault would pay at once, the SDK sends nothing: the outcome isheldwithspendIdnull, andcodeis 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 isheldwith aspendId.allow. The SDK askscheck_spend, then sendsspendonce.
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 covers every status, and 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:
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> 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 explains ledger.unresolved in full.
What the rules do not check#
approval_above. No rule ofdecide()reads it: the vault holds a spend above it, after the SDK sends the spend. Of the rules recorded outsidedecide(), onlycustom.owner_confirms_without_modelreads it.- The vault's USDC balance. A spend the vault cannot cover passes every rule, and its transfer fails, as Spend outcomes shows.
- Whether the signer is one of the vault's agent keys.
decide()takesagentPkas given, so the contract'sNotAgenthas no rule. - The
decision_hashpart ofInvalidInput. The decision hash does not exist yet whendecide()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.
Related pages#
- Spend outcomes: what each outcome means and what to do next.
- Payment checks: the 14 checks a spend meets inside the vault.
- Holds and approvals: a code hold next to a contract hold.
- Mandates: the mandate fields these rules read.
- Why spend has no payee: why only an off-chain rule can refuse a contradicting payee.
- Let an agent pay: run the cycle from your own code.
- Errors: every vault code and SDK error.
Built from 4ab41ca · reviewed
packages/core/src/policy.ts(opens in a new tab)packages/core/src/types.ts(opens in a new tab)packages/core/src/hex.ts(opens in a new tab)packages/core/test/policy.test.ts(opens in a new tab)packages/sdk/src/probarion.ts(opens in a new tab)packages/sdk/src/spend-outcome.ts(opens in a new tab)packages/sdk/src/context.ts(opens in a new tab)packages/sdk/src/input.ts(opens in a new tab)packages/sdk/test/probarion.integration.test.ts(opens in a new tab)contracts/vault/src/checks.rs(opens in a new tab)contracts/vault/src/lib.rs(opens in a new tab)contracts/vault/src/types.rs(opens in a new tab)contracts/vault/src/admin.rs(opens in a new tab)contracts/vault/src/validate.rs(opens in a new tab)packages/cli/src/commands/owner-calls.ts(opens in a new tab)packages/cli/src/commands/mandate.ts(opens in a new tab)apps/web/src/lib/payables/owner-confirms.ts(opens in a new tab)apps/web/src/lib/payables/owner-confirmed.ts(opens in a new tab)apps/web/src/lib/payables/pipeline.ts(opens in a new tab)apps/web/src/lib/payables/number-duplicate.ts(opens in a new tab)apps/web/supabase/migrations/20261108000016_invoice_number_rule.sql(opens in a new tab)apps/web/supabase/migrations/20261110000017_invoice_guard_paid_refusals.sql(opens in a new tab)apps/web/src/lib/payables/confirm.ts(opens in a new tab)apps/web/src/lib/canary/rules.ts(opens in a new tab)apps/web/src/lib/canary/probarion.ts(opens in a new tab)apps/web/src/app/console/[vault]/payables/[id]/page.tsx(opens in a new tab)apps/web/content/docs/build/spend-outcomes.mdx(opens in a new tab)apps/web/content/docs/concepts/payment-checks.mdx(opens in a new tab)apps/web/content/docs/concepts/holds-and-approvals.mdx(opens in a new tab)contracts/deployments/mainnet.json(opens in a new tab)