@armoriq/sdk-dev 0.6.2 → 0.6.5

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 (63) hide show
  1. package/dist/cli/commands/auth.d.ts.map +1 -1
  2. package/dist/cli/commands/auth.js +18 -3
  3. package/dist/cli/commands/auth.js.map +1 -1
  4. package/dist/cli/index.js +0 -0
  5. package/dist/client.d.ts +35 -0
  6. package/dist/client.d.ts.map +1 -1
  7. package/dist/client.js +77 -1
  8. package/dist/client.js.map +1 -1
  9. package/dist/config.d.ts +17 -0
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/config.js +19 -1
  12. package/dist/config.js.map +1 -1
  13. package/dist/crypto_verify.d.ts +3 -0
  14. package/dist/crypto_verify.d.ts.map +1 -1
  15. package/dist/crypto_verify.js +77 -0
  16. package/dist/crypto_verify.js.map +1 -1
  17. package/dist/index.d.ts +4 -3
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +19 -3
  20. package/dist/index.js.map +1 -1
  21. package/dist/models.d.ts +17 -0
  22. package/dist/models.d.ts.map +1 -1
  23. package/dist/observability/handle.d.ts +41 -0
  24. package/dist/observability/handle.d.ts.map +1 -0
  25. package/dist/observability/handle.js +54 -0
  26. package/dist/observability/handle.js.map +1 -0
  27. package/dist/observability/index.d.ts +15 -0
  28. package/dist/observability/index.d.ts.map +1 -0
  29. package/dist/observability/index.js +39 -0
  30. package/dist/observability/index.js.map +1 -0
  31. package/dist/observability/model-prices.d.ts +39 -0
  32. package/dist/observability/model-prices.d.ts.map +1 -0
  33. package/dist/observability/model-prices.js +127 -0
  34. package/dist/observability/model-prices.js.map +1 -0
  35. package/dist/observability/recorder.d.ts +245 -0
  36. package/dist/observability/recorder.d.ts.map +1 -0
  37. package/dist/observability/recorder.js +475 -0
  38. package/dist/observability/recorder.js.map +1 -0
  39. package/dist/observability/schema.d.ts +163 -0
  40. package/dist/observability/schema.d.ts.map +1 -0
  41. package/dist/observability/schema.js +291 -0
  42. package/dist/observability/schema.js.map +1 -0
  43. package/dist/observability/shipper.d.ts +84 -0
  44. package/dist/observability/shipper.d.ts.map +1 -0
  45. package/dist/observability/shipper.js +206 -0
  46. package/dist/observability/shipper.js.map +1 -0
  47. package/dist/observability/trace-summary.d.ts +34 -0
  48. package/dist/observability/trace-summary.d.ts.map +1 -0
  49. package/dist/observability/trace-summary.js +108 -0
  50. package/dist/observability/trace-summary.js.map +1 -0
  51. package/dist/session.d.ts +148 -3
  52. package/dist/session.d.ts.map +1 -1
  53. package/dist/session.js +1035 -195
  54. package/dist/session.js.map +1 -1
  55. package/dist/token_usage.d.ts +18 -1
  56. package/dist/token_usage.d.ts.map +1 -1
  57. package/dist/token_usage.js +112 -2
  58. package/dist/token_usage.js.map +1 -1
  59. package/package.json +2 -1
  60. package/dist/integrations/google_adk_mcp_toolset.d.ts +0 -64
  61. package/dist/integrations/google_adk_mcp_toolset.d.ts.map +0 -1
  62. package/dist/integrations/google_adk_mcp_toolset.js +0 -91
  63. package/dist/integrations/google_adk_mcp_toolset.js.map +0 -1
package/dist/session.js CHANGED
@@ -17,10 +17,13 @@
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.ArmorIQSession = void 0;
19
19
  exports.normalizeDefaultAction = normalizeDefaultAction;
20
+ const crypto_1 = require("crypto");
21
+ const config_1 = require("./config");
20
22
  const crypto_verify_1 = require("./crypto_verify");
21
23
  const exceptions_1 = require("./exceptions");
22
24
  const models_1 = require("./models");
23
25
  const plan_builder_1 = require("./plan_builder");
