@axorum/client
v0.3.0
Published
The official TypeScript client for the Axorum agent plane — submit intents, read ledger state, and recover from a stale policy pin.
Maintainers
Readme
@axorum/client
The official TypeScript SDK for the Axorum agent plane — submit intents, read ledger
state, and recover from a stale policy pin, against an axorum-serve instance.
Axorum is a deontic financial-control ledger. An agent submits an intent envelope; the service permits or refuses it under the policy in force, and records the outcome either way. This client exists to get one thing right that a generated client cannot:
A policy refusal is not an error. It is a
200, it resolves, and the refusal record is the product.
Zero runtime dependencies — the platform's fetch, crypto.randomUUID and AbortSignal
are all it needs. ESM-only, Node ≥ 20.
Install
pnpm add @axorum/clientQuickstart
import { AxorumClient } from "@axorum/client";
const axorum = new AxorumClient({
baseUrl: "http://127.0.0.1:8080",
attestation: paseto, // the PASETO v4.public capability attestation
});
const outcome = await axorum.submitPinned({
agent: "agent://example.com/agent/clerk_01h455vb4pex5vsknk084sn02q",
attestation: paseto, // the same token, carried in the envelope
action: { action: "post", bindings: {} },
entries: [
{ account: cash, side: "Debit", amount: { minor: 1_000n, currency: "USD" } },
{ account: revenue, side: "Credit", amount: { minor: 1_000n, currency: "USD" } },
],
justification: "invoice 4471, approved by the purchase order",
});submitPinned reads the policy in force, stamps it onto the envelope, submits, and — if
the policy moved out from under it — re-pins and submits again. It is the loop every
correct agent writes, so it ships in the client rather than in your code.
The permitted outcome
if (outcome.posted) {
console.log(outcome.verdict); // "Permitted"
console.log(outcome.policy); // the policy it committed under
console.log(outcome.transaction); // the id it committed as
}The refusal — which is a value, not a throw
Point the same code at a policy that forbids the action and nothing about the control flow changes. The promise resolves:
const outcome = await axorum.submitPinned(draft);
if (!outcome.posted) {
console.log(outcome.verdict); // "ForbiddenRejected"
console.log(outcome.policy); // the policy that refused it
// The refusal is ON THE LEDGER. Read it back like any other record.
const record = await axorum.transaction(outcome.transaction);
}The intent reached the commit point, was judged, and the refusal was recorded. That
recording is what the ledger exists to produce. Branch on posted / verdict, never on
a status code. A rejected promise means the ledger never got to answer at all.
The API
new AxorumClient({ baseUrl, attestation?, timeoutMs?, fetch? })
axorum.activePolicy(opts?) // → Promise<string | null> no credential
axorum.submitIntent(envelope, opts?) // → Promise<AgentOutcome> in-envelope
axorum.submitPinned(draft, opts?) // → Promise<AgentOutcome> in-envelope
axorum.submitPinnedFrom(draft, policyId, opts?) // → Promise<AgentOutcome> in-envelope
axorum.transaction(txnId, opts?) // → Promise<TransactionRecord> ATTESTATION
axorum.obligations(actor?, opts?) // → Promise<ObligationsResponse> ATTESTATION
axorum.balance(accountId, opts?) // → Promise<BalanceResponse> ATTESTATION
axorum.usage(usageId, opts?) // → Promise<UsageBalanceReport> ATTESTATION
axorum.withAttestation(token) // → AxorumClient rotate the tokenEvery method takes opts?: { signal?: AbortSignal; requestId?: string }. An
x-request-id (UUID v4) is generated per request when you do not supply one.
Reads are credentialed
An intent authenticates in the envelope — the draft names the agent:// URI and the
PASETO attestation, and the service verifies both before it proposes anything. The reads
used to carry nothing at all and were answered anyway, so any party's obligations were
enumerable and any observed transaction id disclosed another tenant's record.
They are credentialed now, with the same token. The four party-scoped reads present
the attestation as Authorization: Bearer …; 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.
There is no second credential to obtain: an agent that can submit can already read.
const axorum = new AxorumClient({ baseUrl, attestation: paseto });
const owed = await axorum.obligations(); // your party's duties — the token decides
const record = await axorum.transaction(id); // your party's record, or a 404A client built without an attestation still submits and still reads activePolicy —
both carry their own credential or need none — but a party-scoped read throws
MissingAttestationError locally, before a byte leaves the process. The client never
sent a credential, so relaying the service's 401 would blame the service for rejecting
one it was never given.
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:
const outcome = await axorum.submitPinned({
...draft,
consume_usage: { usage, amount: { minor: 1_000n, currency: "USD" } }, // you used it
meter_usage: { usage, amount: { minor: 1_000n, currency: "USD" } }, // it was measured
bill_usage: { usage, amount: { minor: 1_000n, currency: "USD" } }, // it was billed
});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 — metering
something an earlier intent consumed is one move, not three — and an absent move's key is
omitted from the wire entirely, never 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, and nothing posts —
not the usage moves, and not the journal legs that rode with them. Under-recognition
(billed < metered < consumed) is a lawful transient, not a fault.
Read a balance back with usage(usageId). Its counters are bigints, and here that
matters most: consumed takes no ceiling, so a busy meter's total can lawfully outgrow
u64. Its text is the balance narrated in compliance English with exact figures — prefer
it when you have a human to show.
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. Surface the refusal as it stands; there is nothing further
in it to infer.
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.
obligations(actor?) — an assertion, not a selector
?actor= used to choose whose duties came back, which is how they were enumerable.
It is now an assertion the service checks:
- Omit it — you are answered for the party your token names.
- Supply it (a party id
party_…or a DSL party name) — the service confirms it resolves to that same party, and answers403if 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. It can no longer be used to ask about somebody else.
Rotating an expiring token
Attestations expire. withAttestation returns a new client over the same fetch, so
re-credentialing costs a string, not a reconnect — and it is also the honest way for one
process to act for several agents, since no view can see another's data.
const rotated = axorum.withAttestation(freshPaseto);
balanceis authenticated, not party-scoped. The ledger records no account→party edge to scope it by, so any authenticated agent may read any account's balance. That is a known residual gap in the service, not a property of this client — do not treat a balance read as private to your party.
An intent draft is a plain object — entries, evidence, justification and
transaction are optional. Use pinIntent(draft, policy) if you drive the pin yourself.
Transaction ids and idempotency
The client mints the transaction id, and it is the substrate's idempotency key:
re-submitting a committed id replays its stored outcome rather than double-posting. Leave
transaction off your draft and the client mints one — once, before its first attempt,
so every re-pinned retry inside submitPinned carries the same id. That is the entire
reason the retry loop is safe. Mint one yourself with newTransactionId().
Money is bigint
The ledger denominates money in 128-bit integer minor units, on the wire as bare JSON
numbers. JSON.parse decodes those as doubles and silently loses the low bits — the worst
failure a financial client can have, a wrong balance that looks right. So this client does
not use JSON.parse. It ships its own parser, and the rule is one line:
Every JSON integer is a
bigint. Every number with a fraction or exponent is anumber.
So amount.minor, balance, anchor, deadline and the Int/Tick literals are all
bigint, and the generated wire types say so. Write { minor: 50_000n, currency: "USD" }.
The error taxonomy
Everything below is a subclass of AxorumError. Catch by instanceof for the narrowed
fields, or switch on the stable code — both are first-class.
| Class | code | Status | What to do |
|---|---|---|---|
| StalePolicyError | stale_policy / policy_pin_mismatch | 409 | Re-pin against .active and resubmit. Nothing was written. submitPinned does this for you. |
| NoActivePolicyError | no_active_policy | 409 | Nothing to pin to. An operator must activate a policy. |
| UnknownAgentError | unknown_agent | 401 | The agent:// URI is not bound to a party — an authentication failure. |
| AttestationError | attestation | 403 | The PASETO attestation was rejected: bad signature, expired, wrong audience, untrusted issuer. |
| NotPrimaryError | not_primary | 421 | You reached a follower. The primary is in .primaryClientAddr. See below. |
| CommitUnavailableError | commit_unavailable | 503 | The commit is unconfirmed — it may or may not have landed. Retriable. |
| ApiError | the wire code | any | A documented error with no distinct recovery: unknown_account, unknown_usage, substrate_rejected, capability_mapping, … The full body is on .body. |
| MissingAttestationError | missing_attestation | — | A party-scoped read on a client holding no attestation. Raised locally — nothing was sent. Build with attestation, or withAttestation. |
| TransportError | transport | — | The service could not be reached, or the request timed out. Retriable. |
| ConfigError | config | — | The client could not be built. |
| UnexpectedResponseError | unexpected_response | any | A non-2xx that is not an agent-plane body — a proxy, a load balancer. Bytes kept verbatim in .rawBody. |
| DecodeError | decode | any | A success body that did not decode. Bytes kept verbatim. |
Two accessors say what to do next:
error.isRetriable— re-send the identical request. True forTransportErrorandCommitUnavailableError. Safe even for a submission: the transaction id is the idempotency key, so a replay returns the stored outcome. You cannot double-post by retrying.error.requiresRepin— the policy moved. True exactly forStalePolicyError.
The error body's extras are load-bearing and this client never flattens them away:
without pinned/active no client could re-pin, and without primary_client_addr none
could find the primary.
import { CommitUnavailableError, isAxorumError, StalePolicyError } from "@axorum/client";
try {
await axorum.submitPinned(draft);
} catch (error) {
if (error instanceof StalePolicyError) {
console.log(`pinned ${error.pinned}, but ${error.active} is in force`);
} else if (isAxorumError(error) && error.isRetriable) {
// The same envelope, the same transaction id. A replay, not a second posting.
}
}A 421 is never followed automatically
When a follower turns a mutation away, this client surfaces primaryClientAddr and
stops. It will not re-send to the primary for you.
That is deliberate. A client that silently auto-follows a redirect hides a topology change from the operator who most needs to see it: your writes quietly start going somewhere else and nothing in your logs says so. Re-sending to another replica is an operational decision, so the address is handed to you and the move is yours.
catch (error) {
if (error instanceof NotPrimaryError) {
console.warn(`this replica is a follower; the primary is ${error.primaryClientAddr}`);
// Re-target deliberately, and know that you did.
}
}Development
The wire types in src/generated/ are generated from the frozen OpenAPI contract at
spec/openapi/agent-plane.v1.json and committed. Field names are the wire's own —
snake_case stays snake_case, because a second vocabulary is a second thing to keep in
sync.
pnpm install
pnpm generate # regenerate src/generated/ from the frozen spec
pnpm typecheck # tsc --noEmit, strict
pnpm build # → dist/
pnpm test # unit + conformanceThe conformance suite (test/conformance/) runs all thirteen scenarios from the Rust
reference client against a real, booted axorum-serve — real routes, real policy
evaluation, real error bodies. It deliberately does not mock: a mock would put whatever
bytes the test author believed the service emits on the wire, which is exactly the belief
under test. It needs the harness binary at
target/debug/examples/conformance_server, built by the Rust workspace.
pnpm test:unit # no server required
pnpm test:conformance # boots a real axorum-serve per scenario