@armoriq/sdk-dev 0.6.8 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +168 -1
  2. package/dist/_version.d.ts +1 -1
  3. package/dist/_version.js +1 -1
  4. package/dist/cli/commands/auth.d.ts +12 -0
  5. package/dist/cli/commands/auth.d.ts.map +1 -1
  6. package/dist/cli/commands/auth.js +460 -42
  7. package/dist/cli/commands/auth.js.map +1 -1
  8. package/dist/cli/index.js +0 -0
  9. package/dist/client.d.ts +8 -15
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +20 -18
  12. package/dist/client.js.map +1 -1
  13. package/dist/config.d.ts +0 -17
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +1 -19
  16. package/dist/config.js.map +1 -1
  17. package/dist/index.d.ts +3 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +8 -14
  20. package/dist/index.js.map +1 -1
  21. package/dist/integrations/google_adk.d.ts +168 -6
  22. package/dist/integrations/google_adk.d.ts.map +1 -1
  23. package/dist/integrations/google_adk.js +797 -103
  24. package/dist/integrations/google_adk.js.map +1 -1
  25. package/dist/integrations/langchain.d.ts +51 -1
  26. package/dist/integrations/langchain.d.ts.map +1 -1
  27. package/dist/integrations/langchain.js +563 -30
  28. package/dist/integrations/langchain.js.map +1 -1
  29. package/dist/integrations/strands.d.ts +48 -0
  30. package/dist/integrations/strands.d.ts.map +1 -1
  31. package/dist/integrations/strands.js +441 -27
  32. package/dist/integrations/strands.js.map +1 -1
  33. package/dist/models.d.ts +2 -2
  34. package/dist/models.d.ts.map +1 -1
  35. package/dist/observability/content-capture.d.ts +75 -0
  36. package/dist/observability/content-capture.d.ts.map +1 -0
  37. package/dist/observability/content-capture.js +339 -0
  38. package/dist/observability/content-capture.js.map +1 -0
  39. package/dist/observability/index.d.ts +6 -7
  40. package/dist/observability/index.d.ts.map +1 -1
  41. package/dist/observability/index.js +18 -27
  42. package/dist/observability/index.js.map +1 -1
  43. package/dist/observability/otel-config.d.ts +47 -0
  44. package/dist/observability/otel-config.d.ts.map +1 -0
  45. package/dist/observability/otel-config.js +268 -0
  46. package/dist/observability/otel-config.js.map +1 -0
  47. package/dist/observability/otel-export-ceiling.d.ts +96 -0
  48. package/dist/observability/otel-export-ceiling.d.ts.map +1 -0
  49. package/dist/observability/otel-export-ceiling.js +264 -0
  50. package/dist/observability/otel-export-ceiling.js.map +1 -0
  51. package/dist/observability/otel-runtime.d.ts +103 -0
  52. package/dist/observability/otel-runtime.d.ts.map +1 -0
  53. package/dist/observability/otel-runtime.js +668 -0
  54. package/dist/observability/otel-runtime.js.map +1 -0
  55. package/dist/observability/otel-session.d.ts +168 -0
  56. package/dist/observability/otel-session.d.ts.map +1 -0
  57. package/dist/observability/otel-session.js +621 -0
  58. package/dist/observability/otel-session.js.map +1 -0
  59. package/dist/observability/otel-shutdown.d.ts +17 -0
  60. package/dist/observability/otel-shutdown.d.ts.map +1 -0
  61. package/dist/observability/otel-shutdown.js +54 -0
  62. package/dist/observability/otel-shutdown.js.map +1 -0
  63. package/dist/observability/policy-lease.d.ts +22 -0
  64. package/dist/observability/policy-lease.d.ts.map +1 -0
  65. package/dist/observability/policy-lease.js +102 -0
  66. package/dist/observability/policy-lease.js.map +1 -0
  67. package/dist/plan_builder.d.ts +5 -4
  68. package/dist/plan_builder.d.ts.map +1 -1
  69. package/dist/plan_builder.js +14 -15
  70. package/dist/plan_builder.js.map +1 -1
  71. package/dist/session.d.ts +61 -93
  72. package/dist/session.d.ts.map +1 -1
  73. package/dist/session.js +388 -812
  74. package/dist/session.js.map +1 -1
  75. package/dist/token_usage.d.ts +11 -18
  76. package/dist/token_usage.d.ts.map +1 -1
  77. package/dist/token_usage.js +29 -94
  78. package/dist/token_usage.js.map +1 -1
  79. package/dist/tool_name.d.ts +18 -0
  80. package/dist/tool_name.d.ts.map +1 -0
  81. package/dist/tool_name.js +29 -0
  82. package/dist/tool_name.js.map +1 -0
  83. package/dist/tool_push.d.ts +28 -0
  84. package/dist/tool_push.d.ts.map +1 -0
  85. package/dist/tool_push.js +151 -0
  86. package/dist/tool_push.js.map +1 -0
  87. package/dist/tool_registry.d.ts +100 -0
  88. package/dist/tool_registry.d.ts.map +1 -0
  89. package/dist/tool_registry.js +440 -0
  90. package/dist/tool_registry.js.map +1 -0
  91. package/dist/tool_schema.d.ts +22 -0
  92. package/dist/tool_schema.d.ts.map +1 -0
  93. package/dist/tool_schema.js +163 -0
  94. package/dist/tool_schema.js.map +1 -0
  95. package/package.json +13 -7
  96. package/dist/integrations/microsoft_copilot.d.ts +0 -84
  97. package/dist/integrations/microsoft_copilot.d.ts.map +0 -1
  98. package/dist/integrations/microsoft_copilot.js +0 -126
  99. package/dist/integrations/microsoft_copilot.js.map +0 -1
package/dist/session.js CHANGED
@@ -18,12 +18,18 @@ Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.ArmorIQSession = void 0;
19
19
  exports.normalizeDefaultAction = normalizeDefaultAction;
20
20
  const crypto_1 = require("crypto");
21
- const config_1 = require("./config");
21
+ const tool_push_1 = require("./tool_push");
22
+ const tool_registry_1 = require("./tool_registry");
22
23
  const crypto_verify_1 = require("./crypto_verify");
23
24
  const exceptions_1 = require("./exceptions");
24
25
  const models_1 = require("./models");
25
26
  const plan_builder_1 = require("./plan_builder");
26
27
  const observability_1 = require("./observability");
28
+ const _version_1 = require("./_version");
29
+ function isValidUuid(value) {
30
+ return (typeof value === 'string' &&
31
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value));
32
+ }
27
33
  /**
28
34
  * Normalize a policy-authoring default-enforcement-action value into the
29
35
  * SDK-facing terminal enum. Mirrors the backend's enforce-boundary normalization
@@ -54,72 +60,48 @@ const MULTIUSER_PLACEHOLDER = '__sdk_multiuser__';
54
60
  function realIdentity(value) {
55
61
  return !value || value === MULTIUSER_PLACEHOLDER ? undefined : value;
56
62
  }
63
+ const AUDIT_LABEL = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{0,127}$/;
64
+ const AUDIT_SECRET = /(?:\beyJ[A-Za-z0-9_-]{5,}\.|\b(?:ak|sk|pk|rk|ghp|github_pat)_[A-Za-z0-9_-]{8,}\b|bearer|secret|password|credential|private[_-]?key)/i;
65
+ function safeAuditLabel(value) {
66
+ return typeof value === 'string' && AUDIT_LABEL.test(value) && !AUDIT_SECRET.test(value)
67
+ ? value
68
+ : 'unknown';
69
+ }
70
+ function auditPersistencePayload(input) {
71
+ const durationMs = typeof input.durationMs === 'number' &&
72
+ Number.isFinite(input.durationMs) &&
73
+ input.durationMs >= 0
74
+ ? input.durationMs
75
+ : null;
76
+ const action = safeAuditLabel(input.action);
77
+ return {
78
+ plan_id: safeAuditLabel(input.token?.planId),
79
+ step_index: Number.isFinite(input.stepIndex) && input.stepIndex >= 0 ? Math.floor(input.stepIndex) : 0,
80
+ action,
81
+ tool: action,
82
+ mcp: safeAuditLabel(input.mcp),
83
+ input: {},
84
+ status: input.status === 'success' || input.status === 'failed' || input.status === 'error'
85
+ ? input.status
86
+ : 'error',
87
+ duration_ms: durationMs,
88
+ is_delegated: typeof input.isDelegated === 'boolean' ? input.isDelegated : null,
89
+ executed_at: new Date().toISOString(),
90
+ };
91
+ }
57
92
  /** The rule body of a policy snapshot entry, whichever shape it arrived in. */
58
93
  function ruleOf(entry) {
59
94
  if (!entry || typeof entry !== 'object')
60
95
  return entry;
61
96
  return entry.memberRule ?? entry.clientRule ?? entry.rules ?? entry;
62
97
  }