26
+ const observability_1 = require("./observability");
24
27
  /**
25
28
  * Normalize a policy-authoring default-enforcement-action value into the
26
29
  * SDK-facing terminal enum. Mirrors the backend's enforce-boundary normalization
@@ -42,6 +45,63 @@ function normalizeDefaultAction(value) {
42
45
  return 'block';
43
46
  }
44
47
  }
48
+ const TRUNCATE_BYTES = 4 * 1024;
49
+ function truncateForSpan(value) {
50
+ if (value === undefined || value === null)
51
+ return value;
52
+ let json;
53
+ try {
54
+ json = typeof value === 'string' ? value : JSON.stringify(value);
55
+ }
56
+ catch {
57
+ json = String(value);
58
+ }
59
+ const bytes = Buffer.byteLength(json, 'utf8');
60
+ if (bytes <= TRUNCATE_BYTES)
61
+ return value;
62
+ const sliced = json.slice(0, TRUNCATE_BYTES);
63
+ return `${sliced}…(truncated, original ${bytes} bytes)`;
64
+ }
65
+ function safeObs(fn) {
66
+ try {
67
+ return fn();
68
+ }
69
+ catch (err) {
70
+ const msg = err instanceof Error ? err.message : String(err);
71
+ console.warn(`[armoriq] observability emit failed (continuing): ${msg}`);
72
+ return undefined;
73
+ }
74
+ }
75
+ function makeSpanRecord(ctx, name, attributes, status = 'ok', durationMs = null, parentSpanId) {
76
+ const now = new Date().toISOString();
77
+ return {
78
+ id: (0, crypto_1.randomUUID)(),
79
+ parentSpanId: parentSpanId ?? null,
80
+ sessionId: ctx.sessionId,
81
+ kind: 'span',
82
+ name,
83
+ startTime: now,
84
+ endTime: now,
85
+ durationMs,
86
+ status,
87
+ attributes,
88
+ };
89
+ }
90
+ function makeEventRecord(ctx, name, attributes, parentSpanId) {
91
+ const now = new Date().toISOString();
92
+ return {
93
+ id: (0, crypto_1.randomUUID)(),
94
+ parentSpanId: parentSpanId ?? null,
95
+ sessionId: ctx.sessionId,
96
+ kind: 'event',
97
+ name,
98
+ startTime: now,
99
+ endTime: now,
100
+ durationMs: 0,
101
+ status: 'ok',
102
+ attributes,
103
+ };
104
+ }
45
105
  class ArmorIQSession {
46
106
  userEmail;
47
107
  client;
@@ -64,6 +124,34 @@ class ArmorIQSession {
64
124
  currentToken;
65
125
  mcpByAction = new Map();
66
126
  declaredTools = new Set();
127
+ obs = null;
128
+ /**
129
+ * This session's one stable observability session id — stamped onto every
130
+ * trace this session emits (via the recorder's `defaultSessionId`, see
131
+ * `ObservabilityConfig.sessionId`). Always a valid UUID; the backend's
132
+ * `obs_sessions.id` column is UUID-typed, so a non-UUID id (e.g. the
133
+ * legacy `contextId` default of the literal string `'default'`) can never
134
+ * form a session and traces stamped with it can never be grouped.
135
+ *
136
+ * Resolution order:
137
+ * 1. `opts.sessionId` if it's a valid UUID.
138
+ * 2. `opts.contextId` if it's a valid UUID.
139
+ * 3. A freshly minted `crypto.randomUUID()` (default — no arg needed).
140
+ */
141
+ sessionId;
142
+ /**
143
+ * Model A (trace-per-plan): the ONE active observability trace for the
144
+ * current plan. Every chokepoint (enforce/check/report/dispatch/...)
145
+ * records a container span under this trace via `ensurePlanTrace()`
146
+ * instead of opening its own trace — matching Langfuse/OTel's
147
+ * Session → Trace → Observation hierarchy (one IAP plan = one trace).
148
+ *
149
+ * Lifecycle: opened by `ensurePlanTrace()` (called from `startPlan()` or
150
+ * lazily by any chokepoint invoked before `startPlan()` — back-compat);
151
+ * ended by `endPlanTrace()`, called on the NEXT `startPlan()`, on
152
+ * `close()`/`dispose()`, and on `flushObservability()`.
153
+ */
154
+ activePlanTrace = null;
67
155
  constructor(client, opts) {
68
156
  this.client = client;
69
157
  const o = opts ?? {};
@@ -76,12 +164,298 @@ class ArmorIQSession {
76
164
  const granEnv = (typeof process !== 'undefined' && process.env?.ARMORIQ_REANCHOR_GRANULARITY) || '';
77
165
  this.reanchorGranularity =
78
166
  o.reanchorGranularity ?? (granEnv === 'deferred' ? 'deferred' : 'eager');
167
+ this.sessionId = (0, observability_1.isValidUuid)(o.sessionId)
168
+ ? o.sessionId
169
+ : (0, observability_1.isValidUuid)(o.contextId)
170
+ ? o.contextId
171
+ : (0, crypto_1.randomUUID)();
172
+ const obsCfg = o.observability;
173
+ if (obsCfg?.enabled === false) {
174
+ this.obs = null;
175
+ }
176
+ else {
177
+ const internals = this.client._sessionInternals();
178
+ const endpoint = obsCfg?.endpoint ?? internals.backendEndpoint;
179
+ const apiKey = obsCfg?.apiKey ?? internals.apiKey;
180
+ const product = obsCfg?.product ?? config_1.DEFAULT_OBSERVABILITY_PRODUCT;
181
+ // Reuse the session's configured httpClient (consumer interceptors,
182
+ // test mocks) unless the caller explicitly overrides it via
183
+ // `observability.httpClient`. Without this, the shipper falls back to
184
+ // its own bare `axios.create(...)` and silently bypasses whatever
185
+ // transport the rest of the SDK uses — including test mocks, which
186
+ // then fire real network POSTs from the shipper's flush interval.
187
+ const httpClient = obsCfg?.httpClient ?? internals.httpClient;
188
+ // `internals.userId`/`internals.agentId` default to `''` (see the
189
+ // `ArmorIQClient` constructor) when the caller configured neither an
190
+ // option nor the corresponding env var — normalize that to `null` so
191
+ // an unconfigured identity reads as genuinely absent on the trace,
192
+ // rather than shipping an empty string.
193
+ const nonEmpty = (v) => (v ? v : null);
194
+ // `internals.userId` is `client.userEmailOverride || client.userId`
195
+ // (client.ts) — an arbitrary identity string (often an email address,
196
+ // or the multi-user placeholder `'__sdk_multiuser__'`), NOT guaranteed
197
+ // to be a UUID. The observability trace's `userId` column/wire schema
198
+ // IS UUID-typed (`z.uuid()` on the backend) — forwarding a non-UUID
199
+ // value 400s the entire ingest batch at the shipper. Validate before
200
+ // forwarding; a non-UUID userId is dropped to `null` rather than
201
+ // breaking ingestion (`agentId` has no such constraint — see below).
202
+ const validUserId = (v) => (0, observability_1.isValidUuid)(v) ? v : null;
203
+ const merged = {
204
+ ...(0, config_1.defaultObservabilityConfig)(),
205
+ ...(obsCfg ?? {}),
206
+ enabled: true,
207
+ endpoint,
208
+ apiKey,
209
+ product,
210
+ httpClient,
211
+ // The session's one stable UUID — applied to every trace this
212
+ // recorder starts unless a chokepoint explicitly overrides it (none
213
+ // do; see the `startTrace(...)` call sites below, all of which now
214
+ // pass `this.sessionId` directly for clarity/back-compat).
215
+ sessionId: obsCfg?.sessionId ?? this.sessionId,
216
+ // Denormalize the client's configured user/agent identity onto every
217
+ // trace this recorder starts, unless the caller explicitly overrides
218
+ // via `observability.userId`/`observability.agentId`. `internals`
219
+ // already carries both (see `ArmorIQClient._sessionInternals()`);
220
+ // previously neither was forwarded here, so every SDK-emitted trace
221
+ // shipped with `agentId: null` even when the client was constructed
222
+ // with a real agent id. `agentId` is a free-form string on the wire
223
+ // (backend column is `text`, not a UUID FK) — no UUID validation
224
+ // required, unlike `sessionId`/`userId`.
225
+ userId: obsCfg?.userId ?? validUserId(internals.userId),
226
+ agentId: obsCfg?.agentId ?? nonEmpty(internals.agentId),
227
+ };
228
+ this.obs = new observability_1.ObservabilityRecorder(merged);
229
+ }
230
+ }
231
+ /**
232
+ * The session's `ObservabilityRecorder`, or `null` when observability is
233
+ * disabled for this session. Pass this explicitly to
234
+ * `summarizeTranscriptUsage(path, recorder, ctx)` / `client.captureTranscriptTokens({
235
+ * ..., observabilityRecorder, planTraceCtx })` so transcript-derived generation spans are
236
+ * attributed to the correct session — the SDK no longer keeps a
237
+ * process-global "active recorder" (see #4: concurrent sessions would
238
+ * otherwise misattribute usage to whichever session constructed last).
239
+ */
240
+ get observabilityRecorder() {
241
+ return this.obs;
242
+ }
243
+ /**
244
+ * Model A (Task 4): the session's active plan trace context, or `null` if
245
+ * none is open yet (lazily opens on first use — same as every chokepoint).
246
+ * Pass this as `summarizeTranscriptUsage(path, session.observabilityRecorder,
247
+ * session.activePlanTraceContext)` (or via
248
+ * `client.captureTranscriptTokens({ ..., planTraceCtx: session.activePlanTraceContext })`)
249
+ * so transcript-derived generation spans nest under the SAME plan trace as
250
+ * every other chokepoint this session recorded, instead of opening a
251
+ * standalone `llm.usage.report` trace.
252
+ */
253
+ get activePlanTraceContext() {
254
+ return this.obs ? this.ensurePlanTrace() : null;
255
+ }
256
+ // ─── Model A: active-plan-trace lifecycle ──────────────────────
257
+ /**
258
+ * Return the session's active plan trace, lazily opening one if none is
259
+ * active. This is the ONE trace every chokepoint's container span nests
260
+ * under (Model A: trace-per-plan). Returns `null` when observability is
261
+ * disabled — every call site already treats a null ctx as "skip emit".
262
+ *
263
+ * Back-compat (plan §"Global Constraints"): a chokepoint invoked with no
264
+ * prior `startPlan()` (e.g. a standalone `enforceLocal()`/`report()` call)
265
+ * still needs to emit — so this opens a plan trace lazily on first use
266
+ * rather than requiring `startPlan()` to have run first.
267
+ */
268
+ ensurePlanTrace(name = 'iap.plan', attrs = {}) {
269
+ if (!this.obs)
270
+ return null;
271
+ if (this.activePlanTrace)
272
+ return this.activePlanTrace;
273
+ const ctx = safeObs(() => (0, observability_1.startTrace)(this.obs, name, attrs, this.sessionId)) ?? null;
274
+ this.activePlanTrace = ctx;
275
+ return ctx;
276
+ }
277
+ /**
278
+ * End the active plan trace (if any) and clear it. Called on the NEXT
279
+ * `startPlan()` (closing the PRIOR plan before opening a new one),
280
+ * `close()`/`dispose()`, and `flushObservability()` — so the in-flight
281
+ * plan trace always ships before the process exits or a new plan begins.
282
+ */
283
+ endPlanTrace(status = 'ok') {
284
+ if (!this.obs || !this.activePlanTrace)
285
+ return;
286
+ const ctx = this.activePlanTrace;
287
+ this.activePlanTrace = null;
288
+ safeObs(() => (0, observability_1.endTrace)(this.obs, ctx, { status }));
79
289
  }
80
290
  // ─── Plan capture ──────────────────────────────────────────────
291
+ async reanchorCall(intentToken, plan, reason) {
292
+ const startNs = this.obs ? performance.now() : 0;
293
+ const ctx = this.obs ? this.ensurePlanTrace() : null;
294
+ const spanId = ctx && this.obs
295
+ ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
296
+ name: 'iap.reanchor',
297
+ attributes: { reason, planStepCount: plan?.steps?.length ?? null },
298
+ })) ?? undefined
299
+ : undefined;
300
+ let caught;
301
+ let trustId;
302
+ try {
303
+ const result = await this.client.reanchor(intentToken, plan, reason);
304
+ trustId = result?.trustId;
305
+ if (ctx) {
306
+ safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'iap.reanchor.delta', {
307
+ kind: 'event',
308
+ message: 'delta recorded',
309
+ level: 'info',
310
+ trustId: trustId ?? null,
311
+ }, spanId)));
312
+ }
313
+ return { trustId };
314
+ }
315
+ catch (err) {
316
+ caught = err;
317
+ if (ctx) {
318
+ safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'iap.reanchor.delta_failed', {
319
+ kind: 'event',
320
+ message: `delta failed: ${err.message}`,
321
+ level: 'error',
322
+ }, spanId)));
323
+ }
324
+ throw err;
325
+ }
326
+ finally {
327
+ if (ctx) {
328
+ const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
329
+ safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'iap.reanchor.http', {
330
+ kind: 'span',
331
+ trustId: trustId ?? null,
332
+ errorMessage: caught instanceof Error ? caught.message : undefined,
333
+ }, caught ? 'error' : 'ok', computedDuration ?? null, spanId)));
334
+ if (spanId) {
335
+ safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
336
+ status: caught ? 'error' : 'ok',
337
+ durationMs: computedDuration,
338
+ errorMessage: caught instanceof Error ? caught.message : undefined,
339
+ }));
340
+ }
341
+ }
342
+ }
343
+ }
344
+ async getIntentTokenCall(planCapture, validitySeconds) {
345
+ const startNs = this.obs ? performance.now() : 0;
346
+ const ctx = this.obs ? this.ensurePlanTrace() : null;
347
+ const spanId = ctx && this.obs
348
+ ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
349
+ name: 'iap.intent_token.mint',
350
+ attributes: { planStepCount: planCapture?.plan?.steps?.length ?? null },
351
+ })) ?? undefined
352
+ : undefined;
353
+ let caught;
354
+ let token;
355
+ try {
356
+ token = await this.client.getIntentToken(planCapture, undefined, validitySeconds);
357
+ if (ctx) {
358
+ safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'iap.intent_token.minted', {
359
+ kind: 'event',
360
+ message: 'new intent token minted',
361
+ level: 'info',
362
+ tokenId: token?.tokenId ?? null,
363
+ planId: token?.planId ?? null,
364
+ }, spanId)));
365
+ }
366
+ return token;
367
+ }
368
+ catch (err) {
369
+ caught = err;
370
+ throw err;
371
+ }
372
+ finally {
373
+ if (ctx) {
374
+ const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
375
+ safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'iap.intent_token.mint.http', {
376
+ kind: 'span',
377
+ errorMessage: caught instanceof Error ? caught.message : undefined,
378
+ }, caught ? 'error' : 'ok', computedDuration ?? null, spanId)));
379
+ if (spanId) {
380
+ safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
381
+ status: caught ? 'error' : 'ok',
382
+ durationMs: computedDuration,
383
+ errorMessage: caught instanceof Error ? caught.message : undefined,
384
+ }));
385
+ }
386
+ }
387
+ }
388
+ }
81
389
  async startPlan(toolCalls, goal) {
390
+ const startNs = this.obs ? performance.now() : 0;
391
+ // Model A: close the PRIOR plan's trace (if any) before opening the new
392
+ // one — one IAP plan = one trace, so a new startPlan() is a plan
393
+ // boundary. Then open the new active plan trace and record a
394
+ // container span (`iap.plan.start`) for this call's own work, nested
395
+ // under it exactly like every other chokepoint's container span.
396
+ if (this.obs)
397
+ safeObs(() => this.endPlanTrace('ok'));
398
+ const ctx = this.obs
399
+ ? this.ensurePlanTrace('iap.plan', {
400
+ toolCount: toolCalls?.length ?? 0,
401
+ goal: goal ?? null,
402
+ mode: this.mode,
403
+ })
404
+ : null;
405
+ const spanId = ctx && this.obs
406
+ ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
407
+ name: 'iap.plan.start',
408
+ attributes: {
409
+ toolCount: toolCalls?.length ?? 0,
410
+ goal: goal ?? null,
411
+ mode: this.mode,
412
+ },
413
+ })) ?? undefined
414
+ : undefined;
415
+ let caught;
416
+ let planTraceStatus = 'ok';
417
+ let planTraceError;
418
+ try {
419
+ const result = await this._startPlanImpl(toolCalls, goal, ctx);
420
+ if (ctx) {
421
+ safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'iap.plan.grew', {
422
+ kind: 'event',
423
+ message: `plan captured: ${toolCalls?.length ?? 0} tool(s)`,
424
+ level: 'info',
425
+ toolCount: toolCalls?.length ?? 0,
426
+ })));
427
+ }
428
+ return result;
429
+ }
430
+ catch (err) {
431
+ caught = err;
432
+ planTraceStatus = 'error';
433
+ planTraceError = err.message;
434
+ throw err;
435
+ }
436
+ finally {
437
+ if (ctx && spanId) {
438
+ const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
439
+ safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
440
+ status: planTraceStatus === 'error' ? 'error' : 'ok',
441
+ durationMs: computedDuration,
442
+ errorMessage: planTraceError,
443
+ }));
444
+ }
445
+ }
446
+ }
447
+ async _startPlanImpl(toolCalls, goal, _ctx) {
82
448
  if (!toolCalls || toolCalls.length === 0) {
83
449
  throw new Error('startPlan called with no tool calls.');
84
450
  }
451
+ // #519 — in local mode, warm the trusted IAP public key (config/env, else
452
+ // one-shot fetch) so the synchronous enforceLocal() can verify the EdDSA
453
+ // intent-token JWT. Best-effort: a failure here just means enforceLocal
454
+ // fails closed later. Skipped in proxy/sdk mode (the proxy/backend verifies)
455
+ // to avoid an unnecessary network call for those consumers.
456
+ if (this.mode === 'local') {
457
+ await this.client.ensureIapPublicKey();
458
+ }
85
459
  const h = (0, plan_builder_1.hashToolCalls)(toolCalls);
86
460
  if (this.currentToken && this.currentPlanHash === h) {
87
461
  return this.currentToken;
@@ -121,7 +495,7 @@ class ArmorIQSession {
121
495
  // For autoReanchor (pragmatic v2) in deferred mode: still need to
122
496
  // re-mint because step_proofs are baked at issuance. Skip the
123
497
  // delta call here; flushReanchor() catches up later.
124
- const tokenA = await this.client.getIntentToken(planCapture, undefined, this.validitySeconds);
498
+ const tokenA = await this.getIntentTokenCall(planCapture, this.validitySeconds);
125
499
  this.currentPlanHash = h;
126
500
  this.currentToken = tokenA;
127
501
  this.stepIndex = 0;
@@ -134,7 +508,7 @@ class ArmorIQSession {
134
508
  // preserved across the chain (paper's actual C3).
135
509
  if (this.trueReanchor && this.currentToken) {
136
510
  try {
137
- await this.client.reanchor(this.currentToken, plan, 'plan grew (true-reanchor)');
511
+ await this.reanchorCall(this.currentToken, plan, 'plan grew (true-reanchor)');
138
512
  // Update local state without re-minting. The JWT stays the same;
139
513
  // only currentPlanHash advances so subsequent enforceLocal /
140
514
  // dispatch calls see the new plan.
@@ -156,47 +530,97 @@ class ArmorIQSession {
156
530
  // pins to the JWT's plan_hash and step_proofs are baked at issuance.
157
531
  if (this.autoReanchor && this.currentToken) {
158
532
  try {
159
- await this.client.reanchor(this.currentToken, plan, 'plan grew mid-session');
533
+ await this.reanchorCall(this.currentToken, plan, 'plan grew mid-session');
160
534
  }
161
535
  catch (err) {
162
536
  const msg = err instanceof Error ? err.message : String(err);
163
537
  console.warn(`[armoriq] auto-reanchor delta failed (continuing): ${msg}`);
164
538
  }
165
539
  }
166
- const token = await this.client.getIntentToken(planCapture, undefined, this.validitySeconds);
540
+ const token = await this.getIntentTokenCall(planCapture, this.validitySeconds);
167
541
  this.currentPlanHash = h;
168
542
  this.currentToken = token;
169
543
  this.stepIndex = 0;
170
544
  return token;
171
545
  }
172
546
  // ─── Policy enforcement ────────────────────────────────────────
173
- enforceLocal(toolName, toolArgs) {
547
+ /**
548
+ * `parentContainerSpanId` is an internal-only hook (not part of the public
549
+ * signature contract other SDK consumers rely on — it's simply an extra
550
+ * optional trailing arg) so `check()` can nest this call's container span
551
+ * under `iap.check`'s own container span instead of both sitting as
552
+ * siblings directly under the plan trace (Task 3 risk: "check → enforce
553
+ * double-open" — `iap.check`'s span wraps the dispatched enforce span,
554
+ * it never opens a second redundant plan trace or duplicate container).
555
+ */
556
+ enforceLocal(toolName, toolArgs, parentContainerSpanId) {
557
+ const startNs = this.obs ? performance.now() : 0;
558
+ const ctx = this.obs ? this.ensurePlanTrace() : null;
559
+ const spanId = ctx && this.obs
560
+ ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
561
+ name: 'iap.enforce.local',
562
+ attributes: { toolName, toolArgs: truncateForSpan(toolArgs) },
563
+ parentSpanId: parentContainerSpanId ?? null,
564
+ })) ?? undefined
565
+ : undefined;
566
+ let caught;
567
+ try {
568
+ return this._enforceLocalImpl(toolName, toolArgs, ctx, spanId);
569
+ }
570
+ catch (err) {
571
+ caught = err;
572
+ throw err;
573
+ }
574
+ finally {
575
+ if (ctx && spanId) {
576
+ const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
577
+ safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
578
+ status: caught ? 'error' : 'ok',
579
+ durationMs: computedDuration,
580
+ errorMessage: caught instanceof Error ? caught.message : undefined,
581
+ }));
582
+ }
583
+ }
584
+ }
585
+ _enforceLocalImpl(toolName, toolArgs, ctx, spanId) {
174
586
  if (!this.currentToken) {
175
- return {
587
+ const r = {
176
588
  allowed: false,
177
589
  action: 'block',
178
590
  reason: 'No intent token — call startPlan() first',
179
591
  };
592
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', {}, spanId);
593
+ return r;
180
594
  }
181
595
  if (models_1.IntentToken.isExpired(this.currentToken)) {
182
- return { allowed: false, action: 'block', reason: 'token-expired' };
596
+ const r = { allowed: false, action: 'block', reason: 'token-expired' };
597
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', {}, spanId);
598
+ return r;
183
599
  }
184
600
  // The local decision is read from the token's policy fields, so the token
185
601
  // must be cryptographically authentic first - otherwise a forged token
186
- // could assert an arbitrary allowlist. Fail closed if the signature is bad.
187
- if (!(0, crypto_verify_1.verifyIntentTokenSignature)(this.currentToken.rawToken)) {
188
- return { allowed: false, action: 'block', reason: 'token-signature-invalid' };
602
+ // could assert an arbitrary allowlist. #519: verify the backend's EdDSA
603
+ // (Ed25519) JWT against the TRUSTED public key (option/env, or fetched from
604
+ // GET /iap/public-key and warmed in startPlan) — never a key embedded in
605
+ // the token. Fail CLOSED if the key is unavailable or the signature is bad.
606
+ const iapKey = this.client.iapPublicKeySync();
607
+ if (!iapKey || !(0, crypto_verify_1.verifyEdDSAJwt)(this.currentToken.jwtToken ?? '', iapKey)) {
608
+ const r = { allowed: false, action: 'block', reason: 'token-signature-invalid' };
609
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', {}, spanId);
610
+ return r;
189
611
  }
190
612
  const { mcp, action } = this.toolNameParser(toolName);
191
613
  const inPlan = this.declaredTools.has(toolName) ||
192
614
  this.declaredTools.has(action) ||
193
615
  this.declaredTools.has(`${mcp}__${action}`);
194
616
  if (!inPlan) {
195
- return {
617
+ const r = {
196
618
  allowed: false,
197
619
  action: 'block',
198
620
  reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
199
621
  };
622
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp }, spanId);
623
+ return r;
200
624
  }
201
625
  const pv = (this.currentToken.policyValidation ?? {});
202
626
  const snapshot = (this.currentToken.policySnapshot ?? []);
@@ -246,12 +670,14 @@ class ArmorIQSession {
246
670
  : `Tool '${action}' is denied by policy`);
247
671
  // Deny-precedence: an explicitly denied tool is never allowed, even when
248
672
  // the default action is 'allow'. hold only if the policy default is hold.
249
- return {
673
+ const r = {
250
674
  allowed: false,
251
675
  action: defaultAction === 'hold' ? 'hold' : 'block',
252
676
  reason,
253
677
  matchedPolicy: governingPolicyName,
254
678
  };
679
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
680
+ return r;
255
681
  }
256
682
  }
257
683
  // Honor the backend-baked resolved allow set: an explicit *empty*
@@ -259,11 +685,13 @@ class ArmorIQSession {
259
685
  // ⇒ deny-all, regardless of what any individual snapshot rule allows.
260
686
  const bakedAllowedTools = pv.allowed_tools;
261
687
  if (Array.isArray(bakedAllowedTools) && bakedAllowedTools.length === 0) {
262
- return {
688
+ const r = {
263
689
  allowed: false,
264
690
  action: 'block',
265
691
  reason: `Tool '${action}' is not allowed by any policy in scope (empty resolved allow-list)`,
266
692
  };
693
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
694
+ return r;
267
695
  }
268
696
  if (governingRule) {
269
697
  const allowed = governingRule.allowedTools ?? [];
@@ -276,19 +704,23 @@ class ArmorIQSession {
276
704
  // policy default action: an 'allow' default permits it, hold/block
277
705
  // otherwise.
278
706
  if (defaultAction === 'allow') {
279
- return {
707
+ const r = {
280
708
  allowed: true,
281
709
  action: 'allow',
282
710
  reason: `Allowed by default enforcement action for policy '${governingPolicyName}'`,
283
711
  matchedPolicy: governingPolicyName,
284
712
  };
713
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
714
+ return r;
285
715
  }
286
- return {
716
+ const r = {
287
717
  allowed: false,
288
718
  action: defaultAction,
289
719
  reason: `Tool '${action}' is not in the allowed tools for policy '${governingPolicyName}'`,
290
720
  matchedPolicy: governingPolicyName,
291
721
  };
722
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
723
+ return r;
292
724
  }
293
725
  }
294
726
  }
@@ -296,17 +728,81 @@ class ArmorIQSession {
296
728
  const thresholdDecision = this.evaluateAmountThreshold(governingRule, toolArgs, action, mcp);
297
729
  if (thresholdDecision) {
298
730
  thresholdDecision.matchedPolicy = governingPolicyName;
731
+ this._emitEnforcePolicyCall(ctx, toolName, thresholdDecision, 'sdk-local', { mcp, policyValidation: pv }, spanId);
299
732
  return thresholdDecision;
300
733
  }
301
734
  }
302
- return {
735
+ const r = {
303
736
  allowed: true,
304
737
  action: 'allow',
305
738
  reason: 'Allowed by local policy evaluation',
306
739
  matchedPolicy: governingPolicyName,
307
740
  };
741
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
742
+ return r;
743
+ }
744
+ _emitEnforcePolicyCall(ctx, toolName, result, source, extras, parentSpanId) {
745
+ if (!ctx || !this.obs)
746
+ return;
747
+ const decision = result.action === 'allow'
748
+ ? 'allow'
749
+ : result.action === 'hold'
750
+ ? 'hold'
751
+ : 'deny';
752
+ const enforcementAction = result.action;
753
+ safeObs(() => (0, observability_1.recordPolicyCall)(this.obs, ctx, {
754
+ policyId: null,
755
+ policyName: result.matchedPolicy ?? null,
756
+ decision,
757
+ reason: result.reason ?? null,
758
+ source,
759
+ input: { toolName },
760
+ output: {
761
+ allowed: result.allowed,
762
+ action: result.action,
763
+ matchedPolicy: result.matchedPolicy ?? null,
764
+ delegationId: result.delegationId ?? null,
765
+ },
766
+ policyHash: null,
767
+ policyVersion: null,
768
+ matchedRuleId: null,
769
+ dataClasses: [],
770
+ enforcementAction,
771
+ obligations: extras.obligations ?? result.obligations ?? [],
772
+ delegationId: result.delegationId ?? null,
773
+ }, parentSpanId));
774
+ }
775
+ /** See `enforceLocal`'s doc comment re: `parentContainerSpanId` (internal-only hook for `check()`). */
776
+ async enforceSdk(toolName, toolArgs, userEmail, parentContainerSpanId) {
777
+ const startNs = this.obs ? performance.now() : 0;
778
+ const ctx = this.obs ? this.ensurePlanTrace() : null;
779
+ const spanId = ctx && this.obs
780
+ ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
781
+ name: 'iap.enforce.sdk',
782
+ attributes: { toolName, toolArgs: truncateForSpan(toolArgs), userEmail: userEmail ?? null },
783
+ parentSpanId: parentContainerSpanId ?? null,
784
+ })) ?? undefined
785
+ : undefined;
786
+ let caught;
787
+ try {
788
+ return await this._enforceSdkImpl(toolName, toolArgs, userEmail, ctx, spanId);
789
+ }
790
+ catch (err) {
791
+ caught = err;
792
+ throw err;
793
+ }
794
+ finally {
795
+ if (ctx && spanId) {
796
+ const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
797
+ safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
798
+ status: caught ? 'error' : 'ok',
799
+ durationMs: computedDuration,
800
+ errorMessage: caught instanceof Error ? caught.message : undefined,
801
+ }));
802
+ }
803
+ }
308
804
  }
