@armoriq/sdk-dev 0.6.10 → 0.8.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 (96) hide show
  1. package/README.md +168 -1
  2. package/dist/_version.d.ts +1 -1
  3. package/dist/_version.d.ts.map +1 -1
  4. package/dist/_version.js +1 -1
  5. package/dist/_version.js.map +1 -1
  6. package/dist/cli/commands/auth.d.ts +12 -0
  7. package/dist/cli/commands/auth.d.ts.map +1 -1
  8. package/dist/cli/commands/auth.js +522 -49
  9. package/dist/cli/commands/auth.js.map +1 -1
  10. package/dist/client.d.ts +8 -15
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +20 -18
  13. package/dist/client.js.map +1 -1
  14. package/dist/config.d.ts +0 -17
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/config.js +1 -19
  17. package/dist/config.js.map +1 -1
  18. package/dist/index.d.ts +3 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +8 -14
  21. package/dist/index.js.map +1 -1
  22. package/dist/integrations/google_adk.d.ts +155 -7
  23. package/dist/integrations/google_adk.d.ts.map +1 -1
  24. package/dist/integrations/google_adk.js +727 -46
  25. package/dist/integrations/google_adk.js.map +1 -1
  26. package/dist/integrations/langchain.d.ts +48 -2
  27. package/dist/integrations/langchain.d.ts.map +1 -1
  28. package/dist/integrations/langchain.js +528 -33
  29. package/dist/integrations/langchain.js.map +1 -1
  30. package/dist/integrations/strands.d.ts +65 -1
  31. package/dist/integrations/strands.d.ts.map +1 -1
  32. package/dist/integrations/strands.js +456 -36
  33. package/dist/integrations/strands.js.map +1 -1
  34. package/dist/models.d.ts +2 -2
  35. package/dist/models.d.ts.map +1 -1
  36. package/dist/observability/content-capture.d.ts +103 -0
  37. package/dist/observability/content-capture.d.ts.map +1 -0
  38. package/dist/observability/content-capture.js +423 -0
  39. package/dist/observability/content-capture.js.map +1 -0
  40. package/dist/observability/index.d.ts +6 -7
  41. package/dist/observability/index.d.ts.map +1 -1
  42. package/dist/observability/index.js +18 -27
  43. package/dist/observability/index.js.map +1 -1
  44. package/dist/observability/otel-config.d.ts +47 -0
  45. package/dist/observability/otel-config.d.ts.map +1 -0
  46. package/dist/observability/otel-config.js +268 -0
  47. package/dist/observability/otel-config.js.map +1 -0
  48. package/dist/observability/otel-export-ceiling.d.ts +96 -0
  49. package/dist/observability/otel-export-ceiling.d.ts.map +1 -0
  50. package/dist/observability/otel-export-ceiling.js +271 -0
  51. package/dist/observability/otel-export-ceiling.js.map +1 -0
  52. package/dist/observability/otel-runtime.d.ts +103 -0
  53. package/dist/observability/otel-runtime.d.ts.map +1 -0
  54. package/dist/observability/otel-runtime.js +680 -0
  55. package/dist/observability/otel-runtime.js.map +1 -0
  56. package/dist/observability/otel-session.d.ts +168 -0
  57. package/dist/observability/otel-session.d.ts.map +1 -0
  58. package/dist/observability/otel-session.js +630 -0
  59. package/dist/observability/otel-session.js.map +1 -0
  60. package/dist/observability/otel-shutdown.d.ts +17 -0
  61. package/dist/observability/otel-shutdown.d.ts.map +1 -0
  62. package/dist/observability/otel-shutdown.js +54 -0
  63. package/dist/observability/otel-shutdown.js.map +1 -0
  64. package/dist/observability/policy-lease.d.ts +22 -0
  65. package/dist/observability/policy-lease.d.ts.map +1 -0
  66. package/dist/observability/policy-lease.js +102 -0
  67. package/dist/observability/policy-lease.js.map +1 -0
  68. package/dist/plan_builder.d.ts +5 -4
  69. package/dist/plan_builder.d.ts.map +1 -1
  70. package/dist/plan_builder.js +14 -15
  71. package/dist/plan_builder.js.map +1 -1
  72. package/dist/session.d.ts +61 -93
  73. package/dist/session.d.ts.map +1 -1
  74. package/dist/session.js +388 -804
  75. package/dist/session.js.map +1 -1
  76. package/dist/token_usage.d.ts +11 -18
  77. package/dist/token_usage.d.ts.map +1 -1
  78. package/dist/token_usage.js +29 -94
  79. package/dist/token_usage.js.map +1 -1
  80. package/dist/tool_name.d.ts +18 -0
  81. package/dist/tool_name.d.ts.map +1 -0
  82. package/dist/tool_name.js +29 -0
  83. package/dist/tool_name.js.map +1 -0
  84. package/dist/tool_push.d.ts +28 -0
  85. package/dist/tool_push.d.ts.map +1 -0
  86. package/dist/tool_push.js +151 -0
  87. package/dist/tool_push.js.map +1 -0
  88. package/dist/tool_registry.d.ts +100 -0
  89. package/dist/tool_registry.d.ts.map +1 -0
  90. package/dist/tool_registry.js +440 -0
  91. package/dist/tool_registry.js.map +1 -0
  92. package/dist/tool_schema.d.ts +22 -0
  93. package/dist/tool_schema.d.ts.map +1 -0
  94. package/dist/tool_schema.js +163 -0
  95. package/dist/tool_schema.js.map +1 -0
  96. package/package.json +12 -7
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,286 +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 planTraceStatus = 'ok';
435
- let planTraceError;
436
- try {
437
- const result = await this._startPlanImpl(toolCalls, goal, ctx);
438
- if (ctx) {
439
- safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'iap.plan.grew', {
440
- kind: 'event',
441
- message: `plan captured: ${toolCalls?.length ?? 0} tool(s)`,
442
- level: 'info',
443
- toolCount: toolCalls?.length ?? 0,
444
- })));
445
- }
446
- return result;
447
- }
448
- catch (err) {
449
- planTraceStatus = 'error';
450
- planTraceError = err.message;
451
- throw err;
452
- }
453
- finally {
454
- if (ctx && spanId) {
455
- const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
456
- safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
457
- status: planTraceStatus === 'error' ? 'error' : 'ok',
458
- durationMs: computedDuration,
459
- errorMessage: planTraceError,
460
- }));
461
- }
462
- }
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;
463
210
  }
