Metadata-Version: 2.4
Name: axorum-client
Version: 0.3.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", 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:

```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", 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:

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

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

```python
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)`** — 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`, `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`. |
| `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:

```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, *, attestation=None, timeout=10.0, transport=None)
AsyncAxorumClient(base_url, *, attestation=None, timeout=10.0, transport=None)

client.with_attestation(attestation)              -> a new client, sharing the pool

client.active_policy()                            -> str | None          # no attestation
client.submit_intent(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.obligations(actor=None)                    -> ObligationsResponse # attested
client.balance(account_id)                        -> BalanceResponse     # 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`:

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

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