@vellumai/assistant 0.3.26 → 0.3.28

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 (82) hide show
  1. package/ARCHITECTURE.md +48 -1
  2. package/Dockerfile +2 -2
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +6 -2
  5. package/src/__tests__/agent-loop.test.ts +119 -0
  6. package/src/__tests__/bundled-asset.test.ts +107 -0
  7. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  8. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  9. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  10. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  11. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  12. package/src/__tests__/guardian-dispatch.test.ts +19 -19
  13. package/src/__tests__/guardian-routing-invariants.test.ts +954 -0
  14. package/src/__tests__/mcp-cli.test.ts +77 -0
  15. package/src/__tests__/non-member-access-request.test.ts +31 -29
  16. package/src/__tests__/notification-decision-fallback.test.ts +61 -3
  17. package/src/__tests__/notification-decision-strategy.test.ts +17 -0
  18. package/src/__tests__/notification-guardian-path.test.ts +13 -15
  19. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  20. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  21. package/src/__tests__/secret-scanner.test.ts +8 -0
  22. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  23. package/src/__tests__/session-runtime-assembly.test.ts +76 -47
  24. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  25. package/src/agent/loop.ts +46 -3
  26. package/src/approvals/guardian-decision-primitive.ts +285 -0
  27. package/src/approvals/guardian-request-resolvers.ts +539 -0
  28. package/src/calls/guardian-dispatch.ts +46 -40
  29. package/src/calls/relay-server.ts +147 -2
  30. package/src/calls/types.ts +1 -1
  31. package/src/config/system-prompt.ts +2 -1
  32. package/src/config/templates/BOOTSTRAP.md +47 -31
  33. package/src/config/templates/USER.md +5 -0
  34. package/src/config/update-bulletin-template-path.ts +4 -1
  35. package/src/config/vellum-skills/trusted-contacts/SKILL.md +22 -17
  36. package/src/daemon/handlers/guardian-actions.ts +45 -66
  37. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  38. package/src/daemon/lifecycle.ts +3 -16
  39. package/src/daemon/server.ts +18 -0
  40. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  41. package/src/daemon/session-agent-loop.ts +32 -5
  42. package/src/daemon/session-process.ts +68 -307
  43. package/src/daemon/session-runtime-assembly.ts +112 -24
  44. package/src/daemon/session-tool-setup.ts +1 -0
  45. package/src/daemon/session.ts +1 -0
  46. package/src/home-base/prebuilt/seed.ts +2 -1
  47. package/src/hooks/templates.ts +2 -1
  48. package/src/memory/canonical-guardian-store.ts +524 -0
  49. package/src/memory/channel-guardian-store.ts +1 -0
  50. package/src/memory/db-init.ts +16 -0
  51. package/src/memory/guardian-action-store.ts +7 -60
  52. package/src/memory/guardian-approvals.ts +9 -4
  53. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  54. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  55. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  56. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  57. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  58. package/src/memory/migrations/index.ts +4 -0
  59. package/src/memory/migrations/registry.ts +5 -0
  60. package/src/memory/schema-migration.ts +1 -0
  61. package/src/memory/schema.ts +52 -0
  62. package/src/notifications/copy-composer.ts +16 -4
  63. package/src/notifications/decision-engine.ts +57 -0
  64. package/src/permissions/defaults.ts +2 -0
  65. package/src/runtime/access-request-helper.ts +137 -0
  66. package/src/runtime/actor-trust-resolver.ts +225 -0
  67. package/src/runtime/channel-guardian-service.ts +12 -4
  68. package/src/runtime/guardian-context-resolver.ts +32 -7
  69. package/src/runtime/guardian-decision-types.ts +6 -0
  70. package/src/runtime/guardian-reply-router.ts +687 -0
  71. package/src/runtime/http-server.ts +8 -0
  72. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  73. package/src/runtime/routes/conversation-routes.ts +18 -0
  74. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  75. package/src/runtime/routes/inbound-message-handler.ts +170 -525
  76. package/src/runtime/tool-grant-request-helper.ts +195 -0
  77. package/src/tools/executor.ts +13 -1
  78. package/src/tools/sensitive-output-placeholders.ts +203 -0
  79. package/src/tools/tool-approval-handler.ts +44 -1
  80. package/src/tools/types.ts +11 -0
  81. package/src/util/bundled-asset.ts +31 -0
  82. package/src/util/canonicalize-identity.ts +52 -0