464
- async _startPlanImpl(toolCalls, goal, _ctx) {
211
+ async _startPlanImpl(toolCalls, goal) {
465
212
  if (!toolCalls || toolCalls.length === 0) {
466
213
  throw new Error('startPlan called with no tool calls.');
467
214
  }
@@ -561,58 +308,21 @@ class ArmorIQSession {
561
308
  return token;
562
309
  }
563
310
  // ─── Policy enforcement ────────────────────────────────────────
564
- /**
565
- * `parentContainerSpanId` is an internal-only hook (not part of the public
566
- * signature contract other SDK consumers rely on — it's simply an extra
567
- * optional trailing arg) so `check()` can nest this call's container span
568
- * under `iap.check`'s own container span instead of both sitting as
569
- * siblings directly under the plan trace (Task 3 risk: "check → enforce
570
- * double-open" — `iap.check`'s span wraps the dispatched enforce span,
571
- * it never opens a second redundant plan trace or duplicate container).
572
- */
573
- enforceLocal(toolName, toolArgs, parentContainerSpanId) {
574
- const startNs = this.obs ? performance.now() : 0;
575
- const ctx = this.obs ? this.ensurePlanTrace() : null;
576
- const spanId = ctx && this.obs
577
- ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
578
- name: 'iap.enforce.local',
579
- attributes: { toolName, toolArgs: truncateForSpan(toolArgs) },
580
- parentSpanId: parentContainerSpanId ?? null,
581
- })) ?? undefined
582
- : undefined;
583
- let caught;
584
- try {
585
- return this._enforceLocalImpl(toolName, toolArgs, ctx, spanId);
586
- }
587
- catch (err) {
588
- caught = err;
589
- throw err;
590
- }
591
- finally {
592
- if (ctx && spanId) {
593
- const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
594
- safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
595
- status: caught ? 'error' : 'ok',
596
- durationMs: computedDuration,
597
- errorMessage: caught instanceof Error ? caught.message : undefined,
598
- }));
599
- }
600
- }
311
+ enforceLocal(toolName, toolArgs) {
312
+ const decision = this._enforceLocalImpl(toolName, toolArgs);
313
+ this.trackPolicyTelemetry(this.recordPolicyDecision(toolName, toolArgs, decision));
314
+ return decision;
601
315
  }
