axorum-client
The official Python client for the Axorum agent plane — the wire an autonomous agent speaks to submit intents to a deontic financial-control ledger and read what the ledger did about them.
pip install axorum-client # or: uv add axorum-client
Requires Python 3.11+. The only runtime dependency is httpx.
The one rule: a refusal is a value, not an exception
Axorum's job is to judge an intent against the policy in force. When the policy
forbids the action, the intent still reaches the commit point, is still
judged, and the refusal is still recorded — and that record is the
product. It comes back as a 200, as an ordinary AgentOutcome with
posted == False.
So this client returns it. It does not raise.
outcome = client.submit_pinned(draft)
if outcome.posted:
print("permitted, and the entries posted")
else:
# NOT a failure. The ledger recorded a refusal, which is what you asked it to do.
print(f"refused, and the refusal is on the ledger: {outcome.verdict}")
An exception from this client means the ledger never got to answer at all: the caller was not who they claimed, the pin did not hold, the cluster could not confirm, the wire broke. A client that raised on a refusal would be throwing away the very artifact the ledger exists to produce.
Quickstart
from axorum import ActionTerm, Amount, AxorumClient, Entry, IntentDraft
draft = IntentDraft(
agent="agent://example.com/agent/clerk_01h455vb4pex5vsknk084sn02q",
attestation=paseto_token, # the PASETO v4.public capability attestation
action=ActionTerm("post"),
entries=[
Entry.debit(cash_account, Amount(minor=1000, currency="USD")),
Entry.credit(revenue_account, Amount(minor=1000, currency="USD")),
],
justification="settling invoice 42",
)
with AxorumClient("https://axorum.internal:8443", attestation=paseto_token) as client:
outcome = client.submit_pinned(draft)
print(outcome.verdict) # Verdict.PERMITTED
print(outcome.posted) # True
print(outcome.policy) # the policy it was judged under
print(outcome.transaction) # the id you minted, echoed back
…and the same call, refused
Point the same draft at a ledger whose policy forbids post, and nothing about
your code changes. You do not catch anything. You read the outcome:
with AxorumClient("https://axorum.internal:8443") as client:
outcome = client.submit_intent(draft.pin(policy_id))
assert outcome.posted is False
assert outcome.verdict is Verdict.FORBIDDEN_REJECTED
# The refusal is on the ledger, and reads back like any other record.
record = client.transaction(outcome.transaction)
Trusting a private API CA
A deployment's API listener presents a certificate from its own CA, which the default
trust store does not hold. Either point the process at that CA's ca.crt:
SSL_CERT_FILE=/path/to/api/ca.crt python agent.py
or hand the client a transport that trusts it:
import ssl
import httpx
trust = ssl.create_default_context(cafile="/path/to/api/ca.crt")
client = AxorumClient(url, transport=httpx.HTTPTransport(verify=trust))
(httpx.AsyncHTTPTransport for AsyncAxorumClient.) Until one of those is in place,
every call raises UntrustedCertificateError, which is not retriable.
Provenance and evaluation semantics
An outcome's provenance is either a recorded audit trail or the archived form.
Recorded provenance has an optional semantics integer. It is omitted for
pre-versioning records and names the evaluation law, not the outcome. master_data
is also optional and is omitted when the policy did not consult replicated reference
data.
Async
The same client, awaited. Same method names, same arguments, same semantics.
from axorum import AsyncAxorumClient
async with AsyncAxorumClient("https://axorum.internal:8443", attestation=paseto_token) as client:
outcome = await client.submit_pinned(draft)
if not outcome.posted:
print(f"recorded as refused: {outcome.verdict}")
Writes carry their credential; so do reads
An intent authenticates inside the envelope: the draft names the agent:// URI
and the PASETO attestation, and the service verifies both before it proposes
anything. Reads are credentialed with the same token — there is no second
credential to obtain. They present it as Authorization: Bearer …, and the service
takes the reader's identity from that token's verified agent_uri claim and answers
only within the party it is bound to.
So a client that reads holds the attestation:
with AxorumClient("https://axorum.internal:8443", attestation=paseto_token) as client:
owed = client.obligations() # your party's duties
record = client.transaction(txn_id) # your party's record
net = client.balance(account_id)
| Method | Attestation | How |
|---|---|---|
active_policy() |
not needed | a public read: it discloses no party's data |
submit_intent() / submit_pinned() / submit_pinned_from() |
not on the transport | the envelope already carries it |
transaction(id) |
required | Authorization: Bearer … |
obligations(actor=None) |
required | Authorization: Bearer … |
balance(account) |
required | Authorization: Bearer … |
usage(usage) |
required | Authorization: Bearer … |
The attestation is optional on the client: one that only submits needs none. A
party-scoped read on a client that holds none raises MissingAttestationError
locally, before anything reaches the wire — naming the read you attempted,
rather than spending a round trip to be told 401 about a credential that was
never sent.
obligations(actor) is an assertion, not a selector
?actor= used to select whose duties came back, so any party's duties were
enumerable by anyone who could guess a name, with no credential at all. It does not
select any more. Omit it and you are answered for the party your token names.
Supply it — a party id (party_…) or a DSL party name — and the service checks
that it resolves to that same party, answering 403 if it does not. It is there so a
caller can be explicit about who it believes it is, and be told when it is wrong.
Fulfill a specific duty
An intent fulfills existing obligations only when its fulfills collection names
those duty IDs. Read the obligations your authenticated party owes or is authorized
to perform, select the intended contract, and submit its concrete action together
with the required accounting entries and fulfills: [duty.id].
To correlate a duty with an acceptance, match both origin_transaction and
origin_tick against the acceptance outcome's transaction and commit_tick.
Transaction IDs can be reused after the retention window. actor names the party
that owes the duty; performer names the party authorized to fulfill it. They need
not be the same party.
A selection contains at most 64 distinct IDs. All selected duties must validate
atomically: the performer, concrete action, deadline, and current policy must
permit performance. Invalid selections return 422 invalid_fulfillment without
mutation or disclosure of another party's duties. Policy re-pinning preserves the
selected IDs. An empty selection never discharges an existing duty.
Rotating an expiring token
Attestations expire. with_attestation() hands back a client that reads as the
bearer of a different one and shares the connection pool — rotating a token costs
a string, not a reconnect. It is also how one process acts for several agents: each
view sees only its own party's data.
fresh = client.with_attestation(reissued_token) # same pool, new credential
Policy pinning, and why submit_pinned exists
Every intent names the policy the agent believes is in force. If that pin is
stale, the service refuses with a 409 and nothing is written. The recovery
is always the same: re-pin against the policy now in force and submit again.
That is the loop every correct agent writes, so it ships here instead:
submit_pinned(draft)— readGET /policies/active, stamp the pin, submit, and on a409re-pin against the policy the rejection itself named and go again. Bounded at 3 pin attempts.submit_pinned_from(draft, policy_id)— the same, but starting from a policy you already believe is in force. This is what a long-running agent wants: hold the last policy you saw, submit straight against it, and let the409tell you when your belief went stale.submit_intent(envelope)— no recovery, no re-reads. You pinned it; you own it.
Retrying is safe for one specific reason: the transaction id is minted once, on
the draft, before the first attempt, and it is the substrate's idempotency key.
Every re-pinned attempt carries the same id, so an attempt that in fact landed
replays its stored outcome, marked replayed=True with content_verified=True. The
loop cannot double-post. (IntentDraft mints one for you as a UUIDv7; supply your
own if you have one.) Every attempt also carries the id's mint time as
minted_at_ms, the same on every re-pin: the time a UUIDv7 id holds, or the
draft's minted_at_ms for an id that holds none. An id with no time of either
kind is refused before anything is sent.
An id means one intent, for one window:
- Different content under a committed id raises
TransactionReusedError(409 transaction_reused), and nothing is written. Mint a new id for a new intent. - The window is advertised. The service states how long it answers for an id, and
the client refuses to send a key older than that with
StaleIdempotencyKeyError, before anything is sent. Past the window a resubmission may commit a second time, so read the original back withclient.transaction(id)and mint a new id if it did not land.
The exception taxonomy
Everything below inherits from AxorumError. None of them is a refusal.
| Exception | Status / code | What it means, and what to do |
|---|---|---|
StalePolicyError |
409 stale_policy / policy_pin_mismatch |
The pin did not hold and nothing was written. Carries .pinned and .active — re-pin against .active. submit_pinned does this for you. .requires_repin is True. |
NoActivePolicyError |
409 no_active_policy |
No policy is in force at all. Re-pinning cannot help; an operator has to activate one. |
UnknownAgentError |
401 unknown_agent |
The agent:// URI is not bound to a party. Carries .uri. |
AttestationError |
403 attestation |
The PASETO attestation was rejected — bad signature, expired, wrong audience, untrusted issuer. |
NotPrimaryError |
421 not_primary |
A cluster mutation reached a follower. The attempt may or may not have been recorded: a primary stepping down can answer 421 after proposing it. Re-send the same transaction id. Carries .primary_api_addr, the primary's API origin as the follower last knew it (or None): a hint that can be stale, never followed for you. Re-send the same transaction id through your configured endpoints. |
CommitUnavailableError |
503 commit_unavailable |
The request was proposed and its commit could not be confirmed. It may or may not have landed. .is_retriable is True — retrying the same transaction id is safe, or read it back with client.transaction(id). Expect it while the cluster replaces a lost primary: a failover can outlast the server's commit wait (submit_timeout_ms). A request stopped before it was proposed is never this error: 503 idempotency_unseeded means this request was not written and will not be (an earlier attempt with the same transaction id may still land; .is_retriable is True), and 500 idempotency_seed_refused means nothing was written and the primary refuses every submit until it changes (.is_retriable is False). A cluster replica also refuses before proposing with 503 not_normal (a view change or a recovery) or 503 archive_pressure (its local archive is behind), both .is_retriable, and with 503 quarantined or 500 proposal_refused, which need an operator. All four arrive as ApiError and wrote nothing. |
ApiError |
any documented code | A documented error with no distinct recovery: unknown_account, unknown_transaction, unknown_usage, substrate_rejected, capability_mapping, … Carries .status, .code, and the full .body. |
MissingAttestationError |
— | A party-scoped read on a client that holds no attestation. Raised before any request is sent; carries .operation. Build the client with attestation=…, or rotate one in with with_attestation(). |
TransportError |
— | The service could not be reached. .is_retriable is True. |
UntrustedCertificateError |
none | A TransportError whose cause is the service's TLS certificate failing verification, usually a deployment CA the client does not trust yet. .is_retriable is False; see "Trusting a private API CA". |
UnexpectedResponseError |
any | A non-2xx whose body is not an agent-plane error body — a proxy, a load balancer. The bytes are kept verbatim in .body. |
DecodeError |
any | The service and this client disagree about the wire. Bytes kept verbatim. |
ConfigError |
— | The client could not be built: a malformed base URL, an empty attestation. |
Two convenience properties on every error say what to do next:
try:
outcome = client.submit_intent(envelope)
except AxorumError as failure:
if failure.requires_repin:
... # re-pin against failure.active and resubmit
elif failure.is_retriable:
... # re-send the IDENTICAL request — safe, the txn id is the idempotency key
else:
raise
This client never auto-follows a 421
Failover is automatic only inside authorize, across the failover_urls you name.
Every other call talks to the one base_url you give it.
After a view change, a replica that is no longer the primary answers 421 not_primary as
NotPrimaryError carrying primary_api_addr, and your code re-aims the client.
In cluster mode, a mutation that reaches a non-primary replica is refused 421 not_primary. The body names the primary's API origin as the follower last knew it
(https://host:port, or http:// for a plaintext API). This client will not
silently re-send there. It raises NotPrimaryError and hands you
.primary_api_addr.
The origin is informational. It can name a primary that has just died, so the client never routes on it. Re-send the same transaction id through the endpoints you configured; that is always safe, because the transaction id is the idempotency key. If you choose to follow the hint, it is a complete origin, so pass it as is:
try:
outcome = client.submit_intent(envelope)
except NotPrimaryError as failure:
if failure.primary_api_addr is None:
raise # the follower doesn't know either
with AxorumClient(failure.primary_api_addr) as primary:
outcome = primary.submit_intent(envelope) # same txn id: cannot double-post
A follower answering your writes means the topology moved. A client that auto-follows hides that from the operator who most needs to see it, which is why the move is yours.
API surface
AxorumClient(base_url, *, attestation=None, timeout=None, transport=None,
failover_urls=(), on_rotation=None, on_retry=None,
authorize_deadline=15.0, authorize_retries=True)
AsyncAxorumClient(base_url, *, attestation=None, timeout=None, transport=None,
failover_urls=(), on_rotation=None, on_retry=None,
authorize_deadline=15.0, authorize_retries=True)
# timeout=None: 30 s, and a submission outlasts a longer advertised commit wait
client.with_attestation(attestation) -> a new client, sharing the pool
client.authorize(agent, payment) -> Authorization # token in the agent
client.active_policy() -> str | None # no attestation
client.currencies() -> DeclaredCurrencies # attested
client.idempotency_window() -> IdempotencyWindow # no attestation
client.submit_intent(envelope) -> AgentOutcome # token in the envelope
client.submit_keyed(key, window, envelope) -> AgentOutcome # token in the envelope
client.submit_pinned(draft) -> AgentOutcome # token in the envelope
client.submit_pinned_from(draft, policy_id) -> AgentOutcome # token in the envelope
client.transaction(txn_id) -> TransactionRecord # attested; opaque JSON
client.receipt(txn_id) -> Envelope # attested
client.obligations(actor=None) -> ObligationsResponse # attested
client.balance(account_id) -> BalanceResponse # attested
client.usage(usage_id) -> UsageBalanceReport # attested
active_policy() returning None is not an error — "no policy is in force"
is a true and useful fact about a ledger, not a failure to report one.
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:
draft = IntentDraft(
agent=agent_uri,
attestation=token,
action=ActionTerm("post"),
entries=[Entry.debit(cash, Amount(1000, "USD")),
Entry.credit(revenue, Amount(1000, "USD"))],
justification="one metered API call, billed on the spot",
consume_usage=UsageMove(usage, Amount(1000, "USD")), # you used it
meter_usage=UsageMove(usage, Amount(1000, "USD")), # it was measured
bill_usage=UsageMove(usage, Amount(1000, "USD")), # it was billed
)
outcome = client.submit_pinned(draft)
report = client.usage(usage)
print(report.text) # the balance in compliance English, with exact figures
All three 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 is fine too,
and an absent move is omitted from the wire entirely, never sent as null.
Metering more than was consumed is phantom usage; billing more than was metered
is billing with no meter basis. The ledger refuses both with a 422 substrate_rejected
whose message is the compliance-English refusal with exact figures — and nothing
posts, not even the journal legs that rode with the bad move. Under-recognition
(billed < metered < consumed) is a lawful transient, not a fault to correct.
Usage reads are owner-scoped and fail-closed: a balance owned by another party,
one opened with no owner, and one that never existed all answer the same
404 unknown_usage. The cases are deliberately indistinguishable, so that a usage id
is never an existence oracle across a tenant boundary.
A usage balance is opened on the admin plane, which this SDK does not speak — an agent receives a usage id, it does not mint one.
Every request carries an auto-generated x-request-id (UUID v4) so you can find
it in the service's logs. Pass your own through if you are already carrying a
correlation id.
Pure protocol core
axorum.protocol holds every request builder, every response decoder, the read
credentialer (authorize), the error mapper (error_from(status, body)), and the
policy-pin loop (pin_flow) — all
pure functions of their inputs, with no I/O anywhere. The sync and async clients
are thin shells over them, which is why they cannot drift, and why the protocol
is exhaustively testable without a socket.
Development
uv sync
uv run ruff check . && uv run ruff format --check .
uv run mypy --strict src/
uv run pytest # unit tests
uv run pytest -m conformance # against a real axorum-serve
License
Apache-2.0
Metadata
Release files for axorum-client 1.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| axorum_client-1.1.0.tar.gz | 216.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| axorum_client-1.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 340.1 kB
Release files / axorum_client-1.1.0.tar.gz
| Download URL | axorum_client-1.1.0.tar.gz |
|---|---|
| Size | 216.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
442d0b2ee3fdc52232aa459c660db097b3c26f2dcb4329a5a0af334791e328c6
|
|
BLAKE2b-256 checksum How to use checksums |
c746ee9319d1176023f369494e77267645a36fd4bb822ffcf2b6cd3db0c2fbe1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 7, 2026.
Transparency logRelease files / axorum_client-1.1.0-py3-none-any.whl
| Download URL | axorum_client-1.1.0-py3-none-any.whl |
|---|---|
| Size | 124.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
281e42c20c5044ffd71c5289554d12b1e5689bb1f5705ca431b55bddfcbb216a
|
|
BLAKE2b-256 checksum How to use checksums |
708f224389e832b556892cdd5c2563cfdc37ae82b0596a5c33c6fffc4be06255
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 7, 2026.
Transparency log