@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
@@ -5,7 +5,7 @@
5
5
  * its key architectural invariants:
6
6
  *
7
7
  * 1. All decision paths route through `applyCanonicalGuardianDecision`
8
- * 2. Identity checks are enforced before decisions are applied
8
+ * 2. Principal-based authorization is enforced before decisions are applied
9
9
  * 3. Stale/expired/already-resolved decisions are rejected
10
10
  * 4. Code-only messages return clarification (not auto-approve)
11
11
  * 5. Disambiguation with multiple pending requests stays fail-closed
@@ -88,11 +88,14 @@ afterAll(() => {
88
88
  // Helpers
89
89
  // ---------------------------------------------------------------------------
90
90
 
91
+ /** Consistent test principal used across all test actors and requests. */
92
+ const TEST_PRINCIPAL_ID = 'test-principal-id';
93
+
91
94
  function guardianActor(overrides: Partial<ActorContext> = {}): ActorContext {
92
95
  return {
93
96
  externalUserId: 'guardian-1',
94
97
  channel: 'telegram',
95
- isTrusted: false,
98
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
96
99
  ...overrides,
97
100
  };
98
101
  }
@@ -101,7 +104,7 @@ function trustedActor(overrides: Partial<ActorContext> = {}): ActorContext {
101
104
  return {
102
105
  externalUserId: undefined,
103
106
  channel: 'desktop',
104
- isTrusted: true,
107
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
105
108
  ...overrides,
106
109
  };
107
110
  }
@@ -223,25 +226,26 @@ describe('routing invariant: all decision paths reference applyCanonicalGuardian
223
226
  });
224
227
 
225
228
  // ===========================================================================
226
- // SECTION 2: Identity enforcement invariants
229
+ // SECTION 2: Principal-based authorization invariants
227
230
  // ===========================================================================
228
231
 
229
- describe('routing invariant: identity checks enforced before decisions', () => {
232
+ describe('routing invariant: principal-based authorization enforced before decisions', () => {
230
233
  beforeEach(() => resetTables());
231
234
 
232
- test('non-matching actor identity is rejected by canonical primitive', async () => {
235
+ test('mismatching actor principal is rejected by canonical primitive', async () => {
233
236
  const req = createCanonicalGuardianRequest({
234
237
  kind: 'tool_approval',
235
238
  sourceType: 'channel',
236
239
  conversationId: 'conv-1',
237
240
  guardianExternalUserId: 'guardian-1',
241
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
238
242
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
239
243
  });
240
244
 
241
245
  const result = await applyCanonicalGuardianDecision({
242
246
  requestId: req.id,
243
247
  action: 'approve_once',
244
- actorContext: guardianActor({ externalUserId: 'imposter-99' }),
248
+ actorContext: guardianActor({ guardianPrincipalId: 'wrong-principal' }),
245
249
  });
246
250
 
247
251
  expect(result.applied).toBe(false);
@@ -253,12 +257,13 @@ describe('routing invariant: identity checks enforced before decisions', () => {
253
257
  expect(unchanged!.status).toBe('pending');
254
258
  });
255
259
 
256
- test('trusted (desktop) actor bypasses identity check', async () => {
260
+ test('matching principal authorizes desktop actor', async () => {
257
261
  const req = createCanonicalGuardianRequest({
258
262
  kind: 'tool_approval',
259
263
  sourceType: 'desktop',
260
264
  conversationId: 'conv-1',
261
265
  guardianExternalUserId: 'guardian-1',
266
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
262
267
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
263
268
  });
264
269
 
@@ -271,19 +276,19 @@ describe('routing invariant: identity checks enforced before decisions', () => {
271
276
  expect(result.applied).toBe(true);
272
277
  });
273
278
 
274
- test('request with no guardian binding rejects non-trusted actor', async () => {
279
+ test('actor without guardianPrincipalId is rejected', async () => {
275
280
  const req = createCanonicalGuardianRequest({
276
281
  kind: 'tool_approval',
277
282
  sourceType: 'channel',
278
283
  conversationId: 'conv-1',
279
- // No guardianExternalUserId — open request
284
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
280
285
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
281
286
  });
282
287
 
283
288
  const result = await applyCanonicalGuardianDecision({
284
289
  requestId: req.id,
285
290
  action: 'approve_once',
286
- actorContext: guardianActor({ externalUserId: 'anyone' }),
291
+ actorContext: guardianActor({ guardianPrincipalId: undefined }),
287
292
  });
288
293
 
289
294
  expect(result.applied).toBe(false);
@@ -291,12 +296,13 @@ describe('routing invariant: identity checks enforced before decisions', () => {
291
296
  expect(result.reason).toBe('identity_mismatch');
292
297
  });
293
298
 
294
- test('identity mismatch on code-only message blocks detail leakage', async () => {
299
+ test('principal mismatch on code-only message blocks detail leakage', async () => {
295
300
  createCanonicalGuardianRequest({
296
301
  kind: 'tool_approval',
297
302
  sourceType: 'channel',
298
303
  conversationId: 'conv-1',
299
304
  guardianExternalUserId: 'guardian-1',
305
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
300
306
  requestCode: 'ABC123',
301
307
  toolName: 'shell',
302
308
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -304,7 +310,7 @@ describe('routing invariant: identity checks enforced before decisions', () => {
304
310
 
305
311
  const result = await routeGuardianReply(replyCtx({
306
312
  messageText: 'ABC123',
307
- actor: guardianActor({ externalUserId: 'imposter' }),
313
+ actor: guardianActor({ guardianPrincipalId: 'wrong-principal' }),
308
314
  conversationId: 'conv-1',
309
315
  }));
310
316
 
@@ -329,6 +335,7 @@ describe('routing invariant: stale/expired/already-resolved decisions rejected',
329
335
  sourceType: 'channel',
330
336
  conversationId: 'conv-1',
331
337
  guardianExternalUserId: 'guardian-1',
338
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
332
339
  expiresAt: new Date(Date.now() - 10_000).toISOString(), // already expired
333
340
  });
334
341
 
@@ -349,6 +356,7 @@ describe('routing invariant: stale/expired/already-resolved decisions rejected',
349
356
  sourceType: 'channel',
350
357
  conversationId: 'conv-1',
351
358
  guardianExternalUserId: 'guardian-1',
359
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
352
360
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
353
361
  });
354
362
 
@@ -393,6 +401,7 @@ describe('routing invariant: stale/expired/already-resolved decisions rejected',
393
401
  sourceType: 'channel',
394
402
  conversationId: 'conv-1',
395
403
  guardianExternalUserId: 'guardian-1',
404
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
396
405
  requestCode: 'ABC123',
397
406
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
398
407
  });
@@ -423,6 +432,7 @@ describe('routing invariant: stale/expired/already-resolved decisions rejected',
423
432
  sourceType: 'channel',
424
433
  conversationId: 'conv-1',
425
434
  guardianExternalUserId: 'guardian-1',
435
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
426
436
  expiresAt: new Date(Date.now() - 10_000).toISOString(), // already expired
427
437
  });
428
438
 
@@ -451,6 +461,7 @@ describe('routing invariant: code-only messages return clarification', () => {
451
461
  sourceType: 'channel',
452
462
  conversationId: 'conv-1',
453
463
  guardianExternalUserId: 'guardian-1',
464
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
454
465
  requestCode: 'A1B2C3',
455
466
  toolName: 'shell',
456
467
  questionText: 'Run shell command: ls -la',
@@ -482,6 +493,7 @@ describe('routing invariant: code-only messages return clarification', () => {
482
493
  sourceChannel: 'voice',
483
494
  conversationId: 'conv-1',
484
495
  guardianExternalUserId: 'guardian-1',
496
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
485
497
  callSessionId: 'call-1',
486
498
  pendingQuestionId: 'pq-1',
487
499
  requestCode: 'A2B3C4',
@@ -513,6 +525,7 @@ describe('routing invariant: code-only messages return clarification', () => {
513
525
  sourceChannel: 'voice',
514
526
  conversationId: 'conv-1',
515
527
  guardianExternalUserId: 'guardian-1',
528
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
516
529
  callSessionId: 'call-2',
517
530
  pendingQuestionId: 'pq-2',
518
531
  requestCode: 'B2C3D4',
@@ -544,6 +557,7 @@ describe('routing invariant: code-only messages return clarification', () => {
544
557
  sourceType: 'channel',
545
558
  conversationId: 'conv-1',
546
559
  guardianExternalUserId: 'guardian-1',
560
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
547
561
  requestCode: 'A1B2C3',
548
562
  toolName: 'shell',
549
563
  inputDigest: 'sha256:abc',
@@ -570,6 +584,7 @@ describe('routing invariant: code-only messages return clarification', () => {
570
584
  sourceType: 'channel',
571
585
  conversationId: 'conv-1',
572
586
  guardianExternalUserId: 'guardian-1',
587
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
573
588
  requestCode: 'D4E5F6',
574
589
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
575
590
  });
@@ -601,6 +616,7 @@ describe('routing invariant: disambiguation stays fail-closed', () => {
601
616
  sourceType: 'channel',
602
617
  conversationId: 'conv-1',
603
618
  guardianExternalUserId: 'guardian-1',
619
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
604
620
  requestCode: 'DDD444',
605
621
  toolName: 'shell',
606
622
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -628,6 +644,7 @@ describe('routing invariant: disambiguation stays fail-closed', () => {
628
644
  sourceType: 'channel',
629
645
  conversationId: 'conv-1',
630
646
  guardianExternalUserId: 'guardian-1',
647
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
631
648
  requestCode: 'GGG777',
632
649
  toolName: 'shell',
633
650
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -648,12 +665,13 @@ describe('routing invariant: disambiguation stays fail-closed', () => {
648
665
  expect(unchanged!.status).toBe('pending');
649
666
  });
650
667
 
651
- test('explicit empty pendingRequestIds hint stays fail-closed for trusted actors', async () => {
668
+ test('explicit empty pendingRequestIds hint stays fail-closed for desktop actors', async () => {
652
669
  createCanonicalGuardianRequest({
653
670
  kind: 'tool_approval',
654
671
  sourceType: 'channel',
655
672
  conversationId: 'conv-other',
656
673
  guardianExternalUserId: 'guardian-1',
674
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
657
675
  requestCode: 'HHH888',
658
676
  toolName: 'shell',
659
677
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -678,6 +696,7 @@ describe('routing invariant: disambiguation stays fail-closed', () => {
678
696
  sourceType: 'channel',
679
697
  conversationId: 'conv-1',
680
698
  guardianExternalUserId: 'guardian-1',
699
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
681
700
  requestCode: 'EEE555',
682
701
  toolName: 'shell',
683
702
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -688,6 +707,7 @@ describe('routing invariant: disambiguation stays fail-closed', () => {
688
707
  sourceType: 'channel',
689
708
  conversationId: 'conv-1',
690
709
  guardianExternalUserId: 'guardian-1',
710
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
691
711
  requestCode: 'FFF666',
692
712
  toolName: 'file_write',
693
713
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -719,6 +739,7 @@ describe('routing invariant: disambiguation stays fail-closed', () => {
719
739
  sourceType: 'channel',
720
740
  conversationId: 'conv-1',
721
741
  guardianExternalUserId: 'guardian-1',
742
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
722
743
  requestCode: 'AAA111',
723
744
  toolName: 'shell',
724
745
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -729,6 +750,7 @@ describe('routing invariant: disambiguation stays fail-closed', () => {
729
750
  sourceType: 'channel',
730
751
  conversationId: 'conv-1',
731
752
  guardianExternalUserId: 'guardian-1',
753
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
732
754
  requestCode: 'BBB222',
733
755
  toolName: 'file_write',
734
756
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -771,6 +793,7 @@ describe('routing invariant: disambiguation stays fail-closed', () => {
771
793
  sourceChannel: 'voice',
772
794
  conversationId: 'conv-1',
773
795
  guardianExternalUserId: 'guardian-1',
796
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
774
797
  callSessionId: 'call-answer',
775
798
  pendingQuestionId: 'pq-answer',
776
799
  requestCode: 'ABC123',
@@ -784,6 +807,7 @@ describe('routing invariant: disambiguation stays fail-closed', () => {
784
807
  sourceChannel: 'voice',
785
808
  conversationId: 'conv-1',
786
809
  guardianExternalUserId: 'guardian-1',
810
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
787
811
  callSessionId: 'call-approval',
788
812
  pendingQuestionId: 'pq-approval',
789
813
  requestCode: 'DEF456',
@@ -815,6 +839,7 @@ describe('routing invariant: disambiguation stays fail-closed', () => {
815
839
  sourceType: 'channel',
816
840
  conversationId: 'conv-1',
817
841
  guardianExternalUserId: 'guardian-1',
842
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
818
843
  requestCode: 'CCC333',
819
844
  toolName: 'shell',
820
845
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -849,6 +874,7 @@ describe('routing invariant: disambiguation stays fail-closed', () => {
849
874
  sourceType: 'channel',
850
875
  conversationId: 'conv-1',
851
876
  guardianExternalUserId: 'guardian-1',
877
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
852
878
  toolName: 'shell',
853
879
  requestCode: 'GO1234',
854
880
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -876,6 +902,7 @@ describe('routing invariant: disambiguation stays fail-closed', () => {
876
902
  sourceType: 'channel',
877
903
  conversationId: 'conv-1',
878
904
  guardianExternalUserId: 'guardian-1',
905
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
879
906
  requestCode: '111AAA',
880
907
  toolName: 'shell',
881
908
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -885,6 +912,7 @@ describe('routing invariant: disambiguation stays fail-closed', () => {
885
912
  sourceType: 'channel',
886
913
  conversationId: 'conv-2',
887
914
  guardianExternalUserId: 'guardian-1',
915
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
888
916
  requestCode: '222BBB',
889
917
  toolName: 'shell',
890
918
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -951,6 +979,7 @@ describe('routing invariant: approve_always downgraded for guardian-on-behalf',
951
979
  sourceType: 'channel',
952
980
  conversationId: 'conv-1',
953
981
  guardianExternalUserId: 'guardian-1',
982
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
954
983
  toolName: 'shell',
955
984
  inputDigest: 'sha256:abc',
956
985
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -983,6 +1012,7 @@ describe('routing invariant: callback buttons route through canonical primitive'
983
1012
  sourceType: 'channel',
984
1013
  conversationId: 'conv-1',
985
1014
  guardianExternalUserId: 'guardian-1',
1015
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
986
1016
  toolName: 'shell',
987
1017
  inputDigest: 'sha256:abc',
988
1018
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -1009,6 +1039,7 @@ describe('routing invariant: callback buttons route through canonical primitive'
1009
1039
  sourceType: 'channel',
1010
1040
  conversationId: 'conv-1',
1011
1041
  guardianExternalUserId: 'guardian-1',
1042
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
1012
1043
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
1013
1044
  });
1014
1045
  registerPendingToolApprovalInteraction(req.id, 'conv-1', 'shell');
@@ -1032,6 +1063,7 @@ describe('routing invariant: callback buttons route through canonical primitive'
1032
1063
  sourceType: 'channel',
1033
1064
  conversationId: 'conv-other',
1034
1065
  guardianExternalUserId: 'guardian-1',
1066
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
1035
1067
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
1036
1068
  });
1037
1069
  registerPendingToolApprovalInteraction(req.id, 'conv-other', 'shell');
@@ -1044,8 +1076,8 @@ describe('routing invariant: callback buttons route through canonical primitive'
1044
1076
 
1045
1077
  // Should be consumed — conversationId scoping was removed because in
1046
1078
  // cross-channel flows the guardian's conversation differs from the
1047
- // requester's. Identity validation in the canonical decision primitive
1048
- // (guardianExternalUserId match) is the correct security boundary.
1079
+ // requester's. Principal validation in the canonical decision primitive
1080
+ // is the correct security boundary.
1049
1081
  expect(result.consumed).toBe(true);
1050
1082
  expect(result.decisionApplied).toBe(true);
1051
1083
 
@@ -1056,14 +1088,14 @@ describe('routing invariant: callback buttons route through canonical primitive'
1056
1088
  });
1057
1089
 
1058
1090
  // ===========================================================================
1059
- // SECTION 9: Destination hints do not bypass identity binding for tool_approval
1091
+ // SECTION 9: Destination hints do not bypass principal binding for tool_approval
1060
1092
  // ===========================================================================
1061
1093
 
1062
- describe('routing invariant: destination hints do not bypass tool_approval identity binding', () => {
1094
+ describe('routing invariant: destination hints do not bypass tool_approval principal binding', () => {
1063
1095
  beforeEach(() => resetTables());
1064
1096
 
1065
- test('explicit pendingRequestIds still fail closed when guardianExternalUserId is missing', async () => {
1066
- // Voice-originated tool approval with missing guardian identity binding.
1097
+ test('explicit pendingRequestIds still fail closed when guardianPrincipalId does not match', async () => {
1098
+ // Voice-originated tool approval with a different principal than the actor.
1067
1099
  const req = createCanonicalGuardianRequest({
1068
1100
  kind: 'tool_approval',
1069
1101
  sourceType: 'voice',
@@ -1071,8 +1103,8 @@ describe('routing invariant: destination hints do not bypass tool_approval ident
1071
1103
  conversationId: 'conv-voice-1',
1072
1104
  toolName: 'shell',
1073
1105
  requestCode: 'NL1234',
1106
+ guardianPrincipalId: 'request-principal',
1074
1107
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
1075
- // guardianExternalUserId intentionally omitted
1076
1108
  });
1077
1109
  registerPendingToolApprovalInteraction(req.id, 'conv-voice-1', 'shell');
1078
1110
 
@@ -1081,7 +1113,7 @@ describe('routing invariant: destination hints do not bypass tool_approval ident
1081
1113
  const result = await routeGuardianReply(replyCtx({
1082
1114
  messageText: 'approve',
1083
1115
  channel: 'telegram',
1084
- actor: guardianActor({ externalUserId: 'guardian-tg-user' }),
1116
+ actor: guardianActor({ guardianPrincipalId: 'different-principal' }),
1085
1117
  conversationId: 'conv-guardian-chat',
1086
1118
  pendingRequestIds: [req.id],
1087
1119
  approvalConversationGenerator: undefined,
@@ -1095,8 +1127,8 @@ describe('routing invariant: destination hints do not bypass tool_approval ident
1095
1127
  expect(resolved!.status).toBe('pending');
1096
1128
  });
1097
1129
 
1098
- test('without destination hints, missing guardianExternalUserId means no pending requests found', async () => {
1099
- // Voice-originated request: no guardianExternalUserId
1130
+ test('without destination hints, unbound principal means no pending requests found', async () => {
1131
+ // Voice-originated request: different principal
1100
1132
  const req = createCanonicalGuardianRequest({
1101
1133
  kind: 'tool_approval',
1102
1134
  sourceType: 'voice',
@@ -1104,6 +1136,7 @@ describe('routing invariant: destination hints do not bypass tool_approval ident
1104
1136
  conversationId: 'conv-voice-2',
1105
1137
  toolName: 'shell',
1106
1138
  requestCode: 'NL5678',
1139
+ guardianPrincipalId: 'voice-principal',
1107
1140
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
1108
1141
  });
1109
1142
 
@@ -1143,6 +1176,7 @@ describe('routing invariant: invite handoff bypass for access requests', () => {
1143
1176
  sourceChannel: 'telegram',
1144
1177
  conversationId: 'conv-access-1',
1145
1178
  guardianExternalUserId: 'guardian-1',
1179
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
1146
1180
  requestCode: 'INV001',
1147
1181
  toolName: 'ingress_access_request',
1148
1182
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -1171,6 +1205,7 @@ describe('routing invariant: invite handoff bypass for access requests', () => {
1171
1205
  sourceType: 'channel',
1172
1206
  sourceChannel: 'telegram',
1173
1207
  guardianExternalUserId: 'guardian-1',
1208
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
1174
1209
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
1175
1210
  });
1176
1211
 
@@ -1193,6 +1228,7 @@ describe('routing invariant: invite handoff bypass for access requests', () => {
1193
1228
  sourceType: 'channel',
1194
1229
  conversationId: 'conv-1',
1195
1230
  guardianExternalUserId: 'guardian-1',
1231
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
1196
1232
  requestCode: 'TAP001',
1197
1233
  toolName: 'shell',
1198
1234
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -1219,6 +1255,7 @@ describe('routing invariant: invite handoff bypass for access requests', () => {
1219
1255
  sourceChannel: 'telegram',
1220
1256
  conversationId: 'conv-access-2',
1221
1257
  guardianExternalUserId: 'guardian-1',
1258
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
1222
1259
  requestCode: 'A00B01',
1223
1260
  toolName: 'ingress_access_request',
1224
1261
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -1239,13 +1276,14 @@ describe('routing invariant: invite handoff bypass for access requests', () => {
1239
1276
  expect(resolved!.status).toBe('approved');
1240
1277
  });
1241
1278
 
1242
- test('trusted desktop access-request approval returns a verification code reply', async () => {
1279
+ test('desktop access-request approval returns a verification code reply', async () => {
1243
1280
  const req = createCanonicalGuardianRequest({
1244
1281
  kind: 'access_request',
1245
1282
  sourceType: 'channel',
1246
1283
  sourceChannel: 'telegram',
1247
1284
  conversationId: 'conv-access-desktop',
1248
1285
  guardianExternalUserId: 'guardian-1',
1286
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
1249
1287
  requestCode: 'C0D3A5',
1250
1288
  toolName: 'ingress_access_request',
1251
1289
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -1276,6 +1314,7 @@ describe('routing invariant: invite handoff bypass for access requests', () => {
1276
1314
  sourceChannel: 'telegram',
1277
1315
  conversationId: 'conv-access-desktop-nl',
1278
1316
  guardianExternalUserId: 'guardian-1',
1317
+ guardianPrincipalId: TEST_PRINCIPAL_ID,
1279
1318
  requesterExternalUserId: 'requester-1',
1280
1319
  requesterChatId: 'chat-1',
1281
1320
  requestCode: 'A1B2C3',
@@ -211,10 +211,10 @@ describe('inbound-message-handler trusted-contact interactivity', () => {
211
211
  body: JSON.stringify({
212
212
  sourceChannel: 'telegram',
213
213
  interface: 'telegram',
214
- externalChatId: 'chat-123',
214
+ conversationExternalId: 'chat-123',
215
215
  externalMessageId: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
216
216
  content: 'hello',
217
- senderExternalUserId: 'telegram-user-default',
217
+ actorExternalId: 'telegram-user-default',
218
218
  replyCallbackUrl: 'https://gateway.test/deliver/telegram',
219
219
  ...overrides,
220
220
  }),
@@ -374,8 +374,8 @@ describe('inbound-message-handler trusted-contact interactivity', () => {
374
374
 
375
375
  const req = makeInboundRequest({
376
376
  externalMessageId: `msg-unknown-${Date.now()}`,
377
- // No senderExternalUserId => unknown trust class
378
- senderExternalUserId: undefined,
377
+ // No actorExternalId => unknown trust class
378
+ actorExternalId: undefined,
379
379
  });
380
380
 
381
381
  const res = await handleChannelInbound(req, processMessage as any, 'test-token');
@@ -213,7 +213,7 @@ describe('handleUserMessage pending-confirmation reply interception', () => {
213
213
  assistantMessageChannel: 'vellum',
214
214
  userMessageInterface: 'macos',
215
215
  assistantMessageInterface: 'macos',
216
- provenanceActorRole: 'guardian',
216
+ provenanceTrustClass: 'guardian',
217
217
  }),
218
218
  );
219
219
  expect(addMessageMock).toHaveBeenCalledWith(
@@ -225,7 +225,7 @@ describe('handleUserMessage pending-confirmation reply interception', () => {
225
225
  assistantMessageChannel: 'vellum',
226
226
  userMessageInterface: 'macos',
227
227
  assistantMessageInterface: 'macos',
228
- provenanceActorRole: 'guardian',
228
+ provenanceTrustClass: 'guardian',
229
229
  }),
230
230
  );
231
231
  expect(sent.map((msg) => msg.type)).toEqual([
@@ -118,12 +118,12 @@ function buildInboundRequest(overrides: Record<string, unknown> = {}): Request {
118
118
  const body: Record<string, unknown> = {
119
119
  sourceChannel: 'telegram',
120
120
  interface: 'telegram',
121
- externalChatId: 'chat-invite-test',
121
+ conversationExternalId: 'chat-invite-test',
122
122
  externalMessageId: `msg-invite-${Date.now()}-${msgCounter}`,
123
123
  content: '/start iv_sometoken',
124
- senderExternalUserId: 'user-invite-123',
125
- senderName: 'Invite User',
126
- senderUsername: 'invite_user',
124
+ actorExternalId: 'user-invite-123',
125
+ actorDisplayName: 'Invite User',
126
+ actorUsername: 'invite_user',
127
127
  replyCallbackUrl: 'http://localhost:7830/deliver/telegram',
128
128
  sourceMetadata: {
129
129
  commandIntent: { type: 'start', payload: 'iv_sometoken' },
@@ -309,8 +309,8 @@ describe('inbound invite redemption intercept', () => {
309
309
  // Active member sends a normal message (no invite token)
310
310
  const req = buildInboundRequest({
311
311
  content: 'Hello, just a normal message!',
312
- senderExternalUserId: 'user-active-member',
313
- externalChatId: 'chat-active',
312
+ actorExternalId: 'user-active-member',
313
+ conversationExternalId: 'chat-active',
314
314
  sourceMetadata: {},
315
315
  });
316
316
  const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
@@ -353,7 +353,7 @@ describe('inbound invite redemption intercept', () => {
353
353
  });
354
354
 
355
355
  const req = buildInviteRequest(rawToken, {
356
- senderExternalUserId: 'user-already-active',
356
+ actorExternalId: 'user-already-active',
357
357
  });
358
358
  const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
359
359
  const json = await resp.json() as Record<string, unknown>;
@@ -378,7 +378,7 @@ describe('inbound invite redemption intercept', () => {
378
378
  });
379
379
 
380
380
  const req = buildInviteRequest(rawToken, {
381
- senderName: 'Noa Flaherty',
381
+ actorDisplayName: 'Noa Flaherty',
382
382
  });
383
383
  const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
384
384
  const json = await resp.json() as Record<string, unknown>;
@@ -31,16 +31,13 @@ mock.module('../util/logger.js', () => ({
31
31
  }),
32
32
  }));
33
33
 
34
- // Simulated network delay for semantic search (ms). When > 0, the mock
35
- // semantic search sleeps for this duration before returning, simulating the
36
- // Qdrant network round-trip that early termination is designed to skip.
37
- let semanticSearchDelayMs = 0;
34
+ // Counter for semantic search invocations used to verify early termination
35
+ // skips the call entirely rather than relying on flaky wall-clock comparisons.
36
+ let semanticSearchCallCount = 0;
38
37
 
39
38
  mock.module('../memory/search/semantic.js', () => ({
40
39
  semanticSearch: async () => {
41
- if (semanticSearchDelayMs > 0) {
42
- await Bun.sleep(semanticSearchDelayMs);
43
- }
40
+ semanticSearchCallCount++;
44
41
  return [];
45
42
  },
46
43
  isQdrantConnectionError: () => false,
@@ -305,15 +302,11 @@ describe('Memory retrieval benchmark', () => {
305
302
  expect(recall.selectedCount).toBeGreaterThan(0);
306
303
  });
307
304
 
308
- test('early termination is measurably faster than baseline', async () => {
309
- const conversationId = 'conv-bench-et-delta';
305
+ test('early termination skips semantic search entirely', async () => {
306
+ const conversationId = 'conv-bench-et-skip';
310
307
  const now = 1_700_500_000_000;
311
308
  seedMemoryItems(conversationId, 500, now);
312
309
 
313
- // Simulate the Qdrant network round-trip that ET is designed to skip.
314
- // Use 100ms to dominate over variable CPU-bound work on slower hosts.
315
- semanticSearchDelayMs = 100;
316
-
317
310
  const query = 'What do we know about topic-5 and keyword-3?';
318
311
 
319
312
  const etConfig: AssistantConfig = {
@@ -363,40 +356,22 @@ describe('Memory retrieval benchmark', () => {
363
356
  },
364
357
  };
365
358
 
366
- try {
367
- // Warm up to avoid cold-start bias
368
- await buildMemoryRecall(query, conversationId, etConfig);
369
- await buildMemoryRecall(query, conversationId, noEtConfig);
370
-
371
- const iterations = 5;
372
- const etTimes: number[] = [];
373
- const baselineTimes: number[] = [];
374
-
375
- for (let i = 0; i < iterations; i++) {
376
- const t0 = performance.now();
377
- const etRecall = await buildMemoryRecall(query, conversationId, etConfig);
378
- etTimes.push(performance.now() - t0);
379
- expect(etRecall.earlyTerminated).toBe(true);
380
-
381
- const t1 = performance.now();
382
- const baselineRecall = await buildMemoryRecall(query, conversationId, noEtConfig);
383
- baselineTimes.push(performance.now() - t1);
384
- expect(baselineRecall.earlyTerminated).toBe(false);
385
- }
386
-
387
- etTimes.sort((a, b) => a - b);
388
- baselineTimes.sort((a, b) => a - b);
389
- const medianEt = etTimes[Math.floor(iterations / 2)];
390
- const medianBaseline = baselineTimes[Math.floor(iterations / 2)];
391
-
392
- // ET skips the mocked network delay, so it should be measurably faster.
393
- // Use a 15% threshold to tolerate slower CI hosts where CPU-bound work
394
- // takes longer relative to the fixed mock delay.
395
- const speedup = 1 - medianEt / medianBaseline;
396
- expect(speedup).toBeGreaterThanOrEqual(0.15);
397
- } finally {
398
- semanticSearchDelayMs = 0;
399
- }
359
+ // Run with ET enabled — semantic search should be skipped
360
+ semanticSearchCallCount = 0;
361
+ const etRecall = await buildMemoryRecall(query, conversationId, etConfig);
362
+ const etCalls = semanticSearchCallCount;
363
+
364
+ expect(etRecall.earlyTerminated).toBe(true);
365
+ expect(etRecall.semanticHits).toBe(0);
366
+ expect(etCalls).toBe(0);
367
+
368
+ // Run without ET semantic search should be invoked
369
+ semanticSearchCallCount = 0;
370
+ const baselineRecall = await buildMemoryRecall(query, conversationId, noEtConfig);
371
+ const baselineCalls = semanticSearchCallCount;
372
+
373
+ expect(baselineRecall.earlyTerminated).toBe(false);
374
+ expect(baselineCalls).toBeGreaterThan(0);
400
375
  });
401
376
 
402
377
  test('recall.latencyMs tracks wall-clock within 50% tolerance', async () => {