602
- _enforceLocalImpl(toolName, toolArgs, ctx, spanId) {
316
+ _enforceLocalImpl(toolName, toolArgs) {
603
317
  if (!this.currentToken) {
604
- const r = {
318
+ return {
605
319
  allowed: false,
606
320
  action: 'block',
607
321
  reason: 'No intent token — call startPlan() first',
608
322
  };
609
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', {}, spanId);
610
- return r;
611
323
  }
612
324
  if (models_1.IntentToken.isExpired(this.currentToken)) {
613
- const r = { allowed: false, action: 'block', reason: 'token-expired' };
614
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', {}, spanId);
615
- return r;
325
+ return { allowed: false, action: 'block', reason: 'token-expired' };
616
326
  }
617
327
  // The local decision is read from the token's policy fields, so the token
618
328
  // must be cryptographically authentic first - otherwise a forged token
@@ -622,22 +332,18 @@ class ArmorIQSession {
622
332
  // the token. Fail CLOSED if the key is unavailable or the signature is bad.
623
333
  const iapKey = this.client.iapPublicKeySync();
624
334
  if (!iapKey || !(0, crypto_verify_1.verifyEdDSAJwt)(this.currentToken.jwtToken ?? '', iapKey)) {
625
- const r = { allowed: false, action: 'block', reason: 'token-signature-invalid' };
626
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', {}, spanId);
627
- return r;
335
+ return { allowed: false, action: 'block', reason: 'token-signature-invalid' };
628
336
  }
629
337
  const { mcp, action } = this.toolNameParser(toolName);
630
338
  const inPlan = this.declaredTools.has(toolName) ||
631
339
  this.declaredTools.has(action) ||
632
340
  this.declaredTools.has(`${mcp}__${action}`);
633
341
  if (!inPlan) {
634
- const r = {
342
+ return {
635
343
  allowed: false,
636
344
  action: 'block',
637
345
  reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
638
346
  };
639
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp }, spanId);
640
- return r;
641
347
  }
642
348
  const pv = (this.currentToken.policyValidation ?? {});
643
349
  const snapshot = (this.currentToken.policySnapshot ?? []);
@@ -682,14 +388,12 @@ class ArmorIQSession {
682
388
  : `Tool '${action}' is denied by policy`);
683
389
  // Deny-precedence: an explicitly denied tool is never allowed, even when
684
390
  // the default action is 'allow'. hold only if the policy default is hold.
685
- const r = {
391
+ return {
686
392
  allowed: false,
687
393
  action: defaultAction === 'hold' ? 'hold' : 'block',
688
394
  reason,
689
395
  matchedPolicy: governingPolicyName,
690
396
  };
691
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
692
- return r;
693
397
  }
694
398
  }
695
399
  // Honor the backend-baked resolved allow set: an explicit *empty*
@@ -697,42 +401,34 @@ class ArmorIQSession {
697
401
  // ⇒ deny-all, regardless of what any individual snapshot rule allows.
698
402
  const bakedAllowedTools = pv.allowed_tools;
699
403
  if (Array.isArray(bakedAllowedTools) && bakedAllowedTools.length === 0) {
700
- const r = {
404
+ return {
701
405
  allowed: false,
702
406
  action: 'block',
703
407
  reason: `Tool '${action}' is not allowed by any policy in scope (empty resolved allow-list)`,
704
408
  };
705
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
706
- return r;
707
409
  }
708
410
  if (governingRule) {
709
411
  const allowed = governingRule.allowedTools ?? [];
710
412
  if (Array.isArray(allowed) && allowed.length > 0) {
711
- const ok = allowed.includes('*') ||
712
- allowed.includes(action) ||
713
- allowed.includes(toolName);
413
+ const ok = allowed.includes('*') || allowed.includes(action) || allowed.includes(toolName);
714
414
  if (!ok) {
715
415
  // Tool is outside this rule's explicit allow set. Fall back to the
716
416
  // policy default action: an 'allow' default permits it, hold/block
717
417
  // otherwise.
718
418
  if (defaultAction === 'allow') {
719
- const r = {
419
+ return {
720
420
  allowed: true,
721
421
  action: 'allow',
722
422
  reason: `Allowed by default enforcement action for policy '${governingPolicyName}'`,
723
423
  matchedPolicy: governingPolicyName,
724
424
  };
725
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
726
- return r;
727
425
  }
728
- const r = {
426
+ return {
729
427
  allowed: false,
730
428
  action: defaultAction,
731
429
  reason: `Tool '${action}' is not in the allowed tools for policy '${governingPolicyName}'`,
732
430
  matchedPolicy: governingPolicyName,
733
431
  };
734
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
735
- return r;
736
432
  }
737
433
  }
738
434
  }
@@ -740,102 +436,83 @@ class ArmorIQSession {
740
436
  const thresholdDecision = this.evaluateAmountThreshold(governingRule, toolArgs, action, mcp);
741
437
  if (thresholdDecision) {
742
438
  thresholdDecision.matchedPolicy = governingPolicyName;
743
- this._emitEnforcePolicyCall(ctx, toolName, thresholdDecision, 'sdk-local', { mcp, policyValidation: pv }, spanId);
744
439
  return thresholdDecision;
745
440
  }
746
441
  }
747
- const r = {
442
+ return {
748
443
  allowed: true,
749
444
  action: 'allow',
750
445
  reason: 'Allowed by local policy evaluation',
751
446
  matchedPolicy: governingPolicyName,
752
447
  };
753
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk-local', { mcp, policyValidation: pv }, spanId);
754
- return r;
755
448
  }
756
- _emitEnforcePolicyCall(ctx, toolName, result, source, extras, parentSpanId) {
757
- if (!ctx || !this.obs)
758
- return;
759
- const decision = result.action === 'allow'
760
- ? 'allow'
761
- : result.action === 'hold'
762
- ? 'hold'
763
- : 'deny';
764
- const enforcementAction = result.action;
765
- safeObs(() => (0, observability_1.recordPolicyCall)(this.obs, ctx, {
766
- policyId: null,
767
- policyName: result.matchedPolicy ?? null,
768
- decision,
769
- reason: result.reason ?? null,
770
- source,
771
- input: { toolName },
772
- output: {
773
- allowed: result.allowed,
774
- action: result.action,
775
- matchedPolicy: result.matchedPolicy ?? null,
776
- delegationId: result.delegationId ?? null,
777
- },
778
- policyHash: null,
779
- policyVersion: null,
780
- matchedRuleId: null,
781
- dataClasses: [],
782
- enforcementAction,
783
- obligations: extras.obligations ?? result.obligations ?? [],
784
- delegationId: result.delegationId ?? null,
785
- }, 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;
786
459
  }
787
- /** See `enforceLocal`'s doc comment re: `parentContainerSpanId` (internal-only hook for `check()`). */
788
- async enforceSdk(toolName, toolArgs, userEmail, parentContainerSpanId) {
789
- const startNs = this.obs ? performance.now() : 0;
790
- const ctx = this.obs ? this.ensurePlanTrace() : null;
791
- const spanId = ctx && this.obs
792
- ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
793
- name: 'iap.enforce.sdk',
794
- attributes: { toolName, toolArgs: truncateForSpan(toolArgs), userEmail: userEmail ?? null },
795
- parentSpanId: parentContainerSpanId ?? null,
796
- })) ?? undefined
797
- : undefined;
798
- 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() {
799
469
  try {
800
- 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) } : {};
801
472
  }
802
- catch (err) {
803
- caught = err;
804
- throw err;
473
+ catch {
474
+ return {};
805
475
  }
806
- finally {
807
- if (ctx && spanId) {
808
- const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
809
- safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
810
- status: caught ? 'error' : 'ok',
811
- durationMs: computedDuration,
812
- errorMessage: caught instanceof Error ? caught.message : undefined,
813
- }));
814
- }
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;
815
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;
816
489
  }
817
- async _enforceSdkImpl(toolName, toolArgs, userEmail, ctx, spanId) {
490
+ async _enforceSdkImpl(toolName, toolArgs, userEmail) {
818
491
  if (!this.currentToken) {
819
492
  throw new Error(`enforceSdk("${toolName}") called before startPlan().`);
820
493
  }
821
494
  const { mcp, action } = this.toolNameParser(toolName);
822
- const resolvedMcp = this.mcpByAction.get(action) ?? mcp;
495
+ const resolvedMcp = this.resolveMcp(toolName, mcp, action);
823
496
  const inPlan = this.declaredTools.has(toolName) ||
824
497
  this.declaredTools.has(action) ||
825
498
  this.declaredTools.has(`${resolvedMcp}__${action}`);
826
499
  if (!inPlan) {
827
- const r = {
500
+ return {
828
501
  allowed: false,
829
502
  action: 'block',
830
503
  reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
831
504
  };
832
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp }, spanId);
833
- return r;
834
505
  }
835
506
  const internals = this.client._sessionInternals();
836
507
  try {
837
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,
838
513
  tool: action,
514
+ ...this.inventoryFields(toolName, action, resolvedMcp),
515
+ ...this.snapshotHashField(),
839
516
  arguments: toolArgs,
840
517
  intent_token: this.currentToken.rawToken,
841
518
  policy_snapshot: this.currentToken.policySnapshot,
@@ -846,11 +523,11 @@ class ArmorIQSession {
846
523
  timeout: 10000,
847
524
  });
848
525
  const data = response.data ?? {};
526
+ if (data.inventoryUnknown === true)
527
+ (0, tool_push_1.requestPush)(this.client);
849
528
  const allowed = data.allowed === true;
850
529
  const actionDecision = data.enforcementAction ?? (allowed ? 'allow' : 'block');
851
- const matched = typeof data.matchedPolicy === 'object'
852
- ? data.matchedPolicy?.name
853
- : data.matchedPolicy;
530
+ const matched = typeof data.matchedPolicy === 'object' ? data.matchedPolicy?.name : data.matchedPolicy;
854
531
  // Surface gating obligations metadata (additive). The backend has already
855
532
  // collapsed gating into allowed/action; obligations explain *why*.
856
533
  const obligations = Array.isArray(data.obligations)
@@ -864,77 +541,45 @@ class ArmorIQSession {
864
541
  matchedPolicy: matched,
865
542
  obligations,
866
543
  };
867
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp, obligations }, spanId);
868
544
  return this.handleHold(toolName, toolArgs, r, userEmail);
869
545
  }
870
- const r = {
546
+ return {
871
547
  allowed,
872
548
  action: actionDecision,
873
549
  reason: data.reason ?? data.message,
874
550
  matchedPolicy: matched,
875
551
  obligations,
876
552
  };
877
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp, obligations }, spanId);
878
- return r;
879
553
  }