309
- async enforceSdk(toolName, toolArgs, userEmail) {
805
+ async _enforceSdkImpl(toolName, toolArgs, userEmail, ctx, spanId) {
310
806
  if (!this.currentToken) {
311
807
  throw new Error(`enforceSdk("${toolName}") called before startPlan().`);
312
808
  }
@@ -316,11 +812,13 @@ class ArmorIQSession {
316
812
  this.declaredTools.has(action) ||
317
813
  this.declaredTools.has(`${resolvedMcp}__${action}`);
318
814
  if (!inPlan) {
319
- return {
815
+ const r = {
320
816
  allowed: false,
321
817
  action: 'block',
322
818
  reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
323
819
  };
820
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp }, spanId);
821
+ return r;
324
822
  }
325
823
  const internals = this.client._sessionInternals();
326
824
  try {
@@ -347,32 +845,68 @@ class ArmorIQSession {
347
845
  ? data.obligations
348
846
  : [];
349
847
  if (actionDecision === 'hold') {
350
- return this.handleHold(toolName, toolArgs, {
848
+ const r = {
351
849
  allowed: false,
352
850
  action: 'hold',
353
851
  reason: data.reason ?? data.message,
354
852
  matchedPolicy: matched,
355
853
  obligations,
356
- }, userEmail);
854
+ };
855
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp, obligations }, spanId);
856
+ return this.handleHold(toolName, toolArgs, r, userEmail);
357
857
  }
358
- return {
858
+ const r = {
359
859
  allowed,
360
860
  action: actionDecision,
361
861
  reason: data.reason ?? data.message,
362
862
  matchedPolicy: matched,
363
863
  obligations,
364
864
  };
865
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp, obligations }, spanId);
866
+ return r;
365
867
  }
