@vellumai/assistant 0.4.5 → 0.4.7

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 (112) hide show
  1. package/ARCHITECTURE.md +27 -10
  2. package/README.md +6 -6
  3. package/bun.lock +57 -2
  4. package/docs/architecture/memory.md +4 -4
  5. package/docs/trusted-contact-access.md +8 -0
  6. package/package.json +3 -2
  7. package/src/__tests__/actor-token-service.test.ts +9 -6
  8. package/src/__tests__/assistant-feature-flags-integration.test.ts +1 -0
  9. package/src/__tests__/call-controller.test.ts +115 -0
  10. package/src/__tests__/call-domain.test.ts +148 -10
  11. package/src/__tests__/call-pointer-message-composer.test.ts +39 -49
  12. package/src/__tests__/call-pointer-messages.test.ts +105 -43
  13. package/src/__tests__/canonical-guardian-store.test.ts +44 -10
  14. package/src/__tests__/channel-approval-routes.test.ts +67 -65
  15. package/src/__tests__/channel-delivery-store.test.ts +2 -2
  16. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +1 -0
  17. package/src/__tests__/conversation-attention-telegram.test.ts +2 -2
  18. package/src/__tests__/deterministic-verification-control-plane.test.ts +6 -6
  19. package/src/__tests__/gateway-client-managed-outbound.test.ts +147 -0
  20. package/src/__tests__/guardian-actions-endpoint.test.ts +7 -6
  21. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +57 -12
  22. package/src/__tests__/guardian-dispatch.test.ts +39 -1
  23. package/src/__tests__/guardian-grant-minting.test.ts +24 -24
  24. package/src/__tests__/guardian-principal-id-roundtrip.test.ts +205 -0
  25. package/src/__tests__/guardian-routing-invariants.test.ts +64 -25
  26. package/src/__tests__/guardian-routing-state.test.ts +10 -32
  27. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +2 -2
  28. package/src/__tests__/inbound-invite-redemption.test.ts +8 -8
  29. package/src/__tests__/memory-retrieval.benchmark.test.ts +22 -47
  30. package/src/__tests__/no-is-trusted-guard.test.ts +77 -0
  31. package/src/__tests__/non-member-access-request.test.ts +57 -47
  32. package/src/__tests__/notification-decision-fallback.test.ts +232 -0
  33. package/src/__tests__/notification-decision-strategy.test.ts +304 -8
  34. package/src/__tests__/notification-guardian-path.test.ts +38 -1
  35. package/src/__tests__/relay-server.test.ts +136 -5
  36. package/src/__tests__/send-endpoint-busy.test.ts +35 -1
  37. package/src/__tests__/session-tool-setup-tools-disabled.test.ts +155 -0
  38. package/src/__tests__/skill-feature-flags-integration.test.ts +1 -0
  39. package/src/__tests__/skill-projection.benchmark.test.ts +66 -2
  40. package/src/__tests__/system-prompt.test.ts +1 -0
  41. package/src/__tests__/tool-approval-handler.test.ts +1 -1
  42. package/src/__tests__/tool-grant-request-escalation.test.ts +10 -2
  43. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +14 -1
  44. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +24 -24
  45. package/src/__tests__/trusted-contact-multichannel.test.ts +5 -5
  46. package/src/__tests__/trusted-contact-verification.test.ts +10 -10
  47. package/src/approvals/guardian-decision-primitive.ts +29 -25
  48. package/src/approvals/guardian-request-resolvers.ts +9 -5
  49. package/src/calls/call-controller.ts +15 -0
  50. package/src/calls/call-pointer-message-composer.ts +27 -85
  51. package/src/calls/call-pointer-messages.ts +54 -21
  52. package/src/calls/guardian-dispatch.ts +30 -0
  53. package/src/calls/relay-server.ts +58 -24
  54. package/src/calls/types.ts +1 -0
  55. package/src/config/system-prompt.ts +10 -3
  56. package/src/config/templates/BOOTSTRAP.md +6 -5
  57. package/src/config/templates/USER.md +1 -0
  58. package/src/config/user-reference.ts +44 -0
  59. package/src/daemon/handlers/guardian-actions.ts +5 -2
  60. package/src/daemon/handlers/sessions.ts +8 -3
  61. package/src/daemon/lifecycle.ts +109 -3
  62. package/src/daemon/providers-setup.ts +0 -8
  63. package/src/daemon/server.ts +32 -24
  64. package/src/daemon/session-agent-loop.ts +4 -3
  65. package/src/daemon/session-lifecycle.ts +1 -9
  66. package/src/daemon/session-process.ts +2 -2
  67. package/src/daemon/session-runtime-assembly.ts +2 -0
  68. package/src/daemon/session-slash.ts +35 -2
  69. package/src/daemon/session-tool-setup.ts +10 -0
  70. package/src/daemon/session.ts +1 -0
  71. package/src/memory/canonical-guardian-store.ts +40 -0
  72. package/src/memory/conversation-crud.ts +26 -0
  73. package/src/memory/conversation-store.ts +1 -0
  74. package/src/memory/db-init.ts +12 -0
  75. package/src/memory/guardian-bindings.ts +4 -0
  76. package/src/memory/job-handlers/backfill.ts +2 -9
  77. package/src/memory/migrations/039-actor-refresh-token-records.ts +51 -0
  78. package/src/memory/migrations/125-guardian-principal-id-columns.ts +19 -0
  79. package/src/memory/migrations/126-backfill-guardian-principal-id.ts +210 -0
  80. package/src/memory/migrations/index.ts +3 -0
  81. package/src/memory/migrations/registry.ts +5 -0
  82. package/src/memory/schema.ts +22 -0
  83. package/src/notifications/README.md +8 -1
  84. package/src/notifications/copy-composer.ts +160 -30
  85. package/src/notifications/decision-engine.ts +98 -1
  86. package/src/runtime/access-request-helper.ts +43 -28
  87. package/src/runtime/actor-refresh-token-service.ts +309 -0
  88. package/src/runtime/actor-refresh-token-store.ts +157 -0
  89. package/src/runtime/actor-token-service.ts +3 -3
  90. package/src/runtime/actor-trust-resolver.ts +19 -14
  91. package/src/runtime/channel-guardian-service.ts +6 -0
  92. package/src/runtime/gateway-client.ts +239 -0
  93. package/src/runtime/guardian-context-resolver.ts +6 -2
  94. package/src/runtime/guardian-reply-router.ts +33 -16
  95. package/src/runtime/guardian-vellum-migration.ts +29 -5
  96. package/src/runtime/http-server.ts +2 -0
  97. package/src/runtime/http-types.ts +0 -13
  98. package/src/runtime/local-actor-identity.ts +19 -13
  99. package/src/runtime/middleware/actor-token.ts +2 -2
  100. package/src/runtime/routes/channel-delivery-routes.ts +5 -5
  101. package/src/runtime/routes/conversation-routes.ts +45 -35
  102. package/src/runtime/routes/guardian-action-routes.ts +7 -1
  103. package/src/runtime/routes/guardian-approval-interception.ts +52 -52
  104. package/src/runtime/routes/guardian-bootstrap-routes.ts +11 -24
  105. package/src/runtime/routes/guardian-refresh-routes.ts +53 -0
  106. package/src/runtime/routes/inbound-conversation.ts +7 -7
  107. package/src/runtime/routes/inbound-message-handler.ts +105 -94
  108. package/src/runtime/routes/pairing-routes.ts +60 -50
  109. package/src/runtime/tool-grant-request-helper.ts +1 -0
  110. package/src/types/qrcode.d.ts +10 -0
  111. package/src/util/logger.ts +10 -0
  112. package/src/daemon/call-pointer-generators.ts +0 -59
