Docs Concepts
Decision
LiveUpdated
Every request produces exactly one decision, and every decision is sealed into the record whether or not money moved. A reversal is a decision too.
| Outcome | Meaning | What the agent receives |
|---|---|---|
allow | The request matches a policy and fits its limits. | A mandate and the credential the payee accepts. |
step_up | A person must confirm: the amount, payee or category is beyond what the policy lets the agent do alone, an identity or intent rule asks for it, or the model was unsure. | An approval reference and link. The agent waits or moves on. |
deny | No matching policy, a limit exceeded, a failed check, a duplicate, a paused agent, a missing consent or anchor, or a model verdict of deny. | Reasons, machine-readable. The denial itself is sealed. |
Two states sit around those three outcomes and are not outcomes of their own:
| State | Meaning | What the agent receives |
|---|---|---|
clarify | The request lacks something the policy requires (intent.required_slots). Nothing is reserved or issued yet. | questions[] to ask the person; the answers go to POST /v0/payment-requests/{id}/clarifications and the request is decided again (intent and clarification). |
observe | The policy is in shadow mode. The request would have stepped up or been denied; it is allowed and recorded instead. | A mandate as on allow, plus shadow_result: { result, reasons } saying what an enforcing policy would have done. |
An allow, or a step_up a person approved, whose rail cannot issue a credential ends as a rail hand-off instead of a mandate: state handed_off, mandate: null, and handoff { rail, reason, message, next_step } for the person who takes it from there. Nothing was paid (Rails).
Order of evaluation
- Agent state. A paused agent is denied with
agent_pausedat once, no policy needed; the check is namedagent_state. - Policy selection. The organization’s current policy versions for this agent; none matches →
no_matching_policy. - Consent. When the policy sets
require_consentand the agent has no active consent record →consent_revoked. Otherwise theconsentcheck passes and names the consent id, or says “not required”. - Intent. The person’s words and the checkout become slots (intent and clarification).
- Identity. The agent’s anchors and assurance level for this rail, and the merchant check when the policy asks for it or the payee is a domain (trust model).
- Model. Only when the policy enables one and its conditions hold: a verdict on what the rules cannot decide.
- The evaluation, every check reported:
intent_coverage(a missing slot givesclarify),agent_identity,payee_identity,intent_rules, then payee, category, window, cap, budget (reserved atomically), duplicate,retry_policy(when the policy bounds retries), screening and the approval threshold, thenintent_model. A shadow policy turns a would-bestep_upordenyintoobserve. - Risk assessment. A `RiskAssessment` is built from the checks plus
velocity,catalog_snapshot_matchanddeviation_from_intent, signed by Enruta and stored with the decision.
Reasons
Reasons are stable codes, not prose, so agents and dashboards can branch on them. Codes are only ever added.
| Code | When |
|---|---|
no_matching_policy | No policy covers this agent, payee or category |
over_payment_cap | Amount above the per-payment cap |
over_budget | Cumulative budget exhausted after reservation |
payee_not_allowed | Payee outside the allowlist or not verified |
category_not_allowed | Category outside the policy |
outside_window | Time window or expiry; on a reversal, past the policy’s reversal.window_days after settlement |
duplicate | Same payee, amount and reference inside the duplicate window |
idempotent_replay | Same idempotency key; the original decision is returned |
screening_hit | Payee matched a screening list |
approval_required | Step-up: a named person must sign |
approval_declined | The person refused |
approval_expired | Nobody signed before the window closed |
consent_revoked | The policy requires a consent record and the agent has none active; also added to a request whose approval was still pending when its consent was revoked with cascades_scheduled (consent record) |
agent_paused | The agent was paused; the pause is itself a sealed record |
finality_irreversible_cap | Above the policy’s lower cap for rails whose payments cannot be reversed |
deviation_over_tolerance | The delivered price moved more than the policy’s deviation.tolerance |
over_original_amount | Reversal: the amount plus what was already reversed exceeds the original |
stage_not_reversible | Reversal: this kind is not available at the payment’s stage (for example a void after settlement) |
rail_irreversible | Reversal: the rail’s finality_class is irreversible; only credit is possible |
past_cancellable_until | Reversal: the rail’s own deadline for cancelling has passed |
needs_clarification | The request lacks a slot the policy requires; questions were returned |
clarification_expired | The questions were not answered in time |
identity_insufficient | The agent lacks an anchor the policy requires for this amount or rail |
collateral_insufficient | The collateral read on chain does not cover the amount |
payee_unverified | The policy requires a verified merchant and the check did not verify it |
payee_identity_mismatch | The checkout’s merchant signature does not verify against the merchant’s keys; always a denial |
intent_rule_failed | A rule on the intent slots failed |
model_judgement_deny | The model judged the request outside the policy’s instruction |
model_unavailable | The policy needs a model verdict and none was available |
retry_limit_reached | A retry beyond the policy’s retry_policy: more than max_attempts attempts at one payment inside window_seconds, or a retry for another amount under same_amount_only |
rail_unavailable | Not a denial: the reason of a rail hand-off when the rail did not answer while issuing (5xx, 408, 425 or 429, a timeout, no connection) |
rail_error | Not a denial: the reason of a rail hand-off when the rail refused while issuing, such as a 4xx for bad credentials |
Checks
decision.checks[] names every check with its result. Payment checks: payee_allowlist, per_payment_cap, cumulative_budget_atomic, time_window, category, duplicate_idempotency, sanctions_screen, plus consent and agent_state; since 0.3 the trust checks intent_coverage, intent_rules, intent_model, agent_identity, payee_identity and collateral, and retry_policy when the policy bounds retries, with the rules and the model’s verdict also in decision.judgement. The risk assessment adds velocity, catalog_snapshot_match, deviation_from_intent. Reversal checks: original_amount, stage, finality, window, cancellable_until.
Retrying
Every decision says whether the same payment may be requested again (retryable) and from when (retry_after). Both are on the response to POST /v0/payment-requests and its replay, on GET /v0/payment-requests/{id}, in the decision of the payment_request.decided webhook and in the decision of every enruta/0.3 payment record, always together.
- `retryable: true` only for a request that ended without paying (it was denied, its approval or its questions expired, or a rail that failed while issuing handed it off) when every reason that ended it is remediable.
- `retry_after` is the latest time those reasons name: for
over_budget, the start of the next budget window, in UTC.nullmeans at once or no known time, and it is alwaysnullwhenretryableisfalse. - `retryable: false` for everything else: an allow, an observe, a request still waiting for a person, a hand-off to the merchant’s own checkout, and a request ended by any reason that is not remediable.
| Family | What the agent does | Reason codes |
|---|---|---|
| Not remediable | Tells the person and stops. It never retries with another amount, payee or wording; the person can change the policy, the agent cannot. | no_matching_policy, over_payment_cap, payee_not_allowed, category_not_allowed, duplicate, screening_hit, approval_declined, consent_revoked, agent_paused, finality_irreversible_cap, rail_irreversible, over_original_amount, past_cancellable_until, payee_identity_mismatch, intent_rule_failed, model_judgement_deny, retry_limit_reached, unsupported |
| Remediable | May request the same payment again once the condition changes, from retry_after when it names a time. | over_budget, outside_window, approval_expired, clarification_expired, identity_insufficient, payee_unverified, model_unavailable, collateral_insufficient, stage_not_reversible, rail_unavailable, rail_error |
| Waits for a person | Waits: a named person decides (an approval, the answers to questions, a price deviation). | approval_required, needs_clarification, deviation_over_tolerance |
| Hint | Nothing: it changes no outcome. | idempotent_replay |
A code in no family, such as a rail hand-off’s rail_limit or read_only, is treated as not remediable: an agent does not retry what it does not understand. A clarify is not a denial: ask the person and send the answers. An agent never splits a purchase to fit. After a rail hand-off with rail_unavailable or rail_error the same payment can be requested again once the rail answers or its connection is fixed, and the handed-off request does not count as a duplicate; after any other rail reason, a person follows next_step.
What counts as a retry. A request by the same agent, to the same payee, for the same purpose (compared without case or extra spaces) after one that failed: it was denied (a refused retry included), the rail declined it, or it was handed off, to the merchant’s own page or by a rail that could not issue. A request still waiting for a person and a payment that went through are not failed attempts. A policy with retry_policy { max_attempts, window_seconds, same_amount_only } counts the failed attempts inside window_seconds, the first included, and denies the attempt beyond max_attempts with retry_limit_reached; with same_amount_only, a retry for another amount than the attempt it retries is denied the same way. The retry_policy check says where each attempt stands.
// retry_policy { max_attempts: 2, window_seconds: 3600, same_amount_only: true } and a $100 cap
"Standing desk" 150.00 deny [over_payment_cap] retry_policy: not a retry; at most 2 attempts within 1 h
" standing DESK " 150.00 deny [over_payment_cap] retry_policy: attempt 2 of 2 within 1 h, same amount
"Standing desk" 150.00 deny [over_payment_cap, retry_limit_reached] retry_policy: attempt 3 at this payment within 1 h; the policy allows 2
// retryable: false, retry_after: null on all three
// a $500 cap and a $100 budget per calendar month, 150.00 asked on September 14
"decision": { "result": "deny", "reasons": ["over_budget"], "retryable": true, "retry_after": "2026-10-01T00:00:00Z" }
// an approval nobody signed in time
"decision": { "result": "step_up", "reasons": ["approval_required", "approval_expired"], "retryable": true, "retry_after": null }Latency
Decisions run on lookups and counters only. Enruta targets under 10 ms typical and 50 ms at p99 of added latency on the payment path; the Stripe Issuing real-time authorization was answered in 44 ms in production, network round trip included.
What is recorded
The decision, every input to it (policy version, budget state before and after, checks run and their results), the intent and how it was judged, the agent’s identity and the merchant check, the signed risk assessment, the actor for any approval, and the time. Denials and observations are recorded with the same care as allows: the absence of a payment is evidence too.