@vellumai/assistant 0.10.9-staging.1 → 0.10.9-staging.2

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 (100) hide show
  1. package/AGENTS.md +4 -0
  2. package/knip.json +1 -4
  3. package/openapi.yaml +28 -18
  4. package/package.json +1 -1
  5. package/src/__tests__/always-loaded-tools-guard.test.ts +4 -6
  6. package/src/__tests__/app-routes-csp.test.ts +13 -4
  7. package/src/__tests__/conversation-tool-setup-tools-disabled.test.ts +4 -8
  8. package/src/__tests__/disk-pressure-tools.test.ts +4 -4
  9. package/src/__tests__/file-list-tool.test.ts +22 -12
  10. package/src/__tests__/list-all-apps.test.ts +204 -0
  11. package/src/__tests__/messaging-send-tool.test.ts +67 -0
  12. package/src/__tests__/mtime-cache.test.ts +42 -0
  13. package/src/__tests__/oauth-provider-profiles.test.ts +3 -2
  14. package/src/__tests__/outlook-messaging-provider.test.ts +71 -0
  15. package/src/__tests__/plugin-app-serve-routes.test.ts +163 -0
  16. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
  17. package/src/__tests__/scaffold-managed-skill-tool.test.ts +47 -0
  18. package/src/__tests__/subagent-notify-parent.test.ts +4 -3
  19. package/src/__tests__/subagent-tool-filtering.test.ts +21 -21
  20. package/src/__tests__/subagent-tool-gate-mode.test.ts +6 -6
  21. package/src/apps/app-store.ts +302 -2
  22. package/src/cli/AGENTS.md +17 -0
  23. package/src/cli/commands/__tests__/gateway.test.ts +55 -0
  24. package/src/cli/commands/apps.help.ts +17 -11
  25. package/src/cli/commands/apps.ts +5 -15
  26. package/src/cli/commands/gateway.help.ts +30 -0
  27. package/src/cli/commands/gateway.ts +40 -1
  28. package/src/cli/commands/platform/__tests__/status.test.ts +0 -90
  29. package/src/cli/commands/platform/index.help.ts +3 -2
  30. package/src/cli/commands/platform/index.ts +0 -9
  31. package/src/cli/commands/routes.help.ts +13 -7
  32. package/src/cli/commands/routes.ts +5 -3
  33. package/src/cli/commands/status.help.ts +24 -0
  34. package/src/cli/commands/status.ts +29 -0
  35. package/src/cli/lib/__tests__/global-json-option.test.ts +97 -0
  36. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +1 -0
  37. package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +31 -0
  38. package/src/cli/lib/bundled-marketplace.json +187 -173
  39. package/src/cli/lib/global-json-option.ts +41 -0
  40. package/src/cli/lib/plugin-fingerprint.ts +18 -7
  41. package/src/cli/program.ts +5 -0
  42. package/src/config/bundled-skills/messaging/TOOLS.json +2 -2
  43. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +27 -19
  44. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +3 -3
  45. package/src/config/feature-flag-registry.json +1 -9
  46. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +3 -6
  47. package/src/daemon/__tests__/conversation-tool-setup-plugin-scope.test.ts +3 -6
  48. package/src/daemon/__tests__/conversation-tool-setup.test.ts +8 -5
  49. package/src/daemon/conversation-tool-setup.ts +5 -90
  50. package/src/daemon/conversation.ts +2 -2
  51. package/src/messaging/provider-types.ts +9 -0
  52. package/src/messaging/providers/outlook/adapter.ts +17 -2
  53. package/src/messaging/providers/outlook/client.ts +11 -2
  54. package/src/messaging/providers/outlook/types.ts +9 -0
  55. package/src/monitoring/__tests__/plugin-source-watch.test.ts +37 -0
  56. package/src/monitoring/plugin-source-watch.ts +53 -0
  57. package/src/oauth/seed-providers.ts +6 -0
  58. package/src/persistence/embeddings/__tests__/messages-lexical-index.test.ts +3 -0
  59. package/src/persistence/embeddings/messages-lexical-index.ts +11 -0
  60. package/src/plugin-api/conversation-turn.ts +234 -0
  61. package/src/plugin-api/index.ts +12 -0
  62. package/src/plugins/__tests__/source-fingerprint.test.ts +41 -0
  63. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +48 -0
  64. package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +46 -5
  65. package/src/plugins/defaults/memory/graph/tool-handlers.ts +2 -2
  66. package/src/plugins/defaults/memory/graph-topology/__tests__/build-memory-graph.test.ts +81 -2
  67. package/src/plugins/defaults/memory/graph-topology/build-memory-graph.ts +80 -33
  68. package/src/plugins/defaults/memory/memory-retrospective-job.ts +40 -1
  69. package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +28 -2
  70. package/src/plugins/mtime-cache.ts +26 -13
  71. package/src/plugins/plugin-tree-walk.ts +30 -0
  72. package/src/plugins/source-fingerprint.ts +9 -2
  73. package/src/plugins/surface-import.ts +28 -0
  74. package/src/providers/speech-to-text/__tests__/vellum-managed-realtime.test.ts +1 -1
  75. package/src/providers/speech-to-text/__tests__/vellum-speech-relay-connection.test.ts +4 -1
  76. package/src/providers/speech-to-text/vellum-speech-relay-connection.ts +5 -0
  77. package/src/runtime/routes/__tests__/gateway-status-routes.test.ts +84 -0
  78. package/src/runtime/routes/__tests__/plugins-routes.test.ts +47 -3
  79. package/src/runtime/routes/__tests__/user-routes-cli.test.ts +154 -0
  80. package/src/runtime/routes/app-management-routes.ts +96 -32
  81. package/src/runtime/routes/app-routes.ts +33 -17
  82. package/src/runtime/routes/gateway-status-routes.ts +69 -0
  83. package/src/runtime/routes/index.ts +2 -0
  84. package/src/runtime/routes/platform-routes.ts +4 -14
  85. package/src/runtime/routes/plugins-routes.ts +58 -1
  86. package/src/runtime/routes/settings-routes.ts +8 -6
  87. package/src/runtime/routes/user-route-dispatcher.ts +6 -86
  88. package/src/runtime/routes/user-route-resolution.ts +141 -0
  89. package/src/runtime/routes/user-routes-cli.ts +56 -35
  90. package/src/telemetry/AGENTS.md +15 -0
  91. package/src/telemetry/__tests__/telemetry-event-fixtures.ts +258 -0
  92. package/src/telemetry/telemetry-wire-source.json +1 -1
  93. package/src/telemetry/telemetry-wire-validation.test.ts +157 -0
  94. package/src/telemetry/telemetry-wire-validation.ts +125 -0
  95. package/src/telemetry/telemetry-wire.generated.ts +60 -9
  96. package/src/telemetry/types.test.ts +38 -0
  97. package/src/telemetry/types.ts +183 -91
  98. package/src/telemetry/usage-telemetry-reporter.ts +5 -0
  99. package/src/telemetry/watchdog-direct-emit.ts +6 -0
  100. package/src/tools/skills/scaffold-managed.ts +43 -11
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Pre-flush validation of outgoing telemetry events against the
3
+ * platform-generated wire schemas (`telemetry-wire.generated.ts`).
4
+ *
5
+ * The platform ingest endpoint rejects individual events that violate its
6
+ * serializer bounds and skips event types it has no serializer for — both
7
+ * silently from the daemon's point of view (the batch still 2xxes). This
8
+ * module surfaces those would-be-silent drops as structured warnings at the
9
+ * source, right before each POST.
10
+ *
11
+ * Observability only: validation never mutates, filters, or blocks a batch.
12
+ * The server remains the authority on what it accepts; in particular the
13
+ * `.trim()`-transformed parse output is never substituted for the original
14
+ * events.
15
+ */
16
+
17
+ import type { z } from "zod";
18
+
19
+ import type { getLogger } from "../util/logger.js";
20
+ import { telemetryEventSchema } from "./telemetry-wire.generated.js";
21
+
22
+ type Logger = ReturnType<typeof getLogger>;
23
+
24
+ /**
25
+ * Per-event-type wire schema, derived by introspecting the generated
26
+ * discriminated union (`.options` + each member's `type` literal), so a new
27
+ * generated event type is validated with zero hand edits here.
28
+ */
29
+ const wireSchemaByType: ReadonlyMap<string, z.ZodType> = new Map(
30
+ telemetryEventSchema.options.map((option) => [
31
+ option.shape.type.value,
32
+ option,
33
+ ]),
34
+ );
35
+
36
+ /**
37
+ * Event types already warned about as absent from the wire contract.
38
+ * Unknown-type warnings are rate-limited to once per process per type —
39
+ * without this, a daemon-only extension type (e.g. `onboarding_research`)
40
+ * would warn on every 5-minute flush forever.
41
+ */
42
+ const warnedUnknownTypes = new Set<string>();
43
+
44
+ /** Clears the once-per-process unknown-type warning rate limit. Test-only. */
45
+ export function resetUnknownTypeWarningsForTests(): void {
46
+ warnedUnknownTypes.clear();
47
+ }
48
+
49
+ /**
50
+ * Render a zod issue path for logging without leaking dynamic record keys.
51
+ *
52
+ * Rule: keep numeric components (array indices — structural, never data) and
53
+ * keep the first (depth-0) component; redact every deeper string component to
54
+ * a literal `*`. Depth-0 is safe because every generated event schema is a
55
+ * flat `z.object`, so top-level path components are schema-defined field
56
+ * names. Deeper string components can be dynamic record keys — e.g. the turn
57
+ * schema's `client` bag superRefine emits issues at `['client', <key>]` where
58
+ * `<key>` comes from an uncontrolled JSON metadata column and could carry
59
+ * user text.
60
+ */
61
+ function sanitizeIssuePath(path: ReadonlyArray<PropertyKey>): string {
62
+ return path
63
+ .map((component, depth) =>
64
+ typeof component === "number" || depth === 0 ? String(component) : "*",
65
+ )
66
+ .join(".");
67
+ }
68
+
69
+ interface WireValidationResult {
70
+ /** Events whose type had a wire schema and were parsed against it. */
71
+ checked: number;
72
+ /** Checked events that failed their wire schema. */
73
+ invalid: number;
74
+ /** Distinct event types with no wire schema — the server drops these. */
75
+ unknownTypes: string[];
76
+ }
77
+
78
+ /**
79
+ * Validate a batch of outgoing telemetry events against the platform wire
80
+ * schemas, logging a structured warning for each event the server would
81
+ * silently drop. Warn payloads carry the event `type` and issue
82
+ * `{ path, code }` shapes only — never field values, and never dynamic key
83
+ * components inside paths (see {@link sanitizeIssuePath}). That includes
84
+ * `daemon_event_id`: traces/claims can hold PII, and activation-funnel ids
85
+ * embed the onboarding session id.
86
+ *
87
+ * Never mutates, filters, or blocks: callers send the batch unchanged
88
+ * regardless of the result.
89
+ */
90
+ export function validateWireEvents(
91
+ events: readonly { type: string }[],
92
+ log: Logger,
93
+ ): WireValidationResult {
94
+ let checked = 0;
95
+ let invalid = 0;
96
+ const unknownTypes = new Set<string>();
97
+ for (const event of events) {
98
+ const schema = wireSchemaByType.get(event.type);
99
+ if (!schema) {
100
+ unknownTypes.add(event.type);
101
+ if (!warnedUnknownTypes.has(event.type)) {
102
+ warnedUnknownTypes.add(event.type);
103
+ log.warn(
104
+ { eventType: event.type },
105
+ "telemetry event type not in platform wire contract — server drops these; see telemetry-wire.generated.ts",
106
+ );
107
+ }
108
+ continue;
109
+ }
110
+ checked += 1;
111
+ const result = schema.safeParse(event);
112
+ if (!result.success) {
113
+ invalid += 1;
114
+ const issues = result.error.issues.map((issue) => ({
115
+ path: sanitizeIssuePath(issue.path),
116
+ code: issue.code,
117
+ }));
118
+ log.warn(
119
+ { eventType: event.type, issues },
120
+ "telemetry event fails platform wire contract — server will silently drop it",
121
+ );
122
+ }
123
+ }
124
+ return { checked, invalid, unknownTypes: [...unknownTypes] };
125
+ }
@@ -57,10 +57,12 @@ export const TURN_CLIENT_MAX_JSON_BYTES = 2048;
57
57
  // pre-trim a trace it would rather not lose.