@@ -508,7 +508,7 @@ describe('channel-delivery-store', () => {
508
508
  headers: { 'Content-Type': 'application/json' },
509
509
  body: JSON.stringify({
510
510
  sourceChannel: 'telegram',
511
- externalChatId: 'chat-del',
511
+ conversationExternalId: 'chat-del',
512
512
  // Note: no assistantId in the body — it comes from the route param
513
513
  }),
514
514
  });
@@ -558,7 +558,7 @@ describe('channel-delivery-store', () => {
558
558
  headers: { 'Content-Type': 'application/json' },
559
559
  body: JSON.stringify({
560
560
  sourceChannel: 'telegram',
561
- externalChatId: 'chat-def',
561
+ conversationExternalId: 'chat-def',
562
562
  }),
563
563
  });
564
564
 
@@ -118,6 +118,7 @@ function makeCanonicalRequest(overrides: Record<string, unknown> = {}) {
118
118
  conversationId: 'conv-1',
119
119
  requesterExternalUserId: 'requester-1',
120
120
  guardianExternalUserId: 'guardian-1',
121
+ guardianPrincipalId: 'test-principal-id',
121
122
  toolName: 'bash',
122
123
  status: 'pending',
123
124
  requestCode: generateCanonicalRequestCode(),
@@ -119,8 +119,8 @@ function makeInboundRequest(overrides: Record<string, unknown> = {}): Request {
119
119
  const body = {
120
120
  sourceChannel: 'telegram',
121
121
  interface: 'telegram',
122
- externalChatId: 'chat-123',
123
- senderExternalUserId: 'telegram-user-default',
122
+ conversationExternalId: 'chat-123',
123
+ actorExternalId: 'telegram-user-default',
124
124
  externalMessageId: `msg-${Date.now()}-${Math.random()}`,
125
125
  content: 'hello',
126
126
  replyCallbackUrl: 'https://gateway.test/deliver',
@@ -257,11 +257,11 @@ describe('Verification control messages are deterministic (guard)', () => {
257
257
  body: JSON.stringify({
258
258
  sourceChannel: 'telegram',
259
259
  interface: 'telegram',
260
- externalChatId: 'chat-123',
260
+ conversationExternalId: 'chat-123',
261
261
  externalMessageId: `msg-guard-${Date.now()}`,
262
262
  content: secret,
263
- senderExternalUserId: 'user-123',
264
- senderName: 'Test User',
263
+ actorExternalId: 'user-123',
264
+ actorDisplayName: 'Test User',
265
265
  replyCallbackUrl: 'http://localhost/callback',
266
266
  }),
267
267
  });
@@ -330,11 +330,11 @@ describe('Verification control messages are deterministic (guard)', () => {
330
330
  body: JSON.stringify({
331
331
  sourceChannel: 'telegram',
332
332
  interface: 'telegram',
333
- externalChatId: 'chat-bootstrap-123',
333
+ conversationExternalId: 'chat-bootstrap-123',
334
334
  externalMessageId: `msg-bootstrap-${Date.now()}`,
335
335
  content: `/start gv_${bootstrapToken}`,
336
- senderExternalUserId: 'user-bootstrap-123',
337
- senderName: 'Bootstrap User',
336
+ actorExternalId: 'user-bootstrap-123',
337
+ actorDisplayName: 'Bootstrap User',
338
338
  replyCallbackUrl: 'http://localhost/callback',
339
339
  sourceMetadata: {
340
340
  commandIntent: { type: 'start', payload: `gv_${bootstrapToken}` },
@@ -0,0 +1,147 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
2
+
3
+ import { deliverChannelReply } from '../runtime/gateway-client.js';
4
+
5
+ type FetchCall = {
6
+ url: string;
7
+ init: RequestInit;
8
+ };
9
+
10
+ describe('gateway-client managed outbound lane', () => {
11
+ const originalFetch = globalThis.fetch;
12
+ const calls: FetchCall[] = [];
13
+
14
+ beforeEach(() => {
15
+ calls.length = 0;
16
+ globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
17
+ const url = typeof input === 'string'
18
+ ? input
19
+ : input instanceof URL
20
+ ? input.toString()
21
+ : input.url;
22
+ calls.push({ url, init: init ?? {} });
23
+ return new Response(JSON.stringify({ status: 'accepted' }), { status: 202 });
24
+ }) as unknown as typeof globalThis.fetch;
25
+ });
26
+
27
+ afterEach(() => {
28
+ globalThis.fetch = originalFetch;
29
+ });
30
+
31
+ test('translates managed callback URL into managed outbound-send request', async () => {
32
+ await deliverChannelReply(
33
+ 'https://platform.test/v1/internal/managed-gateway/outbound-send/?route_id=route-123&assistant_id=assistant-123&source_channel=sms&source_update_id=SM-inbound-123&callback_token=runtime-token',
34
+ {
35
+ chatId: '+15550001111',
36
+ text: 'hello from runtime',
37
+ },
38
+ );
39
+
40
+ expect(calls).toHaveLength(1);
41
+ const call = calls[0];
42
+ expect(call.url).toBe('https://platform.test/v1/internal/managed-gateway/outbound-send/');
43
+
44
+ const headers = call.init.headers as Record<string, string>;
45
+ expect(headers['Content-Type']).toBe('application/json');
46
+ expect(headers['X-Managed-Gateway-Callback-Token']).toBe('runtime-token');
47
+ expect(headers['X-Idempotency-Key']).toStartWith('mgw-send-');
48
+ expect(headers.Authorization).toBeUndefined();
49
+
50
+ const body = JSON.parse(String(call.init.body)) as {
51
+ route_id: string;
52
+ assistant_id: string;
53
+ normalized_send: {
54
+ sourceChannel: string;
55
+ message: {
56
+ to: string;
57
+ content: string;
58
+ externalMessageId: string;
59
+ };
60
+ source: {
61
+ requestId: string;
62
+ };
63
+ raw: {
64
+ sourceUpdateId: string;
65
+ };
66
+ };
67
+ };
68
+ expect(body.route_id).toBe('route-123');
69
+ expect(body.assistant_id).toBe('assistant-123');
70
+ expect(body.normalized_send.sourceChannel).toBe('sms');
71
+ expect(body.normalized_send.message.to).toBe('+15550001111');
72
+ expect(body.normalized_send.message.content).toBe('hello from runtime');
73
+ expect(body.normalized_send.message.externalMessageId).toStartWith('mgw-send-');
74
+ expect(body.normalized_send.source.requestId).toBe(
75
+ body.normalized_send.message.externalMessageId,
76
+ );
77
+ expect(body.normalized_send.raw.sourceUpdateId).toBe('SM-inbound-123');
78
+ });
79
+
80
+ test('retries managed outbound send on retriable upstream responses with stable idempotency key', async () => {
81
+ calls.length = 0;
82
+ let attempt = 0;
83
+ globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
84
+ const url = typeof input === 'string'
85
+ ? input
86
+ : input instanceof URL
87
+ ? input.toString()
88
+ : input.url;
89
+ calls.push({ url, init: init ?? {} });
90
+ attempt += 1;
91
+ if (attempt === 1) {
92
+ return new Response('temporary upstream error', { status: 502 });
93
+ }
94
+ return new Response(JSON.stringify({ status: 'accepted' }), { status: 202 });
95
+ }) as unknown as typeof globalThis.fetch;
96
+
97
+ await deliverChannelReply(
98
+ 'https://platform.test/v1/internal/managed-gateway/outbound-send/?route_id=route-retry&assistant_id=assistant-retry&source_channel=sms&source_update_id=SM-retry',
99
+ {
100
+ chatId: '+15550002222',
101
+ text: 'retry this outbound send',
102
+ },
103
+ );
104
+
105
+ expect(calls).toHaveLength(2);
106
+
107
+ const firstHeaders = calls[0].init.headers as Record<string, string>;
108
+ const secondHeaders = calls[1].init.headers as Record<string, string>;
109
+ expect(firstHeaders['X-Idempotency-Key']).toBeDefined();
110
+ expect(secondHeaders['X-Idempotency-Key']).toBe(firstHeaders['X-Idempotency-Key']);
111
+
112
+ const firstBody = JSON.parse(String(calls[0].init.body)) as {
113
+ normalized_send: { source: { requestId: string } };
114
+ };
115
+ const secondBody = JSON.parse(String(calls[1].init.body)) as {
116
+ normalized_send: { source: { requestId: string } };
117
+ };
118
+ expect(secondBody.normalized_send.source.requestId).toBe(
119
+ firstBody.normalized_send.source.requestId,
120
+ );
121
+ });
122
+
123
+ test('falls back to standard callback delivery for non-managed callback URL', async () => {
124
+ await deliverChannelReply(
125
+ 'https://gateway.test/deliver/sms',
126
+ {
127
+ chatId: '+15550001111',
128
+ text: 'standard gateway callback',
129
+ },
130
+ 'runtime-bearer',
131
+ );
132
+
133
+ expect(calls).toHaveLength(1);
134
+ const call = calls[0];
135
+ expect(call.url).toBe('https://gateway.test/deliver/sms');
136
+
137
+ const headers = call.init.headers as Record<string, string>;
138
+ expect(headers['Content-Type']).toBe('application/json');
139
+ expect(headers.Authorization).toBe('Bearer runtime-bearer');
140
+
141
+ const body = JSON.parse(String(call.init.body)) as { chatId: string; text: string };
142
+ expect(body).toEqual({
143
+ chatId: '+15550001111',
144
+ text: 'standard gateway callback',
145
+ });
146
+ });
147
+ });
@@ -94,6 +94,7 @@ function createTestCanonicalRequest(overrides: {
94
94
  kind?: string;
95
95
  toolName?: string;
96
96
  guardianExternalUserId?: string;
97
+ guardianPrincipalId?: string;
97
98
  questionText?: string;
98
99
  expiresAt?: string;
99
100
  }) {
@@ -105,6 +106,7 @@ function createTestCanonicalRequest(overrides: {
105
106
  sourceChannel: 'vellum',
106
107
  conversationId: overrides.conversationId,
107
108
  guardianExternalUserId: overrides.guardianExternalUserId,
109
+ guardianPrincipalId: overrides.guardianPrincipalId ?? 'test-principal',
108
110
  toolName: overrides.toolName ?? 'bash',
109
111
  questionText: overrides.questionText,
110
112
  requestCode: generateCanonicalRequestCode(),
@@ -304,7 +306,7 @@ describe('HTTP handleGuardianActionDecision', () => {
304
306
  expect(body.requestId).toBe('req-stale-1');
305
307
  });
306
308
 
307
- test('passes actorContext with undefined externalUserId (unauthenticated endpoint)', async () => {
309
+ test('passes actorContext with vellum channel and guardianPrincipalId', async () => {
308
310
  createTestCanonicalRequest({ conversationId: 'conv-actor', requestId: 'req-actor-1' });
309
311
  mockApplyCanonicalGuardianDecision.mockResolvedValueOnce({ applied: true, requestId: 'req-actor-1', grantMinted: false });
310
312
 
@@ -315,9 +317,8 @@ describe('HTTP handleGuardianActionDecision', () => {
315
317
  await handleGuardianActionDecision(req, mockLoopbackServer);
316
318
  const call = mockApplyCanonicalGuardianDecision.mock.calls[0]![0] as Record<string, unknown>;
317
319
  const actorContext = call.actorContext as Record<string, unknown>;
318
- expect(actorContext.externalUserId).toBeUndefined();
319
320
  expect(actorContext.channel).toBe('vellum');
320
- expect(actorContext.isTrusted).toBe(true);
321
+ expect(actorContext.guardianPrincipalId).toBeDefined();
321
322
  });
322
323
  });
323
324
 
@@ -373,6 +374,7 @@ describe('listGuardianDecisionPrompts', () => {
373
374
  sourceType: 'desktop',
374
375
  sourceChannel: 'vellum',
375
376
  conversationId: 'conv-expired',
377
+ guardianPrincipalId: 'test-principal',
376
378
  toolName: 'bash',
377
379
  requestCode: generateCanonicalRequestCode(),
378
380
  status: 'pending',
@@ -597,7 +599,7 @@ describe('IPC guardian_action_decision', () => {
597
599
  expect(sent[0].reason).toBe('already_resolved');
598
600
  });
599
601
 
600
- test('passes actorContext with undefined externalUserId (unauthenticated endpoint)', async () => {
602
+ test('passes actorContext with vellum channel and guardianPrincipalId', async () => {
601
603
  createTestCanonicalRequest({ conversationId: 'conv-ipc-actor', requestId: 'req-ipc-actor' });
602
604
  mockApplyCanonicalGuardianDecision.mockResolvedValueOnce({ applied: true, requestId: 'req-ipc-actor', grantMinted: false });
603
605
 
@@ -609,9 +611,8 @@ describe('IPC guardian_action_decision', () => {
609
611
  );
610
612
  const call = mockApplyCanonicalGuardianDecision.mock.calls[0]![0] as Record<string, unknown>;
611
613
  const actorContext = call.actorContext as Record<string, unknown>;
612
- expect(actorContext.externalUserId).toBeUndefined();
613
614
  expect(actorContext.channel).toBe('vellum');
614
- expect(actorContext.isTrusted).toBe(true);
615
+ expect(actorContext.guardianPrincipalId).toBeDefined();
615
616
  });
616
617
  });
617
618
 
@@ -64,11 +64,14 @@ afterAll(() => {
64
64
  // Helpers
65
65
  // ---------------------------------------------------------------------------
66
66
 
67
+ /** Consistent test principal used across all test actors and requests. */
68
+ const TEST_PRINCIPAL_ID = 'test-principal-id';
69
+
67
70
  function guardianActor(overrides: Partial<ActorContext> = {}): ActorContext {
68
71
  return {
69
72
  externalUserId: 'guardian-1',
70
73
  channel: 'telegram',
71
- isTrusted: false,
74
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
72
75
  ...overrides,
73
76
  };
74
77
  }
@@ -77,7 +80,7 @@ function trustedActor(overrides: Partial<ActorContext> = {}): ActorContext {
77
80
  return {
78
81
  externalUserId: undefined,
79
82
  channel: 'desktop',
80
- isTrusted: true,
83
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
81
84
  ...overrides,
82
85
  };
83
86
  }
@@ -120,6 +123,7 @@ describe('applyCanonicalGuardianDecision', () => {
120
123
  sourceChannel: 'telegram',
121
124
  conversationId: 'conv-1',
122
125
  guardianExternalUserId: 'guardian-1',
126
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
123
127
  toolName: 'shell',
124
128
  inputDigest: 'sha256:abc',
125
129
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -153,6 +157,7 @@ describe('applyCanonicalGuardianDecision', () => {
153
157
  sourceChannel: 'telegram',
154
158
  conversationId: 'conv-1',
155
159
  guardianExternalUserId: 'guardian-1',
160
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
156
161
  toolName: 'shell',
157
162
  inputDigest: 'sha256:abc',
158
163
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -178,6 +183,7 @@ describe('applyCanonicalGuardianDecision', () => {
178
183
  sourceType: 'voice',
179
184
  sourceChannel: 'twilio',
180
185
  guardianExternalUserId: 'guardian-1',
186
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
181
187
  callSessionId: 'call-1',
182
188
  pendingQuestionId: 'pq-1',
183
189
  questionText: 'What is the gate code?',
@@ -199,21 +205,22 @@ describe('applyCanonicalGuardianDecision', () => {
199
205
  expect(resolved!.answerText).toBe('1234');
200
206
  });
201
207
 
202
- // ── Identity mismatch ──────────────────────────────────────────────
208
+ // ── Principal mismatch ──────────────────────────────────────────────
203
209
 
204
- test('rejects decision when actor does not match guardian', async () => {
210
+ test('rejects decision when actor principal does not match request principal', async () => {
205
211
  const req = createCanonicalGuardianRequest({
206
212
  kind: 'tool_approval',
207
213
  sourceType: 'channel',
208
214
  conversationId: 'conv-1',
209
215
  guardianExternalUserId: 'guardian-1',
216
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
210
217
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
211
218
  });
212
219
 
213
220
  const result = await applyCanonicalGuardianDecision({
214
221
  requestId: req.id,
215
222
  action: 'approve_once',
216
- actorContext: guardianActor({ externalUserId: 'imposter-99' }),
223
+ actorContext: guardianActor({ guardianPrincipalId: 'wrong-principal' }),
217
224
  });
218
225
 
219
226
  expect(result.applied).toBe(false);
@@ -225,12 +232,13 @@ describe('applyCanonicalGuardianDecision', () => {
225
232
  expect(unchanged!.status).toBe('pending');
226
233
  });
227
234
 
228
- test('trusted actor bypasses identity check', async () => {
235
+ test('matching principal authorizes decision (cross-channel same principal)', async () => {
229
236
  const req = createCanonicalGuardianRequest({
230
237
  kind: 'tool_approval',
231
238
  sourceType: 'desktop',
232
239
  conversationId: 'conv-1',
233
240
  guardianExternalUserId: 'guardian-1',
241
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
234
242
  toolName: 'shell',
235
243
  inputDigest: 'sha256:abc',
236
244
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -243,24 +251,48 @@ describe('applyCanonicalGuardianDecision', () => {
243
251
  });
244
252
 
245
253
  expect(result.applied).toBe(true);
246
- // No grant minted because trusted actor has no externalUserId
247
254
  if (!result.applied) return;
255
+ // No grant minted because trusted actor has no externalUserId
248
256
  expect(result.grantMinted).toBe(false);
249
257
  });
250
258
 
251
- test('rejects non-trusted decision when tool approval has no guardian binding', async () => {
259
+ test('rejects decision when request has no guardianPrincipalId', async () => {
260
+ // unknown_kind is not in DECISIONABLE_KINDS so it can be created without
261
+ // guardianPrincipalId, but the decision primitive still rejects because
262
+ // the request is missing its principal binding.
263
+ const req = createCanonicalGuardianRequest({
264
+ kind: 'unknown_kind',
265
+ sourceType: 'channel',
266
+ conversationId: 'conv-1',
267
+ guardianExternalUserId: 'guardian-1',
268
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
269
+ });
270
+
271
+ const result = await applyCanonicalGuardianDecision({
272
+ requestId: req.id,
273
+ action: 'approve_once',
274
+ actorContext: guardianActor({ guardianPrincipalId: 'some-principal' }),
275
+ });
276
+
277
+ expect(result.applied).toBe(false);
278
+ if (result.applied) return;
279
+ expect(result.reason).toBe('identity_mismatch');
280
+ });
281
+
282
+ test('rejects decision when actor has no guardianPrincipalId', async () => {
252
283
  const req = createCanonicalGuardianRequest({
253
284
  kind: 'tool_approval',
254
285
  sourceType: 'channel',
255
286
  conversationId: 'conv-1',
256
- // No guardianExternalUserId — open request
287
+ guardianExternalUserId: 'guardian-1',
288
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
257
289
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
258
290
  });
259
291
 
260
292
  const result = await applyCanonicalGuardianDecision({
261
293
  requestId: req.id,
262
294
  action: 'approve_once',
263
- actorContext: guardianActor({ externalUserId: 'anyone' }),
295
+ actorContext: guardianActor({ guardianPrincipalId: undefined }),
264
296
  });
265
297
 
266
298
  expect(result.applied).toBe(false);
@@ -276,6 +308,7 @@ describe('applyCanonicalGuardianDecision', () => {
276
308
  sourceType: 'channel',
277
309
  conversationId: 'conv-1',
278
310
  guardianExternalUserId: 'guardian-1',
311
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
279
312
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
280
313
  });
281
314
 
@@ -324,6 +357,7 @@ describe('applyCanonicalGuardianDecision', () => {
324
357
  sourceType: 'channel',
325
358
  conversationId: 'conv-1',
326
359
  guardianExternalUserId: 'guardian-1',
360
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
327
361
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
328
362
  });
329
363
 
@@ -351,6 +385,7 @@ describe('applyCanonicalGuardianDecision', () => {
351
385
  sourceChannel: 'telegram',
352
386
  conversationId: 'conv-1',
353
387
  guardianExternalUserId: 'guardian-1',
388
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
354
389
  toolName: 'shell',
355
390
  inputDigest: 'sha256:abc',
356
391
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -385,6 +420,7 @@ describe('applyCanonicalGuardianDecision', () => {
385
420
  sourceType: 'channel',
386
421
  conversationId: 'conv-1',
387
422
  guardianExternalUserId: 'guardian-1',
423
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
388
424
  expiresAt: new Date(Date.now() - 10_000).toISOString(), // already expired
389
425
  });
390
426
 
@@ -405,6 +441,7 @@ describe('applyCanonicalGuardianDecision', () => {
405
441
  sourceType: 'channel',
406
442
  conversationId: 'conv-1',
407
443
  guardianExternalUserId: 'guardian-1',
444
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
408
445
  // No expiresAt
409
446
  });
410
447
 
@@ -426,6 +463,7 @@ describe('applyCanonicalGuardianDecision', () => {
426
463
  sourceChannel: 'telegram',
427
464
  conversationId: 'conv-1',
428
465
  guardianExternalUserId: 'guardian-1',
466
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
429
467
  toolName: 'file_read',
430
468
  inputDigest: 'sha256:def',
431
469
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -446,6 +484,7 @@ describe('applyCanonicalGuardianDecision', () => {
446
484
  sourceType: 'voice',
447
485
  sourceChannel: 'twilio',
448
486
  guardianExternalUserId: 'guardian-1',
487
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
449
488
  callSessionId: 'call-99',
450
489
  pendingQuestionId: 'pq-99',
451
490
  questionText: 'What is the password?',
@@ -464,12 +503,13 @@ describe('applyCanonicalGuardianDecision', () => {
464
503
  expect(resolved!.answerText).toBe('secret123');
465
504
  });
466
505
 
467
- test('succeeds even with no resolver for unknown kind', async () => {
506
+ test('succeeds for non-decisionable kind with matching principal', async () => {
468
507
  const req = createCanonicalGuardianRequest({
469
508
  kind: 'unknown_kind',
470
509
  sourceType: 'channel',
471
510
  conversationId: 'conv-1',
472
511
  guardianExternalUserId: 'guardian-1',
512
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
473
513
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
474
514
  });
475
515
 
@@ -485,7 +525,7 @@ describe('applyCanonicalGuardianDecision', () => {
485
525
  expect(resolved!.status).toBe('approved');
486
526
  });
487
527
 
488
- test('trusted desktop actor still mints scoped grant for approved canonical request', async () => {
528
+ test('desktop actor with matching principal mints scoped grant for approved canonical request', async () => {
489
529
  const req = createCanonicalGuardianRequest({
490
530
  kind: 'unknown_kind',
491
531
  sourceType: 'voice',
@@ -494,6 +534,7 @@ describe('applyCanonicalGuardianDecision', () => {
494
534
  callSessionId: 'call-voice-1',
495
535
  toolName: 'host_bash',
496
536
  inputDigest: 'sha256:voice-digest-1',
537
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
497
538
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
498
539
  });
499
540
 
@@ -530,6 +571,7 @@ describe('mintCanonicalRequestGrant', () => {
530
571
  sourceType: 'channel',
531
572
  sourceChannel: 'telegram',
532
573
  conversationId: 'conv-1',
574
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
533
575
  toolName: 'shell',
534
576
  inputDigest: 'sha256:abc',
535
577
  });
@@ -549,6 +591,7 @@ describe('mintCanonicalRequestGrant', () => {
549
591
  sourceType: 'channel',
550
592
  sourceChannel: 'telegram',
551
593
  conversationId: 'conv-2',
594
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
552
595
  toolName: 'shell',
553
596
  inputDigest: 'sha256:xyz',
554
597
  });
@@ -570,6 +613,7 @@ describe('mintCanonicalRequestGrant', () => {
570
613
  const req = createCanonicalGuardianRequest({
571
614
  kind: 'pending_question',
572
615
  sourceType: 'voice',
616
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
573
617
  // No toolName or inputDigest
574
618
  });
575
619
 
@@ -586,6 +630,7 @@ describe('mintCanonicalRequestGrant', () => {
586
630
  const req = createCanonicalGuardianRequest({
587
631
  kind: 'tool_approval',
588
632
  sourceType: 'channel',
633
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
589
634
  toolName: 'shell',
590
635
  // No inputDigest
591
636
  });
@@ -31,25 +31,45 @@ mock.module('../util/logger.js', () => ({
31
31
 
32
32
  let mockTelegramBinding: unknown = null;
33
33
  let mockSmsBinding: unknown = null;
34
+ let mockVellumBinding: unknown = null;
34
35
 
35
36
  mock.module('../memory/channel-guardian-store.js', () => ({
36
37
  getActiveBinding: (_assistantId: string, channel: string) => {
37
38
  if (channel === 'telegram') return mockTelegramBinding;
38
39
  if (channel === 'sms') return mockSmsBinding;
40
+ if (channel === 'vellum') return mockVellumBinding;
39
41
  return null;
40
42
  },
43
+ createBinding: (params: Record<string, unknown>) => ({
44
+ id: `binding-${Date.now()}`,
45
+ ...params,
46
+ status: 'active',
47
+ verifiedAt: Date.now(),
48
+ verifiedVia: 'test',
49
+ metadataJson: null,
50
+ createdAt: Date.now(),
51
+ updatedAt: Date.now(),
52
+ }),
53
+ listActiveBindingsByAssistant: () => mockVellumBinding ? [mockVellumBinding] : [],
41
54
  }));
42
55
 
43
56
  mock.module('../config/loader.js', () => ({
44
57
  getConfig: () => ({
45
58
  ui: {},
46
-
59
+
47
60
  calls: {
48
61
  userConsultTimeoutSeconds: 120,
49
62
  },
50
63
  }),
51
64
  }));
52
65
 
66
+ // Mock guardian-vellum-migration to use a stable principal, avoiding UNIQUE
67
+ // constraint errors when ensureVellumGuardianBinding is called across tests.
68
+ // Returns a known principal so guardian-dispatch can attribute requests.
69
+ mock.module('../runtime/guardian-vellum-migration.js', () => ({
70
+ ensureVellumGuardianBinding: () => 'test-principal-id',
71
+ }));
72
+
53
73
  const emitCalls: unknown[] = [];
54
74
  let threadCreatedFromMock: ThreadCreatedInfo | null = null;
55
75
  let mockEmitResult: {
@@ -109,12 +129,30 @@ function resetTables(): void {
109
129
  db.run('DELETE FROM canonical_guardian_requests');
110
130
  db.run('DELETE FROM guardian_action_deliveries');
111
131
  db.run('DELETE FROM guardian_action_requests');
132
+ db.run('DELETE FROM channel_guardian_bindings');
112
133
  db.run('DELETE FROM call_pending_questions');
113
134
  db.run('DELETE FROM call_events');
114
135
  db.run('DELETE FROM call_sessions');
115
136
  db.run('DELETE FROM conversations');
137
+
116
138
  mockTelegramBinding = null;
117
139
  mockSmsBinding = null;
140
+ // Pre-seed vellum binding so the self-healing path in dispatchGuardianQuestion
141
+ // never triggers (avoids UNIQUE constraint violations on repeated dispatches).
142
+ mockVellumBinding = {
143
+ id: 'binding-vellum-test',
144
+ assistantId: 'self',
145
+ channel: 'vellum',
146
+ guardianExternalUserId: 'vellum-guardian',
147
+ guardianDeliveryChatId: 'local',
148
+ guardianPrincipalId: 'test-principal-id',
149
+ status: 'active',
150
+ verifiedAt: Date.now(),
151
+ verifiedVia: 'test',
152
+ metadataJson: null,
153
+ createdAt: Date.now(),
154
+ updatedAt: Date.now(),
155
+ };
118
156
  emitCalls.length = 0;
119
157
  threadCreatedFromMock = null;
120
158
  mockEmitResult = {