@vellumai/assistant 0.4.4 → 0.4.6

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 (90) hide show
  1. package/ARCHITECTURE.md +4 -4
  2. package/README.md +6 -6
  3. package/bun.lock +6 -2
  4. package/docs/architecture/memory.md +4 -4
  5. package/package.json +2 -2
  6. package/src/__tests__/actor-token-service.test.ts +5 -2
  7. package/src/__tests__/assistant-feature-flags-integration.test.ts +1 -0
  8. package/src/__tests__/call-controller.test.ts +78 -0
  9. package/src/__tests__/call-domain.test.ts +148 -10
  10. package/src/__tests__/call-pointer-message-composer.test.ts +39 -49
  11. package/src/__tests__/call-pointer-messages.test.ts +105 -43
  12. package/src/__tests__/canonical-guardian-store.test.ts +44 -10
  13. package/src/__tests__/channel-approval-routes.test.ts +67 -65
  14. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +1 -0
  15. package/src/__tests__/conversation-attention-telegram.test.ts +2 -2
  16. package/src/__tests__/deterministic-verification-control-plane.test.ts +6 -6
  17. package/src/__tests__/guardian-actions-endpoint.test.ts +7 -6
  18. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +57 -12
  19. package/src/__tests__/guardian-grant-minting.test.ts +24 -24
  20. package/src/__tests__/guardian-principal-id-roundtrip.test.ts +205 -0
  21. package/src/__tests__/guardian-routing-invariants.test.ts +64 -25
  22. package/src/__tests__/guardian-routing-state.test.ts +4 -4
  23. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +2 -2
  24. package/src/__tests__/inbound-invite-redemption.test.ts +8 -8
  25. package/src/__tests__/memory-retrieval.benchmark.test.ts +22 -47
  26. package/src/__tests__/no-is-trusted-guard.test.ts +77 -0
  27. package/src/__tests__/non-member-access-request.test.ts +50 -47
  28. package/src/__tests__/relay-server.test.ts +71 -0
  29. package/src/__tests__/send-endpoint-busy.test.ts +6 -0
  30. package/src/__tests__/session-tool-setup-tools-disabled.test.ts +155 -0
  31. package/src/__tests__/skill-feature-flags-integration.test.ts +1 -0
  32. package/src/__tests__/skill-projection.benchmark.test.ts +66 -2
  33. package/src/__tests__/system-prompt.test.ts +1 -0
  34. package/src/__tests__/tool-approval-handler.test.ts +1 -1
  35. package/src/__tests__/tool-grant-request-escalation.test.ts +9 -2
  36. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +8 -1
  37. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +22 -22
  38. package/src/__tests__/trusted-contact-multichannel.test.ts +4 -4
  39. package/src/__tests__/trusted-contact-verification.test.ts +10 -10
  40. package/src/approvals/guardian-decision-primitive.ts +29 -25
  41. package/src/approvals/guardian-request-resolvers.ts +9 -5
  42. package/src/calls/call-pointer-message-composer.ts +27 -85
  43. package/src/calls/call-pointer-messages.ts +54 -21
  44. package/src/calls/guardian-dispatch.ts +30 -0
  45. package/src/calls/relay-server.ts +13 -13
  46. package/src/config/system-prompt.ts +10 -3
  47. package/src/config/templates/BOOTSTRAP.md +6 -5
  48. package/src/config/templates/USER.md +1 -0
  49. package/src/config/user-reference.ts +44 -0
  50. package/src/daemon/handlers/guardian-actions.ts +5 -2
  51. package/src/daemon/handlers/sessions.ts +8 -3
  52. package/src/daemon/lifecycle.ts +109 -3
  53. package/src/daemon/server.ts +32 -24
  54. package/src/daemon/session-agent-loop.ts +4 -3
  55. package/src/daemon/session-lifecycle.ts +1 -9
  56. package/src/daemon/session-process.ts +2 -2
  57. package/src/daemon/session-runtime-assembly.ts +2 -0
  58. package/src/daemon/session-tool-setup.ts +10 -0
  59. package/src/daemon/session.ts +1 -0
  60. package/src/memory/canonical-guardian-store.ts +40 -0
  61. package/src/memory/conversation-crud.ts +26 -0
  62. package/src/memory/conversation-store.ts +1 -0
  63. package/src/memory/db-init.ts +8 -0
  64. package/src/memory/guardian-bindings.ts +4 -0
  65. package/src/memory/job-handlers/backfill.ts +2 -9
  66. package/src/memory/migrations/125-guardian-principal-id-columns.ts +19 -0
  67. package/src/memory/migrations/126-backfill-guardian-principal-id.ts +210 -0
  68. package/src/memory/migrations/index.ts +2 -0
  69. package/src/memory/migrations/registry.ts +5 -0
  70. package/src/memory/schema.ts +3 -0
  71. package/src/notifications/copy-composer.ts +2 -2
  72. package/src/runtime/access-request-helper.ts +43 -28
  73. package/src/runtime/actor-trust-resolver.ts +19 -14
  74. package/src/runtime/channel-guardian-service.ts +6 -0
  75. package/src/runtime/guardian-context-resolver.ts +6 -2
  76. package/src/runtime/guardian-reply-router.ts +33 -16
  77. package/src/runtime/guardian-vellum-migration.ts +29 -5
  78. package/src/runtime/http-types.ts +0 -13
  79. package/src/runtime/local-actor-identity.ts +19 -13
  80. package/src/runtime/middleware/actor-token.ts +2 -2
  81. package/src/runtime/routes/channel-delivery-routes.ts +5 -5
  82. package/src/runtime/routes/conversation-routes.ts +45 -35
  83. package/src/runtime/routes/guardian-action-routes.ts +7 -1
  84. package/src/runtime/routes/guardian-approval-interception.ts +52 -52
  85. package/src/runtime/routes/guardian-bootstrap-routes.ts +1 -0
  86. package/src/runtime/routes/inbound-conversation.ts +7 -7
  87. package/src/runtime/routes/inbound-message-handler.ts +105 -94
  88. package/src/runtime/tool-grant-request-helper.ts +1 -0
  89. package/src/util/logger.ts +10 -0
  90. package/src/daemon/call-pointer-generators.ts +0 -59
