@armoriq/sdk-dev 0.6.1 → 0.6.3

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 (69) hide show
  1. package/dist/_build_env.d.ts.map +1 -1
  2. package/dist/_build_env.js +1 -1
  3. package/dist/_build_env.js.map +1 -1
  4. package/dist/cli/commands/auth.d.ts.map +1 -1
  5. package/dist/cli/commands/auth.js +4 -1
  6. package/dist/cli/commands/auth.js.map +1 -1
  7. package/dist/client.d.ts +35 -0
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +77 -1
  10. package/dist/client.js.map +1 -1
  11. package/dist/config.d.ts +17 -0
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +19 -1
  14. package/dist/config.js.map +1 -1
  15. package/dist/crypto_verify.d.ts +3 -0
  16. package/dist/crypto_verify.d.ts.map +1 -1
  17. package/dist/crypto_verify.js +77 -0
  18. package/dist/crypto_verify.js.map +1 -1
  19. package/dist/index.d.ts +4 -3
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +19 -3
  22. package/dist/index.js.map +1 -1
  23. package/dist/integrations/microsoft_copilot.d.ts +84 -0
  24. package/dist/integrations/microsoft_copilot.d.ts.map +1 -0
  25. package/dist/integrations/microsoft_copilot.js +126 -0
  26. package/dist/integrations/microsoft_copilot.js.map +1 -0
  27. package/dist/models.d.ts +17 -0
  28. package/dist/models.d.ts.map +1 -1
  29. package/dist/observability/handle.d.ts +41 -0
  30. package/dist/observability/handle.d.ts.map +1 -0
  31. package/dist/observability/handle.js +54 -0
  32. package/dist/observability/handle.js.map +1 -0
  33. package/dist/observability/index.d.ts +15 -0
  34. package/dist/observability/index.d.ts.map +1 -0
  35. package/dist/observability/index.js +39 -0
  36. package/dist/observability/index.js.map +1 -0
  37. package/dist/observability/model-prices.d.ts +39 -0
  38. package/dist/observability/model-prices.d.ts.map +1 -0
  39. package/dist/observability/model-prices.js +127 -0
  40. package/dist/observability/model-prices.js.map +1 -0
  41. package/dist/observability/recorder.d.ts +245 -0
  42. package/dist/observability/recorder.d.ts.map +1 -0
  43. package/dist/observability/recorder.js +475 -0
  44. package/dist/observability/recorder.js.map +1 -0
  45. package/dist/observability/schema.d.ts +163 -0
  46. package/dist/observability/schema.d.ts.map +1 -0
  47. package/dist/observability/schema.js +291 -0
  48. package/dist/observability/schema.js.map +1 -0
  49. package/dist/observability/shipper.d.ts +84 -0
  50. package/dist/observability/shipper.d.ts.map +1 -0
  51. package/dist/observability/shipper.js +206 -0
  52. package/dist/observability/shipper.js.map +1 -0
  53. package/dist/observability/trace-summary.d.ts +34 -0
  54. package/dist/observability/trace-summary.d.ts.map +1 -0
  55. package/dist/observability/trace-summary.js +108 -0
  56. package/dist/observability/trace-summary.js.map +1 -0
  57. package/dist/session.d.ts +168 -3
  58. package/dist/session.d.ts.map +1 -1
  59. package/dist/session.js +1093 -204
  60. package/dist/session.js.map +1 -1
  61. package/dist/token_usage.d.ts +18 -1
  62. package/dist/token_usage.d.ts.map +1 -1
  63. package/dist/token_usage.js +112 -2
  64. package/dist/token_usage.js.map +1 -1
  65. package/package.json +3 -2
  66. package/dist/integrations/google_adk_mcp_toolset.d.ts +0 -64
  67. package/dist/integrations/google_adk_mcp_toolset.d.ts.map +0 -1
  68. package/dist/integrations/google_adk_mcp_toolset.js +0 -91
  69. package/dist/integrations/google_adk_mcp_toolset.js.map +0 -1
package/dist/session.js CHANGED
@@ -16,10 +16,92 @@
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.ArmorIQSession = void 0;
19
+ exports.normalizeDefaultAction = normalizeDefaultAction;
20
+ const crypto_1 = require("crypto");
21
+ const config_1 = require("./config");
19
22
  const crypto_verify_1 = require("./crypto_verify");
