@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.
Files changed (79) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +537 -2
  3. package/dist/agent-uri.d.ts +33 -0
  4. package/dist/agent-uri.d.ts.map +1 -0
  5. package/dist/agent-uri.js +231 -0
  6. package/dist/agent-uri.js.map +1 -0
  7. package/dist/authorize.d.ts +671 -0
  8. package/dist/authorize.d.ts.map +1 -0
  9. package/dist/authorize.js +947 -0
  10. package/dist/authorize.js.map +1 -0
  11. package/dist/capabilities.d.ts +30 -0
  12. package/dist/capabilities.d.ts.map +1 -0
  13. package/dist/capabilities.js +40 -0
  14. package/dist/capabilities.js.map +1 -0
  15. package/dist/chart.d.ts +74 -0
  16. package/dist/chart.d.ts.map +1 -0
  17. package/dist/chart.js +107 -0
  18. package/dist/chart.js.map +1 -0
  19. package/dist/client.d.ts +645 -0
  20. package/dist/client.d.ts.map +1 -0
  21. package/dist/client.js +1303 -0
  22. package/dist/client.js.map +1 -0
  23. package/dist/errors.d.ts +646 -0
  24. package/dist/errors.d.ts.map +1 -0
  25. package/dist/errors.js +928 -0
  26. package/dist/errors.js.map +1 -0
  27. package/dist/failover.d.ts +197 -0
  28. package/dist/failover.d.ts.map +1 -0
  29. package/dist/failover.js +289 -0
  30. package/dist/failover.js.map +1 -0
  31. package/dist/freshness.d.ts +65 -0
  32. package/dist/freshness.d.ts.map +1 -0
  33. package/dist/freshness.js +148 -0
  34. package/dist/freshness.js.map +1 -0
  35. package/dist/generated/agent-plane.d.ts +1865 -0
  36. package/dist/generated/agent-plane.d.ts.map +1 -0
  37. package/dist/generated/agent-plane.js +19 -0
  38. package/dist/generated/agent-plane.js.map +1 -0
  39. package/dist/idempotency.d.ts +160 -0
  40. package/dist/idempotency.d.ts.map +1 -0
  41. package/dist/idempotency.js +208 -0
  42. package/dist/idempotency.js.map +1 -0
  43. package/dist/index.d.ts +90 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +88 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/intent.d.ts +163 -0
  48. package/dist/intent.d.ts.map +1 -0
  49. package/dist/intent.js +82 -0
  50. package/dist/intent.js.map +1 -0
  51. package/dist/json.d.ts +49 -0
  52. package/dist/json.d.ts.map +1 -0
  53. package/dist/json.js +259 -0
  54. package/dist/json.js.map +1 -0
  55. package/dist/key.d.ts +64 -0
  56. package/dist/key.d.ts.map +1 -0
  57. package/dist/key.js +71 -0
  58. package/dist/key.js.map +1 -0
  59. package/dist/payment.d.ts +172 -0
  60. package/dist/payment.d.ts.map +1 -0
  61. package/dist/payment.js +178 -0
  62. package/dist/payment.js.map +1 -0
  63. package/dist/refusal.d.ts +121 -0
  64. package/dist/refusal.d.ts.map +1 -0
  65. package/dist/refusal.js +176 -0
  66. package/dist/refusal.js.map +1 -0
  67. package/dist/retry.d.ts +215 -0
  68. package/dist/retry.d.ts.map +1 -0
  69. package/dist/retry.js +297 -0
  70. package/dist/retry.js.map +1 -0
  71. package/dist/typeid.d.ts +64 -0
  72. package/dist/typeid.d.ts.map +1 -0
  73. package/dist/typeid.js +119 -0
  74. package/dist/typeid.js.map +1 -0
  75. package/dist/types.d.ts +245 -0
  76. package/dist/types.d.ts.map +1 -0
  77. package/dist/types.js +14 -0
  78. package/dist/types.js.map +1 -0
  79. package/package.json +58 -3
package/README.md CHANGED
@@ -1,3 +1,538 @@
1
- # Temporary Holding Version
1
+ # `@axorum/client`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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"}