366
868
  catch (e) {
367
869
  console.error(`enforceSdk() failed: ${e.message}. Blocking tool call (fail-closed).`);
368
- return {
870
+ const r = {
369
871
  allowed: false,
370
872
  action: 'block',
371
873
  reason: `enforce-unavailable: ${e.message}`,
372
874
  };
875
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp }, spanId);
876
+ return r;
877
+ }
878
+ }
879
+ /** See `enforceLocal`'s doc comment re: `parentContainerSpanId` (internal-only hook for `check()`). */
880
+ async enforce(toolName, toolArgs, parentContainerSpanId) {
881
+ const startNs = this.obs ? performance.now() : 0;
882
+ const ctx = this.obs ? this.ensurePlanTrace() : null;
883
+ const spanId = ctx && this.obs
884
+ ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
885
+ name: 'iap.enforce.proxy',
886
+ attributes: { toolName, toolArgs: truncateForSpan(toolArgs) },
887
+ parentSpanId: parentContainerSpanId ?? null,
888
+ })) ?? undefined
889
+ : undefined;
890
+ let caught;
891
+ try {
892
+ return await this._enforceImpl(toolName, toolArgs, ctx, spanId);
893
+ }
894
+ catch (err) {
895
+ caught = err;
896
+ throw err;
897
+ }
898
+ finally {
899
+ if (ctx && spanId) {
900
+ const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
901
+ safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
902
+ status: caught ? 'error' : 'ok',
903
+ durationMs: computedDuration,
904
+ errorMessage: caught instanceof Error ? caught.message : undefined,
905
+ }));
906
+ }
373
907
  }
