@vellumai/assistant 0.3.27 → 0.4.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 (247) hide show
  1. package/ARCHITECTURE.md +81 -4
  2. package/Dockerfile +2 -2
  3. package/bun.lock +4 -1
  4. package/docs/trusted-contact-access.md +9 -2
  5. package/package.json +6 -3
  6. package/scripts/ipc/generate-swift.ts +9 -5
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  8. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  9. package/src/__tests__/agent-loop.test.ts +119 -0
  10. package/src/__tests__/approval-routes-http.test.ts +13 -5
  11. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  12. package/src/__tests__/asset-search-tool.test.ts +2 -0
  13. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  14. package/src/__tests__/attachments-store.test.ts +2 -0
  15. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  16. package/src/__tests__/bundled-asset.test.ts +107 -0
  17. package/src/__tests__/call-controller.test.ts +30 -29
  18. package/src/__tests__/call-routes-http.test.ts +34 -32
  19. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  20. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  21. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  22. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  23. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  24. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  25. package/src/__tests__/clarification-resolver.test.ts +2 -0
  26. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  27. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  28. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  29. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  30. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  31. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  32. package/src/__tests__/config-schema.test.ts +5 -5
  33. package/src/__tests__/config-watcher.test.ts +3 -1
  34. package/src/__tests__/connection-policy.test.ts +14 -5
  35. package/src/__tests__/contacts-tools.test.ts +3 -1
  36. package/src/__tests__/contradiction-checker.test.ts +2 -0
  37. package/src/__tests__/conversation-pairing.test.ts +10 -0
  38. package/src/__tests__/conversation-routes.test.ts +1 -1
  39. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  40. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  41. package/src/__tests__/credential-vault.test.ts +5 -4
  42. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  43. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  44. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  45. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  46. package/src/__tests__/encrypted-store.test.ts +10 -5
  47. package/src/__tests__/followup-tools.test.ts +3 -1
  48. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  49. package/src/__tests__/gmail-integration.test.ts +0 -1
  50. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  51. package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
  52. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  53. package/src/__tests__/guardian-dispatch.test.ts +21 -19
  54. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  55. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  56. package/src/__tests__/guardian-routing-invariants.test.ts +1092 -0
  57. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  58. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  59. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  60. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  61. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  62. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  63. package/src/__tests__/heartbeat-service.test.ts +20 -0
  64. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  65. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  66. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  67. package/src/__tests__/intent-routing.test.ts +2 -0
  68. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  69. package/src/__tests__/mcp-cli.test.ts +77 -0
  70. package/src/__tests__/media-generate-image.test.ts +21 -0
  71. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  72. package/src/__tests__/memory-regressions.test.ts +20 -20
  73. package/src/__tests__/non-member-access-request.test.ts +212 -36
  74. package/src/__tests__/notification-decision-fallback.test.ts +63 -3
  75. package/src/__tests__/notification-decision-strategy.test.ts +78 -0
  76. package/src/__tests__/notification-guardian-path.test.ts +15 -15
  77. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  78. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  79. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  80. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  81. package/src/__tests__/pairing-routes.test.ts +171 -0
  82. package/src/__tests__/playbook-execution.test.ts +3 -1
  83. package/src/__tests__/playbook-tools.test.ts +3 -1
  84. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  85. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  86. package/src/__tests__/recording-handler.test.ts +11 -0
  87. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  88. package/src/__tests__/recording-state-machine.test.ts +13 -2
  89. package/src/__tests__/registry.test.ts +7 -3
  90. package/src/__tests__/relay-server.test.ts +148 -28
  91. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  92. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  93. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  94. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  95. package/src/__tests__/schedule-tools.test.ts +3 -1
  96. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  97. package/src/__tests__/secret-scanner.test.ts +8 -0
  98. package/src/__tests__/send-endpoint-busy.test.ts +4 -0
  99. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  100. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  101. package/src/__tests__/session-agent-loop.test.ts +16 -0
  102. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  103. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  104. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  105. package/src/__tests__/session-profile-injection.test.ts +21 -0
  106. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  107. package/src/__tests__/session-queue.test.ts +23 -0
  108. package/src/__tests__/session-runtime-assembly.test.ts +126 -59
  109. package/src/__tests__/session-skill-tools.test.ts +27 -5
  110. package/src/__tests__/session-slash-known.test.ts +23 -0
  111. package/src/__tests__/session-slash-queue.test.ts +23 -0
  112. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  113. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  114. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  115. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  116. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  117. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  118. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  119. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  120. package/src/__tests__/skills.test.ts +8 -4
  121. package/src/__tests__/slack-channel-config.test.ts +3 -1
  122. package/src/__tests__/subagent-tools.test.ts +19 -0
  123. package/src/__tests__/swarm-recursion.test.ts +2 -0
  124. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  125. package/src/__tests__/swarm-tool.test.ts +2 -0
  126. package/src/__tests__/system-prompt.test.ts +3 -1
  127. package/src/__tests__/task-compiler.test.ts +3 -1
  128. package/src/__tests__/task-management-tools.test.ts +3 -1
  129. package/src/__tests__/task-tools.test.ts +3 -1
  130. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  131. package/src/__tests__/terminal-tools.test.ts +2 -0
  132. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  133. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  134. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  135. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  136. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  137. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  138. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  139. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  140. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  141. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  142. package/src/__tests__/view-image-tool.test.ts +3 -1
  143. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  144. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  145. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  146. package/src/__tests__/work-item-output.test.ts +3 -1
  147. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  148. package/src/agent/loop.ts +46 -3
  149. package/src/approvals/guardian-decision-primitive.ts +285 -0
  150. package/src/approvals/guardian-request-resolvers.ts +539 -0
  151. package/src/calls/call-controller.ts +26 -23
  152. package/src/calls/guardian-action-sweep.ts +10 -2
  153. package/src/calls/guardian-dispatch.ts +46 -40
  154. package/src/calls/relay-server.ts +358 -24
  155. package/src/calls/types.ts +1 -1
  156. package/src/calls/voice-session-bridge.ts +3 -3
  157. package/src/cli.ts +12 -0
  158. package/src/config/agent-schema.ts +14 -3
  159. package/src/config/calls-schema.ts +6 -6
  160. package/src/config/core-schema.ts +3 -3
  161. package/src/config/feature-flag-registry.json +8 -0
  162. package/src/config/mcp-schema.ts +1 -1
  163. package/src/config/memory-schema.ts +27 -19
  164. package/src/config/schema.ts +21 -21
  165. package/src/config/skills-schema.ts +7 -7
  166. package/src/config/system-prompt.ts +2 -1
  167. package/src/config/templates/BOOTSTRAP.md +47 -31
  168. package/src/config/templates/USER.md +5 -0
  169. package/src/config/update-bulletin-template-path.ts +4 -1
  170. package/src/config/vellum-skills/trusted-contacts/SKILL.md +149 -21
  171. package/src/daemon/handlers/config-inbox.ts +4 -4
  172. package/src/daemon/handlers/guardian-actions.ts +45 -66
  173. package/src/daemon/handlers/sessions.ts +148 -4
  174. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  175. package/src/daemon/ipc-contract/messages.ts +16 -0
  176. package/src/daemon/ipc-contract-inventory.json +1 -0
  177. package/src/daemon/lifecycle.ts +22 -16
  178. package/src/daemon/pairing-store.ts +86 -3
  179. package/src/daemon/server.ts +18 -0
  180. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  181. package/src/daemon/session-agent-loop.ts +33 -6
  182. package/src/daemon/session-lifecycle.ts +25 -17
  183. package/src/daemon/session-memory.ts +2 -2
  184. package/src/daemon/session-process.ts +68 -326
  185. package/src/daemon/session-runtime-assembly.ts +119 -25
  186. package/src/daemon/session-tool-setup.ts +3 -2
  187. package/src/daemon/session.ts +4 -3
  188. package/src/home-base/prebuilt/seed.ts +2 -1
  189. package/src/hooks/templates.ts +2 -1
  190. package/src/memory/canonical-guardian-store.ts +586 -0
  191. package/src/memory/channel-guardian-store.ts +2 -0
  192. package/src/memory/conversation-crud.ts +7 -7
  193. package/src/memory/db-init.ts +20 -0
  194. package/src/memory/embedding-local.ts +257 -39
  195. package/src/memory/embedding-runtime-manager.ts +471 -0
  196. package/src/memory/guardian-action-store.ts +7 -60
  197. package/src/memory/guardian-approvals.ts +9 -4
  198. package/src/memory/guardian-bindings.ts +25 -1
  199. package/src/memory/indexer.ts +3 -3
  200. package/src/memory/ingress-invite-store.ts +45 -0
  201. package/src/memory/job-handlers/backfill.ts +16 -9
  202. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  203. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  204. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  205. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  206. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  207. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  208. package/src/memory/migrations/index.ts +5 -0
  209. package/src/memory/migrations/registry.ts +5 -0
  210. package/src/memory/qdrant-client.ts +31 -22
  211. package/src/memory/schema-migration.ts +1 -0
  212. package/src/memory/schema.ts +56 -0
  213. package/src/notifications/copy-composer.ts +31 -4
  214. package/src/notifications/decision-engine.ts +57 -0
  215. package/src/permissions/defaults.ts +2 -0
  216. package/src/runtime/access-request-helper.ts +173 -0
  217. package/src/runtime/actor-trust-resolver.ts +221 -0
  218. package/src/runtime/channel-guardian-service.ts +12 -4
  219. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  220. package/src/runtime/channel-retry-sweep.ts +18 -6
  221. package/src/runtime/guardian-context-resolver.ts +38 -71
  222. package/src/runtime/guardian-decision-types.ts +6 -0
  223. package/src/runtime/guardian-reply-router.ts +717 -0
  224. package/src/runtime/http-server.ts +8 -0
  225. package/src/runtime/ingress-service.ts +80 -3
  226. package/src/runtime/invite-redemption-service.ts +141 -2
  227. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  228. package/src/runtime/routes/channel-route-shared.ts +1 -1
  229. package/src/runtime/routes/channel-routes.ts +1 -1
  230. package/src/runtime/routes/conversation-routes.ts +20 -2
  231. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  232. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  233. package/src/runtime/routes/inbound-message-handler.ts +205 -529
  234. package/src/runtime/routes/ingress-routes.ts +52 -4
  235. package/src/runtime/routes/pairing-routes.ts +3 -0
  236. package/src/runtime/tool-grant-request-helper.ts +195 -0
  237. package/src/tools/executor.ts +13 -1
  238. package/src/tools/guardian-control-plane-policy.ts +2 -2
  239. package/src/tools/sensitive-output-placeholders.ts +203 -0
  240. package/src/tools/tool-approval-handler.ts +53 -10
  241. package/src/tools/types.ts +13 -2
  242. package/src/util/bundled-asset.ts +31 -0
  243. package/src/util/canonicalize-identity.ts +52 -0
  244. package/src/util/logger.ts +20 -8
  245. package/src/util/platform.ts +10 -0
  246. package/src/util/voice-code.ts +29 -0
  247. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -33,6 +33,10 @@ export interface IngressInvite {
33
33
  redeemedByExternalUserId: string | null;
34
34
  redeemedByExternalChatId: string | null;
35
35
  redeemedAt: number | null;
36
+ // Voice invite fields (null for non-voice invites)
37
+ expectedExternalUserId: string | null;
38
+ voiceCodeHash: string | null;
39
+ voiceCodeDigits: number | null;
36
40
  createdAt: number;
37
41
  updatedAt: number;
38
42
  }
