@axorum/client 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +537 -2
- package/dist/agent-uri.d.ts +33 -0
- package/dist/agent-uri.d.ts.map +1 -0
- package/dist/agent-uri.js +231 -0
- package/dist/agent-uri.js.map +1 -0
- package/dist/authorize.d.ts +671 -0
- package/dist/authorize.d.ts.map +1 -0
- package/dist/authorize.js +947 -0
- package/dist/authorize.js.map +1 -0
- package/dist/capabilities.d.ts +30 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +40 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/chart.d.ts +74 -0
- package/dist/chart.d.ts.map +1 -0
- package/dist/chart.js +107 -0
- package/dist/chart.js.map +1 -0
- package/dist/client.d.ts +645 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1303 -0
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +646 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +928 -0
- package/dist/errors.js.map +1 -0
- package/dist/failover.d.ts +197 -0
- package/dist/failover.d.ts.map +1 -0
- package/dist/failover.js +289 -0
- package/dist/failover.js.map +1 -0
- package/dist/freshness.d.ts +65 -0
- package/dist/freshness.d.ts.map +1 -0
- package/dist/freshness.js +148 -0
- package/dist/freshness.js.map +1 -0
- package/dist/generated/agent-plane.d.ts +1865 -0
- package/dist/generated/agent-plane.d.ts.map +1 -0
- package/dist/generated/agent-plane.js +19 -0
- package/dist/generated/agent-plane.js.map +1 -0
- package/dist/idempotency.d.ts +160 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/idempotency.js +208 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/index.d.ts +90 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +88 -0
- package/dist/index.js.map +1 -0
- package/dist/intent.d.ts +163 -0
- package/dist/intent.d.ts.map +1 -0
- package/dist/intent.js +82 -0
- package/dist/intent.js.map +1 -0
- package/dist/json.d.ts +49 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +259 -0
- package/dist/json.js.map +1 -0
- package/dist/key.d.ts +64 -0
- package/dist/key.d.ts.map +1 -0
- package/dist/key.js +71 -0
- package/dist/key.js.map +1 -0
- package/dist/payment.d.ts +172 -0
- package/dist/payment.d.ts.map +1 -0
- package/dist/payment.js +178 -0
- package/dist/payment.js.map +1 -0
- package/dist/refusal.d.ts +121 -0
- package/dist/refusal.d.ts.map +1 -0
- package/dist/refusal.js +176 -0
- package/dist/refusal.js.map +1 -0
- package/dist/retry.d.ts +215 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +297 -0
- package/dist/retry.js.map +1 -0
- package/dist/typeid.d.ts +64 -0
- package/dist/typeid.d.ts.map +1 -0
- package/dist/typeid.js +119 -0
- package/dist/typeid.js.map +1 -0
- package/dist/types.d.ts +245 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +14 -0
- package/dist/types.js.map +1 -0
- package/package.json +58 -3
package/README.md
CHANGED
|
@@ -1,3 +1,538 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@axorum/client`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The official TypeScript SDK for the **Axorum agent plane** — submit intents, read ledger
|
|
4
|
+
state, and recover from a stale policy pin, against an `axorum-serve` instance.
|
|
5
|
+
|
|
6
|
+
Axorum is a deontic financial-control ledger. An agent submits an *intent envelope*; the
|
|
7
|
+
service permits or refuses it under the policy in force, and **records the outcome either
|
|
8
|
+
way**. This client exists to get one thing right that a generated client cannot:
|
|
9
|
+
|
|
10
|
+
> **A policy refusal is not an error.** It is a `200`, it resolves, and the refusal record
|
|
11
|
+
> is the product.
|
|
12
|
+
|
|
13
|
+
Zero runtime dependencies — the platform's `fetch`, `crypto.randomUUID` and `AbortSignal`
|
|
14
|
+
are all it needs. ESM-only, Node ≥ 20.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
pnpm add @axorum/client
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quickstart
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { AxorumClient } from "@axorum/client";
|
|
26
|
+
|
|
27
|
+
const axorum = new AxorumClient({
|
|
28
|
+
baseUrl: "https://axorum.internal:8443",
|
|
29
|
+
attestation: paseto, // the PASETO v4.public capability attestation
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const outcome = await axorum.submitPinned({
|
|
33
|
+
agent: "agent://example.com/agent/clerk_01h455vb4pex5vsknk084sn02q",
|
|
34
|
+
attestation: paseto, // the same token, carried in the envelope
|
|
35
|
+
action: { action: "post", bindings: {} },
|
|
36
|
+
entries: [
|
|
37
|
+
{ account: cash, side: "Debit", amount: { minor: 1_000n, currency: "USD" } },
|
|
38
|
+
{ account: revenue, side: "Credit", amount: { minor: 1_000n, currency: "USD" } },
|
|
39
|
+
],
|
|
40
|
+
justification: "invoice 4471, approved by the purchase order",
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`submitPinned` reads the policy in force, stamps it onto the envelope, submits, and — if
|
|
45
|
+
the policy moved out from under it — re-pins and submits again. It is the loop every
|
|
46
|
+
correct agent writes, so it ships in the client rather than in your code.
|
|
47
|
+
|
|
48
|
+
### The permitted outcome
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
if (outcome.posted) {
|
|
52
|
+
console.log(outcome.verdict); // "Permitted"
|
|
53
|
+
console.log(outcome.policy); // the policy it committed under
|
|
54
|
+
console.log(outcome.transaction); // the id it committed as
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### The refusal — **which is a value, not a throw**
|
|
59
|
+
|
|
60
|
+
Point the same code at a policy that forbids the action and nothing about the control flow
|
|
61
|
+
changes. The promise **resolves**:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
const outcome = await axorum.submitPinned(draft);
|
|
65
|
+
|
|
66
|
+
if (!outcome.posted) {
|
|
67
|
+
console.log(outcome.verdict); // "ForbiddenRejected"
|
|
68
|
+
console.log(outcome.policy); // the policy that refused it
|
|
69
|
+
|
|
70
|
+
// The refusal is ON THE LEDGER. Read it back like any other record.
|
|
71
|
+
const record = await axorum.transaction(outcome.transaction);
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The intent reached the commit point, was judged, and the refusal was *recorded*. That
|
|
76
|
+
recording is what the ledger exists to produce. **Branch on `posted` / `verdict`, never on
|
|
77
|
+
a status code.** A rejected promise means the ledger never got to answer at all.
|
|
78
|
+
|
|
79
|
+
### Provenance and evaluation semantics
|
|
80
|
+
|
|
81
|
+
The outcome's `provenance` is either the recorded audit trail or the string `"Archived"`.
|
|
82
|
+
Recorded provenance has an optional `semantics?: SemanticsVersion` field. The TypeScript
|
|
83
|
+
SDK represents `SemanticsVersion` as a `bigint`, following its contract-wide integer
|
|
84
|
+
convention; it is omitted for pre-versioning records. The stamp names the evaluation law,
|
|
85
|
+
not the outcome. `master_data` is also optional and is omitted when the policy did not
|
|
86
|
+
consult replicated reference data.
|
|
87
|
+
|
|
88
|
+
### Trusting a private API CA
|
|
89
|
+
|
|
90
|
+
A deployment's API listener presents a certificate from its own CA, which Node's default
|
|
91
|
+
trust store does not hold. Point Node at that CA's `ca.crt` before the process starts:
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
NODE_EXTRA_CA_CERTS=/path/to/api/ca.crt node agent.js
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## The API
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
new AxorumClient({ baseUrl, failoverUrls?, onRotation?, onRetry?, attestation?, timeoutMs?, retry?, authorizeDeadlineMs?, authorizeRetries?, debug?, fetch? })
|
|
101
|
+
|
|
102
|
+
axorum.authorize(agent, payment, opts?) // → Promise<Authorization> one call, see below
|
|
103
|
+
axorum.activePolicy(opts?) // → Promise<string | null> no credential
|
|
104
|
+
axorum.currencies(opts?) // → Promise<DeclaredCurrencies> ATTESTATION
|
|
105
|
+
axorum.idempotencyWindow(opts?) // → Promise<IdempotencyWindow> no credential
|
|
106
|
+
axorum.submitIntent(envelope, opts?) // → Promise<AgentOutcome> in-envelope
|
|
107
|
+
axorum.submitKeyed(key, window, envelope, opts?) // → Promise<AgentOutcome> in-envelope
|
|
108
|
+
axorum.submitPinned(draft, opts?) // → Promise<AgentOutcome> in-envelope
|
|
109
|
+
axorum.submitPinnedFrom(draft, policyId, opts?) // → Promise<AgentOutcome> in-envelope
|
|
110
|
+
axorum.transaction(txnId, opts?) // → Promise<TransactionRecord> ATTESTATION
|
|
111
|
+
axorum.receipt(txnId, opts?) // → Promise<Envelope> ATTESTATION
|
|
112
|
+
axorum.obligations(actor?, opts?) // → Promise<ObligationsResponse> ATTESTATION
|
|
113
|
+
axorum.balance(accountId, opts?) // → Promise<BalanceResponse> ATTESTATION
|
|
114
|
+
axorum.usage(usageId, opts?) // → Promise<UsageBalanceReport> ATTESTATION
|
|
115
|
+
axorum.withAttestation(token) // → AxorumClient rotate the token
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Every method that sends a request takes `opts?: { signal?: AbortSignal; requestId?: string }`,
|
|
119
|
+
except `authorize`, which takes `opts?: { signal?: AbortSignal }`. An
|
|
120
|
+
`x-request-id` (UUID v4) is generated per request when you do not supply one.
|
|
121
|
+
|
|
122
|
+
## Authorize a payment in one call
|
|
123
|
+
|
|
124
|
+
`authorize(agent, payment)` asks the ledger whether an agent may pay, and answers
|
|
125
|
+
**allowed** or **refused with a reason**. It proposes `purchase(amount, payee)` against the
|
|
126
|
+
**default chart**: it debits the spend account and credits the payable account for the
|
|
127
|
+
payment's currency. It derives both account ids offline, so you never name an account.
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import { Agent, AuthorizeError, AxorumClient, payment } from "@axorum/client";
|
|
131
|
+
|
|
132
|
+
const axorum = new AxorumClient({ baseUrl: "https://ledger.acme.com" });
|
|
133
|
+
const buyer = new Agent("agent://acme.com/agent/buyer_01h455vb4pex5vsknk084sn02q", mintToken);
|
|
134
|
+
const order = payment({ amount: { minor: 1_250n, currency: "USD" }, payee: "api.openai.com" });
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const answer = await axorum.authorize(buyer, order);
|
|
138
|
+
if (answer.allowed) {
|
|
139
|
+
await pay();
|
|
140
|
+
} else {
|
|
141
|
+
console.log(answer.refusal.code, answer.refusal.rule?.name, answer.refusal.explanation);
|
|
142
|
+
}
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (error instanceof AuthorizeError && error.retryable) {
|
|
145
|
+
// The ledger never answered. Re-drive the SAME `order`: its transaction id replays.
|
|
146
|
+
}
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
What the deployment needs:
|
|
152
|
+
|
|
153
|
+
- the operator declared the payment's currency (`[[currencies]]` in `axorum-serve`, or
|
|
154
|
+
`axorum currency declare`) and opened the default chart in it (`[default_chart]
|
|
155
|
+
version = 1`, or `axorum chart provision`); otherwise the error is
|
|
156
|
+
`currency_not_declared` (the service checks the currency first), or
|
|
157
|
+
`chart_not_provisioned` when only the chart is missing;
|
|
158
|
+
- the policy in force declares `action purchase(amount: amount, payee: text)`;
|
|
159
|
+
- the payee is in the counterparty registry when a rule or aggregate scopes by it. A payee
|
|
160
|
+
the registry has never heard of is `unknown_payee`, a `422`, not a refusal: nothing is
|
|
161
|
+
recorded. Import it with `approved: false` to have it refused.
|
|
162
|
+
|
|
163
|
+
**Pass the agent URI in canonical form**, the one form `agent-uri` renders: a lowercase scheme
|
|
164
|
+
and host, a port without leading zeros, an IPv4 address with no leading zero in an octet, an
|
|
165
|
+
IPv6 address as RFC 5952 writes it, and no query or fragment. `new Agent` refuses any other spelling with an `InvalidAgentError` whose
|
|
166
|
+
`canonical` names the form to pass, because a keyed payment derives its id from the URI's
|
|
167
|
+
exact bytes and both SDKs must derive the same one.
|
|
168
|
+
|
|
169
|
+
**The SDK binds the payee as text.** A payment rail governed by the same policy must be set
|
|
170
|
+
to `payee_binding = "text"`; a rail left on the default `"party"` binds a different type,
|
|
171
|
+
and the same policy cannot serve both.
|
|
172
|
+
|
|
173
|
+
The agent's attestation can be a string, or a function `() => Promise<string>` that
|
|
174
|
+
`authorize` calls on **every submission** (the first, and each re-pin), so a short-lived
|
|
175
|
+
token is refreshed as it goes. A rejected one is `attestation_rejected` with
|
|
176
|
+
`needsFreshAttestation` set.
|
|
177
|
+
|
|
178
|
+
`payment()` mints the transaction id (UUIDv7) **once**, before any I/O. Every send
|
|
179
|
+
carries it, so one that committed replays its answer instead of paying twice.
|
|
180
|
+
|
|
181
|
+
**Time and retries.** `authorize` runs under one time budget for the whole call:
|
|
182
|
+
`AUTHORIZE_DEADLINE_MS` (15 s), or less with the `authorizeDeadlineMs` client option. The
|
|
183
|
+
idempotency window and policy reads, every submission, every re-pin, every attempt and
|
|
184
|
+
every pause share it. Each submission attempt waits the commit wait a clustered service
|
|
185
|
+
advertises (the `axorum-submit-timeout-ms` header on `GET /api/openapi.json`) plus 1 s, so
|
|
186
|
+
the service's own `503 commit_unavailable` is heard rather than cut off, or 6 s
|
|
187
|
+
(`SUBMIT_ATTEMPT_FALLBACK_MS`) when it advertises none; each read waits 6 s. The `debug`
|
|
188
|
+
client option is the client's diagnostic sink, for everything it would say at debug level;
|
|
189
|
+
it hears, once per client, why attempts wait the fallback. Every attempt is
|
|
190
|
+
clamped to what is left of the budget, and one the clamp cuts below the commit wait plus
|
|
191
|
+
1 s may end `indeterminate`: re-drive the same payment. Requests are re-sent under
|
|
192
|
+
`AUTHORIZE_RETRY_POLICY`: full jitter between 100 ms and 1.6 s, so a fleet of agents does
|
|
193
|
+
not re-send in step, with attempts bounded by the deadline alone. The client's `retry`
|
|
194
|
+
and `timeoutMs` govern its other calls, not `authorize`. The policy mirrors the Rust SDK's `acton-service-client` 0.2,
|
|
195
|
+
and `spec/fixtures/retry-v1.json` pins both SDKs to the same rows.
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
import { AxorumClient } from "@axorum/client";
|
|
199
|
+
|
|
200
|
+
// A checkout that cannot wait 15 s for an answer.
|
|
201
|
+
const axorum = new AxorumClient({ baseUrl, authorizeDeadlineMs: 5_000 });
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Re-sending is on by default. A caller that runs its own re-drive loop sets
|
|
205
|
+
`authorizeRetries: false`: each request is then sent once, still under the deadline, and
|
|
206
|
+
the first unanswered one ends the call, `not_submitted` or `indeterminate` as below.
|
|
207
|
+
|
|
208
|
+
| Re-sent until the deadline | Not re-sent |
|
|
209
|
+
|---|---|
|
|
210
|
+
| connection failures and timeouts | a refusal (it is an answer) |
|
|
211
|
+
| `429`, `502`, `504`, and `423` with `Retry-After`, honouring a `Retry-After` that fits before the deadline | `422`, including `chart_not_provisioned` and `unknown_payee` |
|
|
212
|
+
| `421 not_primary`, on the next of the `failoverUrls`: `primary_api_addr` is a hint, never followed | `409 transaction_reused` |
|
|
213
|
+
| a `503` of any code, to the same URL, by the call itself with its body read | `400` / `401` / `403` / `404` / `500` / `507` |
|
|
214
|
+
| `409` stale pin: re-pinned, at most 3 times | |
|
|
215
|
+
|
|
216
|
+
Every send carries the same transaction id, so re-sending a submission that committed
|
|
217
|
+
replays its answer. A posted outcome whose verdict is not an allow, or a refusal that
|
|
218
|
+
does not say why, fails closed as `unexpected_verdict`.
|
|
219
|
+
|
|
220
|
+
A call whose last answer wrote nothing, with no earlier attempt in doubt, is
|
|
221
|
+
`not_submitted`, never a refusal. Its `cause` names that answer: a connection that never
|
|
222
|
+
opened, the service's own `429`, `503 idempotency_unseeded`, `500
|
|
223
|
+
idempotency_seed_refused`, `503 not_normal`, `503 archive_pressure`, `503 quarantined`,
|
|
224
|
+
`500 proposal_refused`, or a budget spent before the first send (`deadline_exceeded`);
|
|
225
|
+
`tried` lists every attempt, the last included. The six codes count only with the body
|
|
226
|
+
read.
|
|
227
|
+
|
|
228
|
+
Every other attempt leaves doubt: a timeout, a connection that broke after the request
|
|
229
|
+
went out, any other `503`, and every status the transport sent again. The transport
|
|
230
|
+
re-sends on the status alone, without reading the body, so a `421`, or a `429` that was
|
|
231
|
+
sent again, always leaves doubt. When the budget runs out on an answer like these, the
|
|
232
|
+
failure is `indeterminate`, carrying that answer as its cause. After doubt, a typed
|
|
233
|
+
failure, or a last answer that wrote nothing (an unseeded `503`, a `429`), is
|
|
234
|
+
`indeterminate` too, cause `earlier_attempt_in_doubt`, naming the first attempt in
|
|
235
|
+
doubt, unless it settles the transaction id (`transaction_reused`). Re-driving the same payment is safe either way.
|
|
236
|
+
`not_submitted` and `indeterminate` are the two `retryable` failures. If an earlier call
|
|
237
|
+
with this key was ambiguous, the transaction may still commit; retry with the same key
|
|
238
|
+
and the server will replay.
|
|
239
|
+
|
|
240
|
+
### Every replica, for a cluster
|
|
241
|
+
|
|
242
|
+
A clustered deployment accepts writes only on its primary. Name every replica's API URL
|
|
243
|
+
and `authorize` moves an attempt that meets a `421`, a connection that never opens, or an
|
|
244
|
+
attempt timeout on to the next one at once, under the same deadline and the same
|
|
245
|
+
transaction id; a `503` is sent again to the same URL. The next call starts at the URL
|
|
246
|
+
that last answered. `submitKeyed` runs on the same transport.
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
import { AxorumClient, RETRIES_COUNTER, ROTATIONS_COUNTER, rotationLabel } from "@axorum/client";
|
|
250
|
+
|
|
251
|
+
const axorum = new AxorumClient({
|
|
252
|
+
baseUrl: "https://axorum-0.axorum.svc:8443",
|
|
253
|
+
failoverUrls: ["https://axorum-1.axorum.svc:8443", "https://axorum-2.axorum.svc:8443"],
|
|
254
|
+
// The Rust SDK counts on these names; count the same way.
|
|
255
|
+
onRotation: (_left, reason) => metrics.increment(ROTATIONS_COUNTER, { reason: rotationLabel(reason) }),
|
|
256
|
+
onRetry: (_endpoint, reason) => metrics.increment(RETRIES_COUNTER, { reason: rotationLabel(reason) }),
|
|
257
|
+
});
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Each failover URL is a bare origin of `baseUrl`'s scheme; `baseUrl`'s path is used on
|
|
261
|
+
every one. No redirect is followed, and `primary_api_addr` is never dialled: it is a
|
|
262
|
+
hint that may be stale. Reads fail over too under a `retry` policy;
|
|
263
|
+
`submitIntent` sends the caller's own envelope once, to one URL. A client built with one
|
|
264
|
+
URL assumes a load balancer that routes to the primary, and the first one built in the
|
|
265
|
+
process warns: "one endpoint configured: an LB routing to the primary is assumed".
|
|
266
|
+
|
|
267
|
+
## Reads are credentialed
|
|
268
|
+
|
|
269
|
+
An intent authenticates **in the envelope** — the draft names the `agent://` URI and the
|
|
270
|
+
PASETO attestation, and the service verifies both before it proposes anything. The reads
|
|
271
|
+
used to carry nothing at all and were answered anyway, so any party's obligations were
|
|
272
|
+
enumerable and any observed transaction id disclosed another tenant's record.
|
|
273
|
+
|
|
274
|
+
They are credentialed now, with the **same token**. The five party-scoped reads present
|
|
275
|
+
the attestation as `Authorization: Bearer …`; the service takes the reader's identity from
|
|
276
|
+
that token's verified `agent_uri` claim and answers only within the party it is bound to.
|
|
277
|
+
There is no second credential to obtain: an agent that can submit can already read.
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
const axorum = new AxorumClient({ baseUrl, attestation: paseto });
|
|
281
|
+
|
|
282
|
+
const owed = await axorum.obligations(); // your party's duties — the token decides
|
|
283
|
+
const record = await axorum.transaction(id); // your party's record, or a 404
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
A client built **without** an attestation still submits and still reads `activePolicy` —
|
|
287
|
+
both carry their own credential or need none — but a party-scoped read throws
|
|
288
|
+
`MissingAttestationError` **locally**, before a byte leaves the process. The client never
|
|
289
|
+
sent a credential, so relaying the service's `401` would blame the service for rejecting
|
|
290
|
+
one it was never given.
|
|
291
|
+
|
|
292
|
+
### Metered spend is one intent
|
|
293
|
+
|
|
294
|
+
An agent that consumes a metered resource carries the usage moves on the **same** draft as
|
|
295
|
+
the journal legs. They are judged in the same commit, under the usage-balance conservation
|
|
296
|
+
law `0 ≤ billed ≤ metered ≤ consumed`:
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
const outcome = await axorum.submitPinned({
|
|
300
|
+
...draft,
|
|
301
|
+
consume_usage: { usage, amount: { minor: 1_000n, currency: "USD" } }, // you used it
|
|
302
|
+
meter_usage: { usage, amount: { minor: 1_000n, currency: "USD" } }, // it was measured
|
|
303
|
+
bill_usage: { usage, amount: { minor: 1_000n, currency: "USD" } }, // it was billed
|
|
304
|
+
});
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
All three at once is the *lawful* case, not an edge case: the moves of one intent are
|
|
308
|
+
validated **together** against the combined post-state. Any subset is fine too — metering
|
|
309
|
+
something an earlier intent consumed is one move, not three — and an absent move's key is
|
|
310
|
+
omitted from the wire entirely, never `null`.
|
|
311
|
+
|
|
312
|
+
Metering more than was consumed is **phantom usage**; billing more than was metered is
|
|
313
|
+
billing with no meter basis. The ledger refuses both with a `422`, and **nothing posts** —
|
|
314
|
+
not the usage moves, and not the journal legs that rode with them. Under-recognition
|
|
315
|
+
(`billed < metered < consumed`) is a lawful transient, not a fault.
|
|
316
|
+
|
|
317
|
+
Read a balance back with `usage(usageId)`. Its counters are `bigint`s, and here that
|
|
318
|
+
matters most: `consumed` takes no ceiling, so a busy meter's total can lawfully outgrow
|
|
319
|
+
`u64`. Its `text` is the balance narrated in compliance English with exact figures — prefer
|
|
320
|
+
it when you have a human to show.
|
|
321
|
+
|
|
322
|
+
Usage reads are **owner-scoped and fail-closed**: a balance owned by another party, one
|
|
323
|
+
opened with no owner, and one that never existed all answer the same `404 unknown_usage`.
|
|
324
|
+
The cases are deliberately indistinguishable, so that a usage id is never an existence
|
|
325
|
+
oracle across a tenant boundary. Surface the refusal as it stands; there is nothing further
|
|
326
|
+
in it to infer.
|
|
327
|
+
|
|
328
|
+
A usage balance is *opened* on the admin plane, which this SDK does not speak — an agent
|
|
329
|
+
receives a usage id, it does not mint one.
|
|
330
|
+
|
|
331
|
+
### `obligations(actor?)` — an assertion, not a selector
|
|
332
|
+
|
|
333
|
+
`?actor=` used to *choose* whose duties came back, which is how they were enumerable.
|
|
334
|
+
It is now an assertion the service **checks**:
|
|
335
|
+
|
|
336
|
+
- **Omit it** — you are answered for the party your token names.
|
|
337
|
+
- **Supply it** (a party id `party_…` or a DSL party name) — the service confirms it
|
|
338
|
+
resolves to that same party, and answers `403` if it does not.
|
|
339
|
+
|
|
340
|
+
It is there so a caller can be explicit about who it believes it is, and be told when it is
|
|
341
|
+
wrong. It can no longer be used to ask about somebody else.
|
|
342
|
+
|
|
343
|
+
### Fulfill a specific duty
|
|
344
|
+
|
|
345
|
+
An intent fulfills existing obligations only when its `fulfills` collection names
|
|
346
|
+
those duty IDs. Read the obligations your authenticated party owes or is authorized
|
|
347
|
+
to perform, select the intended contract, and submit its concrete `action` together
|
|
348
|
+
with the required accounting entries and `fulfills: [duty.id]`.
|
|
349
|
+
|
|
350
|
+
To correlate a duty with an acceptance, match **both** `origin_transaction` and
|
|
351
|
+
`origin_tick` against the acceptance outcome's `transaction` and `commit_tick`.
|
|
352
|
+
Transaction IDs can be reused after the retention window. `actor` names the party
|
|
353
|
+
that owes the duty; `performer` names the party authorized to fulfill it. They need
|
|
354
|
+
not be the same party.
|
|
355
|
+
|
|
356
|
+
A selection contains at most 64 distinct IDs. All selected duties must validate
|
|
357
|
+
atomically: the performer, concrete action, deadline, and current policy must
|
|
358
|
+
permit performance. Invalid selections return `422 invalid_fulfillment` without
|
|
359
|
+
mutation or disclosure of another party's duties. Policy re-pinning preserves the
|
|
360
|
+
selected IDs. An empty selection never discharges an existing duty.
|
|
361
|
+
|
|
362
|
+
### Rotating an expiring token
|
|
363
|
+
|
|
364
|
+
Attestations expire. `withAttestation` returns a new client over the **same `fetch`**, so
|
|
365
|
+
re-credentialing costs a string, not a reconnect — and it is also the honest way for one
|
|
366
|
+
process to act for several agents, since no view can see another's data.
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
const rotated = axorum.withAttestation(freshPaseto);
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
> **`balance` is owner-scoped and fail-closed.** The service answers only when your party
|
|
373
|
+
> owns the account. An account owned by another party, one opened with no owner, and one
|
|
374
|
+
> that never existed all answer the same `404 unknown_account`, so an account id is never
|
|
375
|
+
> an existence oracle across a tenant boundary.
|
|
376
|
+
|
|
377
|
+
An intent draft is a plain object — `entries`, `evidence`, `justification` and
|
|
378
|
+
`transaction` are optional. Use `pinIntent(draft, policy)` if you drive the pin yourself.
|
|
379
|
+
|
|
380
|
+
### Transaction ids and idempotency
|
|
381
|
+
|
|
382
|
+
The **client** mints the transaction id, and it is the substrate's **idempotency key**:
|
|
383
|
+
re-submitting a committed id with the same content replays its stored outcome rather than
|
|
384
|
+
double-posting, marked `replayed: true` with `content_verified: true`. Leave
|
|
385
|
+
`transaction` off your draft and the client mints one — **once**, before its first attempt,
|
|
386
|
+
so every re-pinned retry inside `submitPinned` carries the same id. That is the entire
|
|
387
|
+
reason the retry loop is safe. Mint one yourself with `newTransactionId()`.
|
|
388
|
+
|
|
389
|
+
An id means one intent, for one window:
|
|
390
|
+
|
|
391
|
+
- **Different content under a committed id throws `TransactionReusedError`**
|
|
392
|
+
(`409 transaction_reused`), and nothing is written. Mint a new id for a new intent.
|
|
393
|
+
- **The window is advertised.** The service states how long it answers for an id, and
|
|
394
|
+
the client refuses to send a key older than that with `StaleIdempotencyKeyError`,
|
|
395
|
+
before anything is sent. Past the window a resubmission may commit a second time, so
|
|
396
|
+
read the original back with `transaction(id)` and mint a new id if it did not land.
|
|
397
|
+
|
|
398
|
+
The id names **one** transaction. Re-submitting it with different content (another agent,
|
|
399
|
+
amount, payee, leg, rail or usage move) is refused with `409 transaction_reused`, naming
|
|
400
|
+
the stored and submitted content hashes, never replayed, and `authorize` never retries it:
|
|
401
|
+
mint a new id for a new payment. The justification, evidence, attestation and policy pin
|
|
402
|
+
are not content. A re-drive that changes only those replays the first answer
|
|
403
|
+
(`replayed: true`), whose `justification_hash` is the **first** justification's;
|
|
404
|
+
`justificationMatches(answer, text)` checks which one the ledger kept.
|
|
405
|
+
|
|
406
|
+
To derive the id from your own records instead of storing it, build the payment with
|
|
407
|
+
`keyedPayment(agent, paymentKey(key, issuedAtMs), init)`: the same agent and key always
|
|
408
|
+
name the same transaction. The key is 1 to 255 bytes of visible ASCII; `issuedAtMs` is when
|
|
409
|
+
your records first issued it, the same value on every rebuild, and becomes the payment's
|
|
410
|
+
mint time. `authorize` sends a keyed payment only for the agent it was keyed for
|
|
411
|
+
(`keyed_for_another_agent` otherwise, and nothing is sent).
|
|
412
|
+
|
|
413
|
+
Every payment carries `idempotencyKey`: its transaction id and when it was minted, sent as
|
|
414
|
+
`minted_at_ms`. To re-drive after a restart, persist both and rebuild with
|
|
415
|
+
`resumePayment(new IdempotencyKey(transaction, mintedAtMs), init)`. `payment({ transaction })`
|
|
416
|
+
takes a UUID v7 id's own time and throws `InvalidPaymentError` for any other id. A key from
|
|
417
|
+
your records (`origin` other than `minted`) is sent only while the service's idempotency
|
|
418
|
+
window covers it: otherwise `authorize` fails with `stale_key` (expired, or a service with no
|
|
419
|
+
heartbeat) or `key_window_unstated`, and nothing is sent. Read the transaction back before
|
|
420
|
+
authorizing a new payment.
|
|
421
|
+
|
|
422
|
+
### Money is `bigint`
|
|
423
|
+
|
|
424
|
+
The ledger denominates money in **128-bit integer minor units**, on the wire as bare JSON
|
|
425
|
+
numbers. `JSON.parse` decodes those as doubles and silently loses the low bits — the worst
|
|
426
|
+
failure a financial client can have, a wrong balance that looks right. So this client does
|
|
427
|
+
not use `JSON.parse`. It ships its own parser, and the rule is one line:
|
|
428
|
+
|
|
429
|
+
> **Every JSON integer is a `bigint`.** Every number with a fraction or exponent is a
|
|
430
|
+
> `number`.
|
|
431
|
+
|
|
432
|
+
So `amount.minor`, `balance`, `anchor`, `deadline` and the `Int`/`Tick` literals are all
|
|
433
|
+
`bigint`, and the generated wire types say so. Write `{ minor: 50_000n, currency: "USD" }`.
|
|
434
|
+
|
|
435
|
+
## The error taxonomy
|
|
436
|
+
|
|
437
|
+
Everything below is a subclass of `AxorumError`. Catch by `instanceof` for the narrowed
|
|
438
|
+
fields, or `switch` on the stable `code` — both are first-class.
|
|
439
|
+
|
|
440
|
+
| Class | `code` | Status | What to do |
|
|
441
|
+
|---|---|---|---|
|
|
442
|
+
| `StalePolicyError` | `stale_policy` / `policy_pin_mismatch` | 409 | Re-pin against `.active` and resubmit. Nothing was written. `submitPinned` does this for you. |
|
|
443
|
+
| `NoActivePolicyError` | `no_active_policy` | 409 | Nothing to pin to. An operator must activate a policy. |
|
|
444
|
+
| `UnknownAgentError` | `unknown_agent` | 401 | The `agent://` URI is not bound to a party — an **authentication** failure. |
|
|
445
|
+
| `AttestationError` | `attestation` | 403 | The PASETO attestation was rejected: bad signature, expired, wrong audience, untrusted issuer. |
|
|
446
|
+
| `NotPrimaryError` | `not_primary` | 421 | You reached a follower. The attempt may or may not have been recorded: a primary stepping down can answer `421` after proposing it. `.primaryApiAddr` is the primary's API origin as the follower last knew it: a hint. **See below.** |
|
|
447
|
+
| `CommitUnavailableError` | `commit_unavailable` | 503 | The commit is *unconfirmed* — it may or may not have landed. Retriable. Expect it while the cluster replaces a lost primary: a failover can outlast the server's commit wait (`submit_timeout_ms`). |
|
|
448
|
+
| `EndpointsExhaustedError` | `endpoints_exhausted` | the last attempt's | Every failover URL failed within the budget. `.attempts` lists each, in order; `.last` is the final error. Retriable. |
|
|
449
|
+
| `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`. |
|
|
450
|
+
| `MissingAttestationError` | `missing_attestation` | — | A party-scoped read on a client holding no attestation. Raised **locally** — nothing was sent. Build with `attestation`, or `withAttestation`. |
|
|
451
|
+
| `TransportError` | `transport` | — | The request failed after its connection opened: it timed out, or the connection broke. It may have committed. Retriable. A connection that never opened is a `ConnectError` (`connect`) instead: nothing was sent. Retriable. |
|
|
452
|
+
| `DeadlineExceededError` | `deadline_exceeded` | — | A `retry` policy's `deadlineMs` (or `authorize`'s budget) ran out before the request was sent. Retriable. |
|
|
453
|
+
| `ConfigError` | `config` | — | The client could not be built. |
|
|
454
|
+
| `UnexpectedResponseError` | `unexpected_response` | any | A non-2xx that is not an agent-plane body — a proxy, a load balancer. Bytes kept verbatim in `.rawBody`. |
|
|
455
|
+
| `DecodeError` | `decode` | any | A success body that did not decode. Bytes kept verbatim. |
|
|
456
|
+
|
|
457
|
+
Two accessors say what to do next:
|
|
458
|
+
|
|
459
|
+
- **`error.isRetriable`** — re-send the *identical* request. True for `ConnectError`,
|
|
460
|
+
`TransportError`, `DeadlineExceededError`, `EndpointsExhaustedError`, `CommitUnavailableError`
|
|
461
|
+
and `RateLimitedError`, and for an `ApiError` of `503` `idempotency_unseeded`, `not_normal` or
|
|
462
|
+
`archive_pressure`, or `507` `idempotency_window_full` or `snapshot_capacity`. Safe even for a submission: the transaction id is the
|
|
463
|
+
idempotency key, so a replay returns the stored outcome. You cannot double-post by
|
|
464
|
+
retrying.
|
|
465
|
+
- **`error.requiresRepin`** — the policy moved. True exactly for `StalePolicyError`.
|
|
466
|
+
|
|
467
|
+
The error body's **extras are load-bearing** and this client never flattens them away:
|
|
468
|
+
without `pinned`/`active` no client could re-pin, and without `primary_api_addr` none
|
|
469
|
+
could tell an operator which replica the follower believed was primary.
|
|
470
|
+
|
|
471
|
+
```ts
|
|
472
|
+
import { CommitUnavailableError, isAxorumError, StalePolicyError } from "@axorum/client";
|
|
473
|
+
|
|
474
|
+
try {
|
|
475
|
+
await axorum.submitPinned(draft);
|
|
476
|
+
} catch (error) {
|
|
477
|
+
if (error instanceof StalePolicyError) {
|
|
478
|
+
console.log(`pinned ${error.pinned}, but ${error.active} is in force`);
|
|
479
|
+
} else if (isAxorumError(error) && error.isRetriable) {
|
|
480
|
+
// The same envelope, the same transaction id. A replay, not a second posting.
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
### A `421` is never followed to `primaryApiAddr`
|
|
486
|
+
|
|
487
|
+
When a follower turns a mutation away, this client surfaces `primaryApiAddr`, the
|
|
488
|
+
primary's API origin as the follower last knew it (`https://host:port`, or `http://` for a
|
|
489
|
+
plaintext API). The origin is informational: it can name a primary that has just died, so
|
|
490
|
+
the client never routes on it.
|
|
491
|
+
|
|
492
|
+
`authorize` re-sends the same transaction id over its `failoverUrls` by itself. For any
|
|
493
|
+
other mutation, re-send the same transaction id through the endpoints you configured; the
|
|
494
|
+
transaction id is the idempotency key, so a re-send cannot double-post. A client that
|
|
495
|
+
silently auto-follows would also hide a topology change from the operator who most needs to
|
|
496
|
+
see it.
|
|
497
|
+
|
|
498
|
+
```ts
|
|
499
|
+
catch (error) {
|
|
500
|
+
if (error instanceof NotPrimaryError) {
|
|
501
|
+
console.warn(`a follower answered; it named ${error.primaryApiAddr ?? "no primary"} as the primary's API origin`);
|
|
502
|
+
// Re-send the same envelope, same transaction id, through your configured endpoints.
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
## Development
|
|
508
|
+
|
|
509
|
+
The wire types in `src/generated/` are generated from the **frozen** OpenAPI contract at
|
|
510
|
+
`spec/openapi/agent-plane.v1.json` and committed. Field names are the wire's own —
|
|
511
|
+
`snake_case` stays `snake_case`, because a second vocabulary is a second thing to keep in
|
|
512
|
+
sync.
|
|
513
|
+
|
|
514
|
+
This package and `@axorum/mcp` are one pnpm workspace rooted at `clients/`, so install
|
|
515
|
+
once from there; CI regenerates `src/generated/` and fails if the committed output has
|
|
516
|
+
drifted from the contract.
|
|
517
|
+
|
|
518
|
+
```sh
|
|
519
|
+
cd clients && pnpm install
|
|
520
|
+
|
|
521
|
+
cd typescript
|
|
522
|
+
pnpm generate # regenerate src/generated/ from the frozen spec
|
|
523
|
+
pnpm typecheck # tsc --noEmit, strict
|
|
524
|
+
pnpm build # → dist/
|
|
525
|
+
pnpm test # unit + conformance
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
The **conformance suite** (`test/conformance/`) runs scenarios ported from the Rust
|
|
529
|
+
reference client, plus its own, against a **real, booted `axorum-serve`** — real routes, real policy
|
|
530
|
+
evaluation, real error bodies. It deliberately does not mock: a mock would put whatever
|
|
531
|
+
bytes the test author *believed* the service emits on the wire, which is exactly the belief
|
|
532
|
+
under test. It needs the harness binary at
|
|
533
|
+
`target/debug/examples/conformance_server`, built by the Rust workspace.
|
|
534
|
+
|
|
535
|
+
```sh
|
|
536
|
+
pnpm test:unit # no server required
|
|
537
|
+
pnpm test:conformance # boots a real axorum-serve per scenario
|
|
538
|
+
```
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `agent://` URIs a payment may name: exactly the canonical form `agent-uri` 0.6.0
|
|
3
|
+
* renders, which the Rust SDK and the service parse with.
|
|
4
|
+
*
|
|
5
|
+
* A keyed payment derives its transaction id from the agent URI's bytes, so two
|
|
6
|
+
* spellings of one agent would derive two ids for one payment. Accepting only the
|
|
7
|
+
* canonical spelling makes both SDKs derive the same id. The rules, all pinned against
|
|
8
|
+
* the Rust parser by `spec/fixtures/agent-uri-canonical-v1.json`:
|
|
9
|
+
*
|
|
10
|
+
* - the grammar: `agent://<trust root>/<capability path>/<agent id>`, within the length
|
|
11
|
+
* and segment limits, where an agent id's type class may be a single letter;
|
|
12
|
+
* - the scheme is lowercase, and `AGENT://` parses with `agent://` as its canonical form;
|
|
13
|
+
* - a host of four dotted numeric labels is an IPv4 address and never a domain, so it
|
|
14
|
+
* parses only with every octet 0-255 and none with a leading zero;
|
|
15
|
+
* - N1: a domain is lowercase;
|
|
16
|
+
* - N2, N3: a port is decimal with no leading zero and no sign;
|
|
17
|
+
* - N4: an IPv6 address is written as RFC 5952 writes it, and an IPv4-mapped one in
|
|
18
|
+
* dotted form (`::ffff:192.0.2.1`), as Rust's `Ipv6Addr` displays it;
|
|
19
|
+
* - no query and no fragment.
|
|
20
|
+
*
|
|
21
|
+
* @packageDocumentation
|
|
22
|
+
*/
|
|
23
|
+
/** Why a string is not a canonical agent URI, and the canonical form when there is one. */
|
|
24
|
+
export interface AgentUriProblem {
|
|
25
|
+
readonly reason: string;
|
|
26
|
+
readonly canonical: string | undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Why `uri` is not an agent URI in canonical form, or `undefined` when it is. When the
|
|
30
|
+
* part before any query or fragment parses, `canonical` names the form to pass. Pure.
|
|
31
|
+
*/
|
|
32
|
+
export declare function agentUriProblem(uri: string): AgentUriProblem | undefined;
|
|
33
|
+
//# sourceMappingURL=agent-uri.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-uri.d.ts","sourceRoot":"","sources":["../src/agent-uri.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,2FAA2F;AAC3F,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;CACxC;AAwBD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAYxE"}
|