374
908
  }
375
- async enforce(toolName, toolArgs) {
909
+ async _enforceImpl(toolName, toolArgs, ctx, spanId) {
376
910
  if (!this.currentToken) {
377
911
  throw new Error(`enforce("${toolName}") called before startPlan().`);
378
912
  }
@@ -382,11 +916,13 @@ class ArmorIQSession {
382
916
  this.declaredTools.has(action) ||
383
917
  this.declaredTools.has(`${resolvedMcp}__${action}`);
384
918
  if (!inPlan) {
385
- return {
919
+ const r = {
386
920
  allowed: false,
387
921
  action: 'block',
388
922
  reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
389
923
  };
924
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
925
+ return r;
390
926
  }
391
927
  const internals = this.client._sessionInternals();
392
928
  try {
@@ -413,16 +949,18 @@ class ArmorIQSession {
413
949
  // Fail closed: only an explicit allow from a 2xx response permits the call.
414
950
  // Any error status (403 or otherwise) or an ambiguous body blocks.
415
951
  if (response.status >= 400) {
416
- return {
952
+ const r = {
417
953
  allowed: false,
418
954
  action: data.action ?? 'block',
419
955
  reason: data.reason ?? data.message ?? `enforce-rejected: HTTP ${response.status}`,
420
956
  matchedPolicy: policyName,
421
957
  };
958
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
959
+ return r;
422
960
  }
423
961
  const allowedFlag = data.allowed === true;
424
962
  const actionDecision = data.enforcementAction ?? data.action ?? (allowedFlag ? 'allow' : 'block');
425
- return {
963
+ const r = {
426
964
  allowed: allowedFlag,
427
965
  action: actionDecision,
428
966
  reason: data.reason,
@@ -430,161 +968,325 @@ class ArmorIQSession {
430
968
  matchedPolicy: policyName,
431
969
  obligations: Array.isArray(data.obligations) ? data.obligations : [],
432
970
  };
971
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
972
+ return r;
433
973
  }
434
974
  catch (e) {
435
975
  console.error(`enforce() failed: ${e.message}. Blocking tool call (fail-closed).`);
436
- return {
976
+ const r = {
437
977
  allowed: false,
438
978
  action: 'block',
439
979
  reason: `enforce-unavailable: ${e.message}`,
440
980
  };
981
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
982
+ return r;
441
983
  }
442
984
  }
443
985
  async check(toolName, toolArgs, userEmail) {
444
- if (this.mode === 'sdk') {
445
- return this.enforceSdk(toolName, toolArgs, userEmail);
986
+ const startNs = this.obs ? performance.now() : 0;
987
+ const ctx = this.obs ? this.ensurePlanTrace() : null;
988
+ // Task 3 risk — "check → enforce double-open": `check` opens its OWN
989
+ // `iap.check` container span (nested under the single active plan
990
+ // trace, never a second trace), then passes this span's id as the
991
+ // PARENT for whichever enforce* it dispatches into. The dispatched
992
+ // enforce's container span therefore nests under `iap.check`'s span —
993
+ // it does not open a redundant plan trace or a sibling/duplicate
994
+ // container span.
995
+ const spanId = ctx && this.obs
996
+ ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
997
+ name: 'iap.check',
998
+ attributes: {
999
+ toolName,
1000
+ toolArgs: truncateForSpan(toolArgs),
1001
+ mode: this.mode,
1002
+ userEmail: userEmail ?? null,
1003
+ },
1004
+ })) ?? undefined
1005
+ : undefined;
1006
+ let caught;
1007
+ try {
1008
+ if (this.mode === 'sdk') {
1009
+ return await this.enforceSdk(toolName, toolArgs, userEmail, spanId);
1010
+ }
1011
+ if (this.mode === 'local') {
1012
+ const decision = this.enforceLocal(toolName, toolArgs, spanId);
1013
+ if (decision.action === 'hold') {
1014
+ return {
1015
+ ...decision,
1016
+ action: 'block',
1017
+ reason: (decision.reason ?? 'requires approval') +
1018
+ ' — switch ARMORIQ_MODE=proxy to enable approval workflows for this action.',
1019
+ };
1020
+ }
1021
+ return decision;
1022
+ }
1023
+ const decision = await this.enforce(toolName, toolArgs, spanId);
1024
+ if (decision.action !== 'hold')
1025
+ return decision;
1026
+ return this.handleHold(toolName, toolArgs, decision, userEmail);
446
1027
  }
447
- if (this.mode === 'local') {
448
- const decision = this.enforceLocal(toolName, toolArgs);
449
- if (decision.action === 'hold') {
450
- return {
451
- ...decision,
452
- action: 'block',
453
- reason: (decision.reason ?? 'requires approval') +
454
- ' — switch ARMORIQ_MODE=proxy to enable approval workflows for this action.',
455
- };
1028
+ catch (err) {
1029
+ caught = err;
1030
+ throw err;
1031
+ }
1032
+ finally {
1033
+ if (ctx && spanId) {
1034
+ const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
1035
+ safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
1036
+ status: caught ? 'error' : 'ok',
1037
+ durationMs: computedDuration,
1038
+ errorMessage: caught instanceof Error ? caught.message : undefined,
1039
+ }));
456
1040
  }
457
- return decision;
458
1041
  }
459
- const decision = await this.enforce(toolName, toolArgs);
460
- if (decision.action !== 'hold')
461
- return decision;
462
- return this.handleHold(toolName, toolArgs, decision, userEmail);
463
1042
  }
464
1043
  // ─── Report / dispatch ─────────────────────────────────────────
465
1044
  async report(toolName, toolArgs, result, opts) {
466
- const o = opts ?? {};
467
- const { mcp, action } = this.toolNameParser(toolName);
468
- const resolvedMcp = this.mcpByAction.get(action) ?? mcp;
469
- const internals = this.client._sessionInternals();
1045
+ const o = {
1046
+ ...opts,
1047
+ durationMs: opts?.durationMs != null && Number.isFinite(opts.durationMs)
1048
+ ? Math.round(opts.durationMs)
1049
+ : opts?.durationMs,
1050
+ };
1051
+ const startNs = this.obs ? performance.now() : 0;
1052
+ const ctx = this.obs ? this.ensurePlanTrace() : null;
1053
+ const spanId = ctx && this.obs
1054
+ ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
1055
+ name: 'tool.report',
1056
+ attributes: {
1057
+ toolName,
1058
+ toolInput: truncateForSpan(toolArgs),
1059
+ status: o.status ?? 'success',
1060
+ },
1061
+ })) ?? undefined
1062
+ : undefined;
1063
+ let caught;
1064
+ let reportStatus = 'ok';
1065
+ let reportError;
470
1066
  try {
471
- const token = this.currentToken;
472
- const userEmail = this.userEmail ?? this.client.userEmailOverride;
473
- let output = result;
474
- if (typeof result === 'string')
475
- output = { text: result };
476
- else if (result === null || result === undefined)
477
- output = {};
478
- await internals.httpClient.post(`${internals.backendEndpoint}/iap/audit`, {
479
- token: token?.jwtToken ?? token?.tokenId ?? 'unknown',
480
- plan_id: token?.planId ?? token?.tokenId ?? 'unknown',
481
- step_index: this.stepIndex,
482
- action,
483
- tool: action,
484
- mcp: resolvedMcp,
485
- input: toolArgs,
486
- output,
487
- status: o.status ?? 'success',
488
- error_message: o.errorMessage,
489
- duration_ms: o.durationMs,
490
- is_delegated: o.isDelegated,
491
- delegated_by: o.delegatedBy,
492
- user_email: userEmail,
493
- delegated_to: o.delegatedTo,
494
- executed_at: new Date().toISOString(),
495
- }, {
496
- headers: { 'X-API-Key': internals.apiKey, 'Content-Type': 'application/json' },
497
- timeout: 5000,
498
- });
1067
+ const { mcp, action } = this.toolNameParser(toolName);
1068
+ const resolvedMcp = this.mcpByAction.get(action) ?? mcp;
1069
+ const internals = this.client._sessionInternals();
1070
+ try {
1071
+ const token = this.currentToken;
1072
+ const userEmail = this.userEmail ?? this.client.userEmailOverride;
1073
+ let output = result;
1074
+ if (typeof result === 'string')
1075
+ output = { text: result };
1076
+ else if (result === null || result === undefined)
1077
+ output = {};
1078
+ await internals.httpClient.post(`${internals.backendEndpoint}/iap/audit`, {
1079
+ token: token?.jwtToken ?? token?.tokenId ?? 'unknown',
1080
+ plan_id: token?.planId ?? token?.tokenId ?? 'unknown',
1081
+ step_index: this.stepIndex,
1082
+ action,
1083
+ tool: action,
1084
+ mcp: resolvedMcp,
1085
+ input: toolArgs,
1086
+ output,
1087
+ status: o.status ?? 'success',
1088
+ error_message: o.errorMessage,
1089
+ duration_ms: o.durationMs,
1090
+ is_delegated: o.isDelegated,
1091
+ delegated_by: o.delegatedBy,
1092
+ user_email: userEmail,
1093
+ delegated_to: o.delegatedTo,
1094
+ executed_at: new Date().toISOString(),
1095
+ }, {
1096
+ headers: { 'X-API-Key': internals.apiKey, 'Content-Type': 'application/json' },
1097
+ timeout: 5000,
1098
+ });
1099
+ }
1100
+ catch (e) {
1101
+ console.warn(`report() failed: ${e.message}`);
1102
+ }
1103
+ this.stepIndex += 1;
499
1104
  }
500
- catch (e) {
501
- console.warn(`report() failed: ${e.message}`);
1105
+ catch (err) {
1106
+ caught = err;
1107
+ reportStatus = 'error';
1108
+ reportError = err.message;
1109
+ throw err;
1110
+ }
1111
+ finally {
1112
+ if (ctx) {
1113
+ const computedDuration = o.durationMs ?? (startNs > 0 ? Math.round(performance.now() - startNs) : undefined);
1114
+ safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'tool.report', {
1115
+ kind: 'span',
1116
+ toolName,
1117
+ toolInput: truncateForSpan(toolArgs),
1118
+ toolOutput: truncateForSpan(result),
1119
+ // `status` here is the caller-supplied tool outcome
1120
+ // ('success'|'failed'|'error' from ReportOptions), a distinct
1121
+ // side-channel signal from the top-level SpanRecord.status
1122
+ // ('ok'|'error'|'denied' span lifecycle status) below —
1123
+ // intentionally NOT a duplicate.
1124
+ status: o.status ?? 'success',
1125
+ errorMessage: o.errorMessage,
1126
+ }, reportStatus === 'error' ? 'error' : 'ok', computedDuration ?? null, spanId)));
1127
+ if (spanId) {
1128
+ safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
1129
+ status: reportStatus === 'error' ? 'error' : 'ok',
1130
+ durationMs: computedDuration,
1131
+ errorMessage: reportError,
1132
+ }));
1133
+ }
1134
+ }
502
1135
  }