880
554
  catch (e) {
881
555
  console.error(`enforceSdk() failed: ${e.message}. Blocking tool call (fail-closed).`);
882
- const r = {
556
+ return {
883
557
  allowed: false,
884
558
  action: 'block',
885
559
  reason: `enforce-unavailable: ${e.message}`,
886
560
  };
887
- this._emitEnforcePolicyCall(ctx, toolName, r, 'sdk', { mcp: resolvedMcp }, spanId);
888
- return r;
889
561
  }
890
562
  }
891
- /** See `enforceLocal`'s doc comment re: `parentContainerSpanId` (internal-only hook for `check()`). */
892
- async enforce(toolName, toolArgs, parentContainerSpanId) {
893
- const startNs = this.obs ? performance.now() : 0;
894
- const ctx = this.obs ? this.ensurePlanTrace() : null;
895
- const spanId = ctx && this.obs
896
- ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
897
- name: 'iap.enforce.proxy',
898
- attributes: { toolName, toolArgs: truncateForSpan(toolArgs) },
899
- parentSpanId: parentContainerSpanId ?? null,
900
- })) ?? undefined
901
- : undefined;
902
- let caught;
903
- try {
904
- return await this._enforceImpl(toolName, toolArgs, ctx, spanId);
905
- }
906
- catch (err) {
907
- caught = err;
908
- throw err;
909
- }
910
- finally {
911
- if (ctx && spanId) {
912
- const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
913
- safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
914
- status: caught ? 'error' : 'ok',
915
- durationMs: computedDuration,
916
- errorMessage: caught instanceof Error ? caught.message : undefined,
917
- }));
918
- }
919
- }
563
+ async enforce(toolName, toolArgs) {
564
+ const decision = await this._enforceImpl(toolName, toolArgs);
565
+ await this.recordPolicyDecision(toolName, toolArgs, decision);
566
+ return decision;
920
567
  }
