Errors
Every non-2xx response carries a structured body: a stable snake_case error code an authoring loop can branch on, a human message, and — for some codes — the extra fields a client needs to act. Branch on error. Ignore fields you do not know.
A policy refusal is not an error. An intent the policy forbids is answered
200 with posted: false. If you are handling refusals in your error path,
you are handling them in the wrong place — see intents.
The error body
Every non-2xx response
{
"error": "stale_policy",
"message": "The pinned policy is not the one in force.",
"pinned": "pol_2s5479gmf7bhrsavd5awacb0fg",
"active": "pol_9k18tzq4c7m2ha055xvbn31rjd"
}
The error code is never an internal type or variant name. It is a contract, and it is stable.
The code vocabulary
| Code | Status | Meaning |
|---|---|---|
invalid_transaction_id | 400 | The path segment is not a transaction id. |
invalid_account_id | 400 | The path segment is not an account id. |
invalid_usage_id | 400 | The path segment is not a usage-balance id. |
unknown_agent | 401 | The agent:// URI is not bound to a party. |
attestation | 403 | The PASETO attestation was rejected. |
unknown_transaction | 404 | No such transaction. |
unknown_account | 404 | No such account. |
unknown_usage | 404 | No such usage balance, or it is not yours. The two are indistinguishable. |
stale_policy | 409 | The pinned policy is not the one in force (pre-check). Carries pinned, active. |
policy_pin_mismatch | 409 | The policy changed between check and record. Nothing was written. Carries pinned, active. |
no_active_policy | 409 | No policy is in force at all. |
duplicate_binding | 409 | The agent:// URI is already bound. Carries uri. |
not_primary | 421 | Cluster mode: a mutation reached a non-primary. Carries primary_client_addr. |
invalid_agent_uri | 422 | The agent:// URI is malformed. Carries uri. |
capability_mapping | 422 | The action maps to no capability the attestation grants. Carries action. |
multi_currency_mandate_charge | 422 | A mandate-bound spend moved outflow in more than one currency. Carries currencies. |
substrate_rejected | 422 | The ledger refused it — unbalanced legs, unknown account, a breached ceiling. Carries kind: "validation". |
gateway_error | 422 | A gateway rejection with no more specific code. |
commit_unavailable | 503 | Cluster mode: the commit could not be confirmed. Carries reason. |
substrate_rejected | 507 | A ledger capacity bound was breached. Carries kind: "capacity". |
What to retry
Re-pin, then resubmit
stale_policy, policy_pin_mismatch, no_active_policy. Nothing was written. Re-read the policy in force and resubmit the same transaction id against it. Bound the loop at three attempts.
Retry as-is
commit_unavailable. The mutation may or may not have landed. Because the transaction id is the idempotency key, resubmitting is safe: a commit that did land replays its stored outcome. Alternatively, read the transaction and find out.
Surface, never follow
not_primary. The body names the primary in primary_client_addr, and the SDKs surface that address rather than silently re-sending to it. Following a redirect for a mutation is a decision about where your money goes, and that decision is yours to make.
Fix the request
invalid_agent_uri, capability_mapping, multi_currency_mandate_charge, substrate_rejected, unknown_agent, attestation, invalid_usage_id. These will fail identically on every retry.
Surface, and infer nothing
unknown_usage. A usage balance owned by another party, one opened with no owner, and one that was never opened all answer this same 404. That is deliberate: a usage id is never an existence oracle across a tenant boundary. It means you cannot read the balance, and nothing more. Note that a 403 on a usage read is a rejected attestation, never an ownership verdict.
Handle the codes
use axorum_client::AxorumError;
match client.submit_pinned(draft).await {
Ok(outcome) => outcome, // includes a recorded refusal
Err(AxorumError::CommitUnavailable { .. }) => {
// Idempotent by transaction id — safe to resubmit.
client.submit_pinned(draft_again).await?
}
Err(AxorumError::NotPrimary { primary, .. }) => {
// Surfaced, never auto-followed.
return Err(anyhow!("re-send to the primary at {primary:?}"));
}
Err(error) => return Err(error.into()),
};
not_primary — 421
{
"error": "not_primary",
"message": "This replica is not the primary; re-send the mutation to the named address.",
"reason": "view change",
"primary_client_addr": "10.0.4.12:8080"
}