503
- this.stepIndex += 1;
504
1136
  }
505
1137
  async dispatch(toolName, toolArgs) {
506
- if (!this.currentToken) {
507
- throw new Error(`dispatch("${toolName}") called before startPlan().`);
1138
+ const startNs = this.obs ? performance.now() : 0;
1139
+ const ctx = this.obs ? this.ensurePlanTrace() : null;
1140
+ const spanId = ctx && this.obs
1141
+ ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
1142
+ name: 'tool.dispatch',
1143
+ attributes: { toolName, toolInput: truncateForSpan(toolArgs) },
1144
+ })) ?? undefined
1145
+ : undefined;
1146
+ let caught;
1147
+ let dispatchResult;
1148
+ let dispatchStatus = 'ok';
1149
+ let dispatchError;
1150
+ try {
1151
+ if (!this.currentToken) {
1152
+ throw new Error(`dispatch("${toolName}") called before startPlan().`);
1153
+ }
1154
+ const { mcp, action } = this.toolNameParser(toolName);
1155
+ const resolvedMcp = this.mcpByAction.get(action) ?? mcp;
1156
+ dispatchResult = await this.client.invoke(resolvedMcp, action, this.currentToken, toolArgs);
1157
+ this.stepIndex += 1;
1158
+ return dispatchResult?.result;
1159
+ }
1160
+ catch (err) {
1161
+ caught = err;
1162
+ dispatchStatus = 'error';
1163
+ dispatchError = err.message;
1164
+ throw err;
1165
+ }
1166
+ finally {
1167
+ if (ctx) {
1168
+ const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
1169
+ safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'tool.dispatch', {
1170
+ kind: 'span',
1171
+ toolName,
1172
+ toolInput: truncateForSpan(toolArgs),
1173
+ result: truncateForSpan(dispatchResult),
1174
+ }, dispatchStatus === 'error' ? 'error' : 'ok', computedDuration ?? null, spanId)));
1175
+ if (spanId) {
1176
+ safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
1177
+ status: dispatchStatus === 'error' ? 'error' : 'ok',
1178
+ durationMs: computedDuration,
1179
+ errorMessage: dispatchError,
1180
+ }));
1181
+ }
1182
+ }
508
1183
  }
509
- const { mcp, action } = this.toolNameParser(toolName);
510
- const resolvedMcp = this.mcpByAction.get(action) ?? mcp;
511
- const result = await this.client.invoke(resolvedMcp, action, this.currentToken, toolArgs);
512
- this.stepIndex += 1;
513
- return result.result;
514
1184
  }
515
1185
  // ─── Helpers ────────────────────────────────────────────────────
