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