58
58
  export const TURN_TRACE_MAX_JSON_BYTES = 262144;
59
59
  export const WATCHDOG_DETAIL_MAX_JSON_BYTES = 4096;
60
+ export const ONBOARDING_RESEARCH_CLAIMS_MAX_JSON_BYTES = 8192;
61
+ export const ONBOARDING_RESEARCH_SUGGESTIONS_MAX_JSON_BYTES = 8192;
60
62
 
61
63
  export const llmUsageTelemetryEventSchema = z.object({
62
64
  type: z.literal("llm_usage"),
63
- daemon_event_id: z.string().trim().min(1).max(36),
65
+ daemon_event_id: z.string().trim().min(1).max(128),
64
66
  recorded_at: z.number().int(),
65
67
  assistant_version: z.string().trim().min(1).max(64).nullable().optional(),
66
68
  provider: z.string().trim().min(1).max(64),
@@ -93,7 +95,7 @@ export type LlmUsageTelemetryEvent = z.infer<
93
95
  export const turnTelemetryEventSchema = z
94
96
  .object({
95
97
  type: z.literal("turn"),
96
- daemon_event_id: z.string().trim().min(1).max(36),
98
+ daemon_event_id: z.string().trim().min(1).max(128),
97
99
  recorded_at: z.number().int(),
98
100
  assistant_version: z.string().trim().min(1).max(64).nullable().optional(),
99
101
  conversation_type: z.string().trim().min(1).max(32).nullable().optional(),
@@ -160,7 +162,7 @@ export type TurnTelemetryEvent = z.infer<typeof turnTelemetryEventSchema>;
160
162
 
161
163
  export const lifecycleTelemetryEventSchema = z.object({
162
164
  type: z.literal("lifecycle"),
163
- daemon_event_id: z.string().trim().min(1).max(36),
165
+ daemon_event_id: z.string().trim().min(1).max(128),
164
166
  recorded_at: z.number().int(),
165
167
  assistant_version: z.string().trim().min(1).max(64).nullable().optional(),
166
168
  event_name: z.string().trim().min(1).max(64),
@@ -172,7 +174,7 @@ export type LifecycleTelemetryEvent = z.infer<
172
174
  export const onboardingTelemetryEventSchema = z
173
175
  .object({
174
176
  type: z.literal("onboarding"),
175
- daemon_event_id: z.string().trim().min(1).max(36),
177
+ daemon_event_id: z.string().trim().min(1).max(128),
176
178
  recorded_at: z.number().int(),
177
179
  assistant_version: z.string().trim().min(1).max(64).nullable().optional(),
178
180
  screen: z.string().trim().min(1).max(64).optional(),
@@ -218,7 +220,7 @@ export type OnboardingTelemetryEvent = z.infer<
218
220
 
219
221
  export const authFallbackTelemetryEventSchema = z.object({
220
222
  type: z.literal("auth_fallback"),
221
- daemon_event_id: z.string().trim().min(1).max(36),
223
+ daemon_event_id: z.string().trim().min(1).max(128),
222
224
  recorded_at: z.number().int(),
223
225
  assistant_version: z.string().trim().min(1).max(64).nullable().optional(),
224
226
  guard: z.string().trim().min(1).max(64),
@@ -244,7 +246,7 @@ export const toolExecutedTelemetryEventSchema = z.object({
244
246
  .nullable()
245
247
  .optional(),
246
248
  type: z.literal("tool_executed"),
247
- daemon_event_id: z.string().trim().min(1).max(36),
249
+ daemon_event_id: z.string().trim().min(1).max(128),
248
250
  recorded_at: z.number().int(),
249
251
  assistant_version: z.string().trim().min(1).max(64).nullable().optional(),
250
252
  tool_name: z.string().trim().min(1).max(255),
@@ -270,7 +272,7 @@ export const skillLoadedTelemetryEventSchema = z.object({
270
272
  .nullable()
271
273
  .optional(),
272
274
  type: z.literal("skill_loaded"),
273
- daemon_event_id: z.string().trim().min(1).max(36),
275
+ daemon_event_id: z.string().trim().min(1).max(128),
274
276
  recorded_at: z.number().int(),
275
277
  assistant_version: z.string().trim().min(1).max(64).nullable().optional(),
276
278
  skill_name: z.string().trim().min(1).max(255),
@@ -284,7 +286,7 @@ export type SkillLoadedTelemetryEvent = z.infer<
284
286
  export const watchdogTelemetryEventSchema = z
285
287
  .object({
286
288
  type: z.literal("watchdog"),
287
- daemon_event_id: z.string().trim().min(1).max(36),
289
+ daemon_event_id: z.string().trim().min(1).max(128),
288
290
  recorded_at: z.number().int(),
289
291
  assistant_version: z.string().trim().min(1).max(64).nullable().optional(),
290
292
  check_name: z.string().trim().min(1).max(128),
@@ -312,7 +314,7 @@ export type WatchdogTelemetryEvent = z.infer<
312
314
 
313
315
  export const configSettingTelemetryEventSchema = z.object({
314
316
  type: z.literal("config_setting"),
315
- daemon_event_id: z.string().trim().min(1).max(36),
317
+ daemon_event_id: z.string().trim().min(1).max(128),
316
318
  recorded_at: z.number().int(),
317
319
  assistant_version: z.string().trim().min(1).max(64).nullable().optional(),
318
320
  config_key: z.string().trim().min(1).max(128),
@@ -322,6 +324,53 @@ export type ConfigSettingTelemetryEvent = z.infer<
322
324
  typeof configSettingTelemetryEventSchema
323
325
  >;
324
326
 
327
+ export const onboardingResearchTelemetryEventSchema = z
328
+ .object({
329
+ type: z.literal("onboarding_research"),
330
+ daemon_event_id: z.string().trim().min(1).max(128),
331
+ recorded_at: z.number().int(),
332
+ assistant_version: z.string().trim().min(1).max(64).nullable().optional(),
333
+ conversation_id: z.string().trim().min(1).max(64).nullable().optional(),
334
+ status: z.string().trim().min(1).max(32),
335
+ claims: z.array(jsonValueSchema).max(20),
336
+ claim_count: z.number().int().min(0),
337
+ claims_confident: z.number().int().min(0),
338
+ claims_maybe: z.number().int().min(0),
339
+ claims_guessing: z.number().int().min(0),
340
+ suggestions: z.array(jsonValueSchema).max(20),
341
+ suggestion_count: z.number().int().min(0),
342
+ plugins: z.array(z.string().trim().min(1).max(128)).max(20),
343
+ installed_plugins: z.array(z.string().trim().min(1).max(128)).max(20),
344
+ })
345
+ .superRefine((val, ctx) => {
346
+ // Mirrors the server's OnboardingResearchTelemetryEventSerializer
347
+ // validate_claims / validate_suggestions — an oversize `claims` or
348
+ // `suggestions` array is rejected by ingest (the event is silently
349
+ // dropped, never the whole batch).
350
+ if (
351
+ jsonByteLength(val.claims) > ONBOARDING_RESEARCH_CLAIMS_MAX_JSON_BYTES
352
+ ) {
353
+ ctx.addIssue({
354
+ code: "custom",
355
+ path: ["claims"],
356
+ message: `\`claims\` JSON exceeds ${ONBOARDING_RESEARCH_CLAIMS_MAX_JSON_BYTES} bytes when serialized.`,
357
+ });
358
+ }
359
+ if (
360
+ jsonByteLength(val.suggestions) >
361
+ ONBOARDING_RESEARCH_SUGGESTIONS_MAX_JSON_BYTES
362
+ ) {
363
+ ctx.addIssue({
364
+ code: "custom",
365
+ path: ["suggestions"],
366
+ message: `\`suggestions\` JSON exceeds ${ONBOARDING_RESEARCH_SUGGESTIONS_MAX_JSON_BYTES} bytes when serialized.`,
367
+ });
368
+ }
369
+ });
370
+ export type OnboardingResearchTelemetryEvent = z.infer<
371
+ typeof onboardingResearchTelemetryEventSchema
372
+ >;
373
+
325
374
  export type WireEventMap = {
326
375
  llm_usage: LlmUsageTelemetryEvent;
327
376
  turn: TurnTelemetryEvent;
@@ -332,6 +381,7 @@ export type WireEventMap = {
332
381
  skill_loaded: SkillLoadedTelemetryEvent;
333
382
  watchdog: WatchdogTelemetryEvent;
334
383
  config_setting: ConfigSettingTelemetryEvent;
384
+ onboarding_research: OnboardingResearchTelemetryEvent;
335
385
  };
336
386
 
337
387
  export const telemetryEventSchema = z.discriminatedUnion("type", [
@@ -344,6 +394,7 @@ export const telemetryEventSchema = z.discriminatedUnion("type", [
344
394
  skillLoadedTelemetryEventSchema,
345
395
  watchdogTelemetryEventSchema,
346
396
  configSettingTelemetryEventSchema,
397
+ onboardingResearchTelemetryEventSchema,
347
398
  ]);
348
399
  export type TelemetryEvent = z.infer<typeof telemetryEventSchema>;
349
400
 
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ turnEventSample,
5
+ wireEventSamples,
6
+ } from "./__tests__/telemetry-event-fixtures.js";
7
+ import { telemetryEventSchema } from "./telemetry-wire.generated.js";
8
+ import type { TurnTelemetryEvent } from "./types.js";
9
+
10
+ // Runtime contract test: events constructed with the daemon's types (the
11
+ // shared fixtures are annotated with them) must parse against the generated
12
+ // wire schemas — the same validation the platform's ingest serializers
13
+ // apply. A daemon type whose values can't round-trip through
14
+ // `telemetryEventSchema` produces events the server silently drops.
15
+
16
+ describe("daemon telemetry types against the wire contract", () => {
17
+ for (const sample of wireEventSamples) {
18
+ test(`daemon-typed ${sample.type} event parses against the wire schema`, () => {
19
+ const result = telemetryEventSchema.safeParse(sample);
20
+ expect(result.error).toBeUndefined();
21
+ expect(result.success).toBe(true);
22
+ });
23
+ }
24
+
25
+ test("turn client bag with a nested value fails the wire superRefine", () => {
26
+ // Structurally valid for the daemon's `TurnTelemetryClientInfo` (extra
27
+ // properties are allowed outside fresh-literal checks), but the wire
28
+ // schema mirrors the server's validate_client: nested objects in the
29
+ // `client` bag reject the event at ingest.
30
+ const nestedClient = { os: "macos", screen: { width: 1512, height: 982 } };
31
+ const invalidTurn: TurnTelemetryEvent = {
32
+ ...turnEventSample,
33
+ client: nestedClient,
34
+ };
35
+ const result = telemetryEventSchema.safeParse(invalidTurn);
36
+ expect(result.success).toBe(false);
37
+ });
38
+ });
@@ -1,5 +1,7 @@
1
1
  import type { LLMCallSite } from "../config/schemas/llm.js";
2
2
  import type { UsageAttributionProfileSource } from "../usage/types.js";
3
+ import type * as wire from "./telemetry-wire.generated.js";
4
+ import type { TurnOutcome } from "./turn-outcome.js";
3
5
 
4
6
  /** Base fields present on every telemetry event. */
5
7
  export interface TelemetryEventBase {
@@ -54,6 +56,18 @@ export interface ModelTelemetryEventBase extends TelemetryEventBase {
54
56
  inference_profile_source: UsageAttributionProfileSource | null;
55
57
  }
56
58
 
59
+ /**
60
+ * Applies the daemon's record-time guarantee to a wire-derived event type.
61
+ *
62
+ * The wire contract marks `assistant_version` optional for back-compat with
63
+ * old daemons; under the platform's per-event-wins contract a present
64
+ * per-event value (including explicit `null`) beats the envelope fallback.
65
+ * This daemon always stamps the field at record time (see
66
+ * {@link TelemetryEventBase.assistant_version} for the full contract), so
67
+ * wire-derived event types are re-typed with the field required.
68
+ */
69
+ type Daemonize<T> = T & { assistant_version: string | null };
70
+
57
71
  /**
58
72
  * LLM usage event — one per persisted usage row. The main agent loop
59
73
  * persists a single row per turn with token totals summed across every
@@ -329,7 +343,7 @@ export interface TurnTelemetryEvent extends TelemetryEventBase {
329
343
  * could land — so `absent + no assistant message in trace` isolates the
330
344
  * genuinely anomalous (crashed/unknown) turns.
331
345
  */
332
- outcome?: "batched" | "failed" | "cancelled";
346
+ outcome?: TurnOutcome;
333
347
  /**
334
348
  * For `outcome: "batched"` turns: the `daemon_event_id` of the
335
349
  * batch-final turn whose window carries the shared response. Omitted
@@ -358,64 +372,6 @@ export interface TurnTelemetryEvent extends TelemetryEventBase {
358
372
  trace?: TurnTrace | null;
359
373
  }
360
374
 
361
- /** Lifecycle event — app_open, hatch, etc. */
362
- export interface LifecycleTelemetryEvent extends TelemetryEventBase {
363
- type: "lifecycle";
364
- event_name: string;
365
- }
366
-
367
- /** Onboarding event — pre-chat selections and Google connect status. */
368
- export interface OnboardingTelemetryEvent extends TelemetryEventBase {
369
- type: "onboarding";
370
- screen: string;
371
- tools?: string[];
372
- tasks?: string[];
373
- tone?: string;
374
- google_connected?: boolean;
375
- google_scopes?: string[];
376
- ab_variant?: string;
377
- /**
378
- * Activation-funnel fields (mirror the web funnel shape and the platform
379
- * serializer). The platform accepts an onboarding event via either the
380
- * legacy `screen` path or the all-funnel-fields path (`session_id` +
381
- * `step_name` + `step_index` + `completed_at` + `funnel_version` +
382
- * `ab_variant`).
383
- */
384
- session_id?: string;
385
- step_name?: string;
386
- step_index?: number;
387
- completed_at?: string;
388
- funnel_version?: string;
389
- user_id?: string;
390
- }
391
-
392
- /**
393
- * Auth-fallback event — aggregated count of requests served via the legacy
394
- * loopback auth fallback. One event per (guard, path, failure_kind) per flush
395
- * window. Lets the platform see which deployments still rely on the loopback
396
- * exemption instead of sending a bearer token.
397
- */
398
- export interface AuthFallbackTelemetryEvent extends TelemetryEventBase {
399
- type: "auth_fallback";
400
- /** Which auth guard fell back: `"edge"` | `"edge-scoped"` | `"edge-guardian"`. */
401
- guard: string;
402
- /** Request pathname that fell back. */
403
- path: string;
404
- /**
405
- * Why the bearer-token check did not succeed before the fallback:
406
- * `"missing_authorization"` | `"malformed_authorization"` |
407
- * `"token_validation_failed"` | `"insufficient_scope"` |
408
- * `"non_actor_principal"` | `"guardian_mismatch"`.
409
- */
410
- failure_kind: string;
411
- /** Number of requests that fell back for this key during the window. */
412
- count: number;
413
- /** Window start (epoch ms) the count was accumulated over. */
414
- window_start: number;
415
- /** Window end (epoch ms) the count was accumulated over. */
416
- window_end: number;
417
- }
418
-
419
375
  /**
420
376
  * Tool-executed event — one per tool invocation. Carries NO tool
421
377
  * args/inputs or result contents (customer PII per ToS) — payload sizes
@@ -490,27 +446,6 @@ export interface WatchdogTelemetryEvent extends TelemetryEventBase {
490
446
  detail: Record<string, unknown> | null;
491
447
  }
492
448
 
493
- /**
494
- * Config-setting event — records a tracked config key's effective value.
495
- * A single string:string pair on top of the standard envelope, mirroring
496
- * the platform `ConfigSettingTelemetryEventSerializer`:
497
- *
498
- * - `config_key` — dotted config path (e.g. `"memory.enabled"`).
499
- * Bounded server-side at 128 chars.
500
- * - `config_value` — the effective value rendered as a string
501
- * (`"true"` / `"false"` for booleans). Bounded server-side at 256
502
- * chars.
503
- *
504
- * Metadata only — emitters record an explicit allowlist of non-sensitive
505
- * settings, never free-form config content. Dedupe downstream on
506
- * `daemon_event_id` (the daemon retries a batch on transient POST failure).
507
- */
508
- export interface ConfigSettingTelemetryEvent extends TelemetryEventBase {
509
- type: "config_setting";
510
- config_key: string;
511
- config_value: string;
512
- }
513
-
514
449
  /** One inferred fact from the onboarding research-onboarding web-search turn. */
515
450
  export interface OnboardingResearchClaim {
516
451
  claim: string;
@@ -553,18 +488,175 @@ export interface OnboardingResearchTelemetryEvent extends TelemetryEventBase {
553
488
  installed_plugins: string[];
554
489
  }
555
490
 
491
+ /**
492
+ * Wire-derived event types with the daemon's record-time
493
+ * `assistant_version` guarantee applied. New event types added to the wire
494
+ * contract flow through here with zero hand edits.
495
+ */
496
+ type WireDaemonized = {
497
+ [K in keyof wire.WireEventMap]: Daemonize<wire.WireEventMap[K]>;
498
+ };
499
+
500
+ /** Events where the daemon's type is intentionally narrower than the wire. */
501
+ type Overrides = {
502
+ llm_usage: LlmUsageTelemetryEvent;
503
+ turn: TurnTelemetryEvent;
504
+ tool_executed: ToolExecutedTelemetryEvent;
505
+ skill_loaded: SkillLoadedTelemetryEvent;
506
+ watchdog: WatchdogTelemetryEvent;
507
+ onboarding_research: OnboardingResearchTelemetryEvent;
508
+ };
509
+
510
+ /**
511
+ * Daemon-only event types not (yet) in the platform wire contract — i.e. types
512
+ * the daemon emits but `POST /v1/telemetry/ingest/` would silently skip because
513
+ * they have no serializer in the platform's `TELEMETRY_EVENT_SERIALIZERS`.
514
+ *
515
+ * Currently EMPTY: every emitted type is in the wire contract. When the daemon
516
+ * needs to emit a type before the platform accepts it, add it here (keyed by
517
+ * its wire `type`). Once the platform adds the serializer and the wire sync
518
+ * lands, the key must leave `Extensions` — into `Overrides` if the daemon type
519
+ * stays narrower than the wire, or plain wire flow-through otherwise. The
520
+ * `_extensionsDontCollide` guard below turns red until that move is made.
521
+ */
522
+ type Extensions = Record<never, never>;
523
+
524
+ type EventMap = Omit<WireDaemonized, keyof Overrides> & Overrides & Extensions;
525
+
556
526
  /** Discriminated union of all telemetry event types. */
557
- export type TelemetryEvent =
558
- | LlmUsageTelemetryEvent
559
- | TurnTelemetryEvent
560
- | LifecycleTelemetryEvent
561
- | OnboardingTelemetryEvent
562
- | AuthFallbackTelemetryEvent
563
- | ToolExecutedTelemetryEvent
564
- | SkillLoadedTelemetryEvent
565
- | WatchdogTelemetryEvent
566
- | ConfigSettingTelemetryEvent
567
- | OnboardingResearchTelemetryEvent;
527
+ export type TelemetryEvent = EventMap[keyof EventMap];
528
+
529
+ /**
530
+ * Lifecycle event — app_open, hatch, etc. 1:1 with the wire contract:
531
+ * `telemetry-wire.generated.ts` (from the platform's
532
+ * `LifecycleTelemetryEventSerializer`) is the source of truth.
533
+ */
534
+ export type LifecycleTelemetryEvent = EventMap["lifecycle"];
535
+
536
+ /**
537
+ * Onboarding event — pre-chat selections, Google connect status, and
538
+ * activation-funnel steps. 1:1 with the wire contract
539
+ * (`OnboardingTelemetryEventSerializer`); the platform accepts either the
540
+ * legacy `screen` shape or the complete funnel step field set — see the wire
541
+ * schema's superRefine.
542
+ */
543
+ export type OnboardingTelemetryEvent = EventMap["onboarding"];
544
+
545
+ /**
546
+ * Auth-fallback event — aggregated count of requests served via the legacy
547
+ * loopback auth fallback, one event per (guard, path, failure_kind) per
548
+ * flush window (`count` is a per-window delta, not a running total). 1:1
549
+ * with the wire contract (`AuthFallbackTelemetryEventSerializer`).
550
+ */
551
+ export type AuthFallbackTelemetryEvent = EventMap["auth_fallback"];
552
+
553
+ /**
554
+ * Config-setting event — a tracked config key's effective value rendered as
555
+ * a string; emitters record an explicit allowlist of non-sensitive settings,
556
+ * never free-form config content. 1:1 with the wire contract
557
+ * (`ConfigSettingTelemetryEventSerializer`).
558
+ */
559
+ export type ConfigSettingTelemetryEvent = EventMap["config_setting"];
560
+
561
+ // ---- Compile-time drift guards ----
562
+ // Each `Overrides` entry is pinned to its wire type in BOTH directions, so a
563
+ // wire sync PR that moves the platform contract turns red here instead of
564
+ // drifting silently:
565
+ //
566
+ // - `_*Narrows` — the daemon type stays assignable to the wire type, i.e.
567
+ // every daemon field's value is acceptable to the wire. Catches wire-side
568
+ // tightening (narrowed unions, newly required fields) and daemon-side
569
+ // loosening.
570
+ // - `_*KeysExist` — every key the daemon emits still exists on the wire
571
+ // type. Structural subtyping treats a daemon field the wire dropped as a
572
+ // harmless extra property, so `_*Narrows` alone stays green when the
573
+ // platform REMOVES or RENAMES a field; this key-set check is what catches
574
+ // those.
575
+ //
576
+ // Flow-through (non-override) events need no guards: they use the generated
577
+ // wire types directly, so their construction sites get excess-property /
578
+ // missing-field errors the moment the contract moves.
579
+ type AssertNarrows<_Narrow extends Wide, Wide> = true;
580
+ type AssertNoWireCollision<_Keys extends never> = true;
581
+
582
+ // `raw_usage` is excluded: the server treats it as an opaque JSONField, so
583
+ // the daemon's `Record<string, unknown>` (not structurally assignable to the
584
+ // wire's recursive `JsonValue`) is wire-safe — any JSON-serializable shape
585
+ // ships fine.
586
+ type _llmUsageNarrows = AssertNarrows<
587
+ Omit<LlmUsageTelemetryEvent, "raw_usage">,
588
+ Omit<wire.LlmUsageTelemetryEvent, "raw_usage">
589
+ >;
590
+ // `trace` and `client` are excluded: the server treats both as opaque JSON
591
+ // (JSONField/DictField), so the daemon's richer `TurnTrace`
592
+ // (`content: unknown`) and `TurnTelemetryClientInfo` shapes are wire-safe
593
+ // even though they are not assignable to `JsonValue`. Runtime bounds are
594
+ // enforced by the wire schema's superRefines, not by these types.
595
+ type _turnNarrows = AssertNarrows<
596
+ Omit<TurnTelemetryEvent, "trace" | "client">,
597
+ Omit<wire.TurnTelemetryEvent, "trace" | "client">
598
+ >;
599
+ type _toolExecutedNarrows = AssertNarrows<
600
+ ToolExecutedTelemetryEvent,
601
+ wire.ToolExecutedTelemetryEvent
602
+ >;
603
+ type _skillLoadedNarrows = AssertNarrows<
604
+ SkillLoadedTelemetryEvent,
605
+ wire.SkillLoadedTelemetryEvent
606
+ >;
607
+ // `detail` is excluded: the server treats it as an opaque JSONField, so the
608
+ // daemon's `Record<string, unknown>` bag is wire-safe; the serialized-size
609
+ // bound is enforced by the wire schema's superRefine.
610
+ type _watchdogNarrows = AssertNarrows<
611
+ Omit<WatchdogTelemetryEvent, "detail">,
612
+ Omit<wire.WatchdogTelemetryEvent, "detail">
613
+ >;
614
+ // Reverse (key-existence) direction. Unlike the `_*Narrows` guards, the
615
+ // opaque-JSON fields are NOT excluded here: opacity is about a field's
616
+ // SHAPE (the daemon's richer types aren't assignable to `JsonValue`), but
617
+ // key PRESENCE is part of the typed contract — the generated wire types
618
+ // declare `raw_usage`/`trace`/`client`/`detail`, and a platform sync that
619
+ // removed or renamed one of them would otherwise leave every guard green
620
+ // while the daemon kept emitting a field the server discards.
621
+ type _llmUsageKeysExist = AssertNarrows<
622
+ keyof LlmUsageTelemetryEvent,
623
+ keyof wire.LlmUsageTelemetryEvent
624
+ >;
625
+ type _turnKeysExist = AssertNarrows<
626
+ keyof TurnTelemetryEvent,
627
+ keyof wire.TurnTelemetryEvent
628
+ >;
629
+ type _toolExecutedKeysExist = AssertNarrows<
630
+ keyof ToolExecutedTelemetryEvent,
631
+ keyof wire.ToolExecutedTelemetryEvent
632
+ >;
633
+ type _skillLoadedKeysExist = AssertNarrows<
634
+ keyof SkillLoadedTelemetryEvent,
635
+ keyof wire.SkillLoadedTelemetryEvent
636
+ >;
637
+ type _watchdogKeysExist = AssertNarrows<
638
+ keyof WatchdogTelemetryEvent,
639
+ keyof wire.WatchdogTelemetryEvent
640
+ >;
641
+ // `claims` and `suggestions` are excluded from the narrows: the server types
642
+ // both as opaque JSON arrays (`z.array(jsonValueSchema)`), so the daemon's
643
+ // structured `OnboardingResearchClaim[]` / `OnboardingResearchSuggestion[]`
644
+ // shapes are wire-safe even though they aren't assignable to `JsonValue[]`.
645
+ // Runtime size bounds are enforced by the wire schema's superRefine.
646
+ type _onboardingResearchNarrows = AssertNarrows<
647
+ Omit<OnboardingResearchTelemetryEvent, "claims" | "suggestions">,
648
+ Omit<wire.OnboardingResearchTelemetryEvent, "claims" | "suggestions">
649
+ >;
650
+ type _onboardingResearchKeysExist = AssertNarrows<
651
+ keyof OnboardingResearchTelemetryEvent,
652
+ keyof wire.OnboardingResearchTelemetryEvent
653
+ >;
654
+ // An `Extensions` key that also exists in the wire map would silently
655
+ // shadow the generated type; this stays `never` only while the key sets
656
+ // are disjoint.
657
+ type _extensionsDontCollide = AssertNoWireCollision<
658
+ keyof Extensions & keyof wire.WireEventMap
659
+ >;
568
660
 
569
661
  /**
570
662
  * Event names backed by the `telemetry_events` outbox. Each name doubles as
@@ -46,6 +46,7 @@ import {
46
46
  watermarkKeysForSource,
47
47
  } from "./telemetry-event-sources.js";
48
48
  import { useReadOnlyMainDbForTelemetry } from "./telemetry-main-db.js";
49
+ import { validateWireEvents } from "./telemetry-wire-validation.js";
49
50
 
50
51
  const log = getLogger("usage-telemetry");
51
52
 
@@ -405,6 +406,10 @@ export class UsageTelemetryReporter {
405
406
  // in cursor order.
406
407
  const typedEvents = batches.flatMap(({ batch }) => batch.events);
407
408
 
409
+ // Pre-flush wire validation — observability only: warns about events
410
+ // the server would silently drop; the batch is POSTed unchanged.
411
+ validateWireEvents(typedEvents, log);
412
+
408
413
  const organizationId = getPlatformOrganizationId() || undefined;
409
414
  const userId = getPlatformUserId() || undefined;
410
415
  const payload = {
@@ -25,6 +25,7 @@ import { arePlatformFeaturesEnabled } from "../platform/feature-gate.js";
25
25
  import { getDeviceId } from "../util/device-id.js";
26
26
  import { getLogger } from "../util/logger.js";
27
27
  import { APP_VERSION } from "../version.js";
28
+ import { validateWireEvents } from "./telemetry-wire-validation.js";
28
29
  import type { WatchdogTelemetryEvent } from "./types.js";
29
30
 
30
31
  const log = getLogger("watchdog-direct-emit");
@@ -62,6 +63,11 @@ export async function emitWatchdogEventDirect(
62
63
  detail,
63
64
  assistant_version: APP_VERSION,
64
65
  };
66
+
67
+ // Pre-flush wire validation — observability only: warns when the server
68
+ // would silently drop the event; the POST proceeds unchanged.
69
+ validateWireEvents([event], log);
70
+
65
71
  const organizationId = getPlatformOrganizationId() || undefined;
66
72
  const userId = getPlatformUserId() || undefined;
67
73
  const resp = await client.fetch(TELEMETRY_INGEST_PATH, {