@@ -90,6 +94,9 @@ function rowToInvite(row: typeof assistantIngressInvites.$inferSelect): IngressI
90
94
  redeemedByExternalUserId: row.redeemedByExternalUserId,
91
95
  redeemedByExternalChatId: row.redeemedByExternalChatId,
92
96
  redeemedAt: row.redeemedAt,
97
+ expectedExternalUserId: row.expectedExternalUserId,
98
+ voiceCodeHash: row.voiceCodeHash,
99
+ voiceCodeDigits: row.voiceCodeDigits,
93
100
  createdAt: row.createdAt,
94
101
  updatedAt: row.updatedAt,
95
102
  };
@@ -127,6 +134,10 @@ export function createInvite(params: {
127
134
  note?: string;
128
135
  maxUses?: number;
129
136
  expiresInMs?: number;
137
+ // Voice invite metadata (all optional — omitted for non-voice invites)
138
+ expectedExternalUserId?: string;
139
+ voiceCodeHash?: string;
140
+ voiceCodeDigits?: number;
130
141
  }): { invite: IngressInvite; rawToken: string } {
131
142
  const db = getDb();
132
143
  const now = Date.now();
@@ -148,6 +159,9 @@ export function createInvite(params: {
148
159
  redeemedByExternalUserId: null,
149
160
  redeemedByExternalChatId: null,
150
161
  redeemedAt: null,
162
+ expectedExternalUserId: params.expectedExternalUserId ?? null,
163
+ voiceCodeHash: params.voiceCodeHash ?? null,
164
+ voiceCodeDigits: params.voiceCodeDigits ?? null,
151
165
  createdAt: now,
152
166
  updatedAt: now,
153
167
  };
@@ -432,3 +446,34 @@ export function findByTokenHash(tokenHash: string): IngressInvite | null {
432
446
 
433
447
  return row ? rowToInvite(row) : null;
434
448
  }
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // findActiveVoiceInvites
452
+ // ---------------------------------------------------------------------------
453
+
454
+ /**
455
+ * Find all active voice invites bound to a specific caller identity.
456
+ * Used by the voice invite redemption flow to locate candidate invites
457
+ * before code hash matching.
458
+ */
459
+ export function findActiveVoiceInvites(params: {
460
+ assistantId: string;
461
+ expectedExternalUserId: string;
462
+ }): IngressInvite[] {
463
+ const db = getDb();
464
+
465
+ const rows = db
466
+ .select()
467
+ .from(assistantIngressInvites)
468
+ .where(
469
+ and(
470
+ eq(assistantIngressInvites.assistantId, params.assistantId),
471
+ eq(assistantIngressInvites.sourceChannel, 'voice'),
472
+ eq(assistantIngressInvites.status, 'active'),
473
+ eq(assistantIngressInvites.expectedExternalUserId, params.expectedExternalUserId),
474
+ ),
475
+ )
476
+ .all();
477
+
478
+ return rows.map(rowToInvite);
479
+ }
@@ -24,21 +24,28 @@ const BACKFILL_CHECKPOINT_ID_KEY = 'memory:backfill:last_message_id';
24
24
  const RELATION_BACKFILL_CHECKPOINT_KEY = 'memory:relation_backfill:last_created_at';
25
25
  const RELATION_BACKFILL_CHECKPOINT_ID_KEY = 'memory:relation_backfill:last_message_id';
26
26
 
27
- type ProvenanceActorRole = 'guardian' | 'non-guardian' | 'unverified_channel';
27
+ type ProvenanceTrustClass = 'guardian' | 'trusted_contact' | 'unknown';
28
28
 
29
- function parseProvenanceActorRole(rawMetadata: string | null): ProvenanceActorRole | undefined {
29
+ function parseProvenanceTrustClass(rawMetadata: string | null): ProvenanceTrustClass | undefined {
30
30
  if (!rawMetadata) return undefined;
31
31
  try {
32
32
  const parsedJson: unknown = JSON.parse(rawMetadata);
33
33
  const parsed = messageMetadataSchema.safeParse(parsedJson);
34
- return parsed.success ? parsed.data.provenanceActorRole : undefined;
34
+ if (!parsed.success) return undefined;
35
+ if (parsed.data.provenanceTrustClass) return parsed.data.provenanceTrustClass;
36
+ // Legacy fallback for rows written before provenanceTrustClass existed.
37
+ const legacyRole = (parsedJson as { provenanceActorRole?: unknown }).provenanceActorRole;
38
+ if (legacyRole === 'guardian') return 'guardian';
39
+ if (legacyRole === 'non-guardian') return 'trusted_contact';
40
+ if (legacyRole === 'unverified_channel') return 'unknown';
41
+ return undefined;
35
42
  } catch {
36
43
  return undefined;
37
44
  }
38
45
  }
39
46
 
40
- function isTrustedActorRole(actorRole: ProvenanceActorRole | undefined): boolean {
41
- return actorRole === 'guardian' || actorRole === undefined;
47
+ function isTrustedTrustClass(trustClass: ProvenanceTrustClass | undefined): boolean {
48
+ return trustClass === 'guardian' || trustClass === undefined;
42
49
  }
43
50
 
44
51
  export function backfillJob(job: MemoryJob, config: AssistantConfig): void {
@@ -68,7 +75,7 @@ export function backfillJob(job: MemoryJob, config: AssistantConfig): void {
68
75
  scopeId = getConversationMemoryScopeId(message.conversationId);
69
76
  scopeCache.set(message.conversationId, scopeId);
70
77
  }
71
- const provenanceActorRole = parseProvenanceActorRole(message.metadata ?? null);
78
+ const provenanceTrustClass = parseProvenanceTrustClass(message.metadata ?? null);
72
79
  indexMessageNow({
73
80
  messageId: message.id,
74
81
  conversationId: message.conversationId,
@@ -76,7 +83,7 @@ export function backfillJob(job: MemoryJob, config: AssistantConfig): void {
76
83
  content: message.content,
77
84
  createdAt: message.createdAt,
78
85
  scopeId,
79
- provenanceActorRole,
86
+ provenanceTrustClass,
80
87
  }, config.memory);
81
88
  }
82
89
  const lastMessage = batch[batch.length - 1];
@@ -139,8 +146,8 @@ export function backfillEntityRelationsJob(job: MemoryJob, config: AssistantConf
139
146
  let queuedExtractEntityJobs = 0;
140
147
  let skippedUntrusted = 0;
141
148
  for (const message of batch) {
142
- const provenanceActorRole = parseProvenanceActorRole(message.metadata ?? null);
143
- if (!isTrustedActorRole(provenanceActorRole)) {
149
+ const provenanceTrustClass = parseProvenanceTrustClass(message.metadata ?? null);
150
+ if (!isTrustedTrustClass(provenanceTrustClass)) {
144
151
  skippedUntrusted += 1;
145
152
  continue;
146
153
  }
@@ -0,0 +1,289 @@
1
+ import { normalizePhoneNumber } from '../../util/phone.js';
2
+ import { type DrizzleDb, getSqliteFrom } from '../db-connection.js';
3
+
4
+ /**
5
+ * One-shot migration: normalize phone-like identity fields to E.164 format.
6
+ *
7
+ * Historical records may contain phone numbers in inconsistent formats
8
+ * (e.g., "(555) 123-4567", "1-555-123-4567", "+1 555 123 4567").
9
+ * This migration normalizes them to E.164 ("+15551234567") using the same
10
+ * normalizePhoneNumber utility used at runtime.
11
+ *
12
+ * Strategy:
13
+ * - Tables with a `channel` column: only process rows where the channel
14
+ * is phone-like (sms, voice, whatsapp).
15
+ * - The `expected_phone_e164` column is always a phone number regardless
16
+ * of channel, so it is normalized unconditionally.
17
+ *
18
+ * Collision handling: source queries are ordered by `updated_at DESC`
19
+ * (falling back to `rowid DESC` when the column is absent) so the
20
+ * most-recently-updated row is processed first and receives the UPDATE.
21
+ * When a subsequent (older) duplicate normalizes to the same value
22
+ * within the same unique-key scope, it is deleted — preserving the
23
+ * most recent state deterministically.
24
+ *
25
+ * Idempotent: already-normalized values pass through normalizePhoneNumber
26
+ * unchanged, and the checkpoint key prevents re-execution.
27
+ */
28
+ export function migrateNormalizePhoneIdentities(database: DrizzleDb): void {
29
+ const raw = getSqliteFrom(database);
30
+ const checkpointKey = 'migration_normalize_phone_identities_v1';
31
+ const checkpoint = raw.query(
32
+ `SELECT 1 FROM memory_checkpoints WHERE key = ?`,
33
+ ).get(checkpointKey);
34
+ if (checkpoint) return;
35
+
36
+ const PHONE_CHANNELS = ['sms', 'voice', 'whatsapp'];
37
+
38
+ /**
39
+ * Unique key scope definition for collision detection.
40
+ * `peerColumns` are the other columns in the composite unique index
41
+ * (besides the column being normalized). When the normalized value
42
+ * matches an existing row with the same peer-column values, the
43
+ * current row is a duplicate and should be deleted.
44
+ * `whereClause` is an optional SQL fragment for partial unique indexes
45
+ * (e.g., `WHERE external_user_id IS NOT NULL`).
46
+ */
47
+ type UniqueKeyScope = {
48
+ peerColumns: string[];
49
+ whereClause?: string;
50
+ };
51
+
52
+ // Helper: normalize a column's phone-like values in a table filtered by channel.
53
+ // When uniqueKeyScope is provided, checks for collisions before updating.
54
+ // Rows are ordered by updated_at DESC (or rowid DESC as fallback) so the
55
+ // most-recently-updated row is processed first and survives collisions.
56
+ function normalizeColumnByChannel(
57
+ table: string,
58
+ column: string,
59
+ channelColumn: string,
60
+ uniqueKeyScope?: UniqueKeyScope,
61
+ ): void {
62
+ const tableExists = raw.query(
63
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`,
64
+ ).get(table);
65
+ if (!tableExists) return;
66
+
67
+ const colExists = raw.query(
68
+ `SELECT 1 FROM pragma_table_info(?) WHERE name = ?`,
69
+ ).get(table, column);
70
+ if (!colExists) return;
71
+
72
+ const chanColExists = raw.query(
73
+ `SELECT 1 FROM pragma_table_info(?) WHERE name = ?`,
74
+ ).get(table, channelColumn);
75
+ if (!chanColExists) return;
76
+
77
+ const hasUpdatedAt = !!raw.query(
78
+ `SELECT 1 FROM pragma_table_info(?) WHERE name = 'updated_at'`,
79
+ ).get(table);
80
+ const orderBy = hasUpdatedAt ? 'updated_at DESC, rowid DESC' : 'rowid DESC';
81
+
82
+ const selectColumns = [`id`, column];
83
+ if (uniqueKeyScope) {
84
+ for (const peer of uniqueKeyScope.peerColumns) {
85
+ if (!selectColumns.includes(peer)) selectColumns.push(peer);
86
+ }
87
+ }
88
+
89
+ const rows = raw.query(
90
+ `SELECT ${selectColumns.join(', ')} FROM ${table} WHERE ${channelColumn} IN (${PHONE_CHANNELS.map(() => '?').join(',')}) AND ${column} IS NOT NULL ORDER BY ${orderBy}`,
91
+ ).all(...PHONE_CHANNELS) as Array<{ id: string; [key: string]: string }>;
92
+
93
+ if (rows.length === 0) return;
94
+
95
+ const update = raw.prepare(
96
+ `UPDATE ${table} SET ${column} = ? WHERE id = ?`,
97
+ );
98
+ const deleteRow = raw.prepare(
99
+ `DELETE FROM ${table} WHERE id = ?`,
100
+ );
101
+
102
+ for (const row of rows) {
103
+ const original = row[column];
104
+ if (!original) continue;
105
+ const normalized = normalizePhoneNumber(original);
106
+ if (normalized && normalized !== original) {
107
+ if (uniqueKeyScope) {
108
+ // Check if another row already has the normalized value within the same unique-key scope
109
+ const peerConditions = uniqueKeyScope.peerColumns
110
+ .map((col) => `${col} = ?`)
111
+ .join(' AND ');
112
+ const peerValues = uniqueKeyScope.peerColumns.map((col) => row[col]);
113
+ const whereExtra = uniqueKeyScope.whereClause ? ` AND (${uniqueKeyScope.whereClause})` : '';
114
+ const existing = raw.query(
115
+ `SELECT 1 FROM ${table} WHERE ${column} = ? AND ${peerConditions} AND id != ?${whereExtra}`,
116
+ ).get(normalized, ...peerValues, row.id);
117
+ if (existing) {
118
+ // A canonical row already exists — delete this duplicate
119
+ deleteRow.run(row.id);
120
+ continue;
121
+ }
122
+ }
123
+ update.run(normalized, row.id);
124
+ }
125
+ }
126
+ }
127
+
128
+ // Helper: normalize a column unconditionally (no channel filter).
129
+ // Used for columns that are always phone numbers (e.g., expected_phone_e164).
130
+ // When uniqueKeyScope is provided, checks for collisions before updating.
131
+ // Rows are ordered by updated_at DESC (or rowid DESC as fallback) so the
132
+ // most-recently-updated row is processed first and survives collisions.
133
+ function normalizeColumnUnconditionally(
134
+ table: string,
135
+ column: string,
136
+ uniqueKeyScope?: UniqueKeyScope,
137
+ ): void {
138
+ const tableExists = raw.query(
139
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`,
140
+ ).get(table);
141
+ if (!tableExists) return;
142
+
143
+ const colExists = raw.query(
144
+ `SELECT 1 FROM pragma_table_info(?) WHERE name = ?`,
145
+ ).get(table, column);
146
+ if (!colExists) return;
147
+
148
+ const hasUpdatedAt = !!raw.query(
149
+ `SELECT 1 FROM pragma_table_info(?) WHERE name = 'updated_at'`,
150
+ ).get(table);
151
+ const orderBy = hasUpdatedAt ? 'updated_at DESC, rowid DESC' : 'rowid DESC';
152
+
153
+ const selectColumns = [`id`, column];
154
+ if (uniqueKeyScope) {
155
+ for (const peer of uniqueKeyScope.peerColumns) {
156
+ if (!selectColumns.includes(peer)) selectColumns.push(peer);
157
+ }
158
+ }
159
+
160
+ const rows = raw.query(
161
+ `SELECT ${selectColumns.join(', ')} FROM ${table} WHERE ${column} IS NOT NULL ORDER BY ${orderBy}`,
162
+ ).all() as Array<{ id: string; [key: string]: string }>;
163
+
164
+ if (rows.length === 0) return;
165
+
166
+ const update = raw.prepare(
167
+ `UPDATE ${table} SET ${column} = ? WHERE id = ?`,
168
+ );
169
+ const deleteRow = raw.prepare(
170
+ `DELETE FROM ${table} WHERE id = ?`,
171
+ );
172
+
173
+ for (const row of rows) {
174
+ const original = row[column];
175
+ if (!original) continue;
176
+ const normalized = normalizePhoneNumber(original);
177
+ if (normalized && normalized !== original) {
178
+ if (uniqueKeyScope) {
179
+ const peerConditions = uniqueKeyScope.peerColumns
180
+ .map((col) => `${col} = ?`)
181
+ .join(' AND ');
182
+ const peerValues = uniqueKeyScope.peerColumns.map((col) => row[col]);
183
+ const whereExtra = uniqueKeyScope.whereClause ? ` AND (${uniqueKeyScope.whereClause})` : '';
184
+ const existing = raw.query(
185
+ `SELECT 1 FROM ${table} WHERE ${column} = ? AND ${peerConditions} AND id != ?${whereExtra}`,
186
+ ).get(normalized, ...peerValues, row.id);
187
+ if (existing) {
188
+ deleteRow.run(row.id);
189
+ continue;
190
+ }
191
+ }
192
+ update.run(normalized, row.id);
193
+ }
194
+ }
195
+ }
196
+
197
+ try {
198
+ raw.exec('BEGIN');
199
+
200
+ // ── channel_guardian_bindings ──────────────────────────────────
201
+ // Has `channel` column — only normalize phone-like channels.
202
+ // Unique index idx_channel_guardian_bindings_active is on (assistant_id, channel)
203
+ // and does NOT include guardian_external_user_id, so no collision risk.
204
+ normalizeColumnByChannel(
205
+ 'channel_guardian_bindings',
206
+ 'guardian_external_user_id',
207
+ 'channel',
208
+ );
209
+
210
+ // ── assistant_ingress_members ─────────────────────────────────
211
+ // Has `source_channel` column — only normalize phone-like channels.
212
+ // Unique index idx_ingress_members_user is on (assistant_id, source_channel, external_user_id)
213
+ // WHERE external_user_id IS NOT NULL — collision possible when two format variants normalize
214
+ // to the same E.164 within the same (assistant_id, source_channel) scope.
215
+ normalizeColumnByChannel(
216
+ 'assistant_ingress_members',
217
+ 'external_user_id',
218
+ 'source_channel',
219
+ {
220
+ peerColumns: ['assistant_id', 'source_channel'],
221
+ whereClause: 'external_user_id IS NOT NULL',
222
+ },
223
+ );
224
+
225
+ // ── channel_guardian_verification_challenges ──────────────────
226
+ // Has `channel` column — normalize identity columns for phone-like channels.
227
+ // Index idx_channel_guardian_challenges_lookup is non-unique, no collision risk.
228
+ normalizeColumnByChannel(
229
+ 'channel_guardian_verification_challenges',
230
+ 'expected_external_user_id',
231
+ 'channel',
232
+ );
233
+ normalizeColumnByChannel(
234
+ 'channel_guardian_verification_challenges',
235
+ 'consumed_by_external_user_id',
236
+ 'channel',
237
+ );
238
+ // expected_phone_e164 is always a phone number regardless of channel.
239
+ // No unique index includes this column, no collision risk.
240
+ normalizeColumnUnconditionally(
241
+ 'channel_guardian_verification_challenges',
242
+ 'expected_phone_e164',
243
+ );
244
+
245
+ // ── canonical_guardian_requests ───────────────────────────────
246
+ // Has `source_channel` column — only normalize phone-like channels.
247
+ // All indexes on this table are non-unique, no collision risk.
248
+ normalizeColumnByChannel(
249
+ 'canonical_guardian_requests',
250
+ 'requester_external_user_id',
251
+ 'source_channel',
252
+ );
253
+ normalizeColumnByChannel(
254
+ 'canonical_guardian_requests',
255
+ 'guardian_external_user_id',
256
+ 'source_channel',
257
+ );
258
+ normalizeColumnByChannel(
259
+ 'canonical_guardian_requests',
260
+ 'decided_by_external_user_id',
261
+ 'source_channel',
262
+ );
263
+
264
+ // ── channel_guardian_rate_limits ──────────────────────────────
265
+ // Has `channel` column — only normalize phone-like channels.
266
+ // Unique index idx_channel_guardian_rate_limits_actor is on
267
+ // (assistant_id, channel, actor_external_user_id, actor_chat_id) —
268
+ // collision possible when two format variants normalize to the same E.164
269
+ // within the same (assistant_id, channel, actor_chat_id) scope.
270
+ normalizeColumnByChannel(
271
+ 'channel_guardian_rate_limits',
272
+ 'actor_external_user_id',
273
+ 'channel',
274
+ {
275
+ peerColumns: ['assistant_id', 'channel', 'actor_chat_id'],
276
+ },
277
+ );
278
+
279
+ // Write checkpoint
280
+ raw.query(
281
+ `INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
282
+ ).run(checkpointKey, Date.now());
283
+
284
+ raw.exec('COMMIT');
285
+ } catch (e) {
286
+ try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
287
+ throw e;
288
+ }
289
+ }
@@ -0,0 +1,16 @@
1
+ import type { DrizzleDb } from '../db-connection.js';
2
+
3
+ /**
4
+ * Add voice invite columns to assistant_ingress_invites for guardian-initiated
5
+ * voice invite codes. All columns are nullable to keep existing invite rows
6
+ * compatible.
7
+ *
8
+ * - expected_external_user_id: E.164 phone number for identity binding
9
+ * - voice_code_hash: SHA-256 hash of the short numeric code
10
+ * - voice_code_digits: configurable digit count (nullable — NULL for non-voice invites)
11
+ */
12
+ export function migrateVoiceInviteColumns(database: DrizzleDb): void {
13
+ try { database.run(/*sql*/ `ALTER TABLE assistant_ingress_invites ADD COLUMN expected_external_user_id TEXT`); } catch { /* already exists */ }
14
+ try { database.run(/*sql*/ `ALTER TABLE assistant_ingress_invites ADD COLUMN voice_code_hash TEXT`); } catch { /* already exists */ }
15
+ try { database.run(/*sql*/ `ALTER TABLE assistant_ingress_invites ADD COLUMN voice_code_digits INTEGER`); } catch { /* already exists */ }
16
+ }
@@ -4,9 +4,9 @@ import type { DrizzleDb } from '../db-connection.js';
4
4
  * Add routing_intent and routing_hints_json columns to reminders table.
5
5
  *
6
6
  * routing_intent controls how the reminder is delivered at trigger time:
7
- * - single_channel (default): deliver to a single best channel
7
+ * - single_channel: deliver to a single best channel
8
8
  * - multi_channel: deliver to a subset of channels
9
- * - all_channels: deliver to every available channel
9
+ * - all_channels (default): deliver to every available channel
10
10
  *
11
11
  * routing_hints_json stores an opaque JSON object with hints for the
12
12
  * routing engine (e.g. preferred channels, exclusions).
@@ -14,7 +14,7 @@ import type { DrizzleDb } from '../db-connection.js';
14
14
  export function migrateReminderRoutingIntent(database: DrizzleDb): void {
15
15
  try {
16
16
  database.run(
17
- /*sql*/ `ALTER TABLE reminders ADD COLUMN routing_intent TEXT NOT NULL DEFAULT 'single_channel'`,
17
+ /*sql*/ `ALTER TABLE reminders ADD COLUMN routing_intent TEXT NOT NULL DEFAULT 'all_channels'`,
18
18
  );
19
19
  } catch { /* already exists */ }
20
20
 
@@ -0,0 +1,59 @@
1
+ import type { DrizzleDb } from '../db-connection.js';
2
+
3
+ /**
4
+ * Create canonical_guardian_requests and canonical_guardian_deliveries tables.
5
+ *
6
+ * These tables unify the split voice (guardian_action_requests / guardian_action_deliveries)
7
+ * and channel (channel_guardian_approval_requests) persistence models into a single
8
+ * canonical domain. Uses CREATE TABLE IF NOT EXISTS for idempotency.
9
+ */
10
+ export function createCanonicalGuardianTables(database: DrizzleDb): void {
11
+ database.run(/*sql*/ `
12
+ CREATE TABLE IF NOT EXISTS canonical_guardian_requests (
13
+ id TEXT PRIMARY KEY,
14
+ kind TEXT NOT NULL,
15
+ source_type TEXT NOT NULL,
16
+ source_channel TEXT,
17
+ conversation_id TEXT,
18
+ requester_external_user_id TEXT,
19
+ guardian_external_user_id TEXT,
20
+ call_session_id TEXT,
21
+ pending_question_id TEXT,
22
+ question_text TEXT,
23
+ request_code TEXT,
24
+ tool_name TEXT,
25
+ input_digest TEXT,
26
+ status TEXT NOT NULL DEFAULT 'pending',
27
+ answer_text TEXT,
28
+ decided_by_external_user_id TEXT,
29
+ followup_state TEXT,
30
+ expires_at TEXT,
31
+ created_at TEXT NOT NULL,
32
+ updated_at TEXT NOT NULL
33
+ )
34
+ `);
35
+
36
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_canonical_guardian_requests_status ON canonical_guardian_requests(status)`);
37
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_canonical_guardian_requests_guardian ON canonical_guardian_requests(guardian_external_user_id, status)`);
38
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_canonical_guardian_requests_conversation ON canonical_guardian_requests(conversation_id, status)`);
39
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_canonical_guardian_requests_source ON canonical_guardian_requests(source_type, status)`);
40
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_canonical_guardian_requests_kind ON canonical_guardian_requests(kind, status)`);
41
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_canonical_guardian_requests_request_code ON canonical_guardian_requests(request_code)`);
42
+
43
+ database.run(/*sql*/ `
44
+ CREATE TABLE IF NOT EXISTS canonical_guardian_deliveries (
45
+ id TEXT PRIMARY KEY,
46
+ request_id TEXT NOT NULL REFERENCES canonical_guardian_requests(id) ON DELETE CASCADE,
47
+ destination_channel TEXT NOT NULL,
48
+ destination_conversation_id TEXT,
49
+ destination_chat_id TEXT,
50
+ destination_message_id TEXT,
51
+ status TEXT NOT NULL DEFAULT 'pending',
52
+ created_at TEXT NOT NULL,
53
+ updated_at TEXT NOT NULL
54
+ )
55
+ `);
56
+
57
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_canonical_guardian_deliveries_request_id ON canonical_guardian_deliveries(request_id)`);
58
+ database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_canonical_guardian_deliveries_status ON canonical_guardian_deliveries(status)`);
59
+ }
@@ -0,0 +1,15 @@
1
+ import type { DrizzleDb } from '../db-connection.js';
2
+
3
+ /**
4
+ * Add requester_chat_id column to canonical_guardian_requests.
5
+ *
6
+ * On channels like Slack, the external chat ID (channel/DM ID) differs from
7
+ * the sender's external user ID. Without this column the access_request
8
+ * resolver would deliver approval/denial messages to the wrong destination.
9
+ *
10
+ * Uses ALTER TABLE ADD COLUMN with try/catch for idempotency — no registry
11
+ * entry needed.
12
+ */
13
+ export function migrateCanonicalGuardianRequesterChatId(database: DrizzleDb): void {
14
+ try { database.run(/*sql*/ `ALTER TABLE canonical_guardian_requests ADD COLUMN requester_chat_id TEXT`); } catch { /* already exists */ }
15
+ }
@@ -0,0 +1,15 @@
1
+ import type { DrizzleDb } from '../db-connection.js';
2
+
3
+ /**
4
+ * Add composite index on canonical_guardian_deliveries(destination_channel, destination_chat_id).
5
+ *
6
+ * The listPendingCanonicalGuardianRequestsByDestinationChat helper queries
7
+ * deliveries by (destination_channel, destination_chat_id) to bridge inbound
8
+ * guardian replies back to canonical requests. Without an index these
9
+ * degrade to full table scans as delivery history grows.
10
+ */
11
+ export function migrateCanonicalGuardianDeliveriesDestinationIndex(database: DrizzleDb): void {
12
+ database.run(
13
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_canonical_guardian_deliveries_destination ON canonical_guardian_deliveries(destination_channel, destination_chat_id)`,
14
+ );
15
+ }
@@ -37,6 +37,8 @@ export { migrateNotificationDeliveryThreadDecision } from './032-notification-de
37
37
  export { createScopedApprovalGrantsTable } from './033-scoped-approval-grants.js';
38
38
  export { migrateGuardianActionToolMetadata } from './034-guardian-action-tool-metadata.js';
39
39
  export { migrateGuardianActionSupersession } from './035-guardian-action-supersession.js';
40
+ export { migrateNormalizePhoneIdentities } from './036-normalize-phone-identities.js';
41
+ export { migrateVoiceInviteColumns } from './037-voice-invite-columns.js';
40
42
  export { createCoreTables } from './100-core-tables.js';
41
43
  export { createWatchersAndLogsTables } from './101-watchers-and-logs.js';
42
44
  export { addCoreColumns } from './102-alter-table-columns.js';
@@ -58,6 +60,9 @@ export { createConversationAttentionTables } from './117-conversation-attention.
58
60
  export { migrateReminderRoutingIntent } from './118-reminder-routing-intent.js';
59
61
  export { migrateSchemaIndexesAndColumns } from './119-schema-indexes-and-columns.js';
60
62
  export { migrateFkCascadeRebuilds } from './120-fk-cascade-rebuilds.js';
63
+ export { createCanonicalGuardianTables } from './121-canonical-guardian-requests.js';
64
+ export { migrateCanonicalGuardianRequesterChatId } from './122-canonical-guardian-requester-chat-id.js';
65
+ export { migrateCanonicalGuardianDeliveriesDestinationIndex } from './123-canonical-guardian-deliveries-destination-index.js';
61
66
  export {
62
67
  MIGRATION_REGISTRY,
63
68
  type MigrationRegistryEntry,
@@ -90,6 +90,11 @@ export const MIGRATION_REGISTRY: MigrationRegistryEntry[] = [
90
90
  dependsOn: ['migration_embedding_vector_blob_v1'],
91
91
  description: 'Rebuild memory_embeddings to make vector_json nullable (pre-100 DBs had NOT NULL)',
92
92
  },
93
+ {
94
+ key: 'migration_normalize_phone_identities_v1',
95
+ version: 14,
96
+ description: 'Normalize phone-like identity fields to E.164 format across guardian bindings, verification challenges, canonical requests, ingress members, and rate limits',
97
+ },
93
98
  ];
94
99
 
95
100
  export interface MigrationValidationResult {
@@ -80,28 +80,37 @@ export class VellumQdrantClient {
80
80
 
81
81
  log.info({ collection: this.collection, vectorSize: this.vectorSize }, 'Creating Qdrant collection');
82
82
 
83
- await this.client.createCollection(this.collection, {
84
- vectors: {
85
- size: this.vectorSize,
86
- distance: 'Cosine',
87
- on_disk: this.onDisk,
88
- },
89
- hnsw_config: {
90
- on_disk: this.onDisk,
91
- m: 16,
92
- ef_construct: 100,
93
- },
94
- quantization_config: this.quantization === 'scalar'
95
- ? {
96
- scalar: {
97
- type: 'int8',
98
- quantile: 0.99,
99
- always_ram: true,
100
- },
101
- }
102
- : undefined,
103
- on_disk_payload: this.onDisk,
104
- });
83
+ try {
84
+ await this.client.createCollection(this.collection, {
85
+ vectors: {
86
+ size: this.vectorSize,
87
+ distance: 'Cosine',
88
+ on_disk: this.onDisk,
89
+ },
90
+ hnsw_config: {
91
+ on_disk: this.onDisk,
92
+ m: 16,
93
+ ef_construct: 100,
94
+ },
95
+ quantization_config: this.quantization === 'scalar'
96
+ ? {
97
+ scalar: {
98
+ type: 'int8',
99
+ quantile: 0.99,
100
+ always_ram: true,
101
+ },
102
+ }
103
+ : undefined,
104
+ on_disk_payload: this.onDisk,
105
+ });
106
+ } catch (err) {
107
+ // 409 = collection was created by a concurrent caller — that's fine
108
+ if (err instanceof Error && 'status' in err && (err as { status: number }).status === 409) {
109
+ this.collectionReady = true;
110
+ return;
111
+ }
112
+ throw err;
113
+ }
105
114
 
106
115
  // Create payload indexes for efficient filtering
107
116
  await Promise.all([
@@ -19,6 +19,7 @@ export {
19
19
  migrateMemoryItemsScopeSaltedFingerprints,
20
20
  migrateMemorySegmentsIndexes,
21
21
  migrateMessagesFtsBackfill,
22
+ migrateNormalizePhoneIdentities,
22
23
  migrateNotificationDeliveryPairingColumns,
23
24
  migrateNotificationDeliveryThreadDecision,
24
25
  migrateNotificationTablesSchema,