63
- const TRUNCATE_BYTES = 4 * 1024;
64
- function truncateForSpan(value) {
65
- if (value === undefined || value === null)
66
- return value;
67
- let json;
68
- try {
69
- json = typeof value === 'string' ? value : JSON.stringify(value);
70
- }
71
- catch {
72
- json = String(value);
73
- }
74
- const bytes = Buffer.byteLength(json, 'utf8');
75
- if (bytes <= TRUNCATE_BYTES)
76
- return value;
77
- const sliced = json.slice(0, TRUNCATE_BYTES);
78
- return `${sliced}…(truncated, original ${bytes} bytes)`;
79
- }
80
- function safeObs(fn) {
81
- try {
82
- return fn();
83
- }
84
- catch (err) {
85
- const msg = err instanceof Error ? err.message : String(err);
86
- console.warn(`[armoriq] observability emit failed (continuing): ${msg}`);
87
- return undefined;
88
- }
89
- }
90
- function makeSpanRecord(ctx, name, attributes, status = 'ok', durationMs = null, 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: 'span',
97
- name,
98
- startTime: now,
99
- endTime: now,
100
- durationMs,
101
- status,
102
- attributes,
103
- };
104
- }
105
- function makeEventRecord(ctx, name, attributes, parentSpanId) {
106
- const now = new Date().toISOString();
107
- return {
108
- id: (0, crypto_1.randomUUID)(),
109
- parentSpanId: parentSpanId ?? null,
110
- sessionId: ctx.sessionId,
111
- kind: 'event',
112
- name,
113
- startTime: now,
114
- endTime: now,
115
- durationMs: 0,
116
- status: 'ok',
117
- attributes,
118
- };
98
+ function isCancellation(error) {
99
+ return (error instanceof Error && (error.name === 'AbortError' || /cancelled/i.test(error.message)));
119
100
  }
120
101
  class ArmorIQSession {
121
102
  userEmail;
122
103
  client;
104
+ pendingPolicyTelemetry = new Set();
123
105
  toolNameParser;
124
106
  defaultMcpName;
125
107
  validitySeconds;
@@ -142,11 +124,15 @@ class ArmorIQSession {
142
124
  // MCPs we have already tried to load semantic metadata for (see
143
125
  // warmToolMetadata) — one attempt each, whether it succeeded or not.
144
126
  metadataWarmed = new Set();
145
- obs = null;
127
+ /** Native OTel bridge. It never changes the application's global provider. */
128
+ otel = null;
129
+ /** Pending delegation spans are ended only by the approval terminal path. */
130
+ pendingDelegationOperations = new Map();
131
+ delegationTerminalOperations = new Map();
132
+ completedDelegations = new Set();
146
133
  /**
147
134
  * This session's one stable observability session id — stamped onto every
148
- * trace this session emits (via the recorder's `defaultSessionId`, see
149
- * `ObservabilityConfig.sessionId`). Always a valid UUID; the backend's
135
+ * trace this session emits. Always a valid UUID; the backend's
150
136
  * `obs_sessions.id` column is UUID-typed, so a non-UUID id (e.g. the
151
137
  * legacy `contextId` default of the literal string `'default'`) can never
152
138
  * form a session and traces stamped with it can never be grouped.
@@ -157,19 +143,6 @@ class ArmorIQSession {
157
143
  * 3. A freshly minted `crypto.randomUUID()` (default — no arg needed).
158
144
  */
159
145
  sessionId;
160
- /**
161
- * Model A (trace-per-plan): the ONE active observability trace for the
162
- * current plan. Every chokepoint (enforce/check/report/dispatch/...)
163
- * records a container span under this trace via `ensurePlanTrace()`
164
- * instead of opening its own trace — matching Langfuse/OTel's
165
- * Session → Trace → Observation hierarchy (one IAP plan = one trace).
166
- *
167
- * Lifecycle: opened by `ensurePlanTrace()` (called from `startPlan()` or
168
- * lazily by any chokepoint invoked before `startPlan()` — back-compat);
169
- * ended by `endPlanTrace()`, called on the NEXT `startPlan()`, on
170
- * `close()`/`dispose()`, and on `flushObservability()`.
171
- */
172
- activePlanTrace = null;
173
146
  constructor(client, opts) {
174
147
  this.client = client;
175
148
  const o = opts ?? {};
@@ -182,288 +155,60 @@ class ArmorIQSession {
182
155
  const granEnv = (typeof process !== 'undefined' && process.env?.ARMORIQ_REANCHOR_GRANULARITY) || '';
183
156
  this.reanchorGranularity =
184
157
  o.reanchorGranularity ?? (granEnv === 'deferred' ? 'deferred' : 'eager');
185
- this.sessionId = (0, observability_1.isValidUuid)(o.sessionId)
158
+ this.sessionId = isValidUuid(o.sessionId)
186
159
  ? o.sessionId
187
- : (0, observability_1.isValidUuid)(o.contextId)
160
+ : isValidUuid(o.contextId)
188
161
  ? o.contextId
189
162
  : (0, crypto_1.randomUUID)();
190
163
  const obsCfg = o.observability;
191
- if (obsCfg?.enabled === false) {
192
- this.obs = null;
193
- }
194
- else {
164
+ const nativeConfig = (0, observability_1.resolveOtelConfig)(obsCfg ?? {});
165
+ if (nativeConfig.enabled) {
195
166
  const internals = this.client._sessionInternals();
196
- const endpoint = obsCfg?.endpoint ?? internals.backendEndpoint;
197
- const apiKey = obsCfg?.apiKey ?? internals.apiKey;
198
- const product = obsCfg?.product ?? config_1.DEFAULT_OBSERVABILITY_PRODUCT;
199
- // Reuse the session's configured httpClient (consumer interceptors,
200
- // test mocks) unless the caller explicitly overrides it via
201
- // `observability.httpClient`. Without this, the shipper falls back to
202
- // its own bare `axios.create(...)` and silently bypasses whatever
203
- // transport the rest of the SDK uses — including test mocks, which
204
- // then fire real network POSTs from the shipper's flush interval.
205
- const httpClient = obsCfg?.httpClient ?? internals.httpClient;
206
- // `internals.userId`/`internals.agentId` default to `''` (see the
207
- // `ArmorIQClient` constructor) when the caller configured neither an
208
- // option nor the corresponding env var, then to the multi-user
209
- // placeholder — normalize both to `null` so an unconfigured identity
210
- // reads as genuinely absent on the trace rather than shipping an empty
211
- // string or a fake agent id.
212
- const nonEmpty = (v) => realIdentity(v) ?? null;
213
- // `internals.userId` is `client.userEmailOverride || client.userId`
214
- // (client.ts) — an arbitrary identity string (often an email address,
215
- // or the multi-user placeholder `'__sdk_multiuser__'`), NOT guaranteed
216
- // to be a UUID. The observability trace's `userId` column/wire schema
217
- // IS UUID-typed (`z.uuid()` on the backend) — forwarding a non-UUID
218
- // value 400s the entire ingest batch at the shipper. Validate before
219
- // forwarding; a non-UUID userId is dropped to `null` rather than
220
- // breaking ingestion (`agentId` has no such constraint — see below).
221
- const validUserId = (v) => (0, observability_1.isValidUuid)(v) ? v : null;
222
- const merged = {
223
- ...(0, config_1.defaultObservabilityConfig)(),
224
- ...(obsCfg ?? {}),
225
- enabled: true,
226
- endpoint,
227
- apiKey,
228
- product,
229
- httpClient,
230
- // The session's one stable UUID — applied to every trace this
231
- // recorder starts unless a chokepoint explicitly overrides it (none
232
- // do; see the `startTrace(...)` call sites below, all of which now
233
- // pass `this.sessionId` directly for clarity/back-compat).
234
- sessionId: obsCfg?.sessionId ?? this.sessionId,
235
- // Denormalize the client's configured user/agent identity onto every
236
- // trace this recorder starts, unless the caller explicitly overrides
237
- // via `observability.userId`/`observability.agentId`. `internals`
238
- // already carries both (see `ArmorIQClient._sessionInternals()`);
239
- // previously neither was forwarded here, so every SDK-emitted trace
240
- // shipped with `agentId: null` even when the client was constructed
241
- // with a real agent id. `agentId` is a free-form string on the wire
242
- // (backend column is `text`, not a UUID FK) — no UUID validation
243
- // required, unlike `sessionId`/`userId`.
244
- userId: obsCfg?.userId ?? validUserId(internals.userId),
245
- agentId: obsCfg?.agentId ?? nonEmpty(internals.agentId),
246
- };
247
- this.obs = new observability_1.ObservabilityRecorder(merged);
167
+ const runtime = new observability_1.ArmorIQTelemetryRuntime({
168
+ backendEndpoint: internals.backendEndpoint,
169
+ apiKey: internals.apiKey,
170
+ sdkVersion: _version_1.VERSION,
171
+ options: obsCfg,
172
+ });
173
+ this.otel = new observability_1.OtelSession(runtime, {
174
+ sessionId: this.sessionId,
175
+ traceName: 'armoriq.agent.run',
176
+ agentId: realIdentity(internals.agentId) ?? null,
177
+ // Never use a user email as an external Langfuse user id implicitly.
178
+ // Callers may opt in with an explicit safe `observability.userId`.
179
+ userId: obsCfg?.userId ?? null,
180
+ });
248
181
  }
249
182
  }
250
- /**
251
- * The session's `ObservabilityRecorder`, or `null` when observability is
252
- * disabled for this session. Pass this explicitly to
253
- * `summarizeTranscriptUsage(path, recorder, ctx)` / `client.captureTranscriptTokens({
254
- * ..., observabilityRecorder, planTraceCtx })` so transcript-derived generation spans are
255
- * attributed to the correct session — the SDK no longer keeps a
256
- * process-global "active recorder" (see #4: concurrent sessions would
257
- * otherwise misattribute usage to whichever session constructed last).
258
- */
259
- get observabilityRecorder() {
260
- return this.obs;
183
+ /** Native request-scoped OTel bridge for framework adapters. */
184
+ get otelSession() {
185
+ return this.otel;
261
186
  }
262
187
  /**
263
- * Model A (Task 4): the session's active plan trace context, or `null` if
264
- * none is open yet (lazily opens on first use — same as every chokepoint).
265
- * Pass this as `summarizeTranscriptUsage(path, session.observabilityRecorder,
266
- * session.activePlanTraceContext)` (or via
267
- * `client.captureTranscriptTokens({ ..., planTraceCtx: session.activePlanTraceContext })`)
268
- * so transcript-derived generation spans nest under the SAME plan trace as
269
- * every other chokepoint this session recorded, instead of opening a
270
- * standalone `llm.usage.report` trace.
188
+ * Explicitly await the observability policy control-plane refresh. Request
189
+ * hooks must not call this: their native telemetry remains fail-open and
190
+ * latency-free while the SDK refreshes in the background.
271
191
  */
272
- get activePlanTraceContext() {
273
- return this.obs ? this.ensurePlanTrace() : null;
274
- }
275
- // ─── Model A: active-plan-trace lifecycle ──────────────────────
276
- /**
277
- * Return the session's active plan trace, lazily opening one if none is
278
- * active. This is the ONE trace every chokepoint's container span nests
279
- * under (Model A: trace-per-plan). Returns `null` when observability is
280
- * disabled — every call site already treats a null ctx as "skip emit".
281
- *
282
- * Back-compat (plan §"Global Constraints"): a chokepoint invoked with no
283
- * prior `startPlan()` (e.g. a standalone `enforceLocal()`/`report()` call)
284
- * still needs to emit — so this opens a plan trace lazily on first use
285
- * rather than requiring `startPlan()` to have run first.
286
- */
287
- ensurePlanTrace(name = 'iap.plan', attrs = {}) {
288
- if (!this.obs)
289
- return null;
290
- if (this.activePlanTrace)
291
- return this.activePlanTrace;
292
- const ctx = safeObs(() => (0, observability_1.startTrace)(this.obs, name, attrs, this.sessionId)) ?? null;
293
- this.activePlanTrace = ctx;
294
- return ctx;
295
- }
296
- /**
297
- * End the active plan trace (if any) and clear it. Called on the NEXT
298
- * `startPlan()` (closing the PRIOR plan before opening a new one),
299
- * `close()`/`dispose()`, and `flushObservability()` — so the in-flight
300
- * plan trace always ships before the process exits or a new plan begins.
301
- */
302
- endPlanTrace(status = 'ok') {
303
- if (!this.obs || !this.activePlanTrace)
304
- return;
305
- const ctx = this.activePlanTrace;
306
- this.activePlanTrace = null;
307
- safeObs(() => (0, observability_1.endTrace)(this.obs, ctx, { status }));
192
+ async refreshObservabilityPolicy() {
193
+ await this.otel?.refreshPolicy();
308
194
  }
309
195
  // ─── Plan capture ──────────────────────────────────────────────
310
196
  async reanchorCall(intentToken, plan, reason) {
311
- const startNs = this.obs ? performance.now() : 0;
312
- const ctx = this.obs ? this.ensurePlanTrace() : null;
313
- const spanId = ctx && this.obs
314
- ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
315
- name: 'iap.reanchor',
316
- attributes: { reason, planStepCount: plan?.steps?.length ?? null },
317
- })) ?? undefined
318
- : undefined;
319
- let caught;
320
- let trustId;
321
- try {
322
- const result = await this.client.reanchor(intentToken, plan, reason);
323
- trustId = result?.trustId;
324
- if (ctx) {
325
- safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'iap.reanchor.delta', {
326
- kind: 'event',
327
- message: 'delta recorded',
328
- level: 'info',
329
- trustId: trustId ?? null,
330
- }, spanId)));
331
- }
332
- return { trustId };
333
- }
334
- catch (err) {
335
- caught = err;
336
- if (ctx) {
337
- safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'iap.reanchor.delta_failed', {
338
- kind: 'event',
339
- message: `delta failed: ${err.message}`,
340
- level: 'error',
341
- }, spanId)));
342
- }
343
- throw err;
344
- }
345
- finally {
346
- if (ctx) {
347
- const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
348
- safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'iap.reanchor.http', {
349
- kind: 'span',
350
- trustId: trustId ?? null,
351
- errorMessage: caught instanceof Error ? caught.message : undefined,
352
- }, caught ? 'error' : 'ok', computedDuration ?? null, spanId)));
353
- if (spanId) {
354
- safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
355
- status: caught ? 'error' : 'ok',
356
- durationMs: computedDuration,
357
- errorMessage: caught instanceof Error ? caught.message : undefined,
358
- }));
359
- }
360
- }
361
- }
197
+ const result = await this.client.reanchor(intentToken, plan, reason);
198
+ return { trustId: result?.trustId };
362
199
  }
