Metadata-Version: 2.4
Name: axorum-client
Version: 0.1.0
Summary: The official Python client for the Axorum agent plane.
Project-URL: Homepage, https://github.com/AxorumHQ/axorum/tree/main/clients/python#readme
Project-URL: Repository, https://github.com/AxorumHQ/axorum
Project-URL: Issues, https://github.com/AxorumHQ/axorum/issues
Author-email: Roland Rodriguez <roland@axorum.com>
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: agent,axorum,deontic,ledger,policy
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: httpx>=0.27
Description-Content-Type: text/markdown

# 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.

```bash
pip install axorum-client     # or: uv add axorum-client
```

Requires Python 3.11+. The only runtime dependency is [httpx](https://www.python-httpx.org/).

---

## 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.

```python
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

```python
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("http://127.0.0.1:8080") 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:

```python
with AxorumClient("http://127.0.0.1:8080") 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)
```

## Async

The same client, awaited. Same method names, same arguments, same semantics.

```python
from axorum import AsyncAxorumClient

async with AsyncAxorumClient("http://127.0.0.1:8080") as client:
    outcome = await client.submit_pinned(draft)
    if not outcome.posted:
        print(f"recorded as refused: {outcome.verdict}")
```

## 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)`** — read `GET /policies/active`, stamp the pin, submit,
  and on a `409` re-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 the `409` tell 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. The loop cannot double-post. (`IntentDraft` mints one
for you from a UUID v4; supply your own if you have one.)

## 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. Nothing was written. Carries `.primary_client_addr` (which may be `None`). |
| `CommitUnavailableError` | 503 `commit_unavailable` | The 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)`. |
| `ApiError` | any documented code | A documented error with no distinct recovery: `unknown_account`, `unknown_transaction`, `substrate_rejected`, `capability_mapping`, … Carries `.status`, `.code`, and the full `.body`. |
| `TransportError` | — | The service could not be reached. `.is_retriable` is `True`. |
| `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 token. |

Two convenience properties on every error say what to do next:

```python
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`

In cluster mode, a mutation that reaches a non-primary replica is refused `421
not_primary`, with the primary's address in the body. **This client will not
silently re-send there.** It raises `NotPrimaryError` and hands you
`.primary_client_addr`.

That is deliberate. Following the redirect is an *operational* decision: a client
that auto-follows hides a topology change from the operator who most needs to see
it — a follower answering your writes means something moved, and you should learn
that from your client, not from a graph three days later. If you want to follow
it, follow it on purpose:

```python
try:
    outcome = client.submit_intent(envelope)
except NotPrimaryError as failure:
    if failure.primary_client_addr is None:
        raise                                    # the follower doesn't know either
    with AxorumClient(f"http://{failure.primary_client_addr}") as primary:
        outcome = primary.submit_intent(envelope)   # same txn id — cannot double-post
```

## API surface

```python
AxorumClient(base_url, *, bearer_token=None, timeout=10.0, transport=None)
AsyncAxorumClient(base_url, *, bearer_token=None, timeout=10.0, transport=None)

client.active_policy()                            -> str | None
client.submit_intent(envelope)                    -> AgentOutcome
client.submit_pinned(draft)                       -> AgentOutcome
client.submit_pinned_from(draft, policy_id)       -> AgentOutcome
client.transaction(txn_id)                        -> TransactionRecord   # opaque JSON
client.obligations(actor)                         -> ObligationsResponse
client.balance(account_id)                        -> BalanceResponse
```

`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.

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 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

```bash
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