@@ -1,6 +1,7 @@
1
1
  import { chmodSync, cpSync, type Dirent,readdirSync, readFileSync, rmSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
 
4
+ import { resolveBundledDir } from '../util/bundled-asset.js';
4
5
  import { pathExists } from '../util/fs.js';
5
6
  import { getLogger } from '../util/logger.js';
6
7
  import { getHooksDir } from '../util/platform.js';
@@ -15,7 +16,7 @@ const log = getLogger('hooks-templates');
15
16
  * - Newly installed hooks are disabled by default.
16
17
  */
17
18
  export function installTemplates(): void {
18
- const templatesDir = join(import.meta.dirname ?? __dirname, '../../hook-templates');
19
+ const templatesDir = resolveBundledDir(import.meta.dirname ?? __dirname, '../../hook-templates', 'hook-templates');
19
20
  if (!pathExists(templatesDir)) return;
20
21
 
21
22
  const hooksDir = getHooksDir();
@@ -0,0 +1,524 @@
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, 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
+ export function updateCanonicalGuardianDelivery(
502
+ id: string,
503
+ updates: UpdateCanonicalGuardianDeliveryParams,
504
+ ): CanonicalGuardianDelivery | null {
505
+ const db = getDb();
506
+ const now = new Date().toISOString();
507
+
508
+ const setValues: Record<string, unknown> = { updatedAt: now };
509
+ if (updates.status !== undefined) setValues.status = updates.status;
510
+ if (updates.destinationMessageId !== undefined) setValues.destinationMessageId = updates.destinationMessageId;
511
+
512
+ db.update(canonicalGuardianDeliveries)
513
+ .set(setValues)
514
+ .where(eq(canonicalGuardianDeliveries.id, id))
515
+ .run();
516
+
517
+ const row = db
518
+ .select()
519
+ .from(canonicalGuardianDeliveries)
520
+ .where(eq(canonicalGuardianDeliveries.id, id))
521
+ .get();
522
+
523
+ return row ? rowToDelivery(row) : null;
524
+ }
@@ -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,
@@ -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,6 +32,7 @@ import {
29
32
  migrateGuardianVerificationPurpose,
30
33
  migrateGuardianVerificationSessions,
31
34
  migrateMessagesFtsBackfill,
35
+ migrateNormalizePhoneIdentities,
32
36
  migrateNotificationDeliveryThreadDecision,
33
37
  migrateReminderRoutingIntent,
34
38
  migrateSchemaIndexesAndColumns,
@@ -145,5 +149,17 @@ export function initializeDb(): void {
145
149
  // 23. Thread decision audit columns on notification_deliveries
146
150
  migrateNotificationDeliveryThreadDecision(database);
147
151
 
152
+ // 24. Canonical guardian requests and deliveries (unified cross-source guardian domain)
153
+ createCanonicalGuardianTables(database);
154
+
155
+ // 24b. Add requester_chat_id to canonical_guardian_requests (chat ID != user ID on some channels)
156
+ migrateCanonicalGuardianRequesterChatId(database);
157
+
158
+ // 24c. Composite index on canonical_guardian_deliveries(destination_channel, destination_chat_id) for chat-based lookups
159
+ migrateCanonicalGuardianDeliveriesDestinationIndex(database);
160
+
161
+ // 25. Normalize phone-like identity fields to E.164 across guardian and ingress tables
162
+ migrateNormalizePhoneIdentities(database);
163
+
148
164
  validateMigrationState(database);
149
165
  }
@@ -7,7 +7,7 @@
7
7
  * answer resolves the request and all other deliveries are marked answered.
8
8
  */
9
9
 
10
- import { and, count, desc, eq, inArray, isNotNull, lt } from 'drizzle-orm';
10
+ import { and, desc, eq, inArray, lt } from 'drizzle-orm';
11
11
  import { v4 as uuid } from 'uuid';
12
12
 
13
13
  import { getLogger } from '../util/logger.js';
@@ -136,6 +136,12 @@ function generateRequestCode(): string {
136
136
  // Guardian Action Requests
137
137
  // ---------------------------------------------------------------------------
138
138
 
139
+ /**
140
+ * @internal Test-only helper. Production code should create guardian requests
141
+ * via `createCanonicalGuardianRequest` in canonical-guardian-store.ts.
142
+ * This function is retained solely so that existing test fixtures that seed
143
+ * legacy guardian action rows continue to compile.
144
+ */
139
145
  export function createGuardianActionRequest(params: {
140
146
  assistantId?: string;
141
147
  kind: string;
@@ -226,65 +232,6 @@ export function getPendingRequestByCallSessionId(callSessionId: string): Guardia
226
232
  return row ? rowToRequest(row) : null;
227
233
  }
228
234
 
229
- /**
230
- * Count pending guardian action requests for a given call session.
231
- * Used as a candidate-affinity hint so the decision engine knows how many
232
- * active guardian requests already exist for the current call.
233
- */
234
- export function countPendingRequestsByCallSessionId(callSessionId: string): number {
235
- const db = getDb();
236
- const row = db
237
- .select({ count: count() })
238
- .from(guardianActionRequests)
239
- .where(
240
- and(
241
- eq(guardianActionRequests.callSessionId, callSessionId),
242
- eq(guardianActionRequests.status, 'pending'),
243
- ),
244
- )
245
- .get();
246
- return row?.count ?? 0;
247
- }
248
-
249
- /**
250
- * Look up the vellum conversation ID used for the first guardian question
251
- * delivery in a given call session. Returns the conversation ID when one
252
- * exists, or null if no vellum delivery has been recorded yet.
253
- *
254
- * Used by guardian-dispatch to enforce deterministic thread affinity:
255
- * all guardian questions within the same call session should route to
256
- * the same vellum conversation.
257
- */
258
- export function getGuardianConversationIdForCallSession(callSessionId: string): string | null {
259
- try {
260
- const db = getDb();
261
- const row = db
262
- .select({ conversationId: guardianActionDeliveries.destinationConversationId })
263
- .from(guardianActionDeliveries)
264
- .innerJoin(
265
- guardianActionRequests,
266
- eq(guardianActionDeliveries.requestId, guardianActionRequests.id),
267
- )
268
- .where(
269
- and(
270
- eq(guardianActionRequests.callSessionId, callSessionId),
271
- eq(guardianActionDeliveries.destinationChannel, 'vellum'),
272
- isNotNull(guardianActionDeliveries.destinationConversationId),
273
- ),
274
- )
275
- .orderBy(guardianActionDeliveries.createdAt)
276
- .limit(1)
277
- .get();
278
- return row?.conversationId ?? null;
279
- } catch (err) {
280
- if (err instanceof Error && err.message.includes('no such table')) {
281
- log.warn({ err }, 'guardian tables not yet created');
282
- return null;
283
- }
284
- throw err;
285
- }
286
- }
287
-
288
235
  /**
289
236
  * First-response-wins resolution. Checks that the request is still
290
237
  * 'pending' before updating; returns the updated request on success
@@ -70,6 +70,12 @@ function rowToApprovalRequest(row: typeof channelGuardianApprovalRequests.$infer
70
70
  // Operations
71
71
  // ---------------------------------------------------------------------------
72
72
 
73
+ /**
74
+ * @internal Test-only helper. Production code should create guardian requests
75
+ * via `createCanonicalGuardianRequest` in canonical-guardian-store.ts.
76
+ * This function is retained solely so that existing test fixtures that seed
77
+ * legacy approval rows continue to compile.
78
+ */
73
79
  export function createApprovalRequest(params: {
74
80
  runId: string;
75
81
  requestId?: string;
@@ -535,10 +541,9 @@ export function countPendingByConversation(
535
541
  }
536
542
 
537
543
  /**
538
- * Check for an existing pending (non-expired) approval request for a specific
539
- * requester on a channel. Used to deduplicate access requests — repeated
540
- * messages from the same non-member should not create duplicate approval
541
- * requests while one is already pending.
544
+ * @internal Test-only helper. Production code should query canonical guardian
545
+ * requests via `listCanonicalGuardianRequests` in canonical-guardian-store.ts.
546
+ * Retained for existing test fixtures that check legacy approval dedup.
542
547
  */
543
548
  export function findPendingAccessRequestForRequester(
544
549
  assistantId: string,