921
- async _enforceImpl(toolName, toolArgs, ctx, spanId) {
568
+ async _enforceImpl(toolName, toolArgs) {
922
569
  if (!this.currentToken) {
923
570
  throw new Error(`enforce("${toolName}") called before startPlan().`);
924
571
  }
925
572
  const { mcp, action } = this.toolNameParser(toolName);
926
- const resolvedMcp = this.mcpByAction.get(action) ?? mcp;
573
+ const resolvedMcp = this.resolveMcp(toolName, mcp, action);
927
574
  const inPlan = this.declaredTools.has(toolName) ||
928
575
  this.declaredTools.has(action) ||
929
576
  this.declaredTools.has(`${resolvedMcp}__${action}`);
930
577
  if (!inPlan) {
931
- const r = {
578
+ return {
932
579
  allowed: false,
933
580
  action: 'block',
934
581
  reason: `tool-not-in-plan: '${toolName}' was not declared in the captured plan`,
935
582
  };
936
- this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
937
- return r;
938
583
  }
939
584
  const internals = this.client._sessionInternals();
940
585
  try {
@@ -961,18 +606,16 @@ class ArmorIQSession {
961
606
  // Fail closed: only an explicit allow from a 2xx response permits the call.
962
607
  // Any error status (403 or otherwise) or an ambiguous body blocks.
963
608
  if (response.status >= 400) {
964
- const r = {
609
+ return {
965
610
  allowed: false,
966
611
  action: data.action ?? 'block',
967
612
  reason: data.reason ?? data.message ?? `enforce-rejected: HTTP ${response.status}`,
968
613
  matchedPolicy: policyName,
969
614
  };
970
- this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
971
- return r;
972
615
  }
973
616
  const allowedFlag = data.allowed === true;
974
617
  const actionDecision = data.enforcementAction ?? data.action ?? (allowedFlag ? 'allow' : 'block');
975
- const r = {
618
+ return {
976
619
  allowed: allowedFlag,
977
620
  action: actionDecision,
978
621
  reason: data.reason,
@@ -980,78 +623,82 @@ class ArmorIQSession {
980
623
  matchedPolicy: policyName,
981
624
  obligations: Array.isArray(data.obligations) ? data.obligations : [],
982
625
  };
983
- this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
984
- return r;
985
626
  }
986
627
  catch (e) {
987
628
  console.error(`enforce() failed: ${e.message}. Blocking tool call (fail-closed).`);
988
- const r = {
629
+ return {
989
630
  allowed: false,
990
631
  action: 'block',
991
632
  reason: `enforce-unavailable: ${e.message}`,
992
633
  };
993
- this._emitEnforcePolicyCall(ctx, toolName, r, 'proxy', { mcp: resolvedMcp }, spanId);
994
- return r;
995
634
  }
996
635
  }
997
- async check(toolName, toolArgs, userEmail) {
998
- const startNs = this.obs ? performance.now() : 0;
999
- const ctx = this.obs ? this.ensurePlanTrace() : null;
1000
- // Task 3 risk — "check → enforce double-open": `check` opens its OWN
1001
- // `iap.check` container span (nested under the single active plan
1002
- // trace, never a second trace), then passes this span's id as the
1003
- // PARENT for whichever enforce* it dispatches into. The dispatched
1004
- // enforce's container span therefore nests under `iap.check`'s span —
1005
- // it does not open a redundant plan trace or a sibling/duplicate
1006
- // container span.
1007
- const spanId = ctx && this.obs
1008
- ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
1009
- name: 'iap.check',
1010
- attributes: {
1011
- toolName,
1012
- toolArgs: truncateForSpan(toolArgs),
1013
- mode: this.mode,
1014
- userEmail: userEmail ?? null,
1015
- },
1016
- })) ?? undefined
1017
- : undefined;
1018
- let caught;
1019
- try {
1020
- if (this.mode === 'sdk') {
1021
- return await this.enforceSdk(toolName, toolArgs, userEmail, spanId);
1022
- }
1023
- if (this.mode === 'local') {
1024
- await this.warmToolMetadata(toolName);
1025
- const decision = this.enforceLocal(toolName, toolArgs, spanId);
1026
- if (decision.action === 'hold') {
1027
- return {
1028
- ...decision,
1029
- action: 'block',
1030
- reason: (decision.reason ?? 'requires approval') +
1031
- ' — switch ARMORIQ_MODE=proxy to enable approval workflows for this action.',
1032
- };
1033
- }
1034
- 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
+ };
1035
651
  }
1036
- const decision = await this.enforce(toolName, toolArgs, spanId);
1037
- if (decision.action !== 'hold')
1038
- return decision;
1039
- return this.handleHold(toolName, toolArgs, decision, userEmail);
1040
- }
1041
- catch (err) {
1042
- caught = err;
1043
- throw err;
1044
652
  }
1045
- finally {
1046
- if (ctx && spanId) {
1047
- const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
1048
- safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
1049
- status: caught ? 'error' : 'ok',
1050
- durationMs: computedDuration,
1051
- errorMessage: caught instanceof Error ? caught.message : undefined,
1052
- }));
653
+ else {
654
+ decision = await this._enforceImpl(toolName, toolArgs);
655
+ if (decision.action === 'hold') {
656
+ decision = await this.handleHold(toolName, toolArgs, decision, userEmail);
1053
657
  }
1054
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]);
1055
702
  }
1056
703
  // ─── Report / dispatch ─────────────────────────────────────────
1057
704
  async report(toolName, toolArgs, result, opts) {
@@ -1061,50 +708,28 @@ class ArmorIQSession {
1061
708
  ? Math.round(opts.durationMs)
1062
709
  : opts?.durationMs,
1063
710
  };
1064
- const startNs = this.obs ? performance.now() : 0;
1065
- const ctx = this.obs ? this.ensurePlanTrace() : null;
1066
- const spanId = ctx && this.obs
1067
- ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
1068
- name: 'tool.report',
1069
- attributes: {
1070
- toolName,
1071
- toolInput: truncateForSpan(toolArgs),
1072
- status: o.status ?? 'success',
1073
- },
1074
- })) ?? undefined
1075
- : undefined;
1076
711
  let reportStatus = 'ok';
1077
712
  let reportError;
713
+ const reportItemOrdinal = this.stepIndex;
714
+ let reportOperation = this.operationForTool(toolName, undefined, reportItemOrdinal, o.operation);
1078
715
  try {
1079
716
  const { mcp, action } = this.toolNameParser(toolName);
1080
- 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
+ }
1081
721
  const internals = this.client._sessionInternals();
