@belticlabs/agent-risk-sdk 0.4.0 → 0.6.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/dist/index.js CHANGED
@@ -1,24 +1,21 @@
1
1
  import {
2
- Verdict
3
- } from "./chunk-4BUUPU3O.js";
2
+ openCall
3
+ } from "./chunk-ZMPKY7AX.js";
4
4
  import {
5
- BelticDisabledError,
6
- DEFAULT_OPEN_RETRY_MS,
7
- Session,
8
- Sessions,
9
- recordCall
10
- } from "./chunk-4MG6VNAU.js";
5
+ summaryOf
6
+ } from "./chunk-M4I3FGZG.js";
11
7
  import {
8
+ Chain,
12
9
  canonicalBytes,
13
- canonicalize,
14
10
  didKeyFromEd25519,
15
11
  fromHex,
16
12
  memorySigner,
17
13
  sha256,
18
14
  toHex
19
- } from "./chunk-X3W2Z5GC.js";
15
+ } from "./chunk-77D74TWX.js";
20
16
 
21
17
  // src/core/api-client.ts
18
+ var TIMEOUT_MS = 1e4;
22
19
  var BelticApiError = class extends Error {
23
20
  constructor(status, code, message, details, requestId) {
24
21
  super(message);
@@ -28,42 +25,31 @@ var BelticApiError = class extends Error {
28
25
  this.requestId = requestId;
29
26
  this.name = "BelticApiError";
30
27
  }
31
- /** 5xx and network failures are retried by the transport; 4xx are not. */
28
+ /** 5xx, 429 and network failures are an outage: retried by the transport, absorbed by the fail-open entries; 4xx are neither (GAP-70). */
32
29
  get retryable() {
33
30
  return this.status === 0 || this.status >= 500 || this.status === 429;
34
31
  }
35
32
  };
36
33
  var ApiClient = class {
37
34
  baseUrl;
38
- fetchImpl;
39
- timeoutMs;
40
35
  headers;
41
- constructor(opts) {
42
- this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
43
- this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
44
- this.timeoutMs = opts.timeoutMs ?? 1e4;
36
+ constructor(baseUrl, apiKey, userAgent) {
37
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
45
38
  this.headers = {
46
- authorization: `Bearer ${opts.apiKey}`,
39
+ authorization: `Bearer ${apiKey}`,
47
40
  "content-type": "application/json",
48
- "user-agent": opts.userAgent ?? "@belticlabs/agent-risk-sdk"
41
+ "user-agent": userAgent
49
42
  };
50
43
  }
51
- post(path, body, headers = {}) {
52
- return this.request("POST", path, JSON.stringify(body), headers);
53
- }
54
- get(path, query = {}) {
55
- const qs = Object.entries(query).filter((e) => e[1] !== void 0).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
56
- return this.request("GET", qs ? `${path}?${qs}` : path, void 0, {});
57
- }
58
- async request(method, path, body, extra) {
44
+ async post(path, body) {
59
45
  const controller = new AbortController();
60
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
46
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
61
47
  let res;
62
48
  try {
63
- res = await this.fetchImpl(`${this.baseUrl}${path}`, {
64
- method,
65
- headers: { ...this.headers, ...extra },
66
- ...body !== void 0 ? { body } : {},
49
+ res = await globalThis.fetch(`${this.baseUrl}${path}`, {
50
+ method: "POST",
51
+ headers: this.headers,
52
+ body: JSON.stringify(body),
67
53
  signal: controller.signal
68
54
  });
69
55
  } catch (err) {
@@ -96,83 +82,35 @@ var ApiClient = class {
96
82
  }
97
83
  };
98
84
 
99
- // src/core/correlation.ts
100
- var MemoryCorrelationStore = class {
101
- constructor(defaultTtlMs = 60 * 60 * 1e3) {
102
- this.defaultTtlMs = defaultTtlMs;
103
- }
104
- entries = /* @__PURE__ */ new Map();
105
- async bind(key, sessionId, ttlMs = this.defaultTtlMs) {
106
- this.entries.set(key, { sessionId, expiresAt: Date.now() + ttlMs });
107
- }
108
- async resolve(key) {
109
- const e = this.entries.get(key);
110
- if (!e) return null;
111
- if (e.expiresAt < Date.now()) {
112
- this.entries.delete(key);
113
- return null;
114
- }
115
- return e.sessionId;
85
+ // src/core/config-error.ts
86
+ var BelticConfigError = class extends Error {
87
+ code = "CONFIG";
88
+ constructor(message) {
89
+ super(`Beltic: ${message}`);
90
+ this.name = "BelticConfigError";
116
91
  }
117
92
  };
118
93
 
119
- // src/core/identity.ts
120
- import { mkdirSync, readFileSync, writeFileSync } from "fs";
121
- import { dirname } from "path";
122
- function identityFromSeed(seed, credential) {
123
- const signer = memorySigner(seed);
124
- const did = didKeyFromEd25519(signer.publicKey);
125
- return { did, signer: { ...signer, keyId: did }, ...credential ? { credential } : {} };
126
- }
127
- function ephemeralIdentity(credential) {
128
- return identityFromSeed(memorySigner().seed, credential);
129
- }
130
- function fileIdentity(path, credential) {
131
- let seed;
132
- try {
133
- seed = fromHex(JSON.parse(readFileSync(path, "utf8")).seed);
134
- } catch {
135
- seed = memorySigner().seed;
136
- mkdirSync(dirname(path), { recursive: true });
137
- writeFileSync(path, `${JSON.stringify({ seed: toHex(seed) })}
138
- `, { mode: 384 });
139
- }
140
- return identityFromSeed(seed, credential);
141
- }
142
-
143
- // src/core/payment-moment.ts
144
- function summaryOf(m) {
145
- return {
146
- protocol: m.protocol,
147
- payee: m.payee,
148
- amount: { value: m.amount.value, currency: m.amount.currency },
149
- ...m.payer ? { payer: m.payer } : {}
150
- };
151
- }
152
- function presentedFrom(summary, raw) {
153
- return { ...summary, artifact: "payment-signature", raw };
154
- }
155
-
156
94
  // src/core/decision.ts
157
95
  var Decision = class _Decision {
158
- constructor(evaluation) {
159
- this.evaluation = evaluation;
96
+ constructor(output) {
97
+ this.output = output;
160
98
  }
161
99
  static ABSENT = new _Decision(null);
162
- static of(evaluation) {
163
- return new _Decision(evaluation);
100
+ static of(output) {
101
+ return new _Decision(output);
164
102
  }
165
103
  static absent() {
166
104
  return _Decision.ABSENT;
167
105
  }
168
106
  get value() {
169
- return this.evaluation?.decision ?? null;
107
+ return this.output?.decision ?? null;
170
108
  }
171
109
  get reasonCodes() {
172
- return this.evaluation?.reasonCodes ?? [];
110
+ return this.output?.reasonCodes ?? [];
173
111
  }
174
112
  get decisionId() {
175
- return this.evaluation?.decisionId ?? null;
113
+ return this.output?.decisionId ?? null;
176
114
  }
177
115
  get allowed() {
178
116
  return this.value === "ALLOW";
@@ -184,22 +122,28 @@ var Decision = class _Decision {
184
122
  return this.value === "REVIEW";
185
123
  }
186
124
  get absent() {
187
- return this.evaluation === null;
188
- }
189
- /** Whether a gate must stop the payment (GAP-52); an absent verdict never blocks. */
190
- blocks(onReview) {
191
- return this.evaluation ? Verdict.of(this.evaluation.decision).blocks(onReview) : false;
125
+ return this.output === null;
192
126
  }
193
127
  /** One sentence for the agent or the person: what Beltic said and why. */
194
128
  explain() {
195
- if (!this.evaluation) return "Beltic could not be asked about this payment.";
129
+ if (!this.output) return "Beltic could not be asked about this payment.";
196
130
  const verb = this.denied ? "denied" : this.review ? "asked for review of" : "allowed";
197
131
  const why = this.reasonCodes.length > 0 ? ` (${this.reasonCodes.join(", ")})` : "";
198
132
  return `Beltic ${verb} this payment${why}.`;
199
133
  }
200
134
  };
201
135
 
202
- // src/core/run.ts
136
+ // src/core/identity.ts
137
+ var SEED_HEX = /^[0-9a-f]{64}$/i;
138
+ function identityFromSeed(seedHex) {
139
+ if (!SEED_HEX.test(seedHex))
140
+ throw new BelticConfigError("agentSeed must be 64 hex characters (a 32-byte Ed25519 seed)");
141
+ const signer = memorySigner(fromHex(seedHex));
142
+ const did = didKeyFromEd25519(signer.publicKey);
143
+ return { did, signer: { ...signer, keyId: did } };
144
+ }
145
+
146
+ // src/core/session.ts
203
147
  var MEMORY = 256;
204
148
  var Memory = class {
205
149
  map = /* @__PURE__ */ new Map();
@@ -212,51 +156,42 @@ var Memory = class {
212
156
  if (this.map.size > MEMORY) this.map.delete(this.map.keys().next().value);
213
157
  }
214
158
  };
215
- var Run = class _Run {
159
+ var Session = class _Session {
216
160
  constructor(deps, key, opts = {}) {
217
161
  this.deps = deps;
218
162
  this.key = key;
219
163
  this.opts = opts;
164
+ this.openedWith = opts.intent ? _Session.hash(opts.intent) : null;
220
165
  }
221
166
  opened = null;
222
167
  current = null;
223
- /** JCS hash of the mandate on the chain, and of the one the open input carried. */
168
+ /** JCS hash of the mandate on the chain, and of the one the options carried. */
224
169
  declared = null;
225
- openedWith = null;
170
+ openedWith;
226
171
  closed = false;
227
172
  timer = null;
228
173
  calls = new Memory();
229
174
  decisions = new Memory();
230
- byPayment = new Memory();
231
- /** The session this run records into — opened on first use, `null` when there is none. */
232
- session() {
233
- const next = this.deps.sessions.open(this.key, this.opener);
175
+ /** The platform's id for this session — opened on first use, `null` while there is none. */
176
+ id() {
177
+ return this.stream().then((stream) => stream?.id ?? null);
178
+ }
179
+ /**
180
+ * The stream this session records into — opened on first use, `null`
181
+ * when there is none. For the integrations; a host never holds it.
182
+ * @internal
183
+ */
184
+ stream() {
185
+ const next = this.deps.streams.open(this.key, this.opts);
234
186
  this.opened = next;
235
- return next.then((session) => {
236
- if (session !== this.current) {
237
- this.current = session;
187
+ return next.then((stream) => {
188
+ if (stream !== this.current) {
189
+ this.current = stream;
238
190
  this.declared = this.openedWith;
239
191
  }
240
- return session;
192
+ return stream;
241
193
  });
242
194
  }
243
- opener = async () => {
244
- const open = this.opts.open;
245
- const input = typeof open === "function" ? await open() : open ?? {};
246
- this.openedWith = input.intent ? _Run.hash(input.intent) : null;
247
- return input;
248
- };
249
- /** `intent.declared`, unless the mandate is the one already on the chain. */
250
- async declare(intent) {
251
- const session = await this.session();
252
- if (!session) return false;
253
- const hash = _Run.hash(intent);
254
- if (hash === this.declared) return false;
255
- const ok = await session.emit("intent.declared", intent);
256
- if (ok) this.declared = hash;
257
- this.touch();
258
- return ok;
259
- }
260
195
  /**
261
196
  * The platform's verdict on a payment about to be presented. Asked once
262
197
  * per call id: a host that re-runs its approval step reads the same
@@ -266,23 +201,21 @@ var Run = class _Run {
266
201
  async decide(payment, opts = {}) {
267
202
  const known = opts.callId ? this.decisions.get(opts.callId) : void 0;
268
203
  if (known) return known;
269
- const session = await this.session();
270
- if (!session) return Decision.absent();
271
- if (opts.intent) await this.declare(opts.intent);
204
+ const stream = await this.stream();
205
+ if (!stream) return Decision.absent();
206
+ if (opts.intent) await this.declare(stream, opts.intent);
272
207
  const summary = summaryOf(payment);
273
- const evaluation = await this.deps.evaluate(session.id, summary);
274
- if (!evaluation) return Decision.absent();
275
- const decision = Decision.of(evaluation);
208
+ const decision = await this.deps.evaluate(stream.id, summary);
209
+ if (decision.absent) return decision;
276
210
  if (opts.callId) this.decisions.set(opts.callId, decision);
277
- for (const key of _Run.paymentKeys(summary)) this.byPayment.set(key, decision);
278
211
  const call = opts.callId ? this.calls.get(opts.callId)?.call : void 0;
279
- await session.emit("gateway.decision", {
212
+ await stream.emit("gateway.decision", {
280
213
  gateway: "beltic",
281
- call: _Run.callOf(call),
282
- decision: evaluation.decision,
283
- reasonCodes: [...evaluation.reasonCodes],
214
+ call: _Session.callOf(call),
215
+ decision: decision.value,
216
+ reasonCodes: [...decision.reasonCodes],
284
217
  record: {
285
- decisionId: evaluation.decisionId,
218
+ decisionId: decision.decisionId,
286
219
  callId: opts.callId ?? null,
287
220
  payment: summary
288
221
  }
@@ -294,24 +227,12 @@ var Run = class _Run {
294
227
  decision(callId) {
295
228
  return this.decisions.get(callId) ?? Decision.absent();
296
229
  }
297
- /**
298
- * The decision given for a payment with the same comparable core (payee,
299
- * amount, payer — or payee and amount when one side names no payer), or
300
- * absent.
301
- */
302
- decisionFor(payment) {
303
- for (const key of _Run.paymentKeys(summaryOf(payment))) {
304
- const known = this.byPayment.get(key);
305
- if (known) return known;
306
- }
307
- return Decision.absent();
308
- }
309
230
  /** A tool call the host runs itself, reported as two events by its own call id. */
310
231
  tools = {
311
232
  start: async (call) => {
312
- const session = await this.session();
313
- if (!session) return false;
314
- const span = session.toolCall(call);
233
+ const stream = await this.stream();
234
+ if (!stream) return false;
235
+ const span = stream.toolCall(call);
315
236
  this.calls.set(call.callId, { call, span });
316
237
  this.touch();
317
238
  return span.opened;
@@ -331,11 +252,11 @@ var Run = class _Run {
331
252
  };
332
253
  /** A person's answer about a call, as the decision it was (GAP-75). */
333
254
  async humanDecided(callId, input) {
334
- const session = await this.session();
335
- if (!session) return false;
336
- const ok = await session.emit("gateway.decision", {
255
+ const stream = await this.stream();
256
+ if (!stream) return false;
257
+ const ok = await stream.emit("gateway.decision", {
337
258
  gateway: "human",
338
- call: _Run.callOf(this.calls.get(callId)?.call),
259
+ call: _Session.callOf(this.calls.get(callId)?.call),
339
260
  decision: input.allowed ? "ALLOW" : "DENY",
340
261
  reasonCodes: [`USER_${input.outcome.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`],
341
262
  record: {
@@ -353,8 +274,14 @@ var Run = class _Run {
353
274
  this.closed = true;
354
275
  if (this.timer) clearTimeout(this.timer);
355
276
  this.deps.onClosed(this);
356
- const session = this.opened ? await this.opened.catch(() => null) : null;
357
- await session?.close(reason);
277
+ const stream = this.opened ? await this.opened.catch(() => null) : null;
278
+ await stream?.close(reason);
279
+ }
280
+ /** `intent.declared`, unless the mandate is the one already on the chain (GAP-76). */
281
+ async declare(stream, intent) {
282
+ const hash = _Session.hash(intent);
283
+ if (hash === this.declared) return;
284
+ if (await stream.emit("intent.declared", intent)) this.declared = hash;
358
285
  }
359
286
  take(callId) {
360
287
  const known = this.calls.get(callId);
@@ -372,27 +299,96 @@ var Run = class _Run {
372
299
  static hash(intent) {
373
300
  return toHex(sha256(canonicalBytes(intent)));
374
301
  }
375
- /** With the payer first, then without it. */
376
- static paymentKeys(summary) {
377
- const { payer, ...core } = summary;
378
- return payer ? [canonicalize(summary), canonicalize(core)] : [canonicalize(core)];
379
- }
380
302
  static callOf(call) {
381
303
  return call ? { tool: call.toolName, args: call.input } : { tool: "unknown" };
382
304
  }
383
305
  };
384
306
 
385
- // src/core/transport.ts
386
- var DEFAULT_TRANSPORT = {
387
- maxBatch: 50,
388
- flushMs: 1e3,
389
- maxBuffered: 5e3,
390
- backoff: { baseMs: 200, maxMs: 3e4, maxAttempts: Number.POSITIVE_INFINITY }
307
+ // src/core/stream.ts
308
+ var Stream = class {
309
+ constructor(deps, id, source, born) {
310
+ this.deps = deps;
311
+ this.id = id;
312
+ this.source = source;
313
+ this.born = born;
314
+ this.chain = Chain.genesis(id, source);
315
+ }
316
+ chain;
317
+ building = Promise.resolve();
318
+ dropped = 0;
319
+ droppedFirstTs = null;
320
+ droppedLastTs = null;
321
+ closed = false;
322
+ get isClosed() {
323
+ return this.closed;
324
+ }
325
+ /**
326
+ * Resolves once the event is sequenced and buffered — not once it is
327
+ * acknowledged. `false` when the event was dropped for lack of room.
328
+ */
329
+ async emit(kind, payload) {
330
+ const halted = this.deps.transport.haltedError(this.id, this.source);
331
+ if (halted) throw halted;
332
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
333
+ if (!this.deps.transport.hasRoom()) {
334
+ this.dropped++;
335
+ this.droppedFirstTs ??= ts;
336
+ this.droppedLastTs = ts;
337
+ return false;
338
+ }
339
+ if (this.dropped > 0) {
340
+ this.deps.transport.enqueue(
341
+ await this.next(
342
+ "transport.gap",
343
+ { dropped: this.dropped, firstTs: this.droppedFirstTs, lastTs: this.droppedLastTs },
344
+ ts
345
+ )
346
+ );
347
+ this.dropped = 0;
348
+ this.droppedFirstTs = this.droppedLastTs = null;
349
+ }
350
+ this.deps.transport.enqueue(await this.next(kind, payload, ts));
351
+ return true;
352
+ }
353
+ /** The tool call whose `execute` the host runs itself; see `ToolCallSpan`. */
354
+ toolCall(call) {
355
+ const { callId, ...start } = call;
356
+ return openCall(this, "tool_call", callId, { transport: "local", ...start });
357
+ }
358
+ async close(reason = "completed", extra = {}) {
359
+ if (this.closed) return;
360
+ this.closed = true;
361
+ await this.emit("session.close", { reason, ...extra });
362
+ await this.flush();
363
+ this.deps.onClosed?.(this);
364
+ }
365
+ /** Read-your-writes: the platform must hold the evidence before anyone judges it (GAP-16/66). */
366
+ flush() {
367
+ return this.deps.transport.flush();
368
+ }
369
+ /** Serialized: two concurrent emits get consecutive seqs, never the same one. */
370
+ next(kind, payload, ts) {
371
+ const run = this.building.then(async () => {
372
+ const built = await this.chain.append({ ts, kind, payload }, this.deps.signer);
373
+ this.chain = built.chain;
374
+ return built.event;
375
+ });
376
+ this.building = run.catch(() => void 0);
377
+ return run;
378
+ }
391
379
  };
380
+
381
+ // src/core/transport.ts
382
+ var FLUSH_MS = 1e3;
383
+ var MAX_BATCH = 50;
384
+ var MAX_BUFFERED = 5e3;
385
+ var BACKOFF_BASE_MS = 200;
386
+ var BACKOFF_MAX_MS = 3e4;
392
387
  var ChainRejectedError = class extends Error {
393
- constructor(sessionId, source, result) {
388
+ constructor(sessionId, source, result, options) {
394
389
  super(
395
- `chain ${sessionId}:${source} halted at seq ${result.seq}: ${result.status}${result.code ? ` ${result.code}` : ""}`
390
+ `chain ${sessionId}:${source} halted at seq ${result.seq}: ${result.status}${result.code ? ` ${result.code}` : ""}`,
391
+ options
396
392
  );
397
393
  this.sessionId = sessionId;
398
394
  this.source = source;
@@ -406,27 +402,28 @@ var TransportClosedError = class extends Error {
406
402
  this.name = "TransportClosedError";
407
403
  }
408
404
  };
409
- var Transport = class {
410
- constructor(api, opts = {}) {
405
+ var Transport = class _Transport {
406
+ constructor(api) {
411
407
  this.api = api;
412
- this.opts = {
413
- ...DEFAULT_TRANSPORT,
414
- ...opts,
415
- backoff: { ...DEFAULT_TRANSPORT.backoff, ...opts.backoff }
416
- };
417
408
  }
418
- opts;
419
409
  chains = /* @__PURE__ */ new Map();
420
410
  buffered = 0;
421
411
  timer = null;
422
412
  closed = false;
423
- inFlightCount = 0;
424
- drainWaiters = [];
413
+ /**
414
+ * What the fail-open entries absorb (GAP-70): the platform could not be
415
+ * reached or failed on its side — a network error, a 5xx, a 429.
416
+ * Everything the platform *rejected* (a 4xx: bad key, unknown session,
417
+ * invalid payload) is a fault of the client and throws.
418
+ */
419
+ static outage(err) {
420
+ return err instanceof BelticApiError && err.retryable;
421
+ }
425
422
  get size() {
426
423
  return this.buffered;
427
424
  }
428
425
  hasRoom() {
429
- return !this.closed && this.buffered < this.opts.maxBuffered;
426
+ return !this.closed && this.buffered < MAX_BUFFERED;
430
427
  }
431
428
  haltedError(sessionId, source) {
432
429
  return this.chains.get(`${sessionId}:${source}`)?.halted ?? null;
@@ -437,230 +434,285 @@ var Transport = class {
437
434
  const key = `${ev.sessionId}:${ev.source}`;
438
435
  let chain = this.chains.get(key);
439
436
  if (!chain) {
440
- chain = { key, pending: [], inFlight: null, attempts: 0, halted: null };
437
+ chain = { pending: [], inFlight: null, retry: null, attempts: 0, halted: null };
441
438
  this.chains.set(key, chain);
442
439
  }
443
440
  if (chain.halted) throw chain.halted;
444
441
  if (!this.hasRoom()) throw new Error("transport buffer is full");
445
442
  chain.pending.push(ev);
446
443
  this.buffered++;
447
- if (chain.pending.length >= this.opts.maxBatch) void this.flushChain(chain);
444
+ if (chain.pending.length >= MAX_BATCH) void this.deliver(chain, false);
448
445
  else this.schedule();
449
446
  }
450
- /** Send everything pending and wait for every in-flight batch to settle (ack or halt). */
447
+ /** One attempt per chain, now a chain waiting out its backoff included; resolves once every attempt settled. */
451
448
  async flush() {
452
449
  this.unschedule();
453
- for (const chain of this.chains.values()) void this.flushChain(chain);
454
- await this.drained();
450
+ await Promise.all([...this.chains.values()].map((chain) => this.deliver(chain, true)));
455
451
  }
456
452
  async close() {
457
453
  await this.flush();
458
454
  this.closed = true;
455
+ for (const chain of this.chains.values()) {
456
+ if (chain.retry) clearTimeout(chain.retry);
457
+ chain.retry = null;
458
+ }
459
459
  }
460
460
  schedule() {
461
461
  if (this.timer) return;
462
- const st = this.opts.setTimeout ?? globalThis.setTimeout;
463
- this.timer = st(() => {
462
+ this.timer = setTimeout(() => {
464
463
  this.timer = null;
465
- for (const chain of this.chains.values()) void this.flushChain(chain);
466
- }, this.opts.flushMs);
464
+ for (const chain of this.chains.values()) void this.deliver(chain, false);
465
+ }, FLUSH_MS);
467
466
  this.timer.unref?.();
468
467
  }
469
468
  unschedule() {
470
469
  if (!this.timer) return;
471
- (this.opts.clearTimeout ?? globalThis.clearTimeout)(this.timer);
470
+ clearTimeout(this.timer);
472
471
  this.timer = null;
473
472
  }
474
- drained() {
475
- if (this.inFlightCount === 0 && [...this.chains.values()].every((c) => c.pending.length === 0 || c.halted)) {
476
- return Promise.resolve();
473
+ /** A chain waiting out its backoff is left alone unless forced: only `flush` cuts a backoff short. */
474
+ deliver(chain, force) {
475
+ if (chain.inFlight) return chain.inFlight;
476
+ if (chain.halted || chain.pending.length === 0) return Promise.resolve();
477
+ if (chain.retry) {
478
+ if (!force) return Promise.resolve();
479
+ clearTimeout(chain.retry);
480
+ chain.retry = null;
477
481
  }
478
- return new Promise((resolve) => this.drainWaiters.push(resolve));
479
- }
480
- settleWaiters() {
481
- if (this.inFlightCount > 0) return;
482
- if (![...this.chains.values()].every((c) => c.pending.length === 0 || c.halted)) return;
483
- const waiters = this.drainWaiters;
484
- this.drainWaiters = [];
485
- for (const w of waiters) w();
486
- }
487
- async flushChain(chain) {
488
- if (chain.inFlight || chain.halted || chain.pending.length === 0) {
489
- this.settleWaiters();
490
- return;
491
- }
492
- const batch = chain.pending.splice(0, this.opts.maxBatch);
493
- chain.inFlight = batch;
494
- this.inFlightCount++;
495
- try {
496
- const ack = await this.send(chain, batch);
497
- this.buffered -= batch.length;
498
- this.opts.onAck?.(ack);
499
- const bad = ack.results.find((r) => r.status === "fork" || r.status === "rejected");
500
- if (bad) {
501
- const first = batch[0];
502
- chain.halted = new ChainRejectedError(first.sessionId, first.source, bad);
503
- this.buffered -= chain.pending.length;
504
- chain.pending = [];
505
- this.opts.onChainHalted?.(chain.halted);
506
- }
507
- } catch (err) {
508
- this.buffered -= batch.length + chain.pending.length;
509
- chain.pending = [];
510
- const first = batch[0];
511
- chain.halted = new ChainRejectedError(first.sessionId, first.source, {
512
- index: 0,
513
- sessionId: first.sessionId,
514
- source: first.source,
515
- seq: first.seq,
516
- status: "rejected",
517
- code: "DELIVERY_FAILED"
518
- });
519
- this.opts.onError?.(err);
520
- this.opts.onChainHalted?.(chain.halted);
521
- } finally {
482
+ chain.inFlight = this.attempt(chain).finally(() => {
522
483
  chain.inFlight = null;
523
- this.inFlightCount--;
524
- }
525
- if (chain.pending.length > 0 && !chain.halted) void this.flushChain(chain);
526
- else this.settleWaiters();
484
+ });
485
+ return chain.inFlight;
527
486
  }
528
- async send(chain, batch) {
529
- const { baseMs, maxMs, maxAttempts } = this.opts.backoff;
530
- for (let attempt = 0; ; attempt++) {
487
+ /** Batches until the chain drains; a retryable failure schedules the next attempt and returns. */
488
+ async attempt(chain) {
489
+ while (chain.pending.length > 0 && !chain.halted) {
490
+ const batch = chain.pending.slice(0, MAX_BATCH);
491
+ const first = batch[0];
492
+ let ack;
531
493
  try {
532
- const ack = await this.api.post("/v1/evidence", batch);
533
- chain.attempts = 0;
534
- return ack;
494
+ ack = await this.api.post("/v1/evidence", batch);
535
495
  } catch (err) {
536
- const retryable = err instanceof BelticApiError ? err.retryable : true;
537
- if (!retryable || attempt + 1 >= maxAttempts) throw err;
538
- this.opts.onError?.(err);
539
- const delay = Math.min(maxMs, baseMs * 2 ** attempt) * (0.5 + Math.random() / 2);
540
- await new Promise((r) => (this.opts.setTimeout ?? globalThis.setTimeout)(r, delay));
496
+ console.error("[beltic]", err);
497
+ if (!_Transport.outage(err)) {
498
+ this.halt(
499
+ chain,
500
+ new ChainRejectedError(
501
+ first.sessionId,
502
+ first.source,
503
+ {
504
+ index: 0,
505
+ sessionId: first.sessionId,
506
+ source: first.source,
507
+ seq: first.seq,
508
+ status: "rejected",
509
+ code: "DELIVERY_FAILED"
510
+ },
511
+ { cause: err }
512
+ )
513
+ );
514
+ return;
515
+ }
516
+ const delay = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** chain.attempts) * (0.5 + Math.random() / 2);
517
+ chain.attempts++;
518
+ chain.retry = setTimeout(() => {
519
+ chain.retry = null;
520
+ void this.deliver(chain, false);
521
+ }, delay);
522
+ chain.retry.unref?.();
523
+ return;
541
524
  }
525
+ chain.pending.splice(0, batch.length);
526
+ this.buffered -= batch.length;
527
+ chain.attempts = 0;
528
+ const bad = ack.results.find((r) => r.status === "fork" || r.status === "rejected");
529
+ if (bad) this.halt(chain, new ChainRejectedError(first.sessionId, first.source, bad));
542
530
  }
543
531
  }
532
+ halt(chain, error) {
533
+ chain.halted = error;
534
+ this.buffered -= chain.pending.length;
535
+ chain.pending = [];
536
+ console.error("[beltic]", error);
537
+ }
538
+ };
539
+
540
+ // src/core/streams.ts
541
+ var OPEN_RETRY_MS = 6e4;
542
+ var Streams = class {
543
+ constructor(deps) {
544
+ this.deps = deps;
545
+ }
546
+ attached = /* @__PURE__ */ new Map();
547
+ /** Buyer streams by the host's own key (GAP-71). */
548
+ opened = /* @__PURE__ */ new Map();
549
+ retryAt = 0;
550
+ /**
551
+ * Buyer half: the stream for a key of the host's own, opened on first
552
+ * use and reused after. A halted chain is reopened as a fresh session
553
+ * that continues the same key; a closed key is forgotten.
554
+ */
555
+ open(key, input = {}) {
556
+ const prior = this.opened.get(key) ?? Promise.resolve(null);
557
+ const next = prior.catch(() => null).then(
558
+ (stream) => stream && !stream.isClosed && !this.deps.transport.haltedError(stream.id, stream.source) ? stream : this.openFresh(input, () => this.forget(key, next))
559
+ );
560
+ this.opened.set(key, next);
561
+ next.catch(() => this.forget(key, next));
562
+ return next;
563
+ }
564
+ /** Seller half: emit INTERNAL_NETWORK evidence into a session the buyer bound, or open a seller-born one. */
565
+ async ensure(sessionId) {
566
+ if (sessionId) return this.attach(sessionId, "INTERNAL_NETWORK", "buyer");
567
+ const out = await this.deps.api.post("/v1/sessions", {
568
+ source: "INTERNAL_NETWORK"
569
+ });
570
+ return this.attach(out.sessionId, "INTERNAL_NETWORK", "seller");
571
+ }
572
+ forget(key, entry) {
573
+ if (this.opened.get(key) === entry) this.opened.delete(key);
574
+ }
575
+ async openFresh(input, onClosed) {
576
+ const identity = this.deps.identity;
577
+ if (!identity)
578
+ throw new BelticConfigError(
579
+ "beltic.session needs an agent seed (new Beltic({ agentSeed }) or BELTIC_AGENT_SEED)"
580
+ );
581
+ if (Date.now() < this.retryAt) return null;
582
+ try {
583
+ const stream = await this.create(identity, input, onClosed);
584
+ this.retryAt = 0;
585
+ return stream;
586
+ } catch (err) {
587
+ if (!Transport.outage(err)) throw err;
588
+ this.retryAt = Date.now() + OPEN_RETRY_MS;
589
+ console.error("[beltic]", err);
590
+ return null;
591
+ }
592
+ }
593
+ async create(identity, input, onClosed) {
594
+ const body = {
595
+ source: "AGENT_TRACE",
596
+ agent: { did: identity.did, credential: identity.did },
597
+ ...input.intent ? { intent: input.intent } : {}
598
+ };
599
+ const out = await this.deps.api.post("/v1/sessions", body);
600
+ const stream = this.attach(out.sessionId, "AGENT_TRACE", "buyer", onClosed);
601
+ await stream.emit("session.open", {
602
+ runtime: {
603
+ sdk: "@belticlabs/agent-risk-sdk",
604
+ version: this.deps.sdkVersion,
605
+ ...input.runtime
606
+ },
607
+ ...input.attestations ? { attestations: input.attestations } : {}
608
+ });
609
+ if (input.intent) await stream.emit("intent.declared", input.intent);
610
+ return stream;
611
+ }
612
+ attach(id, source, born, onClosed) {
613
+ const key = `${id}:${source}`;
614
+ const existing = this.attached.get(key);
615
+ if (existing) return existing;
616
+ const stream = new Stream(
617
+ {
618
+ transport: this.deps.transport,
619
+ signer: source === "AGENT_TRACE" ? this.deps.identity?.signer : void 0,
620
+ onClosed: () => {
621
+ this.attached.delete(key);
622
+ onClosed?.();
623
+ }
624
+ },
625
+ id,
626
+ source,
627
+ born
628
+ );
629
+ this.attached.set(key, stream);
630
+ return stream;
631
+ }
544
632
  };
545
633
 
546
634
  // src/client.ts
547
- var SDK_VERSION = "0.4.0";
548
- var ENV_REQUIRED = ["BELTIC_API_KEY", "BELTIC_BASE_URL", "BELTIC_AGENT_SEED"];
549
- var ENV_CREDENTIAL = "BELTIC_AGENT_CREDENTIAL";
635
+ var SDK_VERSION = "0.6.0";
550
636
  var Beltic = class _Beltic {
637
+ /** The stream registry, for the protocol adapters. @internal */
638
+ streams;
551
639
  api;
552
640
  transport;
553
- sessions;
554
- identity;
555
- correlation;
556
- onReview;
557
- failOpen;
558
- /** `false` for a disabled client (GAP-78): nothing is posted, `run`/`sessions.open` answer without a session. */
559
- enabled;
560
- onError;
561
- runs = /* @__PURE__ */ new Map();
641
+ sessions = /* @__PURE__ */ new Map();
562
642
  /**
563
- * The client the environment describes: `BELTIC_API_KEY`,
564
- * `BELTIC_BASE_URL`, `BELTIC_AGENT_SEED` (64 hex) and optionally
565
- * `BELTIC_AGENT_CREDENTIAL`, fail-open by default. None set a disabled
566
- * client; some set → a configuration error, thrown (GAP-78).
643
+ * The client the environment describes: `BELTIC_API_KEY` and
644
+ * `BELTIC_BASE_URL`, both required, and `BELTIC_AGENT_SEED` (64 hex) for
645
+ * the buyer half. A missing required variable is a configuration error,
646
+ * thrown (GAP-78).
567
647
  */
568
- static fromEnv(env = _Beltic.processEnv(), opts = {}) {
569
- const missing = ENV_REQUIRED.filter((name) => !env[name]);
570
- if (missing.length === ENV_REQUIRED.length) return _Beltic.disabled(opts);
648
+ static fromEnv(env = _Beltic.processEnv()) {
649
+ const missing = ["BELTIC_API_KEY", "BELTIC_BASE_URL"].filter((name) => !env[name]);
571
650
  if (missing.length > 0)
572
- throw new Error(
573
- `Beltic.fromEnv: ${missing.join(", ")} missing \u2014 set all of ${ENV_REQUIRED.join(", ")}, or none to disable`
574
- );
651
+ throw new BelticConfigError(`${missing.join(", ")} missing from the environment`);
575
652
  return new _Beltic({
576
- failOpen: true,
577
- ...opts,
578
653
  apiKey: env.BELTIC_API_KEY,
579
654
  baseUrl: env.BELTIC_BASE_URL,
580
- identity: identityFromSeed(fromHex(env.BELTIC_AGENT_SEED), env[ENV_CREDENTIAL])
581
- });
582
- }
583
- /** A client that records nothing and never throws into the work: the null object for "no evidence stream" (GAP-78). */
584
- static disabled(opts = {}) {
585
- return new _Beltic({
586
- ...opts,
587
- apiKey: "",
588
- baseUrl: "http://beltic.disabled.invalid",
589
- failOpen: true,
590
- enabled: false
655
+ agentSeed: env.BELTIC_AGENT_SEED
591
656
  });
592
657
  }
593
658
  constructor(opts) {
594
- this.enabled = opts.enabled ?? true;
595
- this.failOpen = opts.failOpen ?? false;
596
- this.onError = opts.onError ?? ((err) => console.error("[beltic]", err));
597
- this.api = new ApiClient({ ...opts, userAgent: `@belticlabs/agent-risk-sdk/${SDK_VERSION}` });
598
- this.transport = new Transport(this.api, {
599
- onError: this.onError,
600
- onChainHalted: this.onError,
601
- ...opts.transport
602
- });
603
- this.sessions = new Sessions({
659
+ if (!opts.apiKey) throw new BelticConfigError("apiKey is required");
660
+ if (!URL.canParse(opts.baseUrl))
661
+ throw new BelticConfigError(`baseUrl is not a URL: ${JSON.stringify(opts.baseUrl)}`);
662
+ this.api = new ApiClient(
663
+ opts.baseUrl,
664
+ opts.apiKey,
665
+ `@belticlabs/agent-risk-sdk/${SDK_VERSION}`
666
+ );
667
+ this.transport = new Transport(this.api);
668
+ this.streams = new Streams({
604
669
  api: this.api,
605
670
  transport: this.transport,
606
- sdkVersion: SDK_VERSION,
607
- identity: opts.identity,
608
- redact: opts.redact,
609
- now: opts.now,
610
- failOpen: this.failOpen,
611
- onError: this.onError,
612
- openRetryMs: opts.openRetryMs,
613
- enabled: this.enabled
671
+ identity: opts.agentSeed ? identityFromSeed(opts.agentSeed) : null,
672
+ sdkVersion: SDK_VERSION
614
673
  });
615
- this.identity = opts.identity;
616
- this.correlation = opts.correlation ?? new MemoryCorrelationStore();
617
- this.onReview = opts.onReview ?? "abort";
618
- }
619
- /**
620
- * The platform's verdict on a payment — the seller's before it verifies,
621
- * the buyer's before it presents. Read-your-writes: the buffered evidence
622
- * is flushed first so the platform judges what the caller already saw
623
- * (GAP-16). A recorded moment is accepted as is: only its comparable core
624
- * (payee, amount, payer) is sent. `null` only under `failOpen`, when the
625
- * platform could not be asked.
626
- */
627
- async evaluate(sessionId, payment) {
628
- if (!this.enabled) return null;
629
- const input = { sessionId, payment: summaryOf(payment) };
630
- if (!this.failOpen) return this.decide(input);
631
- try {
632
- return await this.decide(input);
633
- } catch (err) {
634
- this.onError(err);
635
- return null;
636
- }
637
- }
638
- async decide(input) {
639
- await this.transport.flush();
640
- const out = await this.api.post("/v1/evaluate", input);
641
- return { ...out, verdict: Verdict.of(out.decision) };
642
674
  }
643
675
  /**
644
- * The run for a key of the host's own one object per key until it
645
- * closes (the options count on the first call only). See `Run`.
676
+ * The session for a key of the host's own (its session, run or
677
+ * conversation id) one object per key until it closes; the options
678
+ * count on the first call only. See `Session`.
646
679
  */
647
- run(key, opts = {}) {
648
- const existing = this.runs.get(key);
680
+ session(key, opts = {}) {
681
+ const existing = this.sessions.get(key);
649
682
  if (existing) return existing;
650
- const run = new Run(
683
+ const session = new Session(
651
684
  {
652
- sessions: this.sessions,
685
+ streams: this.streams,
653
686
  evaluate: (sessionId, payment) => this.evaluate(sessionId, payment),
654
687
  onClosed: (closed) => {
655
- if (this.runs.get(key) === closed) this.runs.delete(key);
688
+ if (this.sessions.get(key) === closed) this.sessions.delete(key);
656
689
  }
657
690
  },
658
691
  key,
659
692
  opts
660
693
  );
661
- this.runs.set(key, run);
662
- return run;
694
+ this.sessions.set(key, session);
695
+ return session;
696
+ }
697
+ /**
698
+ * The platform's verdict on a payment — the seller's before it verifies,
699
+ * the buyer's before it presents (Fraud SDK RFC › Evaluation Client).
700
+ * Read-your-writes: the buffered evidence is flushed first so the
701
+ * platform judges what the caller already saw (GAP-16). Absent when the
702
+ * platform could not be reached (GAP-70).
703
+ */
704
+ async evaluate(sessionId, payment) {
705
+ const input = { sessionId, payment };
706
+ try {
707
+ await this.transport.flush();
708
+ return Decision.of(await this.api.post("/v1/evaluate", input));
709
+ } catch (err) {
710
+ if (!Transport.outage(err)) throw err;
711
+ console.error("[beltic]", err);
712
+ return Decision.absent();
713
+ }
663
714
  }
715
+ /** Send everything buffered now and wait for that attempt. */
664
716
  flush() {
665
717
  return this.transport.flush();
666
718
  }
@@ -672,30 +724,12 @@ var Beltic = class _Beltic {
672
724
  return globalThis.process?.env ?? {};
673
725
  }
674
726
  };
675
- function createBeltic(opts) {
676
- return new Beltic(opts);
677
- }
678
727
  export {
679
- ApiClient,
680
728
  Beltic,
681
729
  BelticApiError,
682
- BelticDisabledError,
730
+ BelticConfigError,
683
731
  ChainRejectedError,
684
- DEFAULT_OPEN_RETRY_MS,
685
- DEFAULT_TRANSPORT,
686
732
  Decision,
687
- MemoryCorrelationStore,
688
- Run,
689
733
  SDK_VERSION,
690
- Session,
691
- Sessions,
692
- Transport,
693
- TransportClosedError,
694
- createBeltic,
695
- ephemeralIdentity,
696
- fileIdentity,
697
- identityFromSeed,
698
- presentedFrom,
699
- recordCall,
700
- summaryOf
734
+ Session
701
735
  };