Docs Concepts

Decision

LiveUpdated

View as Markdown

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.

payee→category→window→cap→budget→duplicate→screening→threshold→allow/step_up/denyany failed check denies; the budget is reserved at the moment it passes
OutcomeMeaningWhat the agent receives
allowThe request matches a policy and fits its limits.A mandate and the credential the payee accepts.
step_upA 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.
denyNo 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:

StateMeaningWhat the agent receives
clarifyThe 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).
observeThe 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

  1. Agent state. A paused agent is denied with agent_paused at once, no policy needed; the check is named agent_state.
  2. Policy selection. The organization’s current policy versions for this agent; none matches → no_matching_policy.
  3. Consent. When the policy sets require_consent and the agent has no active consent record → consent_revoked. Otherwise the consent check passes and names the consent id, or says “not required”.
  4. Intent. The person’s words and the checkout become slots (intent and clarification).
  5. 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).
  6. Model. Only when the policy enables one and its conditions hold: a verdict on what the rules cannot decide.
  7. The evaluation, every check reported: intent_coverage (a missing slot gives clarify), 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, then intent_model. A shadow policy turns a would-be step_up or deny into observe.
  8. Risk assessment. A `RiskAssessment` is built from the checks plus velocity, catalog_snapshot_match and deviation_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.

CodeWhen
no_matching_policyNo policy covers this agent, payee or category
over_payment_capAmount above the per-payment cap
over_budgetCumulative budget exhausted after reservation
payee_not_allowedPayee outside the allowlist or not verified
category_not_allowedCategory outside the policy
outside_windowTime window or expiry; on a reversal, past the policy’s reversal.window_days after settlement
duplicateSame payee, amount and reference inside the duplicate window
idempotent_replaySame idempotency key; the original decision is returned
screening_hitPayee matched a screening list
approval_requiredStep-up: a named person must sign
approval_declinedThe person refused
approval_expiredNobody signed before the window closed
consent_revokedThe 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_pausedThe agent was paused; the pause is itself a sealed record
finality_irreversible_capAbove the policy’s lower cap for rails whose payments cannot be reversed
deviation_over_toleranceThe delivered price moved more than the policy’s deviation.tolerance
over_original_amountReversal: the amount plus what was already reversed exceeds the original
stage_not_reversibleReversal: this kind is not available at the payment’s stage (for example a void after settlement)
rail_irreversibleReversal: the rail’s finality_class is irreversible; only credit is possible
past_cancellable_untilReversal: the rail’s own deadline for cancelling has passed
needs_clarificationThe request lacks a slot the policy requires; questions were returned
clarification_expiredThe questions were not answered in time
identity_insufficientThe agent lacks an anchor the policy requires for this amount or rail
collateral_insufficientThe collateral read on chain does not cover the amount
payee_unverifiedThe policy requires a verified merchant and the check did not verify it
payee_identity_mismatchThe checkout’s merchant signature does not verify against the merchant’s keys; always a denial
intent_rule_failedA rule on the intent slots failed
model_judgement_denyThe model judged the request outside the policy’s instruction
model_unavailableThe policy needs a model verdict and none was available
retry_limit_reachedA 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_unavailableNot 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_errorNot 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. null means at once or no known time, and it is always null when retryable is false.
  • `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.
FamilyWhat the agent doesReason codes
Not remediableTells 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
RemediableMay 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 personWaits: a named person decides (an approval, the answers to questions, a price deviation).approval_required, needs_clarification, deviation_over_tolerance
HintNothing: 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.