363
200
  async getIntentTokenCall(planCapture, validitySeconds) {
364
- const startNs = this.obs ? performance.now() : 0;
365
- const ctx = this.obs ? this.ensurePlanTrace() : null;
366
- const spanId = ctx && this.obs
367
- ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
368
- name: 'iap.intent_token.mint',
369
- attributes: { planStepCount: planCapture?.plan?.steps?.length ?? null },
370
- })) ?? undefined
371
- : undefined;
372
- let caught;
373
- let token;
374
- try {
375
- token = await this.client.getIntentToken(planCapture, undefined, validitySeconds);
376
- if (ctx) {
377
- safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'iap.intent_token.minted', {
378
- kind: 'event',
379
- message: 'new intent token minted',
380
- level: 'info',
381
- tokenId: token?.tokenId ?? null,
382
- planId: token?.planId ?? null,
383
- }, spanId)));
384
- }
385
- return token;
386
- }
387
- catch (err) {
388
- caught = err;
389
- throw err;
390
- }
391
- finally {
392
- if (ctx) {
393
- const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
394
- safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'iap.intent_token.mint.http', {
395
- kind: 'span',
396
- errorMessage: caught instanceof Error ? caught.message : undefined,
397
- }, caught ? 'error' : 'ok', computedDuration ?? null, spanId)));
398
- if (spanId) {
399
- safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
400
- status: caught ? 'error' : 'ok',
401
- durationMs: computedDuration,
402
- errorMessage: caught instanceof Error ? caught.message : undefined,
403
- }));
404
- }
405
- }
406
- }
201
+ return this.client.getIntentToken(planCapture, undefined, validitySeconds);
407
202
  }
408
203
  async startPlan(toolCalls, goal) {
409
- const startNs = this.obs ? performance.now() : 0;
410
- // Model A: close the PRIOR plan's trace (if any) before opening the new
411
- // one — one IAP plan = one trace, so a new startPlan() is a plan
412
- // boundary. Then open the new active plan trace and record a
413
- // container span (`iap.plan.start`) for this call's own work, nested
414
- // under it exactly like every other chokepoint's container span.
415
- if (this.obs)
416
- safeObs(() => this.endPlanTrace('ok'));
417
- const ctx = this.obs
418
- ? this.ensurePlanTrace('iap.plan', {
419
- toolCount: toolCalls?.length ?? 0,
420
- goal: goal ?? null,
421
- mode: this.mode,
422
- })
423
- : null;
424
- const spanId = ctx && this.obs
425
- ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
426
- name: 'iap.plan.start',
427
- attributes: {
428
- toolCount: toolCalls?.length ?? 0,
429
- goal: goal ?? null,
430
- mode: this.mode,
431
- },
432
- })) ?? undefined
433
- : undefined;
434
- let caught;
435
- let planTraceStatus = 'ok';
436
- let planTraceError;
437
- try {
438
- const result = await this._startPlanImpl(toolCalls, goal, ctx);
439
- if (ctx) {
440
- safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'iap.plan.grew', {
441
- kind: 'event',
442
- message: `plan captured: ${toolCalls?.length ?? 0} tool(s)`,
443
- level: 'info',
444
- toolCount: toolCalls?.length ?? 0,
445
- })));
446
- }
447
- return result;
448
- }
449
- catch (err) {
450
- caught = err;
451
- planTraceStatus = 'error';
452
- planTraceError = err.message;
453
- throw err;
454
- }
455
- finally {
456
- if (ctx && spanId) {
457
- const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
458
- safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
459
- status: planTraceStatus === 'error' ? 'error' : 'ok',
460
- durationMs: computedDuration,
461
- errorMessage: planTraceError,
462
- }));
463
- }
464
- }
204
+ const result = await this._startPlanImpl(toolCalls, goal);
205
+ await this.otel?.startPlan({
206
+ itemCount: toolCalls.length,
207
+ planId: result.planId ?? result.tokenId,
208
+ });
209
+ return result;
465
210
  }
466
- async _startPlanImpl(toolCalls, goal, _ctx) {
211
+ async _startPlanImpl(toolCalls, goal) {
467
212
  if (!toolCalls || toolCalls.length === 0) {
468
213
  throw new Error('startPlan called with no tool calls.');
469
214
  }
@@ -563,58 +308,21 @@ class ArmorIQSession {
563
308
  return token;
564
309
  }
565
310
  // ─── Policy enforcement ────────────────────────────────────────
566
- /**
567
- * `parentContainerSpanId` is an internal-only hook (not part of the public
568
- * signature contract other SDK consumers rely on — it's simply an extra
569
- * optional trailing arg) so `check()` can nest this call's container span
570
- * under `iap.check`'s own container span instead of both sitting as
571
- * siblings directly under the plan trace (Task 3 risk: "check → enforce
572
- * double-open" — `iap.check`'s span wraps the dispatched enforce span,
573
- * it never opens a second redundant plan trace or duplicate container).
574
- */
575
- enforceLocal(toolName, toolArgs, parentContainerSpanId) {
576
- const startNs = this.obs ? performance.now() : 0;
577
- const ctx = this.obs ? this.ensurePlanTrace() : null;
578
- const spanId = ctx && this.obs
579
- ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
580
- name: 'iap.enforce.local',
581
- attributes: { toolName, toolArgs: truncateForSpan(toolArgs) },
582
- parentSpanId: parentContainerSpanId ?? null,
583
- })) ?? undefined
584
- : undefined;
585
- let caught;
586
- try {
587
- return this._enforceLocalImpl(toolName, toolArgs, ctx, spanId);
588
- }
589
- catch (err) {
590
- caught = err;
591
- throw err;
592
- }
593
- finally {
594
- if (ctx && spanId) {
595
- const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
596
- safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
597
- status: caught ? 'error' : 'ok',
598
- durationMs: computedDuration,
599
- errorMessage: caught instanceof Error ? caught.message : undefined,
600
- }));
601
- }
602
- }
311
+ enforceLocal(toolName, toolArgs) {
312
+ const decision = this._enforceLocalImpl(toolName, toolArgs);
313
+ this.trackPolicyTelemetry(this.recordPolicyDecision(toolName, toolArgs, decision));
314
+ return decision;
603
315
  }