20
23
  const exceptions_1 = require("./exceptions");
21
24
  const models_1 = require("./models");
22
25
  const plan_builder_1 = require("./plan_builder");
26
+ const observability_1 = require("./observability");
27
+ /**
28
+ * Normalize a policy-authoring default-enforcement-action value into the
29
+ * SDK-facing terminal enum. Mirrors the backend's enforce-boundary normalization
30
+ * (wire contract §"Canonical enforcement-action enum"):
31
+ * allow | allow_log → allow ; hold → hold ; block | deny → block.
32
+ * Anything unknown fails closed to `block`.
33
+ */
34
+ function normalizeDefaultAction(value) {
35
+ switch (value) {
36
+ case 'allow':
37
+ case 'allow_log':
38
+ return 'allow';
39
+ case 'hold':
40
+ return 'hold';
41
+ case 'block':
42
+ case 'deny':
43
+ return 'block';
44
+ default:
45
+ return 'block';
46
+ }
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
+ }
23
105
  class ArmorIQSession {
24
106
  userEmail;
25
107
  client;
@@ -42,6 +124,34 @@ class ArmorIQSession {
42
124
  currentToken;
43
125
  mcpByAction = new Map();
44
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;
45
155
  constructor(client, opts) {
46
156
  this.client = client;
47
157
  const o = opts ?? {};
@@ -54,12 +164,298 @@ class ArmorIQSession {
54
164
  const granEnv = (typeof process !== 'undefined' && process.env?.ARMORIQ_REANCHOR_GRANULARITY) || '';
55
165
  this.reanchorGranularity =
56
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 }));
57
289
  }