516
1186
  async handleHold(toolName, toolArgs, holdDecision, userEmail) {
517
- const internals = this.client._sessionInternals();
518
- const email = userEmail ?? internals.userId ?? 'unknown@armoriq';
519
- const { mcp, action } = this.toolNameParser(toolName);
520
- const resolvedMcp = this.mcpByAction.get(action) ?? mcp;
521
- const rawAmount = ArmorIQSession.extractAmount(toolArgs) ?? 0;
522
- // Single normalized amount used for BOTH the approved-delegation lookup and
523
- // the create call — must agree, otherwise the SDK keeps creating new pending
524
- // rows because the existing approved one (created at safeAmount) won't match
525
- // a check sent at rawAmount. Backend doesn't filter by amount today (see
526
- // conmap-auto delegation.service.ts checkApprovedDelegation), but staying
527
- // consistent here is forward-compatible.
528
- const safeAmount = typeof rawAmount === 'number' && rawAmount >= 0.01 ? rawAmount : 0.01;
1187
+ const startNs = this.obs ? performance.now() : 0;
1188
+ const ctx = this.obs ? this.ensurePlanTrace() : null;
1189
+ const spanId = ctx && this.obs
1190
+ ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
1191
+ name: 'delegation.hold',
1192
+ attributes: {
1193
+ toolName,
1194
+ toolArgs: truncateForSpan(toolArgs),
1195
+ matchedPolicy: holdDecision.matchedPolicy ?? null,
1196
+ },
1197
+ })) ?? undefined
1198
+ : undefined;
1199
+ let caught;
1200
+ let result;
1201
+ let holdStatus = 'ok';
1202
+ let holdError;
529
1203
  try {
530
- const approved = await this.client.checkApprovedDelegation(email, action, safeAmount);
531
- if (approved) {
532
- try {
533
- if (approved.delegationId) {
534
- await this.client.markDelegationExecuted(email, approved.delegationId);
1204
+ const internals = this.client._sessionInternals();
1205
+ const email = userEmail ?? internals.userId ?? 'unknown@armoriq';
1206
+ const { mcp, action } = this.toolNameParser(toolName);
1207
+ const resolvedMcp = this.mcpByAction.get(action) ?? mcp;
1208
+ const rawAmount = ArmorIQSession.extractAmount(toolArgs) ?? 0;
1209
+ const safeAmount = typeof rawAmount === 'number' && rawAmount >= 0.01 ? rawAmount : 0.01;
1210
+ try {
1211
+ const approved = await this.client.checkApprovedDelegation(email, action, safeAmount);
1212
+ if (approved) {
1213
+ try {
1214
+ if (approved.delegationId) {
1215
+ await this.client.markDelegationExecuted(email, approved.delegationId);
1216
+ }
535
1217
  }
1218
+ catch {
1219
+ // best-effort
1220
+ }
1221
+ result = {
1222
+ allowed: true,
1223
+ action: 'allow',
1224
+ reason: `Allowed by approved delegation ${approved.delegationId ?? ''}`.trim(),
1225
+ delegationId: approved.delegationId,
1226
+ matchedPolicy: holdDecision.matchedPolicy,
1227
+ obligations: holdDecision.obligations ?? [],
1228
+ };
1229
+ return result;
536
1230
  }
537
- catch {
538
- // best-effort
539
- }
540
- return {
541
- allowed: true,
542
- action: 'allow',
543
- reason: `Allowed by approved delegation ${approved.delegationId ?? ''}`.trim(),
544
- delegationId: approved.delegationId,
545
- matchedPolicy: holdDecision.matchedPolicy,
546
- obligations: holdDecision.obligations ?? [],
547
- };
548
1231
  }
1232
+ catch {
1233
+ // best-effort
1234
+ }
1235
+ let delegationId;
1236
+ try {
1237
+ const r = await this.client.createDelegationRequest({
1238
+ tool: action,
1239
+ action,
1240
+ arguments: toolArgs,
1241
+ amount: safeAmount,
1242
+ requesterEmail: email,
1243
+ requesterRole: 'agent_user',
1244
+ requesterLimit: 0,
1245
+ domain: resolvedMcp,
1246
+ planId: this.currentToken?.planId,
1247
+ intentReference: this.currentToken?.tokenId,
1248
+ merkleRoot: (this.currentToken?.rawToken ?? {}).merkle_root,
1249
+ reason: holdDecision.reason,
1250
+ });
1251
+ delegationId = r.delegationId;
1252
+ }
1253
+ catch (e) {
1254
+ console.warn(`createDelegationRequest failed: ${e.message}`);
1255
+ }
1256
+ result = {
1257
+ allowed: false,
1258
+ action: 'hold',
1259
+ reason: holdDecision.reason ?? 'Pending approval',
1260
+ delegationId,
1261
+ matchedPolicy: holdDecision.matchedPolicy,
1262
+ obligations: holdDecision.obligations ?? [],
1263
+ };
1264
+ return result;
549
1265
  }
550
- catch {
551
- // best-effort
1266
+ catch (err) {
1267
+ caught = err;
1268
+ holdStatus = 'error';
1269
+ holdError = err.message;
1270
+ throw err;
552
1271
  }
553
- let delegationId;
554
- try {
555
- // The conmap-auto DTO requires amount/requesterRole/requesterLimit; for
556
- // non-financial holds (PHI write, prior-auth, etc.) the SDK supplies
557
- // healthcare-shaped defaults so the row gets created. The approver UI
558
- // can read the original tool args from `arguments`. requesterRole
559
- // matches the default used elsewhere in the SDK (resolveUserRole
560
- // fallback in client.ts).
561
- const result = await this.client.createDelegationRequest({
562
- tool: action,
563
- action,
564
- arguments: toolArgs,
565
- amount: safeAmount,
566
- requesterEmail: email,
567
- requesterRole: 'agent_user',
568
- requesterLimit: 0,
569
- domain: resolvedMcp,
570
- planId: this.currentToken?.planId,
571
- intentReference: this.currentToken?.tokenId,
572
- merkleRoot: (this.currentToken?.rawToken ?? {}).merkle_root,
573
- reason: holdDecision.reason,
574
- });
575
- delegationId = result.delegationId;
1272
+ finally {
1273
+ if (ctx) {
1274
+ const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
1275
+ safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'delegation.hold', {
1276
+ kind: 'span',
1277
+ delegationId: result?.delegationId ?? null,
1278
+ decision: result?.action ?? 'hold',
1279
+ errorMessage: holdError,
1280
+ }, holdStatus === 'error' ? 'error' : 'ok', computedDuration ?? null, spanId)));
1281
+ if (spanId) {
1282
+ safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
1283
+ status: holdStatus === 'error' ? 'error' : 'ok',
1284
+ durationMs: computedDuration,
1285
+ errorMessage: holdError,
1286
+ }));
1287
+ }
1288
+ }
576
1289
  }
577
- catch (e) {
578
- console.warn(`createDelegationRequest failed: ${e.message}`);
579
- }
580
- return {
581
- allowed: false,
582
- action: 'hold',
583
- reason: holdDecision.reason ?? 'Pending approval',
584
- delegationId,
585
- matchedPolicy: holdDecision.matchedPolicy,
586
- obligations: holdDecision.obligations ?? [],
587
- };
588
1290
  }
589
1291
  /**
590
1292
  * Poll a held delegation until it is decided; non-blocking (async).
@@ -594,38 +1296,86 @@ class ArmorIQSession {
594
1296
  * timeout/interval are in seconds (mirrors the Python SDK await_approval).
595
1297
  */
596
1298
  async awaitApproval(delegationId, opts = {}) {
597
- const timeout = opts.timeout ?? 300;
598
- const interval = opts.interval ?? 5;
599
- const transient = new Set([429, 502, 503, 504]);
600
- const email = opts.userEmail || this.client.userId || 'unknown@armoriq';
601
- const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
602
- let waited = 0;
603
- while (waited < timeout) {
604
- await sleep(interval);
605
- waited += interval;
606
- let status;
607
- try {
608
- status = await this.client.getDelegationStatus(delegationId);
609
- }
610
- catch (e) {
611
- if (e instanceof exceptions_1.DelegationException &&
612
- e.statusCode &&
613
- transient.has(e.statusCode)) {
614
- const backoff = Math.min(interval * 2, 15);
615
- await sleep(backoff);
616
- waited += backoff;
617
- continue;
1299
+ const startNs = this.obs ? performance.now() : 0;
1300
+ const ctx = this.obs ? this.ensurePlanTrace() : null;
1301
+ const spanId = ctx && this.obs
1302
+ ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
1303
+ name: 'delegation.poll',
1304
+ attributes: { delegationId, timeout: opts.timeout ?? 300, interval: opts.interval ?? 5 },
1305
+ })) ?? undefined
1306
+ : undefined;
1307
+ let caught;
1308
+ let pollOutcome = 'timeout';
1309
+ try {
1310
+ const timeout = opts.timeout ?? 300;
1311
+ const interval = opts.interval ?? 5;
1312
+ const transient = new Set([429, 502, 503, 504]);
1313
+ const email = opts.userEmail || this.client.userId || 'unknown@armoriq';
1314
+ const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
1315
+ let waited = 0;
1316
+ let pollCount = 0;
1317
+ while (waited < timeout) {
1318
+ await sleep(interval);
1319
+ waited += interval;
1320
+ pollCount += 1;
1321
+ let status;
1322
+ try {
1323
+ status = await this.client.getDelegationStatus(delegationId);
1324
+ }
1325
+ catch (e) {
1326
+ if (e instanceof exceptions_1.DelegationException &&
1327
+ e.statusCode &&
1328
+ transient.has(e.statusCode)) {
1329
+ const backoff = Math.min(interval * 2, 15);
1330
+ await sleep(backoff);
1331
+ waited += backoff;
1332
+ continue;
1333
+ }
1334
+ throw e;
1335
+ }
1336
+ if (ctx) {
1337
+ safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'delegation.poll.tick', {
1338
+ kind: 'event',
1339
+ message: `poll #${pollCount} status=${status}`,
1340
+ level: 'info',
1341
+ delegationId,
1342
+ }, spanId)));
1343
+ }
1344
+ if (status === 'approved') {
1345
+ await this.client.markDelegationExecuted(email, delegationId);
1346
+ pollOutcome = 'approved';
1347
+ return 'approved';
1348
+ }
1349
+ if (status === 'rejected') {
1350
+ pollOutcome = 'rejected';
1351
+ return 'rejected';
618
1352
  }