604
- _enforceLocalImpl(toolName, toolArgs, ctx, spanId) {
316
+ _enforceLocalImpl(toolName, toolArgs) {
605
317
  if (!this.currentToken) {
606
- const r = {
318
+ return {
607
319
  allowed: false,
608
320
  action: 'block',
609
321
  reason: 'No intent token — call startPlan() first',
610
322
  };
611
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', {}, spanId);
612
- return r;
613
323
  }
614
324
  if (models_1.IntentToken.isExpired(this.currentToken)) {
615
- const r = { allowed: false, action: 'block', reason: 'token-expired' };
616
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', {}, spanId);
617
- return r;
325
+ return { allowed: false, action: 'block', reason: 'token-expired' };
618
326
  }
619
327
  // The local decision is read from the token's policy fields, so the token
620
328
  // must be cryptographically authentic first - otherwise a forged token
@@ -624,22 +332,18 @@ class ArmorIQSession {
624
332
  // the token. Fail CLOSED if the key is unavailable or the signature is bad.
625
333
  const iapKey = this.client.iapPublicKeySync();
626
334
  if (!iapKey || !(0, crypto_verify_1.verifyEdDSAJwt)(this.currentToken.jwtToken ?? '', iapKey)) {
627
- const r = { allowed: false, action: 'block', reason: 'token-signature-invalid' };
628
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', {}, spanId);
629
- return r;
335
+ return { allowed: false, action: 'block', reason: 'token-signature-invalid' };
630
336
  }
631
337
  const { mcp, action } = this.toolNameParser(toolName);
632
338
  const inPlan = this.declaredTools.has(toolName) ||
633
339
  this.declaredTools.has(action) ||
634
340
  this.declaredTools.has(`${mcp}__${action}`);
635
341
  if (!inPlan) {
636
- const r = {
342
+ return {
637
343
  allowed: false,
638
344
  action: 'block',
639
345
  reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
640
346
  };
641
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp }, spanId);
642
- return r;
643
347
  }
644
348
  const pv = (this.currentToken.policyValidation ?? {});
645
349
  const snapshot = (this.currentToken.policySnapshot ?? []);
@@ -684,14 +388,12 @@ class ArmorIQSession {
684
388
  : `Tool '${action}' is denied by policy`);
685
389
  // Deny-precedence: an explicitly denied tool is never allowed, even when
686
390
  // the default action is 'allow'. hold only if the policy default is hold.
687
- const r = {
391
+ return {
688
392
  allowed: false,
689
393
  action: defaultAction === 'hold' ? 'hold' : 'block',
690
394
  reason,
691
395
  matchedPolicy: governingPolicyName,
692
396
  };
693
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
694
- return r;
695
397
  }
696
398
  }
697
399
  // Honor the backend-baked resolved allow set: an explicit *empty*
@@ -699,42 +401,34 @@ class ArmorIQSession {
699
401
  // ⇒ deny-all, regardless of what any individual snapshot rule allows.
700
402
  const bakedAllowedTools = pv.allowed_tools;
701
403
  if (Array.isArray(bakedAllowedTools) && bakedAllowedTools.length === 0) {
702
- const r = {
404
+ return {
703
405
  allowed: false,
704
406
  action: 'block',
705
407
  reason: `Tool '${action}' is not allowed by any policy in scope (empty resolved allow-list)`,
706
408
  };
707
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
708
- return r;
709
409
  }
710
410
  if (governingRule) {
711
411
  const allowed = governingRule.allowedTools ?? [];
712
412
  if (Array.isArray(allowed) && allowed.length > 0) {
713
- const ok = allowed.includes('*') ||
714
- allowed.includes(action) ||
715
- allowed.includes(toolName);
413
+ const ok = allowed.includes('*') || allowed.includes(action) || allowed.includes(toolName);
716
414
  if (!ok) {
717
415
  // Tool is outside this rule's explicit allow set. Fall back to the
718
416
  // policy default action: an 'allow' default permits it, hold/block
719
417
  // otherwise.
720
418
  if (defaultAction === 'allow') {
721
- const r = {
419
+ return {
722
420
  allowed: true,
723
421
  action: 'allow',
724
422
  reason: `Allowed by default enforcement action for policy '${governingPolicyName}'`,
725
423
  matchedPolicy: governingPolicyName,
726
424
  };
727
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
728
- return r;
729
425
  }
730
- const r = {
426
+ return {
731
427
  allowed: false,
732
428
  action: defaultAction,
733
429
  reason: `Tool '${action}' is not in the allowed tools for policy '${governingPolicyName}'`,
734
430
  matchedPolicy: governingPolicyName,
735
431
  };
736
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
737
- return r;
738
432
  }
739
433
  }
740
434
  }
@@ -742,102 +436,83 @@ class ArmorIQSession {
742
436
  const thresholdDecision = this.evaluateAmountThreshold(governingRule, toolArgs, action, mcp);
743
437
  if (thresholdDecision) {
744
438
  thresholdDecision.matchedPolicy = governingPolicyName;
745
- this._emitEnforcePolicyCall(ctx, toolName, thresholdDecision, 'sdk-local', { mcp, policyValidation: pv }, spanId);
746
439
  return thresholdDecision;
747
440
  }
748
441
  }
749
- const r = {
442
+ return {
750
443
  allowed: true,
751
444
  action: 'allow',
752
445
  reason: 'Allowed by local policy evaluation',
753
446
  matchedPolicy: governingPolicyName,
754
447
  };
755
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
756
- return r;
757
448
  }
758
- _emitEnforcePolicyCall(ctx, toolName, result, source, extras, parentSpanId) {
759
- if (!ctx || !this.obs)
760
- return;
761
- const decision = result.action === 'allow'
762
- ? 'allow'
763
- : result.action === 'hold'
764
- ? 'hold'
765
- : 'deny';
766
- const enforcementAction = result.action;
767
- safeObs(() => (0, observability_1.recordPolicyCall)(this.obs, ctx, {
768
- policyId: null,
769
- policyName: result.matchedPolicy ?? null,
770
- decision,
771
- reason: result.reason ?? null,
772
- source,
773
- input: { toolName },
774
- output: {
775
- allowed: result.allowed,
776
- action: result.action,
777
- matchedPolicy: result.matchedPolicy ?? null,
778
- delegationId: result.delegationId ?? null,
779
- },
780
- policyHash: null,
781
- policyVersion: null,
782
- matchedRuleId: null,
783
- dataClasses: [],
784
- enforcementAction,
785
- obligations: extras.obligations ?? result.obligations ?? [],
786
- delegationId: result.delegationId ?? null,
787
- }, parentSpanId));
449
+ /**
450
+ * A server named in the tool itself wins; the plan's action map only answers
451
+ * for unqualified names. The map keeps one server per action, so with
452
+ * `stripe__search` and `github__search` both planned it would otherwise send
453
+ * the last one for either call.
454
+ */
455
+ resolveMcp(toolName, mcp, action) {
456
+ if (toolName !== action)
457
+ return mcp;
458
+ return this.mcpByAction.get(action) ?? mcp;
788
459
  }
789
- /** See `enforceLocal`'s doc comment re: `parentContainerSpanId` (internal-only hook for `check()`). */
790
- async enforceSdk(toolName, toolArgs, userEmail, parentContainerSpanId) {
791
- const startNs = this.obs ? performance.now() : 0;
792
- const ctx = this.obs ? this.ensurePlanTrace() : null;
793
- const spanId = ctx && this.obs
794
- ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
795
- name: 'iap.enforce.sdk',
796
- attributes: { toolName, toolArgs: truncateForSpan(toolArgs), userEmail: userEmail ?? null },
797
- parentSpanId: parentContainerSpanId ?? null,
798
- })) ?? undefined
799
- : undefined;
800
- let caught;
460
+ /**
461
+ * The two fields the tool inventory attributes a call with. `wireName` is the
462
+ * name the framework dispatched, untouched. `mcpServer` is sent only when the
463
+ * SDK actually knows the server (the name carried it, or the bootstrap toolMap
464
+ * named it); a default fallback is a guess and is left out so the backend
465
+ * attributes from its own declarations.
466
+ */
467
+ /** `toolsSnapshotHash` when this client has an inventory; conmap answers `inventoryUnknown` if it holds no snapshot under it. */
468
+ snapshotHashField() {
801
469
  try {
802
- return await this._enforceSdkImpl(toolName, toolArgs, userEmail, ctx, spanId);
470
+ const key = (0, tool_registry_1.agentKeyFor)(this.client);
471
+ return key ? { toolsSnapshotHash: (0, tool_registry_1.defaultToolRegistry)().snapshotHash(key) } : {};
803
472
  }
804
- catch (err) {
805
- caught = err;
806
- throw err;
473
+ catch {
474
+ return {};
807
475
  }
808
- finally {
809
- if (ctx && spanId) {
810
- const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
811
- safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
812
- status: caught ? 'error' : 'ok',
813
- durationMs: computedDuration,
814
- errorMessage: caught instanceof Error ? caught.message : undefined,
815
- }));
816
- }
476
+ }
477
+ inventoryFields(toolName, action, resolvedMcp) {
478
+ const fields = { wireName: toolName };
479
+ if (toolName !== action ||
480
+ (resolvedMcp && resolvedMcp !== this.defaultMcpName && resolvedMcp !== 'unknown')) {
481
+ fields.mcpServer = resolvedMcp;
817
482
  }
483
+ return fields;
484
+ }
485
+ async enforceSdk(toolName, toolArgs, userEmail) {
486
+ const decision = await this._enforceSdkImpl(toolName, toolArgs, userEmail);
487
+ await this.recordPolicyDecision(toolName, toolArgs, decision);
488
+ return decision;
818
489
  }
819
- async _enforceSdkImpl(toolName, toolArgs, userEmail, ctx, spanId) {
490
+ async _enforceSdkImpl(toolName, toolArgs, userEmail) {
820
491
  if (!this.currentToken) {
821
492
  throw new Error(`enforceSdk("${toolName}") called before startPlan().`);
822
493
  }
823
494
  const { mcp, action } = this.toolNameParser(toolName);
824
- const resolvedMcp = this.mcpByAction.get(action) ?? mcp;
495
+ const resolvedMcp = this.resolveMcp(toolName, mcp, action);
825
496
  const inPlan = this.declaredTools.has(toolName) ||
826
497
  this.declaredTools.has(action) ||
827
498
  this.declaredTools.has(`${resolvedMcp}__${action}`);
828
499
  if (!inPlan) {
829
- const r = {
500
+ return {
830
501
  allowed: false,
831
502
  action: 'block',
832
503
  reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
833
504
  };
834
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp }, spanId);
835
- return r;
836
505
  }
837
506
  const internals = this.client._sessionInternals();
838
507
  try {
839
508
  const response = await internals.httpClient.post(`${internals.backendEndpoint}/iap/sdk/enforce`, {
509
+ // The server the call resolved to. Without it a policy rule bound to
510
+ // a server never matches, and shared tool names stay ambiguous to the
511
+ // inventory. Matches the Python SDK (armoriq-sdk-customer#139).
512
+ mcp: resolvedMcp,
840
513
  tool: action,
514
+ ...this.inventoryFields(toolName, action, resolvedMcp),
515
+ ...this.snapshotHashField(),
841
516
  arguments: toolArgs,
842
517
  intent_token: this.currentToken.rawToken,
843
518
  policy_snapshot: this.currentToken.policySnapshot,
@@ -848,11 +523,11 @@ class ArmorIQSession {
848
523
  timeout: 10000,
849
524
  });
850
525
  const data = response.data ?? {};
526
+ if (data.inventoryUnknown === true)
527
+ (0, tool_push_1.requestPush)(this.client);
851
528
  const allowed = data.allowed === true;
852
529
  const actionDecision = data.enforcementAction ?? (allowed ? 'allow' : 'block');
853
- const matched = typeof data.matchedPolicy === 'object'
854
- ? data.matchedPolicy?.name
855
- : data.matchedPolicy;
530
+ const matched = typeof data.matchedPolicy === 'object' ? data.matchedPolicy?.name : data.matchedPolicy;
856
531
  // Surface gating obligations metadata (additive). The backend has already
857
532
  // collapsed gating into allowed/action; obligations explain *why*.
858
533
  const obligations = Array.isArray(data.obligations)
@@ -866,77 +541,45 @@ class ArmorIQSession {
866
541
  matchedPolicy: matched,
867
542
  obligations,
868
543
  };
869
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp, obligations }, spanId);
870
544
  return this.handleHold(toolName, toolArgs, r, userEmail);
871
545
  }
872
- const r = {
546
+ return {
873
547
  allowed,
874
548
  action: actionDecision,
875
549
  reason: data.reason ?? data.message,
876
550
  matchedPolicy: matched,
877
551
  obligations,
878
552
  };
879
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp, obligations }, spanId);
880
- return r;
881
553
  }
882
554
  catch (e) {
883
555
  console.error(`enforceSdk() failed: ${e.message}. Blocking tool call (fail-closed).`);
884
- const r = {
556
+ return {
885
557
  allowed: false,
886
558
  action: 'block',
887
559
  reason: `enforce-unavailable: ${e.message}`,
888
560
  };
889
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp }, spanId);
890
- return r;
891
561
  }
892
562
  }