@@ -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
  });
@@ -188,9 +188,9 @@ describe('guardian grant minting on tool-approval decisions', () => {
188
188
  conversationId: 'guardian-conv-1',
189
189
  callbackData: `apr:${requestId}:approve_once`,
190
190
  content: '',
191
- externalChatId: GUARDIAN_CHAT,
191
+ conversationExternalId: GUARDIAN_CHAT,
192
192
  sourceChannel: 'telegram',
193
- senderExternalUserId: GUARDIAN_USER,
193
+ actorExternalId: GUARDIAN_USER,
194
194
  replyCallbackUrl: 'https://gateway.test/deliver',
195
195
  guardianCtx: makeGuardianContext(),
196
196
  assistantId: ASSISTANT_ID,
@@ -236,9 +236,9 @@ describe('guardian grant minting on tool-approval decisions', () => {
236
236
  conversationId: 'guardian-conv-2',
237
237
  callbackData: `apr:${requestId}:approve_once`,
238
238
  content: '',
239
- externalChatId: GUARDIAN_CHAT,
239
+ conversationExternalId: GUARDIAN_CHAT,
240
240
  sourceChannel: 'telegram',
241
- senderExternalUserId: GUARDIAN_USER,
241
+ actorExternalId: GUARDIAN_USER,
242
242
  replyCallbackUrl: 'https://gateway.test/deliver',
243
243
  guardianCtx: makeGuardianContext(),
244
244
  assistantId: ASSISTANT_ID,
@@ -267,9 +267,9 @@ describe('guardian grant minting on tool-approval decisions', () => {
267
267
  conversationId: 'guardian-conv-2b',
268
268
  callbackData: `apr:${requestId}:approve_once`,
269
269
  content: '',
270
- externalChatId: GUARDIAN_CHAT,
270
+ conversationExternalId: GUARDIAN_CHAT,
271
271
  sourceChannel: 'telegram',
272
- senderExternalUserId: GUARDIAN_USER,
272
+ actorExternalId: GUARDIAN_USER,
273
273
  replyCallbackUrl: 'https://gateway.test/deliver',
274
274
  guardianCtx: makeGuardianContext(),
275
275
  assistantId: ASSISTANT_ID,
@@ -306,9 +306,9 @@ describe('guardian grant minting on tool-approval decisions', () => {
306
306
  conversationId: 'guardian-conv-3',
307
307
  callbackData: `apr:${requestId}:reject`,
308
308
  content: '',
309
- externalChatId: GUARDIAN_CHAT,
309
+ conversationExternalId: GUARDIAN_CHAT,
310
310
  sourceChannel: 'telegram',
311
- senderExternalUserId: GUARDIAN_USER,
311
+ actorExternalId: GUARDIAN_USER,
312
312
  replyCallbackUrl: 'https://gateway.test/deliver',
313
313
  guardianCtx: makeGuardianContext(),
314
314
  assistantId: ASSISTANT_ID,
@@ -335,9 +335,9 @@ describe('guardian grant minting on tool-approval decisions', () => {
335
335
  conversationId: 'guardian-conv-4',
336
336
  callbackData: `apr:${requestId}:approve_once`,
337
337
  content: '',
338
- externalChatId: GUARDIAN_CHAT,
338
+ conversationExternalId: GUARDIAN_CHAT,
339
339
  sourceChannel: 'telegram',
340
- senderExternalUserId: 'wrong-guardian-user',
340
+ actorExternalId: 'wrong-guardian-user',
341
341
  replyCallbackUrl: 'https://gateway.test/deliver',
342
342
  guardianCtx: makeGuardianContext(),
343
343
  assistantId: ASSISTANT_ID,
@@ -366,9 +366,9 @@ describe('guardian grant minting on tool-approval decisions', () => {
366
366
  conversationId: 'guardian-conv-5',
367
367
  callbackData: `apr:${requestId}:approve_once`,
368
368
  content: '',
369
- externalChatId: GUARDIAN_CHAT,
369
+ conversationExternalId: GUARDIAN_CHAT,
370
370
  sourceChannel: 'telegram',
371
- senderExternalUserId: GUARDIAN_USER,
371
+ actorExternalId: GUARDIAN_USER,
372
372
  replyCallbackUrl: 'https://gateway.test/deliver',
373
373
  guardianCtx: makeGuardianContext(),
374
374
  assistantId: ASSISTANT_ID,
@@ -400,9 +400,9 @@ describe('guardian grant minting on tool-approval decisions', () => {
400
400
  const result = await handleApprovalInterception({
401
401
  conversationId: 'guardian-conv-6',
402
402
  content: 'yes, approve it',
403
- externalChatId: GUARDIAN_CHAT,
403
+ conversationExternalId: GUARDIAN_CHAT,
404
404
  sourceChannel: 'telegram',
405
- senderExternalUserId: GUARDIAN_USER,
405
+ actorExternalId: GUARDIAN_USER,
406
406
  replyCallbackUrl: 'https://gateway.test/deliver',
407
407
  guardianCtx: makeGuardianContext(),
408
408
  assistantId: ASSISTANT_ID,
@@ -441,9 +441,9 @@ describe('guardian grant minting on tool-approval decisions', () => {
441
441
  const result = await handleApprovalInterception({
442
442
  conversationId: 'guardian-conv-7',
443
443
  content: 'no, deny it',
444
- externalChatId: GUARDIAN_CHAT,
444
+ conversationExternalId: GUARDIAN_CHAT,
445
445
  sourceChannel: 'telegram',
446
- senderExternalUserId: GUARDIAN_USER,
446
+ actorExternalId: GUARDIAN_USER,
447
447
  replyCallbackUrl: 'https://gateway.test/deliver',
448
448
  guardianCtx: makeGuardianContext(),
449
449
  assistantId: ASSISTANT_ID,
@@ -471,9 +471,9 @@ describe('guardian grant minting on tool-approval decisions', () => {
471
471
  const result = await handleApprovalInterception({
472
472
  conversationId: 'guardian-conv-8',
473
473
  content: 'yes',
474
- externalChatId: GUARDIAN_CHAT,
474
+ conversationExternalId: GUARDIAN_CHAT,
475
475
  sourceChannel: 'telegram',
476
- senderExternalUserId: GUARDIAN_USER,
476
+ actorExternalId: GUARDIAN_USER,
477
477
  replyCallbackUrl: 'https://gateway.test/deliver',
478
478
  guardianCtx: makeGuardianContext(),
479
479
  assistantId: ASSISTANT_ID,
@@ -507,9 +507,9 @@ describe('guardian grant minting on tool-approval decisions', () => {
507
507
  conversationId: 'guardian-conv-9',
508
508
  callbackData: `apr:${requestId}:approve_once`,
509
509
  content: '',
510
- externalChatId: GUARDIAN_CHAT,
510
+ conversationExternalId: GUARDIAN_CHAT,
511
511
  sourceChannel: 'telegram',
512
- senderExternalUserId: GUARDIAN_USER,
512
+ actorExternalId: GUARDIAN_USER,
513
513
  replyCallbackUrl: 'https://gateway.test/deliver',
514
514
  guardianCtx: makeGuardianContext(),
515
515
  assistantId: ASSISTANT_ID,
@@ -550,9 +550,9 @@ describe('approval interception trust-class regression coverage', () => {
550
550
  const result = await handleApprovalInterception({
551
551
  conversationId: CONVERSATION_ID,
552
552
  content: 'approve',
553
- externalChatId: REQUESTER_CHAT,
553
+ conversationExternalId: REQUESTER_CHAT,
554
554
  sourceChannel: 'telegram',
555
- senderExternalUserId: 'intruder-user-1',
555
+ actorExternalId: 'intruder-user-1',
556
556
  replyCallbackUrl: 'https://gateway.test/deliver',
557
557
  guardianCtx: {
558
558
  trustClass: 'unknown',
@@ -576,9 +576,9 @@ describe('approval interception trust-class regression coverage', () => {
576
576
  const result = await handleApprovalInterception({
577
577
  conversationId: CONVERSATION_ID,
578
578
  content: 'approve',
579
- externalChatId: REQUESTER_CHAT,
579
+ conversationExternalId: REQUESTER_CHAT,
580
580
  sourceChannel: 'telegram',
581
- senderExternalUserId: undefined,
581
+ actorExternalId: undefined,
582
582
  replyCallbackUrl: 'https://gateway.test/deliver',
583
583
  guardianCtx: {
584
584
  trustClass: 'unknown',
@@ -0,0 +1,205 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
6
+
7
+ const testDir = mkdtempSync(join(tmpdir(), 'guardian-principal-id-roundtrip-test-'));
8
+
9
+ mock.module('../util/platform.js', () => ({
10
+ getDataDir: () => testDir,
11
+ isMacOS: () => process.platform === 'darwin',
12
+ isLinux: () => process.platform === 'linux',
13
+ isWindows: () => process.platform === 'win32',
14
+ getSocketPath: () => join(testDir, 'test.sock'),
15
+ getPidPath: () => join(testDir, 'test.pid'),
16
+ getDbPath: () => join(testDir, 'test.db'),
17
+ getLogPath: () => join(testDir, 'test.log'),
18
+ ensureDataDir: () => {},
19
+ }));
20
+
21
+ mock.module('../util/logger.js', () => ({
22
+ getLogger: () =>
23
+ new Proxy({} as Record<string, unknown>, {
24
+ get: () => () => {},
25
+ }),
26
+ }));
27
+
28
+ import {
29
+ createCanonicalGuardianRequest,
30
+ getCanonicalGuardianRequest,
31
+ resolveCanonicalGuardianRequest,
32
+ updateCanonicalGuardianRequest,
33
+ } from '../memory/canonical-guardian-store.js';
34
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
35
+ import {
36
+ createBinding,
37
+ getActiveBinding,
38
+ } from '../memory/guardian-bindings.js';
39
+
40
+ initializeDb();
41
+
42
+ function resetTables(): void {
43
+ const db = getDb();
44
+ db.run('DELETE FROM canonical_guardian_deliveries');
45
+ db.run('DELETE FROM canonical_guardian_requests');
46
+ db.run('DELETE FROM channel_guardian_bindings');
47
+ }
48
+
49
+ describe('guardianPrincipalId roundtrip', () => {
50
+ beforeEach(() => {
51
+ resetTables();
52
+ });
53
+
54
+ afterAll(() => {
55
+ resetDb();
56
+ try {
57
+ rmSync(testDir, { recursive: true });
58
+ } catch {
59
+ // best-effort cleanup
60
+ }
61
+ });
62
+
63
+ // ── channel_guardian_bindings ────────────────────────────────────────
64
+
65
+ describe('channel_guardian_bindings', () => {
66
+ test('creates binding with guardianPrincipalId and reads it back', () => {
67
+ const binding = createBinding({
68
+ assistantId: 'self',
69
+ channel: 'telegram',
70
+ guardianExternalUserId: 'tg-user-123',
71
+ guardianDeliveryChatId: 'tg-chat-123',
72
+ guardianPrincipalId: 'principal-abc-def',
73
+ });
74
+
75
+ expect(binding.guardianPrincipalId).toBe('principal-abc-def');
76
+
77
+ const fetched = getActiveBinding('self', 'telegram');
78
+ expect(fetched).not.toBeNull();
79
+ expect(fetched!.guardianPrincipalId).toBe('principal-abc-def');
80
+ });
81
+
82
+ test('creates binding without guardianPrincipalId (defaults to null)', () => {
83
+ const binding = createBinding({
84
+ assistantId: 'self',
85
+ channel: 'sms',
86
+ guardianExternalUserId: 'sms-user-456',
87
+ guardianDeliveryChatId: 'sms-chat-456',
88
+ });
89
+
90
+ expect(binding.guardianPrincipalId).toBeNull();
91
+
92
+ const fetched = getActiveBinding('self', 'sms');
93
+ expect(fetched).not.toBeNull();
94
+ expect(fetched!.guardianPrincipalId).toBeNull();
95
+ });
96
+ });
97
+
98
+ // ── canonical_guardian_requests ──────────────────────────────────────
99
+
100
+ describe('canonical_guardian_requests', () => {
101
+ test('creates request with guardianPrincipalId and reads it back', () => {
102
+ const req = createCanonicalGuardianRequest({
103
+ kind: 'tool_approval',
104
+ sourceType: 'channel',
105
+ sourceChannel: 'telegram',
106
+ guardianExternalUserId: 'guardian-tg-1',
107
+ guardianPrincipalId: 'principal-123',
108
+ });
109
+
110
+ expect(req.guardianPrincipalId).toBe('principal-123');
111
+ expect(req.decidedByPrincipalId).toBeNull();
112
+
113
+ const fetched = getCanonicalGuardianRequest(req.id);
114
+ expect(fetched).not.toBeNull();
115
+ expect(fetched!.guardianPrincipalId).toBe('principal-123');
116
+ expect(fetched!.decidedByPrincipalId).toBeNull();
117
+ });
118
+
119
+ test('access_request requires guardianPrincipalId (decisionable kind)', () => {
120
+ // access_request is now decisionable — creating one without a principal
121
+ // should throw IntegrityError.
122
+ expect(() => createCanonicalGuardianRequest({
123
+ kind: 'access_request',
124
+ sourceType: 'desktop',
125
+ })).toThrow('guardianPrincipalId');
126
+
127
+ // With a principal, creation succeeds
128
+ const req = createCanonicalGuardianRequest({
129
+ kind: 'access_request',
130
+ sourceType: 'desktop',
131
+ guardianPrincipalId: 'access-req-principal',
132
+ });
133
+ expect(req.guardianPrincipalId).toBe('access-req-principal');
134
+ expect(req.decidedByPrincipalId).toBeNull();
135
+ });
136
+
137
+ test('creates request with decidedByPrincipalId', () => {
138
+ const req = createCanonicalGuardianRequest({
139
+ kind: 'tool_approval',
140
+ sourceType: 'voice',
141
+ guardianPrincipalId: 'guardian-principal-1',
142
+ decidedByPrincipalId: 'decider-principal-1',
143
+ });
144
+
145
+ expect(req.decidedByPrincipalId).toBe('decider-principal-1');
146
+ expect(req.guardianPrincipalId).toBe('guardian-principal-1');
147
+ });
148
+
149
+ test('updates decidedByPrincipalId via updateCanonicalGuardianRequest', () => {
150
+ const req = createCanonicalGuardianRequest({
151
+ kind: 'tool_approval',
152
+ sourceType: 'channel',
153
+ guardianPrincipalId: 'principal-for-update-test',
154
+ });
155
+
156
+ const updated = updateCanonicalGuardianRequest(req.id, {
157
+ status: 'approved',
158
+ decidedByPrincipalId: 'principal-decider-abc',
159
+ decidedByExternalUserId: 'ext-user-1',
160
+ });
161
+
162
+ expect(updated).not.toBeNull();
163
+ expect(updated!.decidedByPrincipalId).toBe('principal-decider-abc');
164
+ expect(updated!.decidedByExternalUserId).toBe('ext-user-1');
165
+ expect(updated!.status).toBe('approved');
166
+ });
167
+
168
+ test('resolveCanonicalGuardianRequest writes decidedByPrincipalId', () => {
169
+ const req = createCanonicalGuardianRequest({
170
+ kind: 'tool_approval',
171
+ sourceType: 'voice',
172
+ guardianPrincipalId: 'guardian-principal-xyz',
173
+ });
174
+
175
+ const resolved = resolveCanonicalGuardianRequest(req.id, 'pending', {
176
+ status: 'approved',
177
+ answerText: 'Approved',
178
+ decidedByExternalUserId: 'guardian-ext-1',
179
+ decidedByPrincipalId: 'guardian-principal-xyz',
180
+ });
181
+
182
+ expect(resolved).not.toBeNull();
183
+ expect(resolved!.status).toBe('approved');
184
+ expect(resolved!.decidedByPrincipalId).toBe('guardian-principal-xyz');
185
+ expect(resolved!.decidedByExternalUserId).toBe('guardian-ext-1');
186
+ expect(resolved!.guardianPrincipalId).toBe('guardian-principal-xyz');
187
+ });
188
+
189
+ test('resolve without decidedByPrincipalId leaves it null', () => {
190
+ const req = createCanonicalGuardianRequest({
191
+ kind: 'tool_approval',
192
+ sourceType: 'channel',
193
+ guardianPrincipalId: 'principal-for-resolve-test',
194
+ });
195
+
196
+ const resolved = resolveCanonicalGuardianRequest(req.id, 'pending', {
197
+ status: 'denied',
198
+ decidedByExternalUserId: 'guardian-ext-2',
199
+ });
200
+
201
+ expect(resolved).not.toBeNull();
202
+ expect(resolved!.decidedByPrincipalId).toBeNull();
203
+ });
204
+ });
205
+ });