1082
722
  try {
1083
723
  const token = this.currentToken;
1084
- const userEmail = this.userEmail ?? this.client.userEmailOverride;
1085
- let output = result;
1086
- if (typeof result === 'string')
1087
- output = { text: result };
1088
- else if (result === null || result === undefined)
1089
- output = {};
1090
- await internals.httpClient.post(`${internals.backendEndpoint}/iap/audit`, {
1091
- token: token?.jwtToken ?? token?.tokenId ?? 'unknown',
1092
- plan_id: token?.planId ?? token?.tokenId ?? 'unknown',
1093
- step_index: this.stepIndex,
724
+ await internals.httpClient.post(`${internals.backendEndpoint}/iap/audit`, auditPersistencePayload({
725
+ token,
726
+ stepIndex: this.stepIndex,
1094
727
  action,
1095
- tool: action,
1096
728
  mcp: resolvedMcp,
1097
- input: toolArgs,
1098
- output,
1099
729
  status: o.status ?? 'success',
1100
- error_message: o.errorMessage,
1101
- duration_ms: o.durationMs,
1102
- is_delegated: o.isDelegated,
1103
- delegated_by: o.delegatedBy,
1104
- user_email: userEmail,
1105
- delegated_to: o.delegatedTo,
1106
- executed_at: new Date().toISOString(),
1107
- }, {
730
+ durationMs: o.durationMs,
731
+ isDelegated: o.isDelegated,
732
+ }), {
1108
733
  headers: { 'X-API-Key': internals.apiKey, 'Content-Type': 'application/json' },
1109
734
  timeout: 5000,
1110
735
  });
@@ -1120,49 +745,43 @@ class ArmorIQSession {
1120
745
  throw err;
1121
746
  }
1122
747
  finally {
1123
- if (ctx) {
1124
- const computedDuration = o.durationMs ?? (startNs > 0 ? Math.round(performance.now() - startNs) : undefined);
1125
- safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'tool.report', {
1126
- kind: 'span',
748
+ if (o.emitOtel !== false) {
749
+ await this.otel?.recordTool({
1127
750
  toolName,
1128
- toolInput: truncateForSpan(toolArgs),
1129
- toolOutput: truncateForSpan(result),
1130
- // `status` here is the caller-supplied tool outcome
1131
- // ('success'|'failed'|'error' from ReportOptions), a distinct
1132
- // side-channel signal from the top-level SpanRecord.status
1133
- // ('ok'|'error'|'denied' span lifecycle status) below —
1134
- // intentionally NOT a duplicate.
1135
- status: o.status ?? 'success',
1136
- errorMessage: o.errorMessage,
1137
- }, reportStatus === 'error' ? 'error' : 'ok', computedDuration ?? null, spanId)));
1138
- if (spanId) {
1139
- safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
1140
- status: reportStatus === 'error' ? 'error' : 'ok',
1141
- durationMs: computedDuration,
1142
- errorMessage: reportError,
1143
- }));
1144
- }
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
+ });
1145
760
  }
1146
761
  }
1147
762
  }
1148
763
  async dispatch(toolName, toolArgs) {
1149
- const startNs = this.obs ? performance.now() : 0;
1150
- const ctx = this.obs ? this.ensurePlanTrace() : null;
1151
- const spanId = ctx && this.obs
1152
- ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
1153
- name: 'tool.dispatch',
1154
- attributes: { toolName, toolInput: truncateForSpan(toolArgs) },
1155
- })) ?? undefined
1156
- : undefined;
1157
764
  let dispatchResult;
1158
765
  let dispatchStatus = 'ok';
1159
766
  let dispatchError;
767
+ let nativeTool;
1160
768
  try {
1161
769
  if (!this.currentToken) {
1162
770
  throw new Error(`dispatch("${toolName}") called before startPlan().`);
1163
771
  }
1164
772
  const { mcp, action } = this.toolNameParser(toolName);
1165
- 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
+ });
1166
785
  dispatchResult = await this.client.invoke(resolvedMcp, action, this.currentToken, toolArgs);
1167
786
  this.stepIndex += 1;
1168
787
  return dispatchResult?.result;
@@ -1173,46 +792,23 @@ class ArmorIQSession {
1173
792
  throw err;
1174
793
  }
1175
794
  finally {
1176
- if (ctx) {
1177
- const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
1178
- safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'tool.dispatch', {
1179
- kind: 'span',
1180
- toolName,
1181
- toolInput: truncateForSpan(toolArgs),
1182
- result: truncateForSpan(dispatchResult),
1183
- }, dispatchStatus === 'error' ? 'error' : 'ok', computedDuration ?? null, spanId)));
1184
- if (spanId) {
1185
- safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
1186
- status: dispatchStatus === 'error' ? 'error' : 'ok',
1187
- durationMs: computedDuration,
1188
- errorMessage: dispatchError,
1189
- }));
1190
- }
1191
- }
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
+ });
1192
802
  }
1193
803
  }
1194
804
  // ─── Helpers ────────────────────────────────────────────────────
1195
805
  async handleHold(toolName, toolArgs, holdDecision, userEmail) {
1196
- const startNs = this.obs ? performance.now() : 0;
1197
- const ctx = this.obs ? this.ensurePlanTrace() : null;
1198
- const spanId = ctx && this.obs
1199
- ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
1200
- name: 'delegation.hold',
1201
- attributes: {
1202
- toolName,
1203
- toolArgs: truncateForSpan(toolArgs),
1204
- matchedPolicy: holdDecision.matchedPolicy ?? null,
1205
- },
1206
- })) ?? undefined
1207
- : undefined;
1208
806
  let result;