893
- /** See `enforceLocal`'s doc comment re: `parentContainerSpanId` (internal-only hook for `check()`). */
894
- async enforce(toolName, toolArgs, parentContainerSpanId) {
895
- const startNs = this.obs ? performance.now() : 0;
896
- const ctx = this.obs ? this.ensurePlanTrace() : null;
897
- const spanId = ctx && this.obs
898
- ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
899
- name: 'iap.enforce.proxy',
900
- attributes: { toolName, toolArgs: truncateForSpan(toolArgs) },
901
- parentSpanId: parentContainerSpanId ?? null,
902
- })) ?? undefined
903
- : undefined;
904
- let caught;
905
- try {
906
- return await this._enforceImpl(toolName, toolArgs, ctx, spanId);
907
- }
908
- catch (err) {
909
- caught = err;
910
- throw err;
911
- }
912
- finally {
913
- if (ctx && spanId) {
914
- const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
915
- safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
916
- status: caught ? 'error' : 'ok',
917
- durationMs: computedDuration,
918
- errorMessage: caught instanceof Error ? caught.message : undefined,
919
- }));
920
- }
921
- }
563
+ async enforce(toolName, toolArgs) {
564
+ const decision = await this._enforceImpl(toolName, toolArgs);
565
+ await this.recordPolicyDecision(toolName, toolArgs, decision);
566
+ return decision;
922
567
  }
