Skip to content
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.

LiveTestnetMainnetReferenceFor Developers16 min read.md (this page as Markdown)

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 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 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 as 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
TypeScript
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 idEffectVault code it mirrorsWhen it firesWhat to do
contract.check_failedRefuseInvalidInput, in partinvoice.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.frozenRefuseFrozenThe 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_invalidNoteNone: off-chain onlyA 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.unavailableNoteNone: off-chain onlyproposal 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.noneRefuseNoMandateNo 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.inactiveRefuseMandateInactiveThe 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.expiredRefuseMandateExpirednow, 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_mismatchRefuseNotMandateAgentThe 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_mismatchRefuseNone: off-chain onlyThe 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_mismatchRefuseNone: off-chain onlyThe 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_invalidRefuseZeroAmount, in partinvoice.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_paymentRefuseOverPerPaymentThe 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_exhaustedRefuseUsesExhaustedThe 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_totalRefuseOverMandateTotalspent 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.duplicateRefuseDuplicateInvoice, DuplicateRefThe 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_backstopRefuseOverDailyBackstopdaily.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_lowHoldNone: off-chain onlyThe 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.refusedHold or refuseNone: off-chain onlyA 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_confidenceHoldNone: off-chain onlyA 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 refuseNone: off-chain onlyYour 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 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 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:

packages/sdk/test/probarion.integration.test.ts
TypeScript
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 idEffectRecorded byWhenWhat to do
ledger.unresolvedRefuseThe 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_failedRefuseThe SDK, in the payment entrycheck_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.duplicateNoteThe SDK, in the payment entryThe 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_modeNoteThe SDK, as the first rule of a raw-mode decisionrequestSpend() 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_duplicateRefusePayables (Live on testnet), as the first custom rule of the cycle, before custom.owner_confirms_without_modelAnother 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_modelHold or refusePayables, the console's reference agent (Live on testnet), on every cycle but the owner's confirmed oneNo 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_confirmedRefusePayables (Live on testnet), in the cycle that runs after the owner confirmsThe 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_autopayRefuseThe Canary, Probarion's public-challenge vault (Live on testnet), on every cycleAlways: 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 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 reads 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() takes agentPk as given, so the contract's NotAgent has no rule.
  • The decision_hash part of 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.

Built from 4ab41ca · reviewed

Show

Loading the search index