1209
- let holdStatus = 'ok';
1210
- let holdError;
1211
- try {
807
+ {
1212
808
  const internals = this.client._sessionInternals();
1213
809
  const email = userEmail ?? internals.userId ?? 'unknown@armoriq';
1214
810
  const { mcp, action } = this.toolNameParser(toolName);
1215
- const resolvedMcp = this.mcpByAction.get(action) ?? mcp;
811
+ const resolvedMcp = this.resolveMcp(toolName, mcp, action);
1216
812
  const rawAmount = ArmorIQSession.extractAmount(toolArgs) ?? 0;
1217
813
  const safeAmount = typeof rawAmount === 'number' && rawAmount >= 0.01 ? rawAmount : 0.01;
1218
814
  try {
@@ -1263,6 +859,16 @@ class ArmorIQSession {
1263
859
  catch (e) {
1264
860
  console.warn(`createDelegationRequest failed: ${e.message}`);
1265
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
+ }
1266
872
  result = {
1267
873
  allowed: false,
1268
874
  action: 'hold',
@@ -1273,29 +879,6 @@ class ArmorIQSession {
1273
879
  };
1274
880
  return result;
1275
881
  }
1276
- catch (err) {
1277
- holdStatus = 'error';
1278
- holdError = err.message;
1279
- throw err;
1280
- }
1281
- finally {
1282
- if (ctx) {
1283
- const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
1284
- safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'delegation.hold', {
1285
- kind: 'span',
1286
- delegationId: result?.delegationId ?? null,
1287
- decision: result?.action ?? 'hold',
1288
- errorMessage: holdError,
1289
- }, holdStatus === 'error' ? 'error' : 'ok', computedDuration ?? null, spanId)));
1290
- if (spanId) {
1291
- safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
1292
- status: holdStatus === 'error' ? 'error' : 'ok',
1293
- durationMs: computedDuration,
1294
- errorMessage: holdError,
1295
- }));
1296
- }
1297
- }
1298
- }
1299
882
  }
1300
883
  /**
1301
884
  * Poll a held delegation until it is decided; non-blocking (async).
@@ -1305,16 +888,13 @@ class ArmorIQSession {
1305
888
  * timeout/interval are in seconds (mirrors the Python SDK await_approval).
1306
889
  */
1307
890
  async awaitApproval(delegationId, opts = {}) {
1308
- const startNs = this.obs ? performance.now() : 0;
1309
- const ctx = this.obs ? this.ensurePlanTrace() : null;
1310
- const spanId = ctx && this.obs
1311
- ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
1312
- name: 'delegation.poll',
1313
- attributes: { delegationId, timeout: opts.timeout ?? 300, interval: opts.interval ?? 5 },
1314
- })) ?? undefined
1315
- : undefined;
1316
- let caught;
1317
- 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
+ });
1318
898
  try {
1319
899
  const timeout = opts.timeout ?? 300;
1320
900
  const interval = opts.interval ?? 5;
@@ -1322,19 +902,15 @@ class ArmorIQSession {
1322
902
  const email = opts.userEmail || this.client.userId || 'unknown@armoriq';
1323
903
  const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
1324
904
  let waited = 0;
1325
- let pollCount = 0;
1326
905
  while (waited < timeout) {
1327
906
  await sleep(interval);
1328
907
  waited += interval;
1329
- pollCount += 1;
1330
908
  let status;
1331
909
  try {
1332
910
  status = await this.client.getDelegationStatus(delegationId);
1333
911
  }
1334
912
  catch (e) {
1335
- if (e instanceof exceptions_1.DelegationException &&
1336
- e.statusCode &&
1337
- transient.has(e.statusCode)) {
913
+ if (e instanceof exceptions_1.DelegationException && e.statusCode && transient.has(e.statusCode)) {
1338
914
  const backoff = Math.min(interval * 2, 15);
1339
915
  await sleep(backoff);
1340
916
  waited += backoff;
@@ -1342,49 +918,108 @@ class ArmorIQSession {
1342
918
  }
1343
919
  throw e;
1344
920
  }
1345
- if (ctx) {
1346
- safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeEventRecord(ctx, 'delegation.poll.tick', {
1347
- kind: 'event',
1348
- message: `poll #${pollCount} status=${status}`,
1349
- level: 'info',
1350
- delegationId,
1351
- }, spanId)));
1352
- }
1353
921
  if (status === 'approved') {
1354
922
  await this.client.markDelegationExecuted(email, delegationId, this.currentToken?.planId);
1355
- pollOutcome = 'approved';
923
+ delegationOutcome = 'approved';
1356
924
  return 'approved';
1357
925
  }
1358
926
  if (status === 'rejected') {
1359
- pollOutcome = 'rejected';
927
+ delegationOutcome = 'denied';
1360
928
  return 'rejected';
1361
929
  }
930
+ if (status === 'cancelled') {
931
+ delegationOutcome = 'cancelled';
932
+ return 'timeout';
933
+ }
934
+ if (status === 'expired') {
935
+ delegationOutcome = 'expired';
936
+ return 'timeout';
937
+ }
1362
938
  }
1363
- pollOutcome = 'timeout';
939
+ delegationOutcome = 'expired';
1364
940
  return 'timeout';
1365
941
  }
1366
942
  catch (err) {
1367
- caught = err;
943
+ delegationOutcome = isCancellation(err) ? 'cancelled' : 'failed';
1368
944
  throw err;
1369
945
  }
1370
946
  finally {
1371
- if (ctx) {
1372
- const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
1373
- safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'delegation.poll.terminal', {
1374
- kind: 'span',
1375
- delegationId,
1376
- decision: pollOutcome,
1377
- errorMessage: caught instanceof Error ? caught.message : undefined,
1378
- }, caught ? 'error' : 'ok', computedDuration ?? null, spanId)));
1379
- if (spanId) {
1380
- safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
1381
- status: caught ? 'error' : 'ok',
1382
- durationMs: computedDuration,
1383
- errorMessage: caught instanceof Error ? caught.message : undefined,
1384
- }));
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
+ });
1385
1002
  }
1386
1003
  }
1387
- }
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;
1388
1023
  }