619
- throw e;
620
1353
  }
621
- if (status === 'approved') {
622
- await this.client.markDelegationExecuted(email, delegationId);
623
- return 'approved';
1354
+ pollOutcome = 'timeout';
1355
+ return 'timeout';
1356
+ }
1357
+ catch (err) {
1358
+ caught = err;
1359
+ throw err;
1360
+ }
1361
+ finally {
1362
+ if (ctx) {
1363
+ const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
1364
+ safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'delegation.poll.terminal', {
1365
+ kind: 'span',
1366
+ delegationId,
1367
+ decision: pollOutcome,
1368
+ errorMessage: caught instanceof Error ? caught.message : undefined,
1369
+ }, caught ? 'error' : 'ok', computedDuration ?? null, spanId)));
1370
+ if (spanId) {
1371
+ safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
1372
+ status: caught ? 'error' : 'ok',
1373
+ durationMs: computedDuration,
1374
+ errorMessage: caught instanceof Error ? caught.message : undefined,
1375
+ }));
1376
+ }
624
1377
  }
625
- if (status === 'rejected')
626
- return 'rejected';
627
1378
  }
628
- return 'timeout';
629
1379
  }
630
1380
  static extractAmount(args) {
631
1381
  if (!args || typeof args !== 'object')
@@ -689,31 +1439,63 @@ class ArmorIQSession {
689
1439
  * reanchor is off
690
1440
  */
691
1441
  async flushReanchor() {
692
- if (!this.pendingReanchorPlan) {
693
- return { fired: false };
694
- }
695
- if (!this.currentToken) {
696
- this.pendingReanchorPlan = undefined;
697
- return { fired: false };
698
- }
699
- if (!this.trueReanchor && !this.autoReanchor) {
700
- this.pendingReanchorPlan = undefined;
701
- return { fired: false };
702
- }
703
- const planToFlush = this.pendingReanchorPlan;
704
- // Optimistically clear so a retry in the caller doesn't double-fire.
705
- this.pendingReanchorPlan = undefined;
1442
+ const startNs = this.obs ? performance.now() : 0;
1443
+ const ctx = this.obs ? this.ensurePlanTrace() : null;
1444
+ const spanId = ctx && this.obs
1445
+ ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
1446
+ name: 'iap.reanchor.flush',
1447
+ attributes: {
1448
+ hasPendingPlan: !!this.pendingReanchorPlan,
1449
+ hasToken: !!this.currentToken,
1450
+ },
1451
+ })) ?? undefined
1452
+ : undefined;
1453
+ let caught;
1454
+ let fired = false;
1455
+ let trustId;
706
1456
  try {
707
- const result = await this.client.reanchor(this.currentToken, planToFlush, 'flush at request boundary');
708
- return { fired: true, trustId: result?.trustId };
1457
+ if (!this.pendingReanchorPlan) {
1458
+ return { fired: false };
1459
+ }
1460
+ if (!this.currentToken) {
1461
+ this.pendingReanchorPlan = undefined;
1462
+ return { fired: false };
1463
+ }
1464
+ if (!this.trueReanchor && !this.autoReanchor) {
1465
+ this.pendingReanchorPlan = undefined;
1466
+ return { fired: false };
1467
+ }
1468
+ const planToFlush = this.pendingReanchorPlan;
1469
+ this.pendingReanchorPlan = undefined;
1470
+ const result = await this.reanchorCall(this.currentToken, planToFlush, 'flush at request boundary');
1471
+ fired = true;
1472
+ trustId = result.trustId;
1473
+ return { fired: true, trustId };
709
1474
  }
710
1475
  catch (err) {
711
- // Best-effort: log and stay silent so a flaky IAP doesn't gate
712
- // session lifecycle.
1476
+ caught = err;
713
1477
  const msg = err instanceof Error ? err.message : String(err);
714
1478
  console.warn(`[armoriq] flushReanchor failed: ${msg}`);
715
1479
  return { fired: false };
716
1480
  }
1481
+ finally {
1482
+ if (ctx) {
1483
+ const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
1484
+ safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'iap.reanchor.flush.result', {
1485
+ kind: 'span',
1486
+ fired,
1487
+ trustId: trustId ?? null,
1488
+ errorMessage: caught instanceof Error ? caught.message : undefined,
1489
+ }, caught ? 'error' : 'ok', null, spanId)));
1490
+ if (spanId) {
1491
+ safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
1492
+ status: caught ? 'error' : 'ok',
1493
+ durationMs: computedDuration,
1494
+ errorMessage: caught instanceof Error ? caught.message : undefined,
1495
+ }));
1496
+ }
1497
+ }
1498
+ }
717
1499
  }
718
1500
  get currentTokenValue() {
719
1501
  return this.currentToken;
@@ -721,6 +1503,64 @@ class ArmorIQSession {
721
1503
  get currentMode() {
722
1504
  return this.mode;
723
1505
  }
1506
+ /**
1507
+ * Force-flush all buffered observability traces/spans to the backend ingest
1508
+ * endpoint. No-op when observability is disabled (`observability.enabled: false`).
1509
+ *
1510
+ * Call this at the request boundary (e.g. Stop hook in armorClaude, end of
1511
+ * turn in ADK consumers) to guarantee all spans for the current session have
1512
+ * been POSTed before the process exits. The underlying shipper retries with
1513
+ * exponential backoff and **never throws** into the SDK consumer — a flush
1514
+ * failure is logged and the spans are dropped.
1515
+ */
1516
+ async flushObservability() {
1517
+ if (!this.obs)
1518
+ return;
1519
+ // Model A: end the in-flight active plan trace FIRST so it's handed to
1520
+ // the shipper's queue before we flush — otherwise an in-progress plan
1521
+ // trace (no endTime yet) would be left behind in the ring buffer and
1522
+ // never ship (see ObservabilityRecorder.flush()'s in-flight retention).
1523
+ safeObs(() => this.endPlanTrace('ok'));
1524
+ await (0, observability_1.flushObservability)(this.obs);
1525
+ }
1526
+ /**
1527
+ * Stop the session's observability lifecycle: clears the shipper's
1528
+ * repeating flush interval (`setInterval`, see `ObservabilityShipper`) and
1529
+ * performs one final drain/flush of anything still queued. Safe to call
1530
+ * multiple times; a no-op when observability is disabled.
1531
+ *
1532
+ * **Required at session end.** Without calling this, the recorder's
1533
+ * shipper keeps its interval timer alive indefinitely — in long-running
1534
+ * processes this is a resource leak, and in tests it is why processes
1535
+ * (and jest) can hang or emit "cannot log after tests done" / stray
1536
+ * network activity after teardown. `flushObservability()` alone does NOT
1537
+ * stop the interval — it only flushes; call `close()` (or `dispose()`) at
1538
+ * the natural end of the session's lifetime (e.g. Stop hook in armorClaude,
1539
+ * end of process, or end of a request/turn in ADK consumers) instead of
1540
+ * (or in addition to) `flushObservability()`.
1541
+ *
1542
+ * NEVER throws — observability teardown failures must not propagate into
1543
+ * the SDK consumer, matching every other observability code path.
1544
+ */
1545
+ async close() {
1546
+ if (!this.obs)
1547
+ return;
1548
+ // Model A: end the active plan trace before stopping the shipper so its
1549
+ // final flush (inside obs.stop()) ships the completed plan trace rather
1550
+ // than leaving it stranded in-flight.
1551
+ safeObs(() => this.endPlanTrace('ok'));
1552
+ try {
1553
+ await this.obs.stop();
1554
+ }
1555
+ catch (err) {
1556
+ const msg = err instanceof Error ? err.message : String(err);
1557
+ console.warn(`[armoriq] observability close() failed (continuing): ${msg}`);
1558
+ }
1559
+ }
1560
+ /** Alias for `close()`. Use whichever reads better at the call site. */
1561
+ async dispose() {
1562
+ await this.close();
1563
+ }
724
1564
  }
725
1565
  exports.ArmorIQSession = ArmorIQSession;
726
1566
  //# sourceMappingURL=session.js.map