58
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
+ }
59
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) {
60
448
  if (!toolCalls || toolCalls.length === 0) {
61
449
  throw new Error('startPlan called with no tool calls.');
62
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
+ }
63
459
  const h = (0, plan_builder_1.hashToolCalls)(toolCalls);
64
460
  if (this.currentToken && this.currentPlanHash === h) {
65
461
  return this.currentToken;
@@ -99,7 +495,7 @@ class ArmorIQSession {
99
495
  // For autoReanchor (pragmatic v2) in deferred mode: still need to
100
496
  // re-mint because step_proofs are baked at issuance. Skip the
101
497
  // delta call here; flushReanchor() catches up later.
102
- const tokenA = await this.client.getIntentToken(planCapture, undefined, this.validitySeconds);
498
+ const tokenA = await this.getIntentTokenCall(planCapture, this.validitySeconds);
103
499
  this.currentPlanHash = h;
104
500
  this.currentToken = tokenA;
105
501
  this.stepIndex = 0;
@@ -112,7 +508,7 @@ class ArmorIQSession {
112
508
  // preserved across the chain (paper's actual C3).
113
509
  if (this.trueReanchor && this.currentToken) {
114
510
  try {
115
- await this.client.reanchor(this.currentToken, plan, 'plan grew (true-reanchor)');
511
+ await this.reanchorCall(this.currentToken, plan, 'plan grew (true-reanchor)');
116
512
  // Update local state without re-minting. The JWT stays the same;
117
513
  // only currentPlanHash advances so subsequent enforceLocal /
118
514
  // dispatch calls see the new plan.
@@ -134,47 +530,97 @@ class ArmorIQSession {
134
530
  // pins to the JWT's plan_hash and step_proofs are baked at issuance.
135
531
  if (this.autoReanchor && this.currentToken) {
136
532
  try {
137
- await this.client.reanchor(this.currentToken, plan, 'plan grew mid-session');
533
+ await this.reanchorCall(this.currentToken, plan, 'plan grew mid-session');
138
534
  }
139
535
  catch (err) {
140
536
  const msg = err instanceof Error ? err.message : String(err);
141
537
  console.warn(`[armoriq] auto-reanchor delta failed (continuing): ${msg}`);
142
538
  }
143
539
  }
144
- const token = await this.client.getIntentToken(planCapture, undefined, this.validitySeconds);
540
+ const token = await this.getIntentTokenCall(planCapture, this.validitySeconds);
145
541
  this.currentPlanHash = h;
146
542
  this.currentToken = token;
147
543
  this.stepIndex = 0;
148
544
  return token;
149
545
  }
150
546
  // ─── Policy enforcement ────────────────────────────────────────
151
- 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) {
152
586
  if (!this.currentToken) {
153
- return {
587
+ const r = {
154
588
  allowed: false,
155
589
  action: 'block',
156
590
  reason: 'No intent token — call startPlan() first',
157
591
  };
592
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', {}, spanId);
593
+ return r;
158
594
  }
159
595
  if (models_1.IntentToken.isExpired(this.currentToken)) {
160
- 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;
161
599
  }
162
600
  // The local decision is read from the token's policy fields, so the token
163
601
  // must be cryptographically authentic first - otherwise a forged token
164
- // could assert an arbitrary allowlist. Fail closed if the signature is bad.
165
- if (!(0, crypto_verify_1.verifyIntentTokenSignature)(this.currentToken.rawToken)) {
166
- 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;
167
611
  }
168
612
  const { mcp, action } = this.toolNameParser(toolName);
169
613
  const inPlan = this.declaredTools.has(toolName) ||
170
614
  this.declaredTools.has(action) ||
171
615
  this.declaredTools.has(`${mcp}__${action}`);
172
616
  if (!inPlan) {
173
- return {
617
+ const r = {
174
618
  allowed: false,
175
619
  action: 'block',
176
620
  reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
177
621
  };
622
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp }, spanId);
623
+ return r;
178
624
  }
179
625
  const pv = (this.currentToken.policyValidation ?? {});
180
626
  const snapshot = (this.currentToken.policySnapshot ?? []);
@@ -209,7 +655,10 @@ class ArmorIQSession {
209
655
  }
210
656
  const governingRule = governingEntry ? ruleOf(governingEntry) : undefined;
211
657
  const governingPolicyName = governingEntry?.policyName;
212
- const notAllowedAction = governingEntry?.defaultEnforcementAction ?? pv.default_enforcement_action ?? 'block';
658
+ // Normalize the policy default action into the terminal allow|block|hold set.
659
+ // Previously any non-'hold' value was coerced to 'block', which wrongly
660
+ // blocked 'allow' and 'allow_log' defaults.
661
+ const defaultAction = normalizeDefaultAction(governingEntry?.defaultEnforcementAction ?? pv.default_enforcement_action);
213
662
  const deniedTools = pv.denied_tools;
214
663
  if (Array.isArray(deniedTools)) {
215
664
  if (deniedTools.includes(toolName) || deniedTools.includes(action)) {
@@ -219,14 +668,31 @@ class ArmorIQSession {
219
668
  (governingPolicyName
220
669
  ? `Tool '${action}' is denied by policy '${governingPolicyName}'`
221
670
  : `Tool '${action}' is denied by policy`);
222
- return {
671
+ // Deny-precedence: an explicitly denied tool is never allowed, even when
672
+ // the default action is 'allow'. hold only if the policy default is hold.
673
+ const r = {
223
674
  allowed: false,
224
- action: notAllowedAction === 'hold' ? 'hold' : 'block',
675
+ action: defaultAction === 'hold' ? 'hold' : 'block',
225
676
  reason,
226
677
  matchedPolicy: governingPolicyName,
227
678
  };
679
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
680
+ return r;
228
681
  }
229
682
  }
683
+ // Honor the backend-baked resolved allow set: an explicit *empty*
684
+ // allowed_tools means the same-priority intersection removed every tool
685
+ // ⇒ deny-all, regardless of what any individual snapshot rule allows.
686
+ const bakedAllowedTools = pv.allowed_tools;
687
+ if (Array.isArray(bakedAllowedTools) && bakedAllowedTools.length === 0) {
688
+ const r = {
689
+ allowed: false,
690
+ action: 'block',
691
+ reason: `Tool '${action}' is not allowed by any policy in scope (empty resolved allow-list)`,
692
+ };
693
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
694
+ return r;
695
+ }
230
696
  if (governingRule) {
231
697
  const allowed = governingRule.allowedTools ?? [];
232
698
  if (Array.isArray(allowed) && allowed.length > 0) {
@@ -234,40 +700,109 @@ class ArmorIQSession {
234
700
  allowed.includes(action) ||
235
701
  allowed.includes(toolName);
236
702
  if (!ok) {
237
- return {
703
+ // Tool is outside this rule's explicit allow set. Fall back to the
704
+ // policy default action: an 'allow' default permits it, hold/block
705
+ // otherwise.
706
+ if (defaultAction === 'allow') {
707
+ const r = {
708
+ allowed: true,
709
+ action: 'allow',
710
+ reason: `Allowed by default enforcement action for policy '${governingPolicyName}'`,
711
+ matchedPolicy: governingPolicyName,
712
+ };
713
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
714
+ return r;
715
+ }
716
+ const r = {
238
717
  allowed: false,
239
- action: notAllowedAction === 'hold' ? 'hold' : 'block',
718
+ action: defaultAction,
240
719
  reason: `Tool '${action}' is not in the allowed tools for policy '${governingPolicyName}'`,
241
720
  matchedPolicy: governingPolicyName,
242
721
  };
722
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
723
+ return r;
243
724
  }
244
725
  }
245
726
  }
246
- else if (Array.isArray(snapshot) && snapshot.length > 0) {
247
- const allowedTools = pv.allowed_tools;
248
- if (Array.isArray(allowedTools) && allowedTools.length === 0) {
249
- return {
250
- allowed: false,
251
- action: 'block',
252
- reason: `Tool '${action}' is not allowed by any policy in scope`,
253
- };
254
- }
255
- }
256
727
  if (governingRule) {
257
728
  const thresholdDecision = this.evaluateAmountThreshold(governingRule, toolArgs, action, mcp);
258
729
  if (thresholdDecision) {
259
730
  thresholdDecision.matchedPolicy = governingPolicyName;
731
+ this._emitEnforcePolicyCall(ctx, toolName, thresholdDecision, 'sdk-local', { mcp, policyValidation: pv }, spanId);
260
732
  return thresholdDecision;
261
733
  }
262
734
  }
263
- return {
735
+ const r = {
264
736
  allowed: true,
265
737
  action: 'allow',
266
738
  reason: 'Allowed by local policy evaluation',
267
739
  matchedPolicy: governingPolicyName,
268
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
+ }
269
804
  }
270
- async enforceSdk(toolName, toolArgs, userEmail) {
805
+ async _enforceSdkImpl(toolName, toolArgs, userEmail, ctx, spanId) {
271
806
  if (!this.currentToken) {
272
807
  throw new Error(`enforceSdk("${toolName}") called before startPlan().`);
273
808
  }
@@ -277,11 +812,13 @@ class ArmorIQSession {
277
812
  this.declaredTools.has(action) ||
278
813
  this.declaredTools.has(`${resolvedMcp}__${action}`);
279
814
  if (!inPlan) {
280
- return {
815
+ const r = {
281
816
  allowed: false,
282
817
  action: 'block',
283
818
  reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
284
819
  };
820
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp }, spanId);
821
+ return r;
285
822
  }
286
823
  const internals = this.client._sessionInternals();
287
824
  try {
@@ -302,31 +839,74 @@ class ArmorIQSession {
302
839
  const matched = typeof data.matchedPolicy === 'object'
303
840
  ? data.matchedPolicy?.name
304
841
  : data.matchedPolicy;
842
+ // Surface gating obligations metadata (additive). The backend has already
843
+ // collapsed gating into allowed/action; obligations explain *why*.
844
+ const obligations = Array.isArray(data.obligations)
845
+ ? data.obligations
846
+ : [];
305
847
  if (actionDecision === 'hold') {
306
- return this.handleHold(toolName, toolArgs, {
848
+ const r = {
307
849
  allowed: false,
308
850
  action: 'hold',
309
851
  reason: data.reason ?? data.message,
310
852
  matchedPolicy: matched,
311
- }, userEmail);
853
+ obligations,
854
+ };
855
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp, obligations }, spanId);
856
+ return this.handleHold(toolName, toolArgs, r, userEmail);
312
857
  }
313
- return {
858
+ const r = {
314
859
  allowed,
315
860
  action: actionDecision,
316
861
  reason: data.reason ?? data.message,
317
862
  matchedPolicy: matched,
863
+ obligations,
318
864
  };
865
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp, obligations }, spanId);
866
+ return r;
319
867
  }
320
868
  catch (e) {
321
869
  console.error(`enforceSdk() failed: ${e.message}. Blocking tool call (fail-closed).`);
322
- return {
870
+ const r = {
323
871
  allowed: false,
324
872
  action: 'block',
325
873
  reason: `enforce-unavailable: ${e.message}`,
326
874
  };
875
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp }, spanId);
876
+ return r;
327
877
  }
328
878
  }
329
- async enforce(toolName, toolArgs) {
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
+ }
907
+ }
908
+ }
909
+ async _enforceImpl(toolName, toolArgs, ctx, spanId) {
330
910
  if (!this.currentToken) {
331
911
  throw new Error(`enforce("${toolName}") called before startPlan().`);
332
912
  }
@@ -336,11 +916,13 @@ class ArmorIQSession {
336
916
  this.declaredTools.has(action) ||
337
917
  this.declaredTools.has(`${resolvedMcp}__${action}`);
338
918
  if (!inPlan) {
339
- return {
919
+ const r = {
340
920
  allowed: false,
341
921
  action: 'block',
342
922
  reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
343
923
  };
924
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
925
+ return r;
344
926
  }
345
927
  const internals = this.client._sessionInternals();
346
928
  try {
@@ -367,175 +949,344 @@ class ArmorIQSession {
367
949
  // Fail closed: only an explicit allow from a 2xx response permits the call.
368
950
  // Any error status (403 or otherwise) or an ambiguous body blocks.
369
951
  if (response.status >= 400) {
370
- return {
952
+ const r = {
371
953
  allowed: false,
372
954
  action: data.action ?? 'block',
373
955
  reason: data.reason ?? data.message ?? `enforce-rejected: HTTP ${response.status}`,
374
956
  matchedPolicy: policyName,
375
957
  };
958
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
959
+ return r;
376
960
  }
377
961
  const allowedFlag = data.allowed === true;
378
962
  const actionDecision = data.enforcementAction ?? data.action ?? (allowedFlag ? 'allow' : 'block');
379
- return {
963
+ const r = {
380
964
  allowed: allowedFlag,
381
965
  action: actionDecision,
382
966
  reason: data.reason,
383
967
  delegationId: data.delegation_id,
384
968
  matchedPolicy: policyName,
969
+ obligations: Array.isArray(data.obligations) ? data.obligations : [],
385
970
  };
971
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
972
+ return r;
386
973
  }
387
974
  catch (e) {
388
975
  console.error(`enforce() failed: ${e.message}. Blocking tool call (fail-closed).`);
389
- return {
976
+ const r = {
390
977
  allowed: false,
391
978
  action: 'block',
392
979
  reason: `enforce-unavailable: ${e.message}`,
393
980
  };
981
+ this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
982
+ return r;
394
983
  }
395
984
  }
396
985
  async check(toolName, toolArgs, userEmail) {
397
- if (this.mode === 'sdk') {
398
- 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);
399
1027
  }
400
- if (this.mode === 'local') {
401
- const decision = this.enforceLocal(toolName, toolArgs);
402
- if (decision.action === 'hold') {
403
- return {
404
- ...decision,
405
- action: 'block',
406
- reason: (decision.reason ?? 'requires approval') +
407
- ' — switch ARMORIQ_MODE=proxy to enable approval workflows for this action.',
408
- };
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
+ }));
409
1040
  }
410
- return decision;
411
1041
  }
412
- const decision = await this.enforce(toolName, toolArgs);
413
- if (decision.action !== 'hold')
414
- return decision;
415
- return this.handleHold(toolName, toolArgs, decision, userEmail);
416
1042
  }
417
1043
  // ─── Report / dispatch ─────────────────────────────────────────
418
1044
  async report(toolName, toolArgs, result, opts) {
419
- const o = opts ?? {};
420
- const { mcp, action } = this.toolNameParser(toolName);
421
- const resolvedMcp = this.mcpByAction.get(action) ?? mcp;
422
- 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;
423
1066
  try {
424
- const token = this.currentToken;
425
- const userEmail = this.userEmail ?? this.client.userEmailOverride;
426
- let output = result;
427
- if (typeof result === 'string')
428
- output = { text: result };
429
- else if (result === null || result === undefined)
430
- output = {};
431
- await internals.httpClient.post(`${internals.backendEndpoint}/iap/audit`, {
432
- token: token?.jwtToken ?? token?.tokenId ?? 'unknown',
433
- plan_id: token?.planId ?? token?.tokenId ?? 'unknown',
434
- step_index: this.stepIndex,
435
- action,
436
- tool: action,
437
- mcp: resolvedMcp,
438
- input: toolArgs,
439
- output,
440
- status: o.status ?? 'success',
441
- error_message: o.errorMessage,
442
- duration_ms: o.durationMs,
443
- is_delegated: o.isDelegated,
444
- delegated_by: o.delegatedBy,
445
- user_email: userEmail,
446
- delegated_to: o.delegatedTo,
447
- executed_at: new Date().toISOString(),
448
- }, {
449
- headers: { 'X-API-Key': internals.apiKey, 'Content-Type': 'application/json' },
450
- timeout: 5000,
451
- });
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;
452
1104
  }
453
- catch (e) {
454
- 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
+ }
455
1135
  }
456
- this.stepIndex += 1;
457
1136
  }
458
1137
  async dispatch(toolName, toolArgs) {
459
- if (!this.currentToken) {
460
- 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
+ }
461
1183
  }
462
- const { mcp, action } = this.toolNameParser(toolName);
463
- const resolvedMcp = this.mcpByAction.get(action) ?? mcp;
464
- const result = await this.client.invoke(resolvedMcp, action, this.currentToken, toolArgs);
465
- this.stepIndex += 1;
466
- return result.result;
467
1184
  }
468
1185
  // ─── Helpers ────────────────────────────────────────────────────
469
1186
  async handleHold(toolName, toolArgs, holdDecision, userEmail) {
470
- const internals = this.client._sessionInternals();
471
- const email = userEmail ?? internals.userId ?? 'unknown@armoriq';
472
- const { mcp, action } = this.toolNameParser(toolName);
473
- const resolvedMcp = this.mcpByAction.get(action) ?? mcp;
474
- const rawAmount = ArmorIQSession.extractAmount(toolArgs) ?? 0;
475
- // Single normalized amount used for BOTH the approved-delegation lookup and
476
- // the create call — must agree, otherwise the SDK keeps creating new pending
477
- // rows because the existing approved one (created at safeAmount) won't match
478
- // a check sent at rawAmount. Backend doesn't filter by amount today (see
479
- // conmap-auto delegation.service.ts checkApprovedDelegation), but staying
480
- // consistent here is forward-compatible.
481
- 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;
482
1203
  try {
483
- const approved = await this.client.checkApprovedDelegation(email, action, safeAmount);
484
- if (approved) {
485
- try {
486
- if (approved.delegationId) {
487
- 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
+ }
488
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;
489
1230
  }
490
- catch {
491
- // best-effort
492
- }
493
- return {
494
- allowed: true,
495
- action: 'allow',
496
- reason: `Allowed by approved delegation ${approved.delegationId ?? ''}`.trim(),
497
- delegationId: approved.delegationId,
498
- matchedPolicy: holdDecision.matchedPolicy,
499
- };
500
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;
501
1265
  }
502
- catch {
503
- // best-effort
1266
+ catch (err) {
1267
+ caught = err;
1268
+ holdStatus = 'error';
1269
+ holdError = err.message;
1270
+ throw err;
504
1271
  }
505
- let delegationId;
506
- try {
507
- // The conmap-auto DTO requires amount/requesterRole/requesterLimit; for
508
- // non-financial holds (PHI write, prior-auth, etc.) the SDK supplies
509
- // healthcare-shaped defaults so the row gets created. The approver UI
510
- // can read the original tool args from `arguments`. requesterRole
511
- // matches the default used elsewhere in the SDK (resolveUserRole
512
- // fallback in client.ts).
513
- const result = await this.client.createDelegationRequest({
514
- tool: action,
515
- action,
516
- arguments: toolArgs,
517
- amount: safeAmount,
518
- requesterEmail: email,
519
- requesterRole: 'agent_user',
520
- requesterLimit: 0,
521
- domain: resolvedMcp,
522
- planId: this.currentToken?.planId,
523
- intentReference: this.currentToken?.tokenId,
524
- merkleRoot: (this.currentToken?.rawToken ?? {}).merkle_root,
525
- reason: holdDecision.reason,
526
- });
527
- 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
+ }
528
1289
  }
529
- catch (e) {
530
- console.warn(`createDelegationRequest failed: ${e.message}`);
531
- }
532
- return {
533
- allowed: false,
534
- action: 'hold',
535
- reason: holdDecision.reason ?? 'Pending approval',
536
- delegationId,
537
- matchedPolicy: holdDecision.matchedPolicy,
538
- };
539
1290
  }
540
1291
  /**
541
1292
  * Poll a held delegation until it is decided; non-blocking (async).
@@ -545,38 +1296,86 @@ class ArmorIQSession {
545
1296
  * timeout/interval are in seconds (mirrors the Python SDK await_approval).
546
1297
  */
547
1298
  async awaitApproval(delegationId, opts = {}) {
548
- const timeout = opts.timeout ?? 300;
549
- const interval = opts.interval ?? 5;
550
- const transient = new Set([429, 502, 503, 504]);
551
- const email = opts.userEmail || this.client.userId || 'unknown@armoriq';
552
- const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
553
- let waited = 0;
554
- while (waited < timeout) {
555
- await sleep(interval);
556
- waited += interval;
557
- let status;
558
- try {
559
- status = await this.client.getDelegationStatus(delegationId);
560
- }
561
- catch (e) {
562
- if (e instanceof exceptions_1.DelegationException &&
563
- e.statusCode &&
564
- transient.has(e.statusCode)) {
565
- const backoff = Math.min(interval * 2, 15);
566
- await sleep(backoff);
567
- waited += backoff;
568
- 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';
569
1352
  }
570
- throw e;
571
1353
  }
572
- if (status === 'approved') {
573
- await this.client.markDelegationExecuted(email, delegationId);
574
- 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
+ }
575
1377
  }
576
- if (status === 'rejected')
577
- return 'rejected';
578
1378
  }
579
- return 'timeout';
580
1379
  }
581
1380
  static extractAmount(args) {
582
1381
  if (!args || typeof args !== 'object')
@@ -640,31 +1439,63 @@ class ArmorIQSession {
640
1439
  * reanchor is off
641
1440
  */
642
1441
  async flushReanchor() {
643
- if (!this.pendingReanchorPlan) {
644
- return { fired: false };
645
- }
646
- if (!this.currentToken) {
647
- this.pendingReanchorPlan = undefined;
648
- return { fired: false };
649
- }
650
- if (!this.trueReanchor && !this.autoReanchor) {
651
- this.pendingReanchorPlan = undefined;
652
- return { fired: false };
653
- }
654
- const planToFlush = this.pendingReanchorPlan;
655
- // Optimistically clear so a retry in the caller doesn't double-fire.
656
- 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;
657
1456
  try {
658
- const result = await this.client.reanchor(this.currentToken, planToFlush, 'flush at request boundary');
659
- 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 };
660
1474
  }
661
1475
  catch (err) {
662
- // Best-effort: log and stay silent so a flaky IAP doesn't gate
663
- // session lifecycle.
1476
+ caught = err;
664
1477
  const msg = err instanceof Error ? err.message : String(err);
665
1478
  console.warn(`[armoriq] flushReanchor failed: ${msg}`);
666
1479
  return { fired: false };
667
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
+ }
668
1499
  }
669
1500
  get currentTokenValue() {
670
1501
  return this.currentToken;
@@ -672,6 +1503,64 @@ class ArmorIQSession {
672
1503
  get currentMode() {
673
1504
  return this.mode;
674
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
+ }
675
1564
  }
676
1565
  exports.ArmorIQSession = ArmorIQSession;
677
1566
  //# sourceMappingURL=session.js.map