923
- async _enforceImpl(toolName, toolArgs, ctx, spanId) {
568
+ async _enforceImpl(toolName, toolArgs) {
924
569
  if (!this.currentToken) {
925
570
  throw new Error(`enforce("${toolName}") called before startPlan().`);
926
571
  }
927
572
  const { mcp, action } = this.toolNameParser(toolName);
928
- const resolvedMcp = this.mcpByAction.get(action) ?? mcp;
573
+ const resolvedMcp = this.resolveMcp(toolName, mcp, action);
929
574
  const inPlan = this.declaredTools.has(toolName) ||
930
575
  this.declaredTools.has(action) ||
931
576
  this.declaredTools.has(`${resolvedMcp}__${action}`);
932
577
  if (!inPlan) {
933
- const r = {
578
+ return {
934
579
  allowed: false,
935
580
  action: 'block',
936
581
  reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
937
582
  };
938
- this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
939
- return r;
940
583
  }
941
584
  const internals = this.client._sessionInternals();
942
585
  try {
@@ -963,18 +606,16 @@ class ArmorIQSession {
963
606
  // Fail closed: only an explicit allow from a 2xx response permits the call.
964
607
  // Any error status (403 or otherwise) or an ambiguous body blocks.
965
608
  if (response.status >= 400) {
966
- const r = {
609
+ return {
967
610
  allowed: false,
968
611
  action: data.action ?? 'block',
969
612
  reason: data.reason ?? data.message ?? `enforce-rejected: HTTP ${response.status}`,
970
613
  matchedPolicy: policyName,
971
614
  };
972
- this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
973
- return r;
974
615
  }
975
616
  const allowedFlag = data.allowed === true;
976
617
  const actionDecision = data.enforcementAction ?? data.action ?? (allowedFlag ? 'allow' : 'block');
977
- const r = {
618
+ return {
978
619
  allowed: allowedFlag,
979
620
  action: actionDecision,
980
621
  reason: data.reason,
@@ -982,78 +623,82 @@ class ArmorIQSession {
982
623
  matchedPolicy: policyName,
983
624
  obligations: Array.isArray(data.obligations) ? data.obligations : [],
984
625
  };
985
- this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
986
- return r;
987
626
  }
988
627
  catch (e) {
989
628
  console.error(`enforce() failed: ${e.message}. Blocking tool call (fail-closed).`);
990
- const r = {
629
+ return {
991
630
  allowed: false,
992
631
  action: 'block',
993
632
  reason: `enforce-unavailable: ${e.message}`,
994
633
  };
995
- this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
996
- return r;
997
634
  }
998
635
  }
999
- async check(toolName, toolArgs, userEmail) {
1000
- const startNs = this.obs ? performance.now() : 0;
1001
- const ctx = this.obs ? this.ensurePlanTrace() : null;
1002
- // Task 3 risk — "check → enforce double-open": `check` opens its OWN
1003
- // `iap.check` container span (nested under the single active plan
1004
- // trace, never a second trace), then passes this span's id as the
1005
- // PARENT for whichever enforce* it dispatches into. The dispatched
1006
- // enforce's container span therefore nests under `iap.check`'s span —
1007
- // it does not open a redundant plan trace or a sibling/duplicate
1008
- // container span.
1009
- const spanId = ctx && this.obs
1010
- ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
1011
- name: 'iap.check',
1012
- attributes: {
1013
- toolName,
1014
- toolArgs: truncateForSpan(toolArgs),
1015
- mode: this.mode,
1016
- userEmail: userEmail ?? null,
1017
- },
1018
- })) ?? undefined
1019
- : undefined;
1020
- let caught;
1021
- try {
1022
- if (this.mode === 'sdk') {
1023
- return await this.enforceSdk(toolName, toolArgs, userEmail, spanId);
1024
- }
1025
- if (this.mode === 'local') {
1026
- await this.warmToolMetadata(toolName);
1027
- const decision = this.enforceLocal(toolName, toolArgs, spanId);
1028
- if (decision.action === 'hold') {
1029
- return {
1030
- ...decision,
1031
- action: 'block',
1032
- reason: (decision.reason ?? 'requires approval') +
1033
- ' — switch ARMORIQ_MODE=proxy to enable approval workflows for this action.',
1034
- };
1035
- }
1036
- return decision;
636
+ async check(toolName, toolArgs, userEmail, telemetry = {}) {
637
+ let decision;
638
+ if (this.mode === 'sdk') {
639
+ decision = await this._enforceSdkImpl(toolName, toolArgs, userEmail);
640
+ }
641
+ else if (this.mode === 'local') {
642
+ await this.warmToolMetadata(toolName);
643
+ decision = this._enforceLocalImpl(toolName, toolArgs);
644
+ if (decision.action === 'hold') {
645
+ decision = {
646
+ ...decision,
647
+ action: 'block',
648
+ reason: (decision.reason ?? 'requires approval') +
649
+ ' — switch ARMORIQ_MODE=proxy to enable approval workflows for this action.',
650
+ };
1037
651
  }
1038
- const decision = await this.enforce(toolName, toolArgs, spanId);
1039
- if (decision.action !== 'hold')
1040
- return decision;
1041
- return this.handleHold(toolName, toolArgs, decision, userEmail);
1042
- }
1043
- catch (err) {
1044
- caught = err;
1045
- throw err;
1046
652
  }
1047
- finally {
1048
- if (ctx && spanId) {
1049
- const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
1050
- safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
1051
- status: caught ? 'error' : 'ok',
1052
- durationMs: computedDuration,
1053
- errorMessage: caught instanceof Error ? caught.message : undefined,
1054
- }));
653
+ else {
654
+ decision = await this._enforceImpl(toolName, toolArgs);
655
+ if (decision.action === 'hold') {
656
+ decision = await this.handleHold(toolName, toolArgs, decision, userEmail);
1055
657
  }
1056
658
  }
659
+ if (telemetry.emitOtel !== false) {
660
+ await this.recordPolicyDecision(toolName, toolArgs, decision);
661
+ }
662
+ return decision;
663
+ }
664
+ async recordPolicyDecision(toolName, toolArgs, decision) {
665
+ if (!this.otel)
666
+ return;
667
+ const policy = decision;
668
+ const failed = typeof decision.reason === 'string' && decision.reason.startsWith('enforce-unavailable:');
669
+ const policyDecision = decision.allowed
670
+ ? 'allow'
671
+ : decision.action === 'hold'
672
+ ? 'hold'
673
+ : decision.action === 'block'
674
+ ? 'block'
675
+ : undefined;
676
+ await this.otel.recordPolicy({ toolName, arguments: toolArgs }, {
677
+ ...(!failed && policyDecision ? { decision: policyDecision } : {}),
678
+ policyName: decision.matchedPolicy,
679
+ ...(typeof policy.policyId === 'string' ? { policyId: policy.policyId } : {}),
680
+ ...(typeof policy.policyVersion === 'string'
681
+ ? { policyVersion: policy.policyVersion }
682
+ : {}),
683
+ ...(typeof policy.policySource === 'string' ? { policySource: policy.policySource } : {}),
684
+ ...(typeof policy.matchedRuleId === 'string'
685
+ ? { matchedRuleId: policy.matchedRuleId }
686
+ : {}),
687
+ policyReasonCode: decision.reason,
688
+ ...(failed ? { error: new Error('policy evaluation failed') } : {}),
689
+ ...(typeof policy.defaultAction === 'string'
690
+ ? { defaultAction: policy.defaultAction }
691
+ : {}),
692
+ });
693
+ }
694
+ trackPolicyTelemetry(operation) {
695
+ const tracked = operation
696
+ .catch(() => undefined)
697
+ .finally(() => this.pendingPolicyTelemetry.delete(tracked));
698
+ this.pendingPolicyTelemetry.add(tracked);
699
+ }
700
+ async drainPolicyTelemetry() {
701
+ await Promise.all([...this.pendingPolicyTelemetry]);
1057
702
  }
1058
703
  // ─── Report / dispatch ─────────────────────────────────────────
1059
704
  async report(toolName, toolArgs, result, opts) {
@@ -1063,51 +708,28 @@ class ArmorIQSession {
1063
708
  ? Math.round(opts.durationMs)
1064
709
  : opts?.durationMs,
1065
710
  };
1066
- const startNs = this.obs ? performance.now() : 0;
1067
- const ctx = this.obs ? this.ensurePlanTrace() : null;
1068
- const spanId = ctx && this.obs
1069
- ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
1070
- name: 'tool.report',
1071
- attributes: {
1072
- toolName,
1073
- toolInput: truncateForSpan(toolArgs),
1074
- status: o.status ?? 'success',
1075
- },
1076
- })) ?? undefined
1077
- : undefined;
1078
- let caught;
1079
711
  let reportStatus = 'ok';
1080
712
  let reportError;
713
+ const reportItemOrdinal = this.stepIndex;
714
+ let reportOperation = this.operationForTool(toolName, undefined, reportItemOrdinal, o.operation);
1081
715
  try {
1082
716
  const { mcp, action } = this.toolNameParser(toolName);
1083
- const resolvedMcp = this.mcpByAction.get(action) ?? mcp;
717
+ const resolvedMcp = this.resolveMcp(toolName, mcp, action);
718
+ if (o.emitOtel !== false) {
719
+ reportOperation = this.operationForTool(toolName, resolvedMcp, reportItemOrdinal, o.operation);
720
+ }
1084
721
  const internals = this.client._sessionInternals();
1085
722
  try {
1086
723
  const token = this.currentToken;
1087
- const userEmail = this.userEmail ?? this.client.userEmailOverride;
1088
- let output = result;
1089
- if (typeof result === 'string')
1090
- output = { text: result };
1091
- else if (result === null || result === undefined)
1092
- output = {};
1093
- await internals.httpClient.post(`${internals.backendEndpoint}/iap/audit`, {
1094
- token: token?.jwtToken ?? token?.tokenId ?? 'unknown',
1095
- plan_id: token?.planId ?? token?.tokenId ?? 'unknown',
1096
- step_index: this.stepIndex,
724
+ await internals.httpClient.post(`${internals.backendEndpoint}/iap/audit`, auditPersistencePayload({
725
+ token,
726
+ stepIndex: this.stepIndex,
1097
727
  action,
1098
- tool: action,
1099
728
  mcp: resolvedMcp,
1100
- input: toolArgs,
1101
- output,
1102
729
  status: o.status ?? 'success',
1103
- error_message: o.errorMessage,
1104
- duration_ms: o.durationMs,
1105
- is_delegated: o.isDelegated,
1106
- delegated_by: o.delegatedBy,
1107
- user_email: userEmail,
1108
- delegated_to: o.delegatedTo,
1109
- executed_at: new Date().toISOString(),
1110
- }, {
730
+ durationMs: o.durationMs,
731
+ isDelegated: o.isDelegated,
732
+ }), {
1111
733
  headers: { 'X-API-Key': internals.apiKey, 'Content-Type': 'application/json' },
1112
734
  timeout: 5000,
1113
735
  });
@@ -1118,108 +740,75 @@ class ArmorIQSession {
1118
740
  this.stepIndex += 1;
1119
741
  }
1120
742
  catch (err) {
1121
- caught = err;
1122
743
  reportStatus = 'error';
1123
744
  reportError = err.message;
1124
745
  throw err;
1125
746
  }
1126
747
  finally {
1127
- if (ctx) {
1128
- const computedDuration = o.durationMs ?? (startNs > 0 ? Math.round(performance.now() - startNs) : undefined);
1129
- safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'tool.report', {
1130
- kind: 'span',
748
+ if (o.emitOtel !== false) {
749
+ await this.otel?.recordTool({
1131
750
  toolName,
1132
- toolInput: truncateForSpan(toolArgs),
1133
- toolOutput: truncateForSpan(result),
1134
- // `status` here is the caller-supplied tool outcome
1135
- // ('success'|'failed'|'error' from ReportOptions), a distinct
1136
- // side-channel signal from the top-level SpanRecord.status
1137
- // ('ok'|'error'|'denied' span lifecycle status) below —
1138
- // intentionally NOT a duplicate.
1139
- status: o.status ?? 'success',
1140
- errorMessage: o.errorMessage,
1141
- }, reportStatus === 'error' ? 'error' : 'ok', computedDuration ?? null, spanId)));
1142
- if (spanId) {
1143
- safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
1144
- status: reportStatus === 'error' ? 'error' : 'ok',
1145
- durationMs: computedDuration,
1146
- errorMessage: reportError,
1147
- }));
1148
- }
751
+ itemOrdinal: reportItemOrdinal,
752
+ arguments: toolArgs,
753
+ operation: reportOperation,
754
+ }, {
755
+ outcome: reportStatus === 'ok' && (o.status ?? 'success') === 'success' ? 'success' : 'error',
756
+ durationMs: o.durationMs,
757
+ error: reportError,
758
+ result,
759
+ });
1149
760
  }
1150
761
  }
1151
762
  }
1152
763
  async dispatch(toolName, toolArgs) {
1153
- const startNs = this.obs ? performance.now() : 0;
1154
- const ctx = this.obs ? this.ensurePlanTrace() : null;
1155
- const spanId = ctx && this.obs
1156
- ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
1157
- name: 'tool.dispatch',
1158
- attributes: { toolName, toolInput: truncateForSpan(toolArgs) },
1159
- })) ?? undefined
1160
- : undefined;
1161
- let caught;
1162
764
  let dispatchResult;