1389
1024
  static extractAmount(args) {
1390
1025
  if (!args || typeof args !== 'object')
@@ -1509,20 +1144,6 @@ class ArmorIQSession {
1509
1144
  * reanchor is off
1510
1145
  */
1511
1146
  async flushReanchor() {
1512
- const startNs = this.obs ? performance.now() : 0;
1513
- const ctx = this.obs ? this.ensurePlanTrace() : null;
1514
- const spanId = ctx && this.obs
1515
- ? safeObs(() => (0, observability_1.openSpan)(this.obs, ctx, {
1516
- name: 'iap.reanchor.flush',
1517
- attributes: {
1518
- hasPendingPlan: !!this.pendingReanchorPlan,
1519
- hasToken: !!this.currentToken,
1520
- },
1521
- })) ?? undefined
1522
- : undefined;
1523
- let caught;
1524
- let fired = false;
1525
- let trustId;
1526
1147
  try {
1527
1148
  if (!this.pendingReanchorPlan) {
1528
1149
  return { fired: false };
@@ -1538,34 +1159,13 @@ class ArmorIQSession {
1538
1159
  const planToFlush = this.pendingReanchorPlan;
1539
1160
  this.pendingReanchorPlan = undefined;
1540
1161
  const result = await this.reanchorCall(this.currentToken, planToFlush, 'flush at request boundary');
1541
- fired = true;
1542
- trustId = result.trustId;
1543
- return { fired: true, trustId };
1162
+ return { fired: true, trustId: result.trustId };
1544
1163
  }
1545
1164
  catch (err) {
1546
- caught = err;
1547
1165
  const msg = err instanceof Error ? err.message : String(err);
1548
1166
  console.warn(`[armoriq] flushReanchor failed: ${msg}`);
1549
1167
  return { fired: false };
1550
1168
  }
1551
- finally {
1552
- if (ctx) {
1553
- const computedDuration = startNs > 0 ? Math.round(performance.now() - startNs) : undefined;
1554
- safeObs(() => (0, observability_1.recordSpan)(this.obs, ctx, makeSpanRecord(ctx, 'iap.reanchor.flush.result', {
1555
- kind: 'span',
1556
- fired,
1557
- trustId: trustId ?? null,
1558
- errorMessage: caught instanceof Error ? caught.message : undefined,
1559
- }, caught ? 'error' : 'ok', null, spanId)));
1560
- if (spanId) {
1561
- safeObs(() => (0, observability_1.closeSpan)(this.obs, ctx, spanId, {
1562
- status: caught ? 'error' : 'ok',
1563
- durationMs: computedDuration,
1564
- errorMessage: caught instanceof Error ? caught.message : undefined,
1565
- }));
1566
- }
1567
- }
1568
- }
1569
1169
  }
1570
1170
  get currentTokenValue() {
1571
1171
  return this.currentToken;
@@ -1574,57 +1174,41 @@ class ArmorIQSession {
1574
1174
  return this.mode;
1575
1175
  }
1576
1176
  /**
1577
- * Force-flush all buffered observability traces/spans to the backend ingest
1578
- * 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.
1579
1179
  *
1580
1180
  * Call this at the request boundary (e.g. Stop hook in armorClaude, end of
1581
- * turn in ADK consumers) to guarantee all spans for the current session have
1582
- * been POSTed before the process exits. The underlying shipper retries with
1583
- * exponential backoff and **never throws** into the SDK consumer — a flush
1584
- * 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.
1585
1183
  */
1586
1184
  async flushObservability() {
1587
- if (!this.obs)
1185
+ if (!this.otel)
1588
1186
  return;
1589
- // Model A: end the in-flight active plan trace FIRST so it's handed to
1590
- // the shipper's queue before we flush — otherwise an in-progress plan
1591
- // trace (no endTime yet) would be left behind in the ring buffer and
1592
- // never ship (see ObservabilityRecorder.flush()'s in-flight retention).
1593
- safeObs(() => this.endPlanTrace('ok'));
1594
- await (0, observability_1.flushObservability)(this.obs);
1187
+ await this.drainPolicyTelemetry();
1188
+ await this.otel.flush('ok');
1595
1189
  }
1596
1190
  /**
1597
- * Stop the session's observability lifecycle: clears the shipper's
1598
- * repeating flush interval (`setInterval`, see `ObservabilityShipper`) and
1599
- * performs one final drain/flush of anything still queued. Safe to call
1600
- * 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.
1601
1194
  *
1602
- * **Required at session end.** Without calling this, the recorder's
1603
- * shipper keeps its interval timer alive indefinitely — in long-running
1604
- * processes this is a resource leak, and in tests it is why processes
1605
- * (and jest) can hang or emit "cannot log after tests done" / stray
1606
- * network activity after teardown. `flushObservability()` alone does NOT
1607
- * stop the interval — it only flushes; call `close()` (or `dispose()`) at
1608
- * the natural end of the session's lifetime (e.g. Stop hook in armorClaude,
1609
- * end of process, or end of a request/turn in ADK consumers) instead of
1610
- * (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.
1611
1197
  *
1612
1198
  * NEVER throws — observability teardown failures must not propagate into
1613
1199
  * the SDK consumer, matching every other observability code path.
1614
1200
  */
1615
- async close(status = 'ok') {
1616
- if (!this.obs)
1201
+ async close(status = 'ok', taskOutcome, content = {}) {
1202
+ if (!this.otel)
1617
1203
  return;
1618
- // Model A: end the active plan trace before stopping the shipper so its
1619
- // final flush (inside obs.stop()) ships the completed plan trace rather
1620
- // than leaving it stranded in-flight.
1621
- safeObs(() => this.endPlanTrace(status));
1622
1204
  try {
1623
- await this.obs.stop();
1205
+ await this.drainPolicyTelemetry();
1206
+ await this.otel?.close(status, taskOutcome, content);
1624
1207
  }
1625
- catch (err) {
1626
- const msg = err instanceof Error ? err.message : String(err);
1627
- console.warn(`[armoriq] observability close() failed (continuing): ${msg}`);
1208
+ finally {
1209
+ this.pendingDelegationOperations.clear();
1210
+ this.delegationTerminalOperations.clear();
1211
+ this.completedDelegations.clear();
1628
1212
  }
1629
1213
  }
1630
1214
  /** Alias for `close()`. Use whichever reads better at the call site. */