@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
@@ -0,0 +1,586 @@
1
+ /**
2
+ * Store for canonical guardian requests and deliveries.
3
+ *
4
+ * Unifies voice guardian action requests/deliveries and channel guardian
5
+ * approval requests into a single persistence model. Resolution uses
6
+ * compare-and-swap (CAS) semantics: the first writer to transition a
7
+ * request from the expected status wins.
8
+ */
9
+
10
+ import { and, desc, eq } from 'drizzle-orm';
11
+ import { v4 as uuid } from 'uuid';
12
+
13
+ import { getDb, rawChanges } from './db.js';
14
+ import {
15
+ canonicalGuardianDeliveries,
16
+ canonicalGuardianRequests,
17
+ } from './schema.js';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Types
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export type CanonicalRequestStatus = 'pending' | 'approved' | 'denied' | 'expired' | 'cancelled';
24
+
25
+ export interface CanonicalGuardianRequest {
26
+ id: string;
27
+ kind: string;
28
+ sourceType: string;
29
+ sourceChannel: string | null;
30
+ conversationId: string | null;
31
+ requesterExternalUserId: string | null;
32
+ requesterChatId: string | null;
33
+ guardianExternalUserId: string | null;
34
+ callSessionId: string | null;
35
+ pendingQuestionId: string | null;
36
+ questionText: string | null;
37
+ requestCode: string | null;
38
+ toolName: string | null;
39
+ inputDigest: string | null;
40
+ status: CanonicalRequestStatus;
41
+ answerText: string | null;
42
+ decidedByExternalUserId: string | null;
43
+ followupState: string | null;
44
+ expiresAt: string | null;
45
+ createdAt: string;
46
+ updatedAt: string;
47
+ }
48
+
49
+ export interface CanonicalGuardianDelivery {
50
+ id: string;
51
+ requestId: string;
52
+ destinationChannel: string;
53
+ destinationConversationId: string | null;
54
+ destinationChatId: string | null;
55
+ destinationMessageId: string | null;
56
+ status: string;
57
+ createdAt: string;
58
+ updatedAt: string;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Request code generation
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Generate a short human-readable request code (6 hex chars, uppercase).
67
+ *
68
+ * Checks for collisions against existing PENDING canonical requests and
69
+ * retries up to 5 times to avoid code reuse among active requests.
70
+ */
71
+ export function generateCanonicalRequestCode(): string {
72
+ const MAX_RETRIES = 5;
73
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
74
+ const code = uuid().replace(/-/g, '').slice(0, 6).toUpperCase();
75
+ // Only check for collisions among pending requests — resolved requests
76
+ // with the same code are harmless since getCanonicalGuardianRequestByCode
77
+ // already filters by status='pending'.
78
+ const existing = getCanonicalGuardianRequestByCodeInternal(code);
79
+ if (!existing) return code;
80
+ }
81
+ // Last resort: return the code even if it collides (extremely unlikely
82
+ // with 16^6 = ~16.7M possible codes).
83
+ return uuid().replace(/-/g, '').slice(0, 6).toUpperCase();
84
+ }
85
+
86
+ /**
87
+ * Internal code lookup used by the collision checker. Avoids circular
88
+ * dependency with the public getCanonicalGuardianRequestByCode by
89
+ * inlining the same query logic.
90
+ */
91
+ function getCanonicalGuardianRequestByCodeInternal(code: string): boolean {
92
+ const db = getDb();
93
+ const row = db
94
+ .select()
95
+ .from(canonicalGuardianRequests)
96
+ .where(
97
+ and(
98
+ eq(canonicalGuardianRequests.requestCode, code),
99
+ eq(canonicalGuardianRequests.status, 'pending'),
100
+ ),
101
+ )
102
+ .get();
103
+ return !!row;
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Helpers
108
+ // ---------------------------------------------------------------------------
109
+
110
+ function rowToRequest(row: typeof canonicalGuardianRequests.$inferSelect): CanonicalGuardianRequest {
111
+ return {
112
+ id: row.id,
113
+ kind: row.kind,
114
+ sourceType: row.sourceType,
115
+ sourceChannel: row.sourceChannel,
116
+ conversationId: row.conversationId,
117
+ requesterExternalUserId: row.requesterExternalUserId,
118
+ requesterChatId: row.requesterChatId,
119
+ guardianExternalUserId: row.guardianExternalUserId,
120
+ callSessionId: row.callSessionId,
121
+ pendingQuestionId: row.pendingQuestionId,
122
+ questionText: row.questionText,
123
+ requestCode: row.requestCode,
124
+ toolName: row.toolName,
125
+ inputDigest: row.inputDigest,
126
+ status: row.status as CanonicalRequestStatus,
127
+ answerText: row.answerText,
128
+ decidedByExternalUserId: row.decidedByExternalUserId,
129
+ followupState: row.followupState,
130
+ expiresAt: row.expiresAt,
131
+ createdAt: row.createdAt,
132
+ updatedAt: row.updatedAt,
133
+ };
134
+ }
135
+
136
+ function rowToDelivery(row: typeof canonicalGuardianDeliveries.$inferSelect): CanonicalGuardianDelivery {
137
+ return {
138
+ id: row.id,
139
+ requestId: row.requestId,
140
+ destinationChannel: row.destinationChannel,
141
+ destinationConversationId: row.destinationConversationId,
142
+ destinationChatId: row.destinationChatId,
143
+ destinationMessageId: row.destinationMessageId,
144
+ status: row.status,
145
+ createdAt: row.createdAt,
146
+ updatedAt: row.updatedAt,
147
+ };
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Canonical Guardian Requests
152
+ // ---------------------------------------------------------------------------
153
+
154
+ export interface CreateCanonicalGuardianRequestParams {
155
+ id?: string;
156
+ kind: string;
157
+ sourceType: string;
158
+ sourceChannel?: string;
159
+ conversationId?: string;
160
+ requesterExternalUserId?: string;
161
+ requesterChatId?: string;
162
+ guardianExternalUserId?: string;
163
+ callSessionId?: string;
164
+ pendingQuestionId?: string;
165
+ questionText?: string;
166
+ requestCode?: string;
167
+ toolName?: string;
168
+ inputDigest?: string;
169
+ status?: CanonicalRequestStatus;
170
+ answerText?: string;
171
+ decidedByExternalUserId?: string;
172
+ followupState?: string;
173
+ expiresAt?: string;
174
+ }
175
+
176
+ export function createCanonicalGuardianRequest(params: CreateCanonicalGuardianRequestParams): CanonicalGuardianRequest {
177
+ const db = getDb();
178
+ const now = new Date().toISOString();
179
+ const id = params.id ?? uuid();
180
+
181
+ const row = {
182
+ id,
183
+ kind: params.kind,
184
+ sourceType: params.sourceType,
185
+ sourceChannel: params.sourceChannel ?? null,
186
+ conversationId: params.conversationId ?? null,
187
+ requesterExternalUserId: params.requesterExternalUserId ?? null,
188
+ requesterChatId: params.requesterChatId ?? null,
189
+ guardianExternalUserId: params.guardianExternalUserId ?? null,
190
+ callSessionId: params.callSessionId ?? null,
191
+ pendingQuestionId: params.pendingQuestionId ?? null,
192
+ questionText: params.questionText ?? null,
193
+ requestCode: params.requestCode ?? generateCanonicalRequestCode(),
194
+ toolName: params.toolName ?? null,
195
+ inputDigest: params.inputDigest ?? null,
196
+ status: params.status ?? ('pending' as const),
197
+ answerText: params.answerText ?? null,
198
+ decidedByExternalUserId: params.decidedByExternalUserId ?? null,
199
+ followupState: params.followupState ?? null,
200
+ expiresAt: params.expiresAt ?? null,
201
+ createdAt: now,
202
+ updatedAt: now,
203
+ };
204
+
205
+ db.insert(canonicalGuardianRequests).values(row).run();
206
+ return rowToRequest(row);
207
+ }
208
+
209
+ export function getCanonicalGuardianRequest(id: string): CanonicalGuardianRequest | null {
210
+ const db = getDb();
211
+ const row = db
212
+ .select()
213
+ .from(canonicalGuardianRequests)
214
+ .where(eq(canonicalGuardianRequests.id, id))
215
+ .get();
216
+ return row ? rowToRequest(row) : null;
217
+ }
218
+
219
+ /**
220
+ * Look up a canonical guardian request by its short request code.
221
+ * Scoped to pending (unresolved) requests so that codes recycled by older,
222
+ * already-resolved requests do not collide with the active one.
223
+ */
224
+ export function getCanonicalGuardianRequestByCode(code: string): CanonicalGuardianRequest | null {
225
+ const db = getDb();
226
+ const row = db
227
+ .select()
228
+ .from(canonicalGuardianRequests)
229
+ .where(
230
+ and(
231
+ eq(canonicalGuardianRequests.requestCode, code),
232
+ eq(canonicalGuardianRequests.status, 'pending'),
233
+ ),
234
+ )
235
+ .get();
236
+ return row ? rowToRequest(row) : null;
237
+ }
238
+
239
+ export interface ListCanonicalGuardianRequestsFilters {
240
+ status?: CanonicalRequestStatus;
241
+ guardianExternalUserId?: string;
242
+ requesterExternalUserId?: string;
243
+ conversationId?: string;
244
+ sourceType?: string;
245
+ sourceChannel?: string;
246
+ kind?: string;
247
+ toolName?: string;
248
+ }
249
+
250
+ export function listCanonicalGuardianRequests(filters?: ListCanonicalGuardianRequestsFilters): CanonicalGuardianRequest[] {
251
+ const db = getDb();
252
+
253
+ const conditions = [];
254
+ if (filters?.status) {
255
+ conditions.push(eq(canonicalGuardianRequests.status, filters.status));
256
+ }
257
+ if (filters?.guardianExternalUserId) {
258
+ conditions.push(eq(canonicalGuardianRequests.guardianExternalUserId, filters.guardianExternalUserId));
259
+ }
260
+ if (filters?.conversationId) {
261
+ conditions.push(eq(canonicalGuardianRequests.conversationId, filters.conversationId));
262
+ }
263
+ if (filters?.requesterExternalUserId) {
264
+ conditions.push(eq(canonicalGuardianRequests.requesterExternalUserId, filters.requesterExternalUserId));
265
+ }
266
+ if (filters?.sourceType) {
267
+ conditions.push(eq(canonicalGuardianRequests.sourceType, filters.sourceType));
268
+ }
269
+ if (filters?.sourceChannel) {
270
+ conditions.push(eq(canonicalGuardianRequests.sourceChannel, filters.sourceChannel));
271
+ }
272
+ if (filters?.kind) {
273
+ conditions.push(eq(canonicalGuardianRequests.kind, filters.kind));
274
+ }
275
+ if (filters?.toolName) {
276
+ conditions.push(eq(canonicalGuardianRequests.toolName, filters.toolName));
277
+ }
278
+
279
+ if (conditions.length === 0) {
280
+ return db.select().from(canonicalGuardianRequests).all().map(rowToRequest);
281
+ }
282
+
283
+ return db
284
+ .select()
285
+ .from(canonicalGuardianRequests)
286
+ .where(and(...conditions))
287
+ .all()
288
+ .map(rowToRequest);
289
+ }
290
+
291
+ export interface UpdateCanonicalGuardianRequestParams {
292
+ status?: CanonicalRequestStatus;
293
+ answerText?: string;
294
+ decidedByExternalUserId?: string;
295
+ followupState?: string;
296
+ expiresAt?: string;
297
+ }
298
+
299
+ export function updateCanonicalGuardianRequest(
300
+ id: string,
301
+ updates: UpdateCanonicalGuardianRequestParams,
302
+ ): CanonicalGuardianRequest | null {
303
+ const db = getDb();
304
+ const now = new Date().toISOString();
305
+
306
+ const setValues: Record<string, unknown> = { updatedAt: now };
307
+ if (updates.status !== undefined) setValues.status = updates.status;
308
+ if (updates.answerText !== undefined) setValues.answerText = updates.answerText;
309
+ if (updates.decidedByExternalUserId !== undefined) setValues.decidedByExternalUserId = updates.decidedByExternalUserId;
310
+ if (updates.followupState !== undefined) setValues.followupState = updates.followupState;
311
+ if (updates.expiresAt !== undefined) setValues.expiresAt = updates.expiresAt;
312
+
313
+ db.update(canonicalGuardianRequests)
314
+ .set(setValues)
315
+ .where(eq(canonicalGuardianRequests.id, id))
316
+ .run();
317
+
318
+ return getCanonicalGuardianRequest(id);
319
+ }
320
+
321
+ export interface ResolveDecision {
322
+ status: CanonicalRequestStatus;
323
+ answerText?: string;
324
+ decidedByExternalUserId?: string;
325
+ }
326
+
327
+ /**
328
+ * Compare-and-swap resolve: only transitions the request from `expectedStatus`
329
+ * to the new status atomically. Returns the updated request on success, or
330
+ * null if the current status did not match `expectedStatus` (first-writer-wins).
331
+ */
332
+ export function resolveCanonicalGuardianRequest(
333
+ id: string,
334
+ expectedStatus: CanonicalRequestStatus,
335
+ decision: ResolveDecision,
336
+ ): CanonicalGuardianRequest | null {
337
+ const db = getDb();
338
+ const now = new Date().toISOString();
339
+
340
+ const setValues: Record<string, unknown> = {
341
+ status: decision.status,
342
+ updatedAt: now,
343
+ };
344
+ if (decision.answerText !== undefined) setValues.answerText = decision.answerText;
345
+ if (decision.decidedByExternalUserId !== undefined) setValues.decidedByExternalUserId = decision.decidedByExternalUserId;
346
+
347
+ db.update(canonicalGuardianRequests)
348
+ .set(setValues)
349
+ .where(
350
+ and(
351
+ eq(canonicalGuardianRequests.id, id),
352
+ eq(canonicalGuardianRequests.status, expectedStatus),
353
+ ),
354
+ )
355
+ .run();
356
+
357
+ if (rawChanges() === 0) return null;
358
+
359
+ return getCanonicalGuardianRequest(id);
360
+ }
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // Canonical Guardian Deliveries
364
+ // ---------------------------------------------------------------------------
365
+
366
+ export interface CreateCanonicalGuardianDeliveryParams {
367
+ id?: string;
368
+ requestId: string;
369
+ destinationChannel: string;
370
+ destinationConversationId?: string;
371
+ destinationChatId?: string;
372
+ destinationMessageId?: string;
373
+ status?: string;
374
+ }
375
+
376
+ export function createCanonicalGuardianDelivery(params: CreateCanonicalGuardianDeliveryParams): CanonicalGuardianDelivery {
377
+ const db = getDb();
378
+ const now = new Date().toISOString();
379
+ const id = params.id ?? uuid();
380
+
381
+ const row = {
382
+ id,
383
+ requestId: params.requestId,
384
+ destinationChannel: params.destinationChannel,
385
+ destinationConversationId: params.destinationConversationId ?? null,
386
+ destinationChatId: params.destinationChatId ?? null,
387
+ destinationMessageId: params.destinationMessageId ?? null,
388
+ status: params.status ?? ('pending' as const),
389
+ createdAt: now,
390
+ updatedAt: now,
391
+ };
392
+
393
+ db.insert(canonicalGuardianDeliveries).values(row).run();
394
+ return rowToDelivery(row);
395
+ }
396
+
397
+ export function listCanonicalGuardianDeliveries(requestId: string): CanonicalGuardianDelivery[] {
398
+ const db = getDb();
399
+ return db
400
+ .select()
401
+ .from(canonicalGuardianDeliveries)
402
+ .where(eq(canonicalGuardianDeliveries.requestId, requestId))
403
+ .all()
404
+ .map(rowToDelivery);
405
+ }
406
+
407
+ /**
408
+ * List pending canonical requests that were delivered to a specific
409
+ * destination conversation.
410
+ *
411
+ * This bridges inbound guardian replies (which arrive on the destination
412
+ * conversation) back to their canonical request records. The caller can
413
+ * optionally scope by destination channel when the same conversation ID
414
+ * namespace could exist across channels.
415
+ */
416
+ export function listPendingCanonicalGuardianRequestsByDestinationConversation(
417
+ destinationConversationId: string,
418
+ destinationChannel?: string,
419
+ ): CanonicalGuardianRequest[] {
420
+ const db = getDb();
421
+
422
+ const deliveryConditions = [eq(canonicalGuardianDeliveries.destinationConversationId, destinationConversationId)];
423
+ if (destinationChannel) {
424
+ deliveryConditions.push(eq(canonicalGuardianDeliveries.destinationChannel, destinationChannel));
425
+ }
426
+
427
+ const deliveries = db
428
+ .select()
429
+ .from(canonicalGuardianDeliveries)
430
+ .where(and(...deliveryConditions))
431
+ .all();
432
+
433
+ if (deliveries.length === 0) return [];
434
+
435
+ const seenRequestIds = new Set<string>();
436
+ const pendingRequests: CanonicalGuardianRequest[] = [];
437
+
438
+ for (const delivery of deliveries) {
439
+ if (seenRequestIds.has(delivery.requestId)) continue;
440
+ seenRequestIds.add(delivery.requestId);
441
+
442
+ const request = getCanonicalGuardianRequest(delivery.requestId);
443
+ if (request && request.status === 'pending') {
444
+ pendingRequests.push(request);
445
+ }
446
+ }
447
+
448
+ return pendingRequests;
449
+ }
450
+
451
+ /**
452
+ * List pending canonical requests that were delivered to a specific
453
+ * destination chat (channel + chatId pair).
454
+ *
455
+ * This bridges inbound guardian replies (which arrive on a specific chat)
456
+ * back to their canonical request records. Unlike the conversation-based
457
+ * variant, this uses the chat-level addressing that channel transports
458
+ * (Telegram, SMS) natively provide — critical for voice-originated
459
+ * `pending_question` requests that lack `guardianExternalUserId`.
460
+ */
461
+ export function listPendingCanonicalGuardianRequestsByDestinationChat(
462
+ destinationChannel: string,
463
+ destinationChatId: string,
464
+ ): CanonicalGuardianRequest[] {
465
+ const db = getDb();
466
+
467
+ const deliveries = db
468
+ .select()
469
+ .from(canonicalGuardianDeliveries)
470
+ .where(
471
+ and(
472
+ eq(canonicalGuardianDeliveries.destinationChannel, destinationChannel),
473
+ eq(canonicalGuardianDeliveries.destinationChatId, destinationChatId),
474
+ ),
475
+ )
476
+ .all();
477
+
478
+ if (deliveries.length === 0) return [];
479
+
480
+ const seenRequestIds = new Set<string>();
481
+ const pendingRequests: CanonicalGuardianRequest[] = [];
482
+
483
+ for (const delivery of deliveries) {
484
+ if (seenRequestIds.has(delivery.requestId)) continue;
485
+ seenRequestIds.add(delivery.requestId);
486
+
487
+ const request = getCanonicalGuardianRequest(delivery.requestId);
488
+ if (request && request.status === 'pending') {
489
+ pendingRequests.push(request);
490
+ }
491
+ }
492
+
493
+ return pendingRequests;
494
+ }
495
+
496
+ export interface UpdateCanonicalGuardianDeliveryParams {
497
+ status?: string;
498
+ destinationMessageId?: string;
499
+ }
500
+
501
+ // ---------------------------------------------------------------------------
502
+ // Call-controller convenience functions
503
+ // ---------------------------------------------------------------------------
504
+
505
+ /**
506
+ * Find the most recent pending canonical guardian request for a given call session.
507
+ * Used by the call-controller's consultation timeout handler.
508
+ */
509
+ export function getPendingCanonicalRequestByCallSessionId(callSessionId: string): CanonicalGuardianRequest | null {
510
+ const db = getDb();
511
+ const row = db
512
+ .select()
513
+ .from(canonicalGuardianRequests)
514
+ .where(
515
+ and(
516
+ eq(canonicalGuardianRequests.callSessionId, callSessionId),
517
+ eq(canonicalGuardianRequests.status, 'pending'),
518
+ ),
519
+ )
520
+ .orderBy(desc(canonicalGuardianRequests.createdAt))
521
+ .get();
522
+ return row ? rowToRequest(row) : null;
523
+ }
524
+
525
+ /**
526
+ * Find a canonical guardian request by its linked pending question ID.
527
+ * Used after async dispatch completes to locate the newly created request.
528
+ */
529
+ export function getCanonicalRequestByPendingQuestionId(questionId: string): CanonicalGuardianRequest | null {
530
+ const db = getDb();
531
+ const row = db
532
+ .select()
533
+ .from(canonicalGuardianRequests)
534
+ .where(eq(canonicalGuardianRequests.pendingQuestionId, questionId))
535
+ .get();
536
+ return row ? rowToRequest(row) : null;
537
+ }
538
+
539
+ /**
540
+ * Expire a canonical guardian request and all its deliveries.
541
+ * Atomically transitions the request from 'pending' to 'expired'.
542
+ */
543
+ export function expireCanonicalGuardianRequest(id: string): void {
544
+ const db = getDb();
545
+ const now = new Date().toISOString();
546
+
547
+ db.update(canonicalGuardianRequests)
548
+ .set({ status: 'expired', updatedAt: now })
549
+ .where(
550
+ and(
551
+ eq(canonicalGuardianRequests.id, id),
552
+ eq(canonicalGuardianRequests.status, 'pending'),
553
+ ),
554
+ )
555
+ .run();
556
+
557
+ db.update(canonicalGuardianDeliveries)
558
+ .set({ status: 'expired', updatedAt: now })
559
+ .where(eq(canonicalGuardianDeliveries.requestId, id))
560
+ .run();
561
+ }
562
+
563
+ export function updateCanonicalGuardianDelivery(
564
+ id: string,
565
+ updates: UpdateCanonicalGuardianDeliveryParams,
566
+ ): CanonicalGuardianDelivery | null {
567
+ const db = getDb();
568
+ const now = new Date().toISOString();
569
+
570
+ const setValues: Record<string, unknown> = { updatedAt: now };
571
+ if (updates.status !== undefined) setValues.status = updates.status;
572
+ if (updates.destinationMessageId !== undefined) setValues.destinationMessageId = updates.destinationMessageId;
573
+
574
+ db.update(canonicalGuardianDeliveries)
575
+ .set(setValues)
576
+ .where(eq(canonicalGuardianDeliveries.id, id))
577
+ .run();
578
+
579
+ const row = db
580
+ .select()
581
+ .from(canonicalGuardianDeliveries)
582
+ .where(eq(canonicalGuardianDeliveries.id, id))
583
+ .get();
584
+
585
+ return row ? rowToDelivery(row) : null;
586
+ }
@@ -13,6 +13,7 @@
13
13
  export {
14
14
  type ApprovalRequestStatus,
15
15
  countPendingByConversation,
16
+ // @internal — test-only helpers; production code uses canonical-guardian-store
16
17
  createApprovalRequest,
17
18
  findPendingAccessRequestForRequester,
18
19
  getAllPendingApprovalsByGuardianChat,
@@ -36,6 +37,7 @@ export {
36
37
  createBinding,
37
38
  getActiveBinding,
38
39
  type GuardianBinding,
40
+ listActiveBindingsByAssistant,
39
41
  revokeBinding,
40
42
  } from './guardian-bindings.js';
41
43
  export {
@@ -39,7 +39,7 @@ export const messageMetadataSchema = z.object({
39
39
  assistantMessageInterface: interfaceIdSchema.optional(),
40
40
  subagentNotification: subagentNotificationSchema.optional(),
41
41
  // Provenance fields for trust-aware memory gating (M3)
42
- provenanceActorRole: z.enum(['guardian', 'non-guardian', 'unverified_channel']).optional(),
42
+ provenanceTrustClass: z.enum(['guardian', 'trusted_contact', 'unknown']).optional(),
43
43
  provenanceSourceChannel: channelIdSchema.optional(),
44
44
  provenanceGuardianExternalUserId: z.string().optional(),
45
45
  provenanceRequesterIdentifier: z.string().optional(),
@@ -49,14 +49,14 @@ export type MessageMetadata = z.infer<typeof messageMetadataSchema>;
49
49
 
50
50
  /**
51
51
  * Extract provenance metadata fields from a GuardianRuntimeContext.
52
- * When no guardian context is provided, defaults to 'unverified_channel'
53
- * because the absence of guardian context means we cannot verify trust —
52
+ * When no guardian context is provided, defaults to 'unknown' because the
53
+ * absence of trust context means we cannot verify trust —
54
54
  * callers with actual guardian trust should always supply a real context.
55
55
  */
56
56
  export function provenanceFromGuardianContext(ctx: GuardianRuntimeContext | null | undefined): Record<string, unknown> {
57
- if (!ctx) return { provenanceActorRole: 'unverified_channel' };
57
+ if (!ctx) return { provenanceTrustClass: 'unknown' };
58
58
  return {
59
- provenanceActorRole: ctx.actorRole,
59
+ provenanceTrustClass: ctx.trustClass,
60
60
  provenanceSourceChannel: ctx.sourceChannel,
61
61
  provenanceGuardianExternalUserId: ctx.guardianExternalUserId,
62
62
  provenanceRequesterIdentifier: ctx.requesterIdentifier,
@@ -280,7 +280,7 @@ export async function addMessage(conversationId: string, role: string, content:
280
280
  const config = getConfig();
281
281
  const scopeId = getConversationMemoryScopeId(conversationId);
282
282
  const parsed = metadata ? messageMetadataSchema.safeParse(metadata) : null;
283
- const provenanceActorRole = parsed?.success ? parsed.data.provenanceActorRole : undefined;
283
+ const provenanceTrustClass = parsed?.success ? parsed.data.provenanceTrustClass : undefined;
284
284
  indexMessageNow({
285
285
  messageId: message.id,
286
286
  conversationId: message.conversationId,
@@ -288,7 +288,7 @@ export async function addMessage(conversationId: string, role: string, content:
288
288
  content: message.content,
289
289
  createdAt: message.createdAt,
290
290
  scopeId,
291
- provenanceActorRole,
291
+ provenanceTrustClass,
292
292
  }, config.memory);
293
293
  } catch (err) {
294
294
  log.warn({ err, conversationId, messageId: message.id }, 'Failed to index message for memory');
@@ -3,6 +3,7 @@ import {
3
3
  addCoreColumns,
4
4
  createAssistantInboxTables,
5
5
  createCallSessionsTables,
6
+ createCanonicalGuardianTables,
6
7
  createChannelGuardianTables,
7
8
  createContactsAndTriageTables,
8
9
  createConversationAttentionTables,
@@ -18,6 +19,8 @@ import {
18
19
  createTasksAndWorkItemsTables,
19
20
  createWatchersAndLogsTables,
20
21
  migrateCallSessionMode,
22
+ migrateCanonicalGuardianDeliveriesDestinationIndex,
23
+ migrateCanonicalGuardianRequesterChatId,
21
24
  migrateChannelInboundDeliveredSegments,
22
25
  migrateConversationsThreadTypeIndex,
23
26
  migrateFkCascadeRebuilds,
@@ -29,9 +32,11 @@ import {
29
32
  migrateGuardianVerificationPurpose,
30
33
  migrateGuardianVerificationSessions,
31
34
  migrateMessagesFtsBackfill,
35
+ migrateNormalizePhoneIdentities,
32
36
  migrateNotificationDeliveryThreadDecision,
33
37
  migrateReminderRoutingIntent,
34
38
  migrateSchemaIndexesAndColumns,
39
+ migrateVoiceInviteColumns,
35
40
  recoverCrashedMigrations,
36
41
  runComplexMigrations,
37
42
  runLateMigrations,
@@ -145,5 +150,20 @@ export function initializeDb(): void {
145
150
  // 23. Thread decision audit columns on notification_deliveries
146
151
  migrateNotificationDeliveryThreadDecision(database);
147
152
 
153
+ // 24. Canonical guardian requests and deliveries (unified cross-source guardian domain)
154
+ createCanonicalGuardianTables(database);
155
+
156
+ // 24b. Add requester_chat_id to canonical_guardian_requests (chat ID != user ID on some channels)
157
+ migrateCanonicalGuardianRequesterChatId(database);
158
+
159
+ // 24c. Composite index on canonical_guardian_deliveries(destination_channel, destination_chat_id) for chat-based lookups
160
+ migrateCanonicalGuardianDeliveriesDestinationIndex(database);
161
+
162
+ // 25. Normalize phone-like identity fields to E.164 across guardian and ingress tables
163
+ migrateNormalizePhoneIdentities(database);
164
+
165
+ // 26. Voice invite columns on assistant_ingress_invites
166
+ migrateVoiceInviteColumns(database);
167
+
148
168
  validateMigrationState(database);
149
169
  }