1163
765
  let dispatchStatus = 'ok';
1164
766
  let dispatchError;
767
+ let nativeTool;
1165
768
  try {
1166
769
  if (!this.currentToken) {
1167
770
  throw new Error(`dispatch("${toolName}") called before startPlan().`);
1168
771
  }
1169
772
  const { mcp, action } = this.toolNameParser(toolName);
1170
- const resolvedMcp = this.mcpByAction.get(action) ?? mcp;
773
+ const resolvedMcp = this.resolveMcp(toolName, mcp, action);
774
+ nativeTool = await this.otel?.beginTool({
775
+ toolName: action,
776
+ itemOrdinal: this.stepIndex,
777
+ arguments: toolArgs,
778
+ operation: {
779
+ category: 'mcp',
780
+ name: 'mcp.execute',
781
+ toolType: 'mcp',
782
+ mcpServer: resolvedMcp,
783
+ },
784
+ });
1171
785
  dispatchResult = await this.client.invoke(resolvedMcp, action, this.currentToken, toolArgs);
1172
786
  this.stepIndex += 1;
1173
787
  return dispatchResult?.result;
1174
788
  }
1175
789
  catch (err) {
1176
- caught = err;
1177
790
  dispatchStatus = 'error';
1178
791
  dispatchError = err.message;
1179
792
  throw err;
1180
793
  }
1181
794
  finally {
1182
- if (ctx) {
1183
- const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
1184
- safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'tool.dispatch', {
1185
- kind: 'span',
1186
- toolName,
1187
- toolInput: truncateForSpan(toolArgs),
1188
- result: truncateForSpan(dispatchResult),
1189
- }, dispatchStatus === 'error' ? 'error' : 'ok', computedDuration ?? null, spanId)));
1190
- if (spanId) {
1191
- safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
1192
- status: dispatchStatus === 'error' ? 'error' : 'ok',
1193
- durationMs: computedDuration,
1194
- errorMessage: dispatchError,
1195
- }));
1196
- }
1197
- }
795
+ await this.otel?.endTool(nativeTool ?? { span: null, name: 'mcp.execute' }, {
796
+ outcome: dispatchStatus === 'ok' ? 'success' : 'error',
797
+ error: dispatchError,
798
+ ...(dispatchStatus === 'ok'
799
+ ? { result: dispatchResult?.result }
800
+ : {}),
801
+ });
1198
802
  }
1199
803
  }
1200
804
  // ─── Helpers ────────────────────────────────────────────────────
1201
805
  async handleHold(toolName, toolArgs, holdDecision, userEmail) {
1202
- const startNs = this.obs ? performance.now() : 0;
1203
- const ctx = this.obs ? this.ensurePlanTrace() : null;
1204
- const spanId = ctx && this.obs
1205
- ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
1206
- name: 'delegation.hold',
1207
- attributes: {
1208
- toolName,
1209
- toolArgs: truncateForSpan(toolArgs),
1210
- matchedPolicy: holdDecision.matchedPolicy ?? null,
1211
- },
1212
- })) ?? undefined
1213
- : undefined;
1214
- let caught;
1215
806
  let result;
1216
- let holdStatus = 'ok';
1217
- let holdError;
1218
- try {
807
+ {
1219
808
  const internals = this.client._sessionInternals();
1220
809
  const email = userEmail ?? internals.userId ?? 'unknown@armoriq';
1221
810
  const { mcp, action } = this.toolNameParser(toolName);
1222
- const resolvedMcp = this.mcpByAction.get(action) ?? mcp;
811
+ const resolvedMcp = this.resolveMcp(toolName, mcp, action);
1223
812
  const rawAmount = ArmorIQSession.extractAmount(toolArgs) ?? 0;
1224
813
  const safeAmount = typeof rawAmount === 'number' && rawAmount >= 0.01 ? rawAmount : 0.01;
1225
814
  try {
@@ -1270,6 +859,16 @@ class ArmorIQSession {
1270
859
  catch (e) {
1271
860
  console.warn(`createDelegationRequest failed: ${e.message}`);
1272
861
  }
862
+ if (delegationId) {
863
+ const operation = await this.otel?.beginOperation({
864
+ category: 'delegation',
865
+ name: 'delegation.wait',
866
+ delegationId,
867
+ delegationOutcome: 'pending',
868
+ });
869
+ if (operation?.span)
870
+ this.pendingDelegationOperations.set(delegationId, operation);
871
+ }
1273
872
  result = {
1274
873
  allowed: false,
1275
874
  action: 'hold',
@@ -1280,30 +879,6 @@ class ArmorIQSession {
1280
879
  };
1281
880
  return result;
1282
881
  }
1283
- catch (err) {
1284
- caught = err;
1285
- holdStatus = 'error';
1286
- holdError = err.message;
1287
- throw err;
1288
- }
1289
- finally {
1290
- if (ctx) {
1291
- const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
1292
- safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'delegation.hold', {
1293
- kind: 'span',
1294
- delegationId: result?.delegationId ?? null,
1295
- decision: result?.action ?? 'hold',
1296
- errorMessage: holdError,
1297
- }, holdStatus === 'error' ? 'error' : 'ok', computedDuration ?? null, spanId)));
1298
- if (spanId) {
1299
- safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
1300
- status: holdStatus === 'error' ? 'error' : 'ok',
1301
- durationMs: computedDuration,
1302
- errorMessage: holdError,
1303
- }));
1304
- }
1305
- }
1306
- }
1307
882
  }
1308
883
  /**
1309
884
  * Poll a held delegation until it is decided; non-blocking (async).
@@ -1313,16 +888,13 @@ class ArmorIQSession {
1313
888
  * timeout/interval are in seconds (mirrors the Python SDK await_approval).
1314
889
  */
1315
890
  async awaitApproval(delegationId, opts = {}) {
1316
- const startNs = this.obs ? performance.now() : 0;
1317
- const ctx = this.obs ? this.ensurePlanTrace() : null;
1318
- const spanId = ctx && this.obs
1319
- ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
1320
- name: 'delegation.poll',
1321
- attributes: { delegationId, timeout: opts.timeout ?? 300, interval: opts.interval ?? 5 },
1322
- })) ?? undefined
1323
- : undefined;
1324
- let caught;
1325
- let pollOutcome = 'timeout';
891
+ let delegationOutcome = 'expired';
892
+ const approvalStarted = performance.now();
893
+ const nativeApproval = await this.otel?.beginOperation({
894
+ category: 'approval',
895
+ name: 'approval.wait',
896
+ delegationId,
897
+ });
1326
898
  try {
1327
899
  const timeout = opts.timeout ?? 300;
1328
900
  const interval = opts.interval ?? 5;
@@ -1330,19 +902,15 @@ class ArmorIQSession {
1330
902
  const email = opts.userEmail || this.client.userId || 'unknown@armoriq';
1331
903
  const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
1332
904
  let waited = 0;
1333
- let pollCount = 0;
1334
905
  while (waited < timeout) {
1335
906
  await sleep(interval);
1336
907
  waited += interval;
1337
- pollCount += 1;
1338
908
  let status;
1339
909
  try {
1340
910
  status = await this.client.getDelegationStatus(delegationId);
1341
911
  }
1342
912
  catch (e) {
1343
- if (e instanceof exceptions_1.DelegationException &&
1344
- e.statusCode &&
1345
- transient.has(e.statusCode)) {
913
+ if (e instanceof exceptions_1.DelegationException && e.statusCode && transient.has(e.statusCode)) {
1346
914
  const backoff = Math.min(interval * 2, 15);
1347
915
  await sleep(backoff);
1348
916
  waited += backoff;
@@ -1350,49 +918,108 @@ class ArmorIQSession {
1350
918
  }
1351
919
  throw e;
1352
920
  }
1353
- if (ctx) {
1354
- safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'delegation.poll.tick', {
1355
- kind: 'event',
1356
- message: `poll #${pollCount} status=${status}`,
1357
- level: 'info',
1358
- delegationId,
1359
- }, spanId)));
1360
- }
1361
921
  if (status === 'approved') {
1362
922
  await this.client.markDelegationExecuted(email, delegationId, this.currentToken?.planId);
1363
- pollOutcome = 'approved';
923
+ delegationOutcome = 'approved';
1364
924
  return 'approved';
1365
925
  }
1366
926
  if (status === 'rejected') {
1367
- pollOutcome = 'rejected';
927
+ delegationOutcome = 'denied';
1368
928
  return 'rejected';
1369
929
  }
930
+ if (status === 'cancelled') {
931
+ delegationOutcome = 'cancelled';
932
+ return 'timeout';
933
+ }
934
+ if (status === 'expired') {
935
+ delegationOutcome = 'expired';
936
+ return 'timeout';
937
+ }
1370
938
  }
1371
- pollOutcome = 'timeout';
939
+ delegationOutcome = 'expired';
1372
940
  return 'timeout';
1373
941
  }
1374
942
  catch (err) {
1375
- caught = err;
943
+ delegationOutcome = isCancellation(err) ? 'cancelled' : 'failed';
1376
944
  throw err;
1377
945
  }
