@sentrial/sdk 0.4.5 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,35 +19,92 @@ pnpm add @sentrial/sdk
19
19
  yarn add @sentrial/sdk
20
20
  ```
21
21
 
22
+ ## Breaking changes
23
+
24
+ **0.5.0**
25
+
26
+ - **Transport is now OpenTelemetry (OTLP).** Telemetry ships over `/api/otel/v1/traces`; this SDK no
27
+ longer POSTs sessions/events to the legacy `/api/sdk/*` endpoints (those endpoints remain available
28
+ server-side as thin compatibility adapters that translate onto the OTLP ingest pipeline, so older SDK
29
+ releases keep working — new integrations use OTLP; the Python sync and async clients both emit OTLP
30
+ too). The OTLP ingest route requires the API
31
+ key to carry **both `sessions:write` and `events:write`** scopes — dashboard-generated keys include
32
+ both, but a manually-scoped `sessions:write`-only key will silently drop data. Add `events:write`
33
+ to the key (see [Transport](#transport)).
34
+ - **Timestamp fields are now `string` (ISO 8601), not `Date`.** The `Session` fields (`createdAt` /
35
+ `updatedAt` / `completedAt`) and `Event.timestamp` were retyped because the API returns JSON and
36
+ `response.json()` never revived them into `Date` objects, so the old `Date` typing was incorrect —
37
+ but this is a compile-breaking change for code like `session.createdAt.getTime()`. Migrate by
38
+ parsing where needed: `new Date(session.createdAt).getTime()`.
39
+ - **`PiiConfig.enhancedDetection` was removed.** The field was a no-op (never read by redaction
40
+ logic) and is gone from `PiiConfig`, mirroring the Python SDK. Drop it from any `PiiConfig`
41
+ literal — it had no effect.
42
+
43
+ Shipped under a minor bump (not a patch), per the SDK compatibility policy.
44
+
22
45
  ## CLI
23
46
 
24
47
  The SDK package also ships a `sentrial` binary for scriptable workflows. This is the
25
48
  first packaging step toward a dedicated `npm install sentrial` style CLI package.
26
49
 
27
50
  ```bash
28
- # Run the CLI without a global install and set up the current project
29
- npx --yes --package=@sentrial/sdk@latest sentrial install
51
+ # Install globally
52
+ npm install -g @sentrial/sdk
53
+
54
+ # Or run without installing
55
+ npx -p @sentrial/sdk sentrial sessions list
30
56
 
31
57
  # Save credentials locally
32
- npx sentrial login --api-key sentrial_live_xxx
58
+ sentrial login --api-key sentrial_live_xxx
33
59
 
34
60
  # Install the SDK package and scaffold a safe example integration
35
- npx sentrial install
61
+ sentrial install
36
62
 
37
63
  # Or scaffold only, without installing packages
38
- npx sentrial init
64
+ sentrial init
39
65
 
40
66
  # Inspect production runs
41
- npx sentrial sessions list --last 7d --status failed --agent support
42
- npx sentrial sessions get <session-id>
43
- npx sentrial events list <session-id> --type tool_call
67
+ sentrial sessions list --last 7d --status failed --agent support
68
+ sentrial sessions get <session-id>
69
+ sentrial events list <session-id> --type tool_call
44
70
 
45
71
  # Review signals and run CI gates
46
- npx sentrial signals list --type negative --last 30d
47
- npx sentrial diagnose <session-id>
48
- npx sentrial evaluate <experiment-id> --min-score 80
72
+ sentrial signals list --type negative --last 30d
73
+ sentrial diagnose <session-id>
74
+ sentrial evaluate <experiment-id> --min-score 80
75
+ ```
76
+
77
+ > Note: `sentrial evaluate` calls `/api/v1/evaluate`, which requires an API key
78
+ > scoped with `metrics:read`. Make sure the key you use (via `--api-key`,
79
+ > `SENTRIAL_API_KEY`, or `sentrial login`) carries that scope, or the CI gate
80
+ > will fail with a 403.
81
+
82
+ ### Guided setup and delivery checks
83
+
84
+ Setup and Doctor require `@sentrial/sdk` 0.6.0 or later. Start in your agent project:
85
+
86
+ ```bash
87
+ npx -p @sentrial/sdk@0.6.0 sentrial setup --dry-run --json
88
+ npx -p @sentrial/sdk@0.6.0 sentrial setup
89
+ # Coding agents / CI: set SENTRIAL_API_KEY privately first.
90
+ npx -p @sentrial/sdk@0.6.0 sentrial setup --yes --non-interactive --json
91
+ npx -p @sentrial/sdk@0.6.0 sentrial doctor --workspace <organization-uuid> --json
92
+ npx -p @sentrial/sdk@0.6.0 sentrial doctor --session <session-uuid> --json
49
93
  ```
50
94
 
95
+ Setup verifies workspace read access, inspects installed dependencies and creates an integration
96
+ example while preserving existing files. A prompted key is hidden and is not saved. It does not
97
+ execute the example or call a model. Keep the key in your agent runtime, adapt the example to your
98
+ agent, run it, then use Doctor to inspect its session ID. Python-only projects can use
99
+ `python -m sentrial.cli setup` from Python SDK 0.8.0 without Node.
100
+
101
+ Doctor's `ready_for_instrumentation` result verifies read access, not delivery. `trace_verified`
102
+ means this completed trace's source-numbered events were read back without reported gaps or
103
+ inspection warnings. It does not establish business success, application-wide coverage or signal
104
+ accuracy. Session listing needs `sessions:read`; delivery readback also needs `events:read` and
105
+ an API deployment supporting `/api/v1/sessions/<id>/health`. Both reads share one bounded
106
+ `--timeout-ms` deadline (100–30000 ms). Redirects are refused; reports omit trace content and keys.
107
+
51
108
  ## Quick Start (30 seconds)
52
109
 
53
110
  ### Option 1: Auto-Wrap LLM Clients (Recommended)
@@ -95,11 +152,11 @@ const supportAgent = withSession('support-agent', async (userId: string, message
95
152
  ### Option 3: Vercel AI SDK
96
153
 
97
154
  ```typescript
98
- import { configure, wrapAISDK } from '@sentrial/sdk';
155
+ import { configureVercel, wrapAISDK } from '@sentrial/sdk';
99
156
  import * as ai from 'ai';
100
157
  import { openai } from '@ai-sdk/openai';
101
158
 
102
- configure({ apiKey: process.env.SENTRIAL_API_KEY, defaultAgent: 'my-agent' });
159
+ configureVercel({ apiKey: process.env.SENTRIAL_API_KEY });
103
160
 
104
161
  const { generateText, streamText } = wrapAISDK(ai);
105
162
 
@@ -110,6 +167,59 @@ const { text } = await generateText({
110
167
  });
111
168
  ```
112
169
 
170
+ ### Option 4: OpenAI Agents SDK
171
+
172
+ One call registers a trace processor with the Agents SDK (`@openai/agents`, an optional peer
173
+ dependency, supported range `>=0.1.0 <1.0.0`) — every run becomes a Sentrial session with LLM
174
+ events, tool calls, handoffs, and guardrails. Don't combine with `wrapOpenAI()` on the same run
175
+ (LLM calls would be double-counted).
176
+
177
+ ```typescript
178
+ import { Agent, run, addTraceProcessor } from '@openai/agents';
179
+ import { sentrial, instrumentOpenAIAgents } from '@sentrial/sdk';
180
+
181
+ sentrial.configure({ apiKey: process.env.SENTRIAL_API_KEY });
182
+ instrumentOpenAIAgents({ addTraceProcessor }, { userId: 'user_123' });
183
+
184
+ const agent = new Agent({ name: 'Assistant', instructions: 'Be helpful.' });
185
+ const result = await run(agent, 'Write a haiku about recursion.');
186
+ // The run is now a Sentrial session with LLM events, tool calls, handoffs.
187
+ ```
188
+
189
+ See the [OpenAI Agents SDK integration docs](https://www.sentrial.com/docs/integrations/openai-agents)
190
+ for configuration options, the run→session mapping, and batching/flush guidance.
191
+
192
+ ### Option 5: Google ADK
193
+
194
+ Register one Plugin on your Google ADK `Runner` (`@google/adk`, an optional peer dependency,
195
+ supported range `>=0.1.0 <2.0.0`) — every run becomes a Sentrial session with LLM events, tool
196
+ calls, tokens, cost, and errors. The Plugin fires globally across every agent, tool, and model
197
+ call in the invocation. Don't combine with `wrapGoogle()` on the same run (LLM calls would be
198
+ double-counted).
199
+
200
+ ```typescript
201
+ import { InMemoryRunner, LlmAgent } from '@google/adk';
202
+ import { sentrial, SentrialAdkPlugin } from '@sentrial/sdk';
203
+
204
+ sentrial.configure({ apiKey: process.env.SENTRIAL_API_KEY });
205
+
206
+ const agent = new LlmAgent({ name: 'assistant', model: 'gemini-2.0-flash' });
207
+ const runner = new InMemoryRunner({ agent, plugins: [new SentrialAdkPlugin()] });
208
+ // Every runner.runAsync(...) is now a Sentrial session with LLM events, tool calls, and cost.
209
+ ```
210
+
211
+ Already constructed the `Runner`? Attach after the fact — `instrumentAdk()` is idempotent per
212
+ runner:
213
+
214
+ ```typescript
215
+ import { instrumentAdk } from '@sentrial/sdk';
216
+
217
+ instrumentAdk(runner, { userId: 'user_123' });
218
+ ```
219
+
220
+ See the [Google ADK integration docs](https://www.sentrial.com/docs/integrations/google-adk)
221
+ for configuration options, the run→session mapping, and batching/flush guidance.
222
+
113
223
  ## Features
114
224
 
115
225
  ### LLM Auto-Wrappers
@@ -170,7 +280,7 @@ await experiment.run(async (testCase, variant, tracker) => {
170
280
 
171
281
  ### Vercel AI SDK Integration
172
282
 
173
- Full support for Vercel AI SDK v3-v6:
283
+ Full support for Vercel AI SDK v3-v7:
174
284
 
175
285
  ```typescript
176
286
  import { wrapAISDK } from '@sentrial/sdk';
@@ -193,6 +303,8 @@ const { text } = await generateText({
193
303
  });
194
304
  ```
195
305
 
306
+ > **Note:** `zod` is not a direct dependency of `@sentrial/sdk` — it comes transitively from the Vercel AI SDK (`ai`), which you install to use `wrapAISDK`. The `import { z } from 'zod'` above resolves because `ai` pulls `zod` in as a peer.
307
+
196
308
  ## Configuration
197
309
 
198
310
  ### Environment Variables
@@ -202,6 +314,45 @@ SENTRIAL_API_URL=https://api.sentrial.com
202
314
  SENTRIAL_API_KEY=sentrial_live_xxx
203
315
  ```
204
316
 
317
+ ### Optional agent reports
318
+
319
+ An agent can report missing context, a tool problem, a missing capability, or uncertain completion without marking the session failed:
320
+
321
+ ```ts
322
+ await interaction.reportDiagnostic({
323
+ reportId: 'claim-context-1',
324
+ category: 'missing_context',
325
+ state: 'reported',
326
+ description: 'The policy number needed for this claim is missing.',
327
+ });
328
+ // Later, reuse the report ID and set state: 'recovered' with a new description.
329
+ ```
330
+
331
+ The equivalent client method is `client.reportDiagnostic({ sessionId, report })`. Both a problem and its claimed recovery are agent assertions. They do not create a confirmed signal, training label, business outcome, or automatic action. Each call records a separate event; no prior report is overwritten. There are no model calls.
332
+
333
+ Use an opaque report ID (1–128 letters, digits, `.`, `_`, `:`, or `-`, starting with a letter/digit). Descriptions are limited to 4,096 UTF-8 bytes. Optional `relatedSpanIds` contains at most eight distinct, nonzero, lowercase 16-digit OTel span IDs from the current trace. These references are agent-supplied and are not verified or followed automatically. Normal metadata redaction, batching, transport retry and fail-silent behavior apply. A resolved call is not a durable-delivery receipt; use delivery diagnostics and inspect the retained event. Unknown/degraded/finished sessions cannot accept new reports.
334
+
335
+ The updated collector and trace UI display these as **Agent report**, including **Reported recovered**. Older collectors preserve generic event metadata but do not supply the validated report view. Existing signal evidence selectors do not consume reports as authoritative evidence; measuring incremental detection and review noise requires a separately reviewed evaluation.
336
+
337
+ ### Transport
338
+
339
+ The SDK sends telemetry over **OpenTelemetry (OTLP)** to `/api/otel/v1/traces` — this is the sole
340
+ ingestion path for this SDK (it no longer POSTs sessions/events to the legacy `/api/sdk/*` endpoints,
341
+ which remain available server-side as thin compatibility adapters translated onto the OTLP ingest
342
+ pipeline so older SDK releases keep working; the Python sync and async clients both emit OTLP too).
343
+ The OTLP ingest route
344
+ requires the API key to carry **both `sessions:write` and `events:write`** scopes — keys
345
+ generated in the dashboard include both automatically. If you use a manually-scoped key that
346
+ only has `sessions:write`, session creation will 403 and tracking is silently dropped; add
347
+ `events:write` to the key.
348
+
349
+ Events and completion attach to an in-memory span the client holds from
350
+ `createSession()` / `begin()`, so they only apply to sessions **created on the same client
351
+ instance**. If you obtain a session id elsewhere — a session created out-of-band, shared across
352
+ processes, or tracked on a different `SentrialClient` — its events and finalization are skipped
353
+ (the SDK logs a one-time warning). Use the same client instance for the full begin→track→finish
354
+ flow.
355
+
205
356
  ### Programmatic
206
357
 
207
358
  ```typescript
@@ -284,23 +435,23 @@ const cost = calculateOpenAICost({
284
435
 
285
436
  ## Framework Compatibility
286
437
 
287
- | Framework | Integration | Status |
288
- | -------------------- | ----------------- | ------ |
289
- | **Direct OpenAI** | `wrapOpenAI()` | ✔️ |
290
- | **Direct Anthropic** | `wrapAnthropic()` | ✔️ |
291
- | **Direct Gemini** | `wrapGoogle()` | ✔️ |
292
- | **Vercel AI SDK** | `wrapAISDK()` | ✔️ |
293
- | **Express/Fastify** | Decorators | ✔️ |
294
- | **Next.js** | Decorators | ✔️ |
295
- | **Custom agents** | Full API | ✔️ |
438
+ | Framework | Integration | Status |
439
+ | --------------------- | -------------------------- | ------ |
440
+ | **Direct OpenAI** | `wrapOpenAI()` | ✔️ |
441
+ | **Direct Anthropic** | `wrapAnthropic()` | ✔️ |
442
+ | **Direct Gemini** | `wrapGoogle()` | ✔️ |
443
+ | **Vercel AI SDK** | `wrapAISDK()` | ✔️ |
444
+ | **OpenAI Agents SDK** | `instrumentOpenAIAgents()` | ✔️ |
445
+ | **Google ADK** | `instrumentAdk()` | ✔️ |
446
+ | **Express/Fastify** | Decorators | ✔️ |
447
+ | **Next.js** | Decorators | ✔️ |
448
+ | **Custom agents** | Full API | ✔️ |
296
449
 
297
450
  ## Documentation
298
451
 
299
- See the `docs/` folder in the repository:
300
-
301
- - `docs/sdks/typescript-sdk.md` - Full SDK reference
302
- - `docs/integrations/vercel-ai-sdk.md` - Vercel AI SDK integration
303
- - `docs/features/experiments.md` - Experiments guide
452
+ - Full reference: [https://sentrial.com/docs](https://sentrial.com/docs)
453
+ - Repository README: the root `README.md` maps every SDK entry point and the ingest flow
454
+ - Framework wrappers live in `src/vercel.ts` (Vercel AI SDK), `src/openai-agents.ts` (OpenAI Agents SDK), `src/google-adk.ts` (Google ADK), `src/wrappers.ts` (OpenAI/Anthropic), and `src/decorators.ts`
304
455
 
305
456
  ## Support
306
457
 
@@ -313,20 +464,185 @@ See the `docs/` folder in the repository:
313
464
  Both SDKs share the same core API (`begin`/`finish`, tool & LLM tracking, production
314
465
  experiments, provider wrappers). Framework integrations currently differ:
315
466
 
316
- | Capability | TypeScript | Python |
317
- | ----------------------------------------------------- | --------------------- | ------------------- |
318
- | Core tracking (begin/finish, tools, LLM, experiments) | ✓ | ✓ |
319
- | OpenAI / Anthropic / Google wrappers | ✓ | ✓ |
320
- | Vercel AI SDK helper | ✓ | — (JS-only) |
321
- | Claude Agent SDK | partial (single-turn) | ✓ (full multi-turn) |
322
- | OpenTelemetry export | — | ✓ |
323
- | Langfuse dual-write | — | ✓ |
324
- | AutoGen / CrewAI / LangChain handlers | — | ✓ |
325
-
326
- Porting the missing TypeScript integrations (OpenTelemetry, Langfuse, Claude
327
- multi-turn) is planned; new TS integrations must keep V1 API response shapes
328
- backward-compatible.
467
+ | Capability | TypeScript | Python |
468
+ | ----------------------------------------------------- | -------------------------- | ------------------- |
469
+ | Core tracking (begin/finish, tools, LLM, experiments) | ✓ | ✓ |
470
+ | OpenAI / Anthropic / Google wrappers | ✓ | ✓ |
471
+ | Vercel AI SDK helper | ✓ | — (JS-only) |
472
+ | Claude Agent SDK | partial (single-turn) | ✓ (full multi-turn) |
473
+ | OpenTelemetry export | ✓ (default OTLP transport) | ✓ |
474
+ | Langfuse dual-write | — | ✓ |
475
+ | AutoGen / CrewAI / LangChain handlers | — | ✓ |
476
+ | OpenAI Agents SDK trace processor | ✓ | ✓ |
477
+ | Google ADK Runner plugin | ✓ | ✓ |
478
+
479
+ Porting the missing TypeScript integrations (Langfuse, Claude multi-turn) is
480
+ planned; new TS integrations must keep V1 API response shapes backward-compatible.
329
481
 
330
482
  ## License
331
483
 
332
484
  MIT
485
+
486
+ ## Delivery diagnostics and worker handoffs
487
+
488
+ ### Large tool content (opt-in preview)
489
+
490
+ For enabled workspaces, the native SDK can upload serialized tool inputs/outputs larger than 64 KiB directly to private storage and attach a small reference to the tool span. The configured PII policy runs before byte counting, hashing and upload. Enable the policy fields you need; `redactionRevision` labels that policy and does not itself redact data.
491
+
492
+ ```typescript
493
+ const client = new SentrialClient({
494
+ apiKey: process.env.SENTRIAL_API_KEY,
495
+ pii: { enabled: true },
496
+ largeToolContent: {
497
+ enabled: true,
498
+ redactionRevision: 'claims-redaction-v1',
499
+ timeoutMs: 20_000,
500
+ },
501
+ });
502
+ ```
503
+
504
+ This remains disabled by default and requires separately enabled server storage and workspace quotas. It applies to named `tool_call` input/output fields, including calls recorded through the native SDK integrations; arbitrary external OpenTelemetry spans, LLM messages, metadata and state snapshots are not uploaded. The [Python clients](../python-sdk/README.md#large-tool-content-opt-in-preview) implement the same opt-in; hosted acceptance remains pending.
505
+
506
+ Each field retains a prefix of at most 4 KiB of UTF-8 source bytes inline, with a 16 MiB object limit and two simultaneous uploads per client. Excess concurrent fields, quota failures, unavailable storage, cancellation and oversized objects keep the excerpt plus an explicit missing-content marker. Small fields and clients without the opt-in keep their existing inline behavior. Uploads use at most 24 requests and a configurable 1–60 second per-field deadline (20 seconds by default); this does not bound the subsequent normal trace delivery. A lost PUT acknowledgement is reconciled against retained parts before any retry. Recovery is in memory; restarting a process does not resume its SDK upload.
507
+
508
+ `completeSession()` and `flush()` wait for uploads already started before draining their telemetry. `shutdown()` cancels outstanding uploads and drains their fallback events; call `flush()` first when you want to wait for uploads. Do not emit more events after finishing a session. Abrupt process exit cannot guarantee delivery.
509
+
510
+ `getContentUploadDiagnostics()` returns payload-free attempted/submitted/missing/active/pending-event counts, or `null` when disabled. **Submitted is not verified or inspected:** the server must verify stored bytes, and a detector must separately load the relevant content within its evidence budget. A UI preview also does not establish detector coverage. See the [implementation and rollout limits](../../docs/plans/2026-10-17-telemetry-large-content.md).
511
+
512
+ ### Request and delivery diagnostics
513
+
514
+ For request latency and response reports, read `client.getTransportDiagnostics()` (also available without batching). It separates logical requests, retries, in-flight attempts, response outcomes, reported span acceptance and fixed server timing stages. It retains lifetime aggregates plus 32 recent completed attempts, with no payloads or identifiers. Missing values stay unknown; a successful HTTP response is not proof of unique durable writes. [Usage and measurement limits](../../docs/testing/sdk-transport-timing.md).
515
+
516
+ Call finish after the work belonging to a session has completed, then explicitly flush/close before process exit. Native SDK spans carry source sequence numbers and a final count, so missing events can be detected independently of server arrival order. This proves delivery of emitted spans, not that every application action was instrumented. Queues remain in memory and can drop events at capacity; abrupt termination cannot guarantee a flush.
517
+
518
+ Read `client.getDeliveryDiagnostics()` for payload-free batcher counters (`null` when batching is disabled). A `failed` request was not confirmed delivered; it may have reached the server.
519
+
520
+ ```typescript
521
+ import { actionMetadata, resumeHandoffContext } from '@sentrial/sdk';
522
+
523
+ const parent = await client.begin({
524
+ userId: 'example', event: 'workflow',
525
+ metadata: actionMetadata({ actionId: 'order:42' }),
526
+ });
527
+ const carrier = parent.sessionId
528
+ ? await client.handoff({ sessionId: parent.sessionId, actionId: 'order:42' })
529
+ : null;
530
+ // Pass carrier through your job queue, then in the worker:
531
+ const child = await workerClient.begin({
532
+ userId: 'example', event: 'worker', ...(carrier ? resumeHandoffContext(carrier) : {}),
533
+ });
534
+ const metadata = actionMetadata({ actionId: 'order:42', attemptId: 'try-2', retryOfAttemptId: 'try-1' });
535
+ ```
536
+
537
+ Handoff records delegation before returning the carrier and the worker starts a separate trace linked by parent session. The carrier is correlation metadata, not a credential or verified business outcome. Use opaque identifiers, never customer content or secrets. Low-level `createHandoffContext` / `create_handoff_context` only serializes a carrier; it does not record delegation. V2 signal evidence treats detached outcomes as unresolved until their context is supported.
538
+
539
+ ### Account for worker completion
540
+
541
+ The v2 lifecycle adds a dispatch ID and four explicit records. Existing `handoff()` carriers remain supported. Use a fresh UUID per worker execution; preserve it only when retrying delivery of the same dispatch. A retry that executes work again gets a new dispatch ID (and a new `attemptId`, optionally linked with `retryOfAttemptId`). Use opaque identifiers.
542
+
543
+ ```typescript
544
+ // Parent: record intent before sending the carrier through your queue/RPC.
545
+ import { randomUUID } from 'node:crypto';
546
+
547
+ const carrier = await parentClient.dispatchHandoff({
548
+ sessionId: parentSessionId,
549
+ actionId: 'order:42',
550
+ dispatchId: randomUUID(),
551
+ });
552
+
553
+ // Worker: use its own session, initialized from the received carrier.
554
+ const worker = await workerClient.begin({
555
+ userId: 'example', event: 'order-worker', ...resumeHandoffContext(carrier),
556
+ });
557
+ const workerSessionId = worker.getSessionId();
558
+ if (workerSessionId) {
559
+ await workerClient.acceptHandoff(workerSessionId, carrier);
560
+ // Execute the work here. Report the actual execution outcome.
561
+ await workerClient.finishHandoff(workerSessionId, carrier, 'completed');
562
+ }
563
+ await worker.finish({ output: 'Worker finished' });
564
+
565
+ // Parent: only after your application receives the worker's terminal result.
566
+ // Send the worker session ID and actual outcome with that result.
567
+ if (workerSessionId) {
568
+ await parentClient.receiveHandoff(parentSessionId, carrier, workerSessionId, 'completed');
569
+ }
570
+ ```
571
+
572
+ `completed`, `failed` and `cancelled` describe worker execution, not business success. Record `finishHandoff` before closing the worker session and `receiveHandoff` before closing the parent. Do not emit a completion in an unconditional `finally`; crashes, timeouts or lost results must remain unresolved. A telemetry method returning does not prove the queue or collector received anything. Continue using delivery diagnostics and flush/shutdown normally. If telemetry session creation fails, application work should still continue without these optional records.
573
+
574
+ The internal related-evidence reader detects missing phases, duplicate worker claims, conflicting outcomes and invalid local order. It uses SDK producer sequences rather than arrival order or clocks across machines. Captures remain incomplete for definitive signal judgments. See the [handoff verification contract](../../docs/plans/2026-10-09-handoff-lifecycle-delivery.md).
575
+
576
+ ### Report experiment exposure explicitly
577
+
578
+ `getVariant()` and `createSession()` associate an assignment with a session, including
579
+ holdouts. An association does **not** assert that your application used the configuration.
580
+ After using the returned settings, report that usage explicitly:
581
+
582
+ ```typescript
583
+ const assignment = await client.getVariant({
584
+ experimentName: 'support-prompt',
585
+ agentName: 'Support agent',
586
+ userId: 'stable-user-id',
587
+ });
588
+ const sessionId = await client.createSession({
589
+ name: 'Support request',
590
+ agentName: 'Support agent',
591
+ userId: 'stable-user-id',
592
+ });
593
+ // Apply the returned settings in your application before recording exposure.
594
+ if (sessionId && assignment.assignmentId && assignment.configurationHash) {
595
+ const receipt = await client.recordExperimentExposure({
596
+ sessionId,
597
+ assignmentId: assignment.assignmentId,
598
+ variant: assignment.variant,
599
+ configurationHash: assignment.configurationHash,
600
+ });
601
+ // null means delivery failed under failSilently:true; retry these same fields.
602
+ }
603
+ ```
604
+
605
+ Only report when your application used that configuration. Do not gate holdout reporting
606
+ on `assignment.active`: a holdout has `active:false` and `reason:'holdout'`. Historical
607
+ assignments may lack `configurationHash` and cannot produce a verified matching receipt.
608
+ The digest identifies the frozen returned configuration, not a model provider's behavior.
609
+
610
+ Receipts are idempotent per assignment/session; `recordedAt` is server arrival time.
611
+ Conflicting configuration reports return 409. Deleted source sessions return 410, with
612
+ history retained as tombstones. Delayed reports for previously issued assignments may
613
+ arrive after the experiment stops. The method flushes queued telemetry before sending;
614
+ it does not run inference. `failSilently:false` propagates delivery errors.
615
+
616
+ For overlapping sessions on one client, pass `sessionId` to `getVariant()` or pass
617
+ `experimentAssignmentIds` explicitly when creating a session. Automatic linking targets
618
+ the next session and should only be used when that matches your application's ordering.
619
+ Each experiment/session may belong to only one assignment identity.
620
+
621
+ ### Assignment identity and telemetry acknowledgment
622
+
623
+ An experiment's declared assignment unit is required: supply `userId`/`user_id` for
624
+ user assignment, `convoId`/`convo_id` for conversation assignment, or
625
+ `anonymousId`/`anonymous_id` for anonymous assignment. Another identifier or a session
626
+ ID will not substitute for it. Missing or whitespace-only identity returns
627
+ `active:false`, `reason:'missing_identity'`, the required `assignmentUnit`, and your
628
+ default variant. No assignment is created. Preserve a stable identifier across requests.
629
+ Pass that same user or conversation identifier when creating the session. A conflicting
630
+ session identifier rejects the link; once linked, it cannot be changed or cleared.
631
+ Assignments from different experiments must also agree on each shared assignment unit.
632
+ An anonymous assignment has no corresponding session identity field, so its consistency
633
+ is checked against the session's other anonymous assignments. This does not verify that
634
+ the caller's identifier represents a real person or remains stable outside this session.
635
+
636
+ New assignments report `identityProvenance:'declared'` and their immutable
637
+ `assignmentUnit`. Existing assignments report `identityProvenance:'legacy_unknown'`
638
+ when their historical fallback identity cannot be established. A lookup does not upgrade
639
+ that provenance. Start a new experiment for a prospectively defined identity/analysis plan.
640
+
641
+ Native telemetry commits a session's assignment links in the same transaction as its
642
+ session/events. A failed link fails the request, and a retry preserves the original trace
643
+ identity. Unknown or foreign assignments cannot be silently skipped. Explicit assignment
644
+ lists must contain at most 64 UUIDs; malformed or oversized lists receive HTTP 400 before
645
+ any trace in that batch writes. UUIDs are deduplicated without dropping distinct entries.
646
+ A valid list whose assignment was deleted or is unavailable fails instead of returning a
647
+ false success; correct the references before retrying a permanent error. The existing SDK
648
+ fail-silent/strict error policy still applies.