Intents
An intent is what an agent submits: the entries it proposes to post, the action it claims the authority to take, and the policy it believes is in force. Axorum judges it at the commit point and records the outcome — including a refusal.
The intent envelope
Everything the agent knows, plus the one claim it makes about the service's state: the policy pin.
Properties
- Name
agent- Type
- string
- Description
The agent's
agent://URI, e.g.agent://example.com/agent/clerk_01h455vb4pex5vsknk084sn02q. It must be bound to a party, or the request is a401 unknown_agent.
- Name
attestation- Type
- string
- Description
The PASETO v4.public capability attestation proving the agent may act. See authentication.
- Name
transaction- Type
- string
- Description
The client-minted transaction id (
txn_…) — the substrate's idempotency key. Re-submitting a committed id replays its outcome rather than posting twice.
- Name
entries- Type
- array
- Description
The accounting journal legs. Each leg posts a positive
amountto oneside(DebitorCredit) of oneaccount. Debits and credits must balance per currency.
- Name
consume_usage- Type
- object | optional
- Description
A usage move on the balance's
consumedtotal: the agent used a metered resource. A usage id plus anAmount. Omitted entirely when absent, nevernull. See usages.
- Name
meter_usage- Type
- object | optional
- Description
A usage move on the balance's
meteredtotal: the consumption was measured. Metering pastconsumedis phantom usage, and it is refused.
- Name
bill_usage- Type
- object | optional
- Description
A usage move on the balance's
billedtotal: the metering was billed for. Billing pastmeteredis billing with no meter basis, and it is refused.
- Name
action- Type
- object
- Description
The proposed deontic action and its typed bindings, e.g.
{"action": "post", "bindings": {}}. The action must map to a capability the attestation grants, or the request is a422 capability_mapping.
- Name
evidence- Type
- array
- Description
Lowercase-hex SHA-256 digests of external evidence the agent references.
- Name
justification- Type
- string
- Description
The agent's stated reason. Hashed at the gateway: the hash goes on-ledger, the text is retained off-ledger by you.
- Name
policy- Type
- string
- Description
The policy the agent believes is in force (
pol_…) — the staleness pin. A stale pin is refused409and nothing is written.
Submit an intent
Commits at the current server tick. The response is the outcome: the verdict, whether the entries posted, the policy that judged it, and every obligation the actor now owes.
A refusal is a 200
An intent the policy in force forbids is recorded and answered 200 OK with posted: false and a ForbiddenRejected verdict. The refusal record is the product — preventive evidence that a control stopped something before it happened, not an alarm reporting a loss.
Branch on verdict and posted. Never on the status code.
Statuses
- Name
200- Type
- outcome
- Description
Judged. Includes a recorded refusal. Also the replay of an already-committed id.
- Name
401- Type
- unknown_agent
- Description
The
agent://URI is not bound to a party.
- Name
403- Type
- attestation
- Description
The attestation was rejected: bad signature, expired, wrong audience, or an untrusted issuer.
- Name
409- Type
- stale_policy
- Description
The pin did not hold, and nothing was written. Also
policy_pin_mismatchandno_active_policy. Carriespinnedandactive.
- Name
421- Type
- not_primary
- Description
Cluster mode: this mutation reached a non-primary replica. Carries
primary_client_addr.
- Name
422- Type
- unprocessable
- Description
Well-formed HTTP that could not be recorded:
invalid_agent_uri,capability_mapping,multi_currency_mandate_charge,substrate_rejected(kind: "validation"), orgateway_error.
- Name
503- Type
- commit_unavailable
- Description
Cluster mode: the commit could not be confirmed. It may or may not have landed — retry (submission is idempotent) or read the transaction.
Submit an intent
use std::collections::BTreeMap;
use axorum_client::{AxorumClient, IntentDraft};
use axorum_deontic::{ActionTerm, ActionType};
use axorum_substrate::{Amount, Entry, Side, TransactionId};
let client = AxorumClient::builder("http://127.0.0.1:8080").build()?;
let transaction = TransactionId::from_uuid(uuid::Uuid::now_v7());
let draft = IntentDraft::new(
"agent://example.com/agent/clerk_01h455vb4pex5vsknk084sn02q",
attestation,
transaction,
ActionTerm::new(ActionType::new("post")?, BTreeMap::new()),
)
.entries(vec![
Entry { account: cash, side: Side::Debit, amount: Amount::new(50_000, "USD") },
Entry { account: revenue, side: Side::Credit, amount: Amount::new(50_000, "USD") },
])
.justification("invoice 2214, net 30, within the standing purchase mandate");
let outcome = client.submit_pinned(draft).await?;
Request
{
"agent": "agent://example.com/agent/clerk_01h455vb4pex5vsknk084sn02q",
"attestation": "v4.public.eyJhZ2VudCI6ImFnZW50Oi8vZXhhbX...",
"transaction": "txn_7w5b4trnc7b2ja027jyyb64395",
"entries": [
{
"account": "acc_2n4kqx21g5bty927z2tj955css",
"side": "Debit",
"amount": { "minor": 50000, "currency": "USD" }
},
{
"account": "acc_5j8dmv93k1nzq047x8ph622bkt",
"side": "Credit",
"amount": { "minor": 50000, "currency": "USD" }
}
],
"action": { "action": "post", "bindings": {} },
"evidence": ["3b7f1c2e9a48d05f6b1e7c3a92d4f80b5e6c1a97d2f3b8e40c5a7d19f6b2e8c3"],
"justification": "invoice 2214, net 30, within the standing purchase mandate",
"policy": "pol_2s5479gmf7bhrsavd5awacb0fg"
}
Response — permitted
{
"transaction": "txn_7w5b4trnc7b2ja027jyyb64395",
"verdict": "Permitted",
"posted": true,
"policy": "pol_2s5479gmf7bhrsavd5awacb0fg",
"obligations_pending": [],
"justification_hash": "9f2cbe41e0a3c7d1f4b8e2a95c7d3f6018bb24e7c9a1d5f30e8b7c2a4d9f1e6b",
"provenance": { "tick": 4471 }
}
Response — refused (200)
{
"transaction": "txn_7w5b4trnc7b2ja027jyyb64395",
"verdict": "ForbiddenRejected",
"posted": false,
"policy": "pol_2s5479gmf7bhrsavd5awacb0fg",
"obligations_pending": [],
"justification_hash": "9f2cbe41e0a3c7d1f4b8e2a95c7d3f6018bb24e7c9a1d5f30e8b7c2a4d9f1e6b",
"provenance": { "tick": 4472 }
}
The policy pin
The pin is a claim about the service's state, so getting it wrong is a 409 — and on a 409, nothing is written. There is no partial commit to unwind.
Two codes, one meaning:
stale_policy— the pre-propose fast path. The pinned policy is not the one in force.policy_pin_mismatch— the race-closing check at apply time. The policy in force changed between the request being checked and it being recorded.
Both carry pinned and active. Both are answered identically on the standalone and cluster paths. In both cases: re-read GET /api/v1/policies/active and resubmit against the policy now in force, carrying the same transaction id.
Bound the loop at three attempts. Each attempt pins against the policy the service just named, so a second failure means the policy moved again in the microseconds since.
A stale pin — 409, nothing written
{
"error": "policy_pin_mismatch",
"message": "This request was checked against policy 'pol_2s54…', but policy 'pol_9k18…' took effect before it could be recorded. Nothing was recorded — resubmit the request against the policy now in force.",
"pinned": "pol_2s5479gmf7bhrsavd5awacb0fg",
"active": "pol_9k18tzq4c7m2ha055xvbn31rjd"
}
Re-pin by hand
// What `submit_pinned` does for you, bounded at three attempts.
match client.submit_intent(draft.clone().pin(policy)).await {
Err(AxorumError::StalePolicy(failure)) => {
let policy = failure.active.clone();
client.submit_intent(draft.pin(policy)).await?
}
other => other?,
};
Metered spend is one intent
An agent that consumes a metered resource carries the usage moves on the same draft as the journal legs. They are judged in the same commit, under the usage-balance conservation law 0 <= billed <= metered <= consumed.
All three moves at once is the lawful case, not an edge case: the moves of one intent are validated together against the combined post-state. Any subset works too, and metering something an earlier intent consumed is one move, not three.
A move that breaks the chain is a 422 substrate_rejected with kind: "validation", and nothing posts: not the usage moves, and not the journal legs that rode with them. The refusal is a plain-English sentence carrying the exact figures.
An absent move is omitted from the envelope entirely. The key is not there, and it is never null. The usages page covers the law, the read, and who may move a balance.
A money movement and its usage moves
use axorum_client::{IntentDraft, UsageMove};
use axorum_substrate::{Amount, Entry, Side};
let spend = Amount::new(1_000, "USD");
let draft = IntentDraft::new(agent, attestation, transaction, action)
.entries(vec![
Entry { account: expense, side: Side::Debit, amount: spend.clone() },
Entry { account: cash, side: Side::Credit, amount: spend.clone() },
])
.consume_usage(UsageMove { usage: usage.clone(), amount: spend.clone() })
.meter_usage(UsageMove { usage: usage.clone(), amount: spend.clone() })
.bill_usage(UsageMove { usage: usage.clone(), amount: spend })
.justification("1,000 inference calls, metered and billed at $10.00");
let outcome = client.submit_pinned(draft).await?;
Request: the moves ride the envelope
{
"transaction": "txn_7w5b4trnc7b2ja027jyyb64395",
"entries": [
{
"account": "acc_2n4kqx21g5bty927z2tj955css",
"side": "Debit",
"amount": { "minor": 1000, "currency": "USD" }
},
{
"account": "acc_5j8dmv93k1nzq047x8ph622bkt",
"side": "Credit",
"amount": { "minor": 1000, "currency": "USD" }
}
],
"consume_usage": {
"usage": "usage_2n4kqx21g5bty927z2tj955css",
"amount": { "minor": 1000, "currency": "USD" }
},
"meter_usage": {
"usage": "usage_2n4kqx21g5bty927z2tj955css",
"amount": { "minor": 1000, "currency": "USD" }
},
"bill_usage": {
"usage": "usage_2n4kqx21g5bty927z2tj955css",
"amount": { "minor": 1000, "currency": "USD" }
},
"action": { "action": "post", "bindings": {} },
"evidence": [],
"justification": "1,000 inference calls, metered and billed at $10.00",
"policy": "pol_2s5479gmf7bhrsavd5awacb0fg"
}
A chain violation: 422, and nothing posted
{
"error": "substrate_rejected",
"kind": "validation",
"message": "metering exceeds consumption for usage balance 'usage_2n4kqx21g5bty927z2tj955css': attempted metered total 7500 exceeds consumed total 7000 (already metered 5000; 2000 consumed but not yet metered)"
}