1378
946
  finally {
1379
- if (ctx) {
1380
- const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
1381
- safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'delegation.poll.terminal', {
1382
- kind: 'span',
1383
- delegationId,
1384
- decision: pollOutcome,
1385
- errorMessage: caught instanceof Error ? caught.message : undefined,
1386
- }, caught ? 'error' : 'ok', computedDuration ?? null, spanId)));
1387
- if (spanId) {
1388
- safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
1389
- status: caught ? 'error' : 'ok',
1390
- durationMs: computedDuration,
1391
- errorMessage: caught instanceof Error ? caught.message : undefined,
1392
- }));
947
+ const approvalOutcome = delegationOutcome;
948
+ await this.otel?.endOperation(nativeApproval ?? { span: null, name: 'approval.wait' }, {
949
+ category: 'approval',
950
+ name: 'approval.wait',
951
+ delegationId,
952
+ approvalOutcome,
953
+ approvalWaitMs: Math.max(0, Math.round(performance.now() - approvalStarted)),
954
+ });
955
+ await this.finalizeDelegationOperation(delegationId, delegationOutcome);
956
+ }
957
+ }
958
+ operationForTool(toolName, mcpServer, planItemOrdinal, override) {
959
+ if (override) {
960
+ return {
961
+ ...override,
962
+ mcpServer: override.mcpServer ?? mcpServer,
963
+ toolName,
964
+ planItemOrdinal,
965
+ };
966
+ }
967
+ return {
968
+ category: 'tool',
969
+ name: 'tool.execute',
970
+ toolType: 'function',
971
+ toolName,
972
+ planItemOrdinal,
973
+ };
974
+ }
975
+ async finalizeDelegationOperation(delegationId, delegationOutcome) {
976
+ if (this.completedDelegations.has(delegationId))
977
+ return;
978
+ const terminal = this.delegationTerminalOperations.get(delegationId);
979
+ if (terminal)
980
+ return terminal;
981
+ const finalization = (async () => {
982
+ const pending = this.pendingDelegationOperations.get(delegationId);
983
+ this.pendingDelegationOperations.delete(delegationId);
984
+ if (pending) {
985
+ try {
986
+ // Span names are immutable: finish the measured wait as a wait, then
987
+ // record the canonical terminal operation separately.
988
+ await this.otel?.endOperation(pending, {
989
+ category: 'delegation',
990
+ name: 'delegation.wait',
991
+ delegationId,
992
+ delegationOutcome,
993
+ });
994
+ }
995
+ finally {
996
+ await this.otel?.recordOperation({
997
+ category: 'delegation',
998
+ name: 'delegation.complete',
999
+ delegationId,
1000
+ delegationOutcome,
1001
+ });
1393
1002
  }
1394
1003
  }
1395
- }
1004
+ else {
1005
+ await this.otel?.recordOperation({
1006
+ category: 'delegation',
1007
+ name: 'delegation.complete',
1008
+ delegationId,
1009
+ delegationOutcome,
1010
+ });
1011
+ }
1012
+ try {
1013
+ this.completedDelegations.add(delegationId);
1014
+ if (this.completedDelegations.size > 256)
1015
+ this.completedDelegations.delete(this.completedDelegations.values().next().value);
1016
+ }
1017
+ finally {
1018
+ this.delegationTerminalOperations.delete(delegationId);
1019
+ }
1020
+ })();
1021
+ this.delegationTerminalOperations.set(delegationId, finalization);
1022
+ return finalization;
1396
1023
  }
1397
1024
  static extractAmount(args) {
1398
1025
  if (!args || typeof args !== 'object')
@@ -1517,20 +1144,6 @@ class ArmorIQSession {
1517
1144
  * reanchor is off
1518
1145
  */
1519
1146
  async flushReanchor() {
1520
- const startNs = this.obs ? performance.now() : 0;
1521
- const ctx = this.obs ? this.ensurePlanTrace() : null;
1522
- const spanId = ctx && this.obs
1523
- ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
1524
- name: 'iap.reanchor.flush',
1525
- attributes: {
1526
- hasPendingPlan: !!this.pendingReanchorPlan,
1527
- hasToken: !!this.currentToken,
1528
- },
1529
- })) ?? undefined
1530
- : undefined;
1531
- let caught;
1532
- let fired = false;
1533
- let trustId;
1534
1147
  try {
1535
1148
  if (!this.pendingReanchorPlan) {
1536
1149
  return { fired: false };
@@ -1546,34 +1159,13 @@ class ArmorIQSession {
1546
1159
  const planToFlush = this.pendingReanchorPlan;
1547
1160
  this.pendingReanchorPlan = undefined;
1548
1161
  const result = await this.reanchorCall(this.currentToken, planToFlush, 'flush at request boundary');
1549
- fired = true;
1550
- trustId = result.trustId;
1551
- return { fired: true, trustId };
1162
+ return { fired: true, trustId: result.trustId };
1552
1163
  }
1553
1164
  catch (err) {
1554
- caught = err;
1555
1165
  const msg = err instanceof Error ? err.message : String(err);
1556
1166
  console.warn(`[armoriq] flushReanchor failed: ${msg}`);
1557
1167
  return { fired: false };
1558
1168
  }
1559
- finally {
1560
- if (ctx) {
1561
- const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
1562
- safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'iap.reanchor.flush.result', {
1563
- kind: 'span',
1564
- fired,
1565
- trustId: trustId ?? null,
1566
- errorMessage: caught instanceof Error ? caught.message : undefined,
1567
- }, caught ? 'error' : 'ok', null, spanId)));
1568
- if (spanId) {
1569
- safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
1570
- status: caught ? 'error' : 'ok',
1571
- durationMs: computedDuration,
1572
- errorMessage: caught instanceof Error ? caught.message : undefined,
1573
- }));
1574
- }
1575
- }
1576
- }
1577
1169
  }
1578
1170
  get currentTokenValue() {
1579
1171
  return this.currentToken;
@@ -1582,57 +1174,41 @@ class ArmorIQSession {
1582
1174
  return this.mode;
1583
1175
  }
1584
1176
  /**
1585
- * Force-flush all buffered observability traces/spans to the backend ingest
1586
- * endpoint. No-op when observability is disabled (`observability.enabled: false`).
1177
+ * End the active native OTel plan span and force-flush its exporter.
1178
+ * No-op when native observability is disabled.
1587
1179
  *
1588
1180
  * Call this at the request boundary (e.g. Stop hook in armorClaude, end of
1589
- * turn in ADK consumers) to guarantee all spans for the current session have
1590
- * been POSTed before the process exits. The underlying shipper retries with
1591
- * exponential backoff and **never throws** into the SDK consumer — a flush
1592
- * failure is logged and the spans are dropped.
1181
+ * turn in ADK consumers) to make completed native spans available before the
1182
+ * process exits. Export remains fail-open and does not change tool execution.
1593
1183
  */
1594
1184
  async flushObservability() {
1595
- if (!this.obs)
1185
+ if (!this.otel)
1596
1186
  return;
1597
- // Model A: end the in-flight active plan trace FIRST so it's handed to
1598
- // the shipper's queue before we flush — otherwise an in-progress plan
1599
- // trace (no endTime yet) would be left behind in the ring buffer and
1600
- // never ship (see ObservabilityRecorder.flush()'s in-flight retention).
1601
- safeObs(() => this.endPlanTrace('ok'));
1602
- await (0, observability_1.flushObservability)(this.obs);
1187
+ await this.drainPolicyTelemetry();
1188
+ await this.otel.flush('ok');
1603
1189
  }
1604
1190
  /**
1605
- * Stop the session's observability lifecycle: clears the shipper's
1606
- * repeating flush interval (`setInterval`, see `ObservabilityShipper`) and
1607
- * performs one final drain/flush of anything still queued. Safe to call
1608
- * multiple times; a no-op when observability is disabled.
1191
+ * Close the native OTel session: end any active plan and root spans, then
1192
+ * perform the final exporter shutdown. Safe to call multiple times; a no-op
1193
+ * when native observability is disabled.
1609
1194
  *
1610
- * **Required at session end.** Without calling this, the recorder's
1611
- * shipper keeps its interval timer alive indefinitely — in long-running
1612
- * processes this is a resource leak, and in tests it is why processes
1613
- * (and jest) can hang or emit "cannot log after tests done" / stray
1614
- * network activity after teardown. `flushObservability()` alone does NOT
1615
- * stop the interval — it only flushes; call `close()` (or `dispose()`) at
1616
- * the natural end of the session's lifetime (e.g. Stop hook in armorClaude,
1617
- * end of process, or end of a request/turn in ADK consumers) instead of
1618
- * (or in addition to) `flushObservability()`.
1195
+ * Call `close()` (or `dispose()`) at the natural end of the session's
1196
+ * lifetime, such as an adapter Stop hook or the end of a request/turn.
1619
1197
  *
1620
1198
  * NEVER throws — observability teardown failures must not propagate into
1621
1199
  * the SDK consumer, matching every other observability code path.
1622
1200
  */
1623
- async close() {
1624
- if (!this.obs)
1201
+ async close(status = 'ok', taskOutcome, content = {}) {
1202
+ if (!this.otel)
1625
1203
  return;
1626
- // Model A: end the active plan trace before stopping the shipper so its
1627
- // final flush (inside obs.stop()) ships the completed plan trace rather
1628
- // than leaving it stranded in-flight.
1629
- safeObs(() => this.endPlanTrace('ok'));
1630
1204
  try {
1631
- await this.obs.stop();
1205
+ await this.drainPolicyTelemetry();
1206
+ await this.otel?.close(status, taskOutcome, content);
1632
1207
  }
1633
- catch (err) {
1634
- const msg = err instanceof Error ? err.message : String(err);
1635
- console.warn(`[armoriq] observability close() failed (continuing): ${msg}`);
1208
+ finally {
1209
+ this.pendingDelegationOperations.clear();
1210
+ this.delegationTerminalOperations.clear();
1211
+ this.completedDelegations.clear();
1636
1212
  }
1637
1213
  }
1638
1214
  /** Alias for `close()`. Use whichever reads better at the call site. */