@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
@@ -22,6 +22,27 @@ mock.module('../util/logger.js', () => ({
22
22
  new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
23
23
  }));
24
24
 
25
+ // Mock config loader and feature flags to avoid filesystem reads on CI.
26
+ // getConfig returns a minimal config; all feature flags default to enabled.
27
+ mock.module('../config/loader.js', () => ({
28
+ getConfig: () => ({}),
29
+ loadConfig: () => ({}),
30
+ applyNestedDefaults: (c: unknown) => c,
31
+ saveConfig: () => {},
32
+ invalidateConfigCache: () => {},
33
+ loadRawConfig: () => ({}),
34
+ saveRawConfig: () => {},
35
+ getNestedValue: () => undefined,
36
+ setNestedValue: () => {},
37
+ API_KEY_PROVIDERS: [],
38
+ }));
39
+
40
+ mock.module('../config/assistant-feature-flags.js', () => ({
41
+ isAssistantFeatureFlagEnabled: () => true,
42
+ loadDefaultsRegistry: () => ({}),
43
+ getFeatureFlagDefault: () => true,
44
+ }));
45
+
25
46
  // Skill catalog: returns a configurable list of fake skills
26
47
  let catalogSkillIds: string[] = [];
27
48
  mock.module('../config/skills.js', () => ({
@@ -35,6 +56,21 @@ mock.module('../config/skills.js', () => ({
35
56
  bundled: false,
36
57
  userInvocable: false,
37
58
  })),
59
+ // Needed by transitive deps (skill-state.ts imports this)
60
+ checkSkillRequirements: () => ({ eligible: true, missing: {} }),
61
+ getSkillsDir: () => '/tmp/fake-skills',
62
+ getBundledSkillsDir: () => '/tmp/fake-bundled-skills',
63
+ resolveSkillSelector: () => ({ found: false }),
64
+ loadSkillBySelector: () => ({ found: false }),
65
+ readCachedSkillIcon: () => undefined,
66
+ }));
67
+
68
+ // Mock skill-state.js to break the transitive import chain — the benchmark
69
+ // only needs skillFlagKey and doesn't exercise resolveSkillStates.
70
+ mock.module('../config/skill-state.js', () => ({
71
+ skillFlagKey: (id: string) => `feature_flags.${id}.enabled`,
72
+ isSkillFeatureEnabled: () => true,
73
+ resolveSkillStates: () => [],
38
74
  }));
39
75
 
40
76
  mock.module('../skills/tool-manifest.js', () => ({
@@ -91,9 +127,37 @@ mock.module('../tools/skills/skill-tool-factory.js', () => ({
91
127
  })),
92
128
  }));
93
129
 
94
- // existsSync mock — TOOLS.json always exists for fake skills
130
+ // node:fs mock — TOOLS.json always exists for fake skills.
131
+ // Must include ALL named exports that any transitive dep imports, otherwise
132
+ // Bun's module resolver throws "Export named '…' not found".
133
+ const statStub = () => ({ isSymbolicLink: () => false, isDirectory: () => false, isFile: () => true });
95
134
  mock.module('node:fs', () => ({
96
- existsSync: () => true,
135
+ existsSync: (p: string) => typeof p === 'string' && p.endsWith('TOOLS.json'),
136
+ readFileSync: () => '',
137
+ lstatSync: statStub,
138
+ readdirSync: () => [],
139
+ realpathSync: (p: string) => p,
140
+ statSync: statStub,
141
+ statfsSync: statStub,
142
+ mkdirSync: () => undefined,
143
+ mkdtempSync: () => '/tmp/mock',
144
+ renameSync: () => undefined,
145
+ rmSync: () => undefined,
146
+ unlinkSync: () => undefined,
147
+ writeFileSync: () => undefined,
148
+ appendFileSync: () => undefined,
149
+ copyFileSync: () => undefined,
150
+ cpSync: () => undefined,
151
+ chmodSync: () => undefined,
152
+ symlinkSync: () => undefined,
153
+ utimesSync: () => undefined,
154
+ openSync: () => 0,
155
+ closeSync: () => undefined,
156
+ readSync: () => 0,
157
+ createWriteStream: () => ({ write: () => true, end: () => {}, on: () => {} }),
158
+ watch: () => ({ close: () => {} }),
159
+ Dirent: class {},
160
+ Stats: class {},
97
161
  }));
98
162
 
99
163
  const benchmarkRegistry = new Map<string, unknown>();
@@ -57,6 +57,7 @@ mock.module('../config/loader.js', () => ({
57
57
 
58
58
  mock.module('../config/user-reference.js', () => ({
59
59
  resolveUserReference: () => 'John',
60
+ resolveUserPronouns: () => null,
60
61
  }));
61
62
 
62
63
  // Import after mock
@@ -286,7 +286,7 @@ describe('ToolApprovalHandler / pre-exec gate grant check', () => {
286
286
  expect(result.allowed).toBe(true);
287
287
  });
288
288
 
289
- test('undefined actor role (desktop/trusted) bypasses grant check', async () => {
289
+ test('undefined actor role (desktop) bypasses grant check', async () => {
290
290
  const toolName = 'bash';
291
291
  const input = { command: 'deploy' };
292
292
 
@@ -162,7 +162,7 @@ function guardianActor(overrides: Partial<ActorContext> = {}): ActorContext {
162
162
  return {
163
163
  externalUserId: 'guardian-1',
164
164
  channel: 'telegram',
165
- isTrusted: false,
165
+ guardianPrincipalId: 'test-principal-id',
166
166
  ...overrides,
167
167
  };
168
168
  }
@@ -361,6 +361,7 @@ describe('applyCanonicalGuardianDecision / tool_grant_request', () => {
361
361
  conversationId: 'conv-1',
362
362
  requesterExternalUserId: 'requester-1',
363
363
  guardianExternalUserId: 'guardian-1',
364
+ guardianPrincipalId: 'test-principal-id',
364
365
  toolName: 'bash',
365
366
  inputDigest: 'sha256:testdigest',
366
367
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -390,6 +391,7 @@ describe('applyCanonicalGuardianDecision / tool_grant_request', () => {
390
391
  conversationId: 'conv-1',
391
392
  requesterExternalUserId: 'requester-1',
392
393
  guardianExternalUserId: 'guardian-1',
394
+ guardianPrincipalId: 'test-principal-id',
393
395
  toolName: 'bash',
394
396
  inputDigest: 'sha256:testdigest',
395
397
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -417,6 +419,7 @@ describe('applyCanonicalGuardianDecision / tool_grant_request', () => {
417
419
  conversationId: 'conv-1',
418
420
  requesterExternalUserId: 'requester-1',
419
421
  guardianExternalUserId: 'guardian-1',
422
+ guardianPrincipalId: 'test-principal-id',
420
423
  toolName: 'bash',
421
424
  inputDigest: 'sha256:testdigest',
422
425
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -425,7 +428,7 @@ describe('applyCanonicalGuardianDecision / tool_grant_request', () => {
425
428
  const result = await applyCanonicalGuardianDecision({
426
429
  requestId: req.id,
427
430
  action: 'approve_once',
428
- actorContext: guardianActor({ externalUserId: 'imposter-99' }),
431
+ actorContext: guardianActor({ guardianPrincipalId: 'imposter-principal' }),
429
432
  });
430
433
 
431
434
  expect(result.applied).toBe(false);
@@ -564,6 +567,7 @@ describe('inline wait-and-resume', () => {
564
567
  conversationId: 'conv-1',
565
568
  requesterExternalUserId: 'requester-1',
566
569
  guardianExternalUserId: 'guardian-1',
570
+ guardianPrincipalId: 'test-principal-id',
567
571
  toolName: 'bash',
568
572
  inputDigest: 'sha256:waitgrant',
569
573
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -606,6 +610,7 @@ describe('inline wait-and-resume', () => {
606
610
  conversationId: 'conv-1',
607
611
  requesterExternalUserId: 'requester-1',
608
612
  guardianExternalUserId: 'guardian-1',
613
+ guardianPrincipalId: 'test-principal-id',
609
614
  toolName: 'bash',
610
615
  inputDigest: 'sha256:denywait',
611
616
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -645,6 +650,7 @@ describe('inline wait-and-resume', () => {
645
650
  conversationId: 'conv-1',
646
651
  requesterExternalUserId: 'requester-1',
647
652
  guardianExternalUserId: 'guardian-1',
653
+ guardianPrincipalId: 'test-principal-id',
648
654
  toolName: 'bash',
649
655
  inputDigest: 'sha256:timeoutwait',
650
656
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -679,6 +685,7 @@ describe('inline wait-and-resume', () => {
679
685
  conversationId: 'conv-1',
680
686
  requesterExternalUserId: 'requester-1',
681
687
  guardianExternalUserId: 'guardian-1',
688
+ guardianPrincipalId: 'test-principal-id',
682
689
  toolName: 'bash',
683
690
  inputDigest: 'sha256:abortwait',
684
691
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -221,7 +221,7 @@ function guardianActor(overrides: Partial<ActorContext> = {}): ActorContext {
221
221
  return {
222
222
  externalUserId: 'guardian-1',
223
223
  channel: 'telegram',
224
- isTrusted: false,
224
+ guardianPrincipalId: 'test-principal-id',
225
225
  ...overrides,
226
226
  };
227
227
  }
@@ -381,6 +381,7 @@ describe('(b) prompt-path flow: confirmation_request bridges to guardian', () =>
381
381
  conversationId: 'conv-bridge-1',
382
382
  requesterExternalUserId: 'requester-1',
383
383
  guardianExternalUserId: 'guardian-1',
384
+ guardianPrincipalId: 'test-principal-id',
384
385
  toolName: 'bash',
385
386
  status: 'pending',
386
387
  expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
@@ -419,6 +420,7 @@ describe('(b) prompt-path flow: confirmation_request bridges to guardian', () =>
419
420
  conversationId: 'conv-unified-1',
420
421
  requesterExternalUserId: 'requester-1',
421
422
  guardianExternalUserId: 'guardian-1',
423
+ guardianPrincipalId: 'test-principal-id',
422
424
  toolName: 'bash',
423
425
  status: 'pending',
424
426
  expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
@@ -508,6 +510,7 @@ describe('(c) no-binding flow: trusted contact fails fast without guardian bindi
508
510
  conversationId: 'conv-nobinding',
509
511
  requesterExternalUserId: 'requester-1',
510
512
  guardianExternalUserId: 'guardian-1',
513
+ guardianPrincipalId: 'test-principal-id',
511
514
  toolName: 'bash',
512
515
  status: 'pending',
513
516
  expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
@@ -607,6 +610,7 @@ describe('(d) unknown actor flow: fail-closed with no interactive approval', ()
607
610
  conversationId: 'conv-unknown',
608
611
  requesterExternalUserId: 'unknown-user',
609
612
  guardianExternalUserId: 'guardian-1',
613
+ guardianPrincipalId: 'test-principal-id',
610
614
  toolName: 'bash',
611
615
  status: 'pending',
612
616
  expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
@@ -794,6 +798,7 @@ describe('(f) timeout/stale flow: stale guardian decision after inline wait time
794
798
  requesterExternalUserId: 'requester-1',
795
799
  requesterChatId: 'requester-chat-1',
796
800
  guardianExternalUserId: 'guardian-1',
801
+ guardianPrincipalId: 'test-principal-id',
797
802
  toolName: 'bash',
798
803
  inputDigest: 'sha256:stale',
799
804
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -842,6 +847,7 @@ describe('(f) timeout/stale flow: stale guardian decision after inline wait time
842
847
  requesterExternalUserId: 'requester-1',
843
848
  requesterChatId: 'requester-chat-1',
844
849
  guardianExternalUserId: 'guardian-1',
850
+ guardianPrincipalId: 'test-principal-id',
845
851
  toolName: 'bash',
846
852
  inputDigest: 'sha256:fresh',
847
853
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
@@ -974,6 +980,7 @@ describe('cross-milestone integration checks', () => {
974
980
  conversationId: 'conv-consistency',
975
981
  requesterExternalUserId: 'requester-1',
976
982
  guardianExternalUserId: 'guardian-1',
983
+ guardianPrincipalId: 'test-principal-id',
977
984
  toolName: 'bash',
978
985
  status: 'pending',
979
986
  expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
@@ -125,12 +125,12 @@ function buildInboundRequest(overrides: Record<string, unknown> = {}): Request {
125
125
  const body: Record<string, unknown> = {
126
126
  sourceChannel: 'telegram',
127
127
  interface: 'telegram',
128
- externalChatId: 'chat-123',
128
+ conversationExternalId: 'chat-123',
129
129
  externalMessageId: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
130
130
  content: 'Hello',
131
- senderExternalUserId: 'requester-user-456',
132
- senderName: 'Alice Requester',
133
- senderUsername: 'alice_req',
131
+ actorExternalId: 'requester-user-456',
132
+ actorDisplayName: 'Alice Requester',
133
+ actorUsername: 'alice_req',
134
134
  replyCallbackUrl: 'http://localhost:7830/deliver/telegram',
135
135
  ...overrides,
136
136
  };
@@ -192,9 +192,9 @@ describe('trusted contact lifecycle notification signals', () => {
192
192
 
193
193
  // Guardian denies via callback button
194
194
  const guardianReq = buildInboundRequest({
195
- externalChatId: 'guardian-chat-789',
196
- senderExternalUserId: 'guardian-user-789',
197
- senderName: 'Guardian',
195
+ conversationExternalId: 'guardian-chat-789',
196
+ actorExternalId: 'guardian-user-789',
197
+ actorDisplayName: 'Guardian',
198
198
  content: '',
199
199
  callbackData: `apr:${testRequestId}:reject`,
200
200
  });
@@ -267,9 +267,9 @@ describe('trusted contact lifecycle notification signals', () => {
267
267
 
268
268
  // Guardian approves via callback button
269
269
  const guardianReq = buildInboundRequest({
270
- externalChatId: 'guardian-chat-789',
271
- senderExternalUserId: 'guardian-user-789',
272
- senderName: 'Guardian',
270
+ conversationExternalId: 'guardian-chat-789',
271
+ actorExternalId: 'guardian-user-789',
272
+ actorDisplayName: 'Guardian',
273
273
  content: '',
274
274
  callbackData: `apr:${testRequestId}:approve_once`,
275
275
  });
@@ -340,9 +340,9 @@ describe('trusted contact lifecycle notification signals', () => {
340
340
 
341
341
  // All guardian_decision signals include the approval ID in the dedupe key
342
342
  const guardianReq = buildInboundRequest({
343
- externalChatId: 'guardian-chat-789',
344
- senderExternalUserId: 'guardian-user-789',
345
- senderName: 'Guardian',
343
+ conversationExternalId: 'guardian-chat-789',
344
+ actorExternalId: 'guardian-user-789',
345
+ actorDisplayName: 'Guardian',
346
346
  content: '',
347
347
  callbackData: `apr:${testRequestId}:reject`,
348
348
  });
@@ -389,8 +389,8 @@ describe('trusted contact activated notification signal', () => {
389
389
  // Requester enters the verification code
390
390
  const verifyReq = buildInboundRequest({
391
391
  content: session.secret,
392
- externalChatId: 'chat-123',
393
- senderExternalUserId: 'requester-user-456',
392
+ conversationExternalId: 'chat-123',
393
+ actorExternalId: 'requester-user-456',
394
394
  });
395
395
 
396
396
  await handleChannelInbound(verifyReq, undefined, TEST_BEARER_TOKEN);
@@ -449,9 +449,9 @@ describe('trusted contact activated notification signal', () => {
449
449
 
450
450
  const verifyReq = buildInboundRequest({
451
451
  content: session.secret,
452
- externalChatId: 'chat-123',
453
- senderExternalUserId: 'requester-user-456',
454
- senderName: 'Noa Flaherty',
452
+ conversationExternalId: 'chat-123',
453
+ actorExternalId: 'requester-user-456',
454
+ actorDisplayName: 'Noa Flaherty',
455
455
  });
456
456
 
457
457
  await handleChannelInbound(verifyReq, undefined, TEST_BEARER_TOKEN);
@@ -476,8 +476,8 @@ describe('trusted contact activated notification signal', () => {
476
476
  // "Guardian" enters the verification code
477
477
  const verifyReq = buildInboundRequest({
478
478
  content: secret,
479
- externalChatId: 'guardian-chat-new',
480
- senderExternalUserId: 'guardian-user-new',
479
+ conversationExternalId: 'guardian-chat-new',
480
+ actorExternalId: 'guardian-user-new',
481
481
  });
482
482
 
483
483
  await handleChannelInbound(verifyReq, undefined, TEST_BEARER_TOKEN);
@@ -520,8 +520,8 @@ describe('trusted contact activated notification signal', () => {
520
520
 
521
521
  const verifyReq = buildInboundRequest({
522
522
  content: session.secret,
523
- externalChatId: 'chat-123',
524
- senderExternalUserId: 'requester-user-456',
523
+ conversationExternalId: 'chat-123',
524
+ actorExternalId: 'requester-user-456',
525
525
  });
526
526
 
527
527
  await handleChannelInbound(verifyReq, undefined, TEST_BEARER_TOKEN);
@@ -160,12 +160,12 @@ function buildInboundRequest(
160
160
  const body: Record<string, unknown> = {
161
161
  sourceChannel: config.channel,
162
162
  interface: config.channel,
163
- externalChatId: config.externalChatId,
163
+ conversationExternalId: config.externalChatId,
164
164
  externalMessageId: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
165
165
  content: 'Hello, can I use this assistant?',
166
- senderExternalUserId: config.senderExternalUserId,
167
- senderName: 'Test Requester',
168
- senderUsername: 'test_requester',
166
+ actorExternalId: config.senderExternalUserId,
167
+ actorDisplayName: 'Test Requester',
168
+ actorUsername: 'test_requester',
169
169
  replyCallbackUrl: `http://localhost:7830${config.deliverEndpoint}`,
170
170
  ...overrides,
171
171
  };
@@ -158,8 +158,8 @@ describe('trusted contact verification → member activation', () => {
158
158
  const trust = resolveActorTrust({
159
159
  assistantId: 'self',
160
160
  sourceChannel: 'telegram',
161
- externalChatId: 'requester-chat-jeff',
162
- senderExternalUserId: 'requester-user-jeff',
161
+ conversationExternalId: 'requester-chat-jeff',
162
+ actorExternalId: 'requester-user-jeff',
163
163
  });
164
164
 
165
165
  expect(trust.trustClass).toBe('trusted_contact');
@@ -184,10 +184,10 @@ describe('trusted contact verification → member activation', () => {
184
184
  const trust = resolveActorTrust({
185
185
  assistantId: 'self',
186
186
  sourceChannel: 'telegram',
187
- externalChatId: 'requester-chat-jeff-priority',
188
- senderExternalUserId: 'requester-user-jeff-priority',
189
- senderUsername: 'jeffrey_telegram',
190
- senderDisplayName: 'Jeffrey',
187
+ conversationExternalId: 'requester-chat-jeff-priority',
188
+ actorExternalId: 'requester-user-jeff-priority',
189
+ actorUsername: 'jeffrey_telegram',
190
+ actorDisplayName: 'Jeffrey',
191
191
  });
192
192
 
193
193
  expect(trust.trustClass).toBe('trusted_contact');
@@ -216,10 +216,10 @@ describe('trusted contact verification → member activation', () => {
216
216
  const trust = resolveActorTrust({
217
217
  assistantId: 'self',
218
218
  sourceChannel: 'telegram',
219
- externalChatId: 'shared-group-chat',
220
- senderExternalUserId: 'actual-sender-in-group',
221
- senderUsername: 'actual_sender_handle',
222
- senderDisplayName: 'Actual Sender',
219
+ conversationExternalId: 'shared-group-chat',
220
+ actorExternalId: 'actual-sender-in-group',
221
+ actorUsername: 'actual_sender_handle',
222
+ actorDisplayName: 'Actual Sender',
223
223
  });
224
224
 
225
225
  // The member record returned by findMember matched on chatId but belongs
@@ -18,7 +18,8 @@
18
18
  * 9. Kind-specific resolver dispatch via the resolver registry
19
19
  *
20
20
  * Security invariants enforced here:
21
- * - Decision application is identity-bound to expected guardian identity
21
+ * - Decision authorization is purely principal-based:
22
+ * actor.guardianPrincipalId === request.guardianPrincipalId (strict equality)
22
23
  * - Decisions are first-response-wins (CAS-like stale protection)
23
24
  * - `approve_always` is rejected/downgraded for guardian-on-behalf requests
24
25
  * - Scoped grant minting only on explicit approve for requests with tool metadata
@@ -352,44 +353,46 @@ export async function applyCanonicalGuardianDecision(
352
353
  return { applied: false, reason: 'invalid_action', detail: `invalid action: ${action}` };
353
354
  }
354
355
 
355
- // 2c. Validate identity: actor must match guardian_external_user_id
356
- // unless the actor is trusted (desktop).
357
- //
358
- // Channel tool-approval requests must always be identity-bound. Treat
359
- // missing guardianExternalUserId as unauthorized (fail-closed) so a
360
- // non-guardian actor can never approve an unbound request.
361
- if (
362
- !actorContext.isTrusted &&
363
- request.kind === 'tool_approval' &&
364
- !request.guardianExternalUserId
365
- ) {
356
+ // 2c. Principal-based authorization: actor.guardianPrincipalId must match
357
+ // request.guardianPrincipalId for any applied decision. This is the single
358
+ // authorization gate — principal identity must always match.
359
+
360
+ if (!request.guardianPrincipalId) {
366
361
  log.warn(
367
362
  {
368
- event: 'canonical_decision_missing_guardian_binding',
363
+ event: 'canonical_decision_missing_request_principal',
369
364
  requestId,
370
365
  kind: request.kind,
371
366
  sourceType: request.sourceType,
372
367
  },
373
- 'Canonical tool approval missing guardian binding; rejecting decision',
368
+ 'Canonical request missing guardianPrincipalId; rejecting decision',
369
+ );
370
+ return { applied: false, reason: 'identity_mismatch', detail: 'request missing guardianPrincipalId' };
371
+ }
372
+
373
+ if (!actorContext.guardianPrincipalId) {
374
+ log.warn(
375
+ {
376
+ event: 'canonical_decision_missing_actor_principal',
377
+ requestId,
378
+ actorChannel: actorContext.channel,
379
+ },
380
+ 'Actor missing guardianPrincipalId; rejecting decision',
374
381
  );
375
- return { applied: false, reason: 'identity_mismatch', detail: 'missing guardian binding' };
382
+ return { applied: false, reason: 'identity_mismatch', detail: 'actor missing guardianPrincipalId' };
376
383
  }
377
384
 
378
- if (
379
- request.guardianExternalUserId &&
380
- !actorContext.isTrusted &&
381
- actorContext.externalUserId !== request.guardianExternalUserId
382
- ) {
385
+ if (actorContext.guardianPrincipalId !== request.guardianPrincipalId) {
383
386
  log.warn(
384
387
  {
385
- event: 'canonical_decision_identity_mismatch',
388
+ event: 'canonical_decision_principal_mismatch',
386
389
  requestId,
387
- expectedGuardian: request.guardianExternalUserId,
388
- actualActor: actorContext.externalUserId,
390
+ expectedPrincipal: request.guardianPrincipalId,
391
+ actualPrincipal: actorContext.guardianPrincipalId,
389
392
  },
390
- 'Actor identity does not match expected guardian',
393
+ 'Actor principal does not match request principal',
391
394
  );
392
- return { applied: false, reason: 'identity_mismatch' };
395
+ return { applied: false, reason: 'identity_mismatch', detail: 'principal mismatch' };
393
396
  }
394
397
 
395
398
  // 2d. Check expiry
@@ -416,6 +419,7 @@ export async function applyCanonicalGuardianDecision(
416
419
  status: targetStatus,
417
420
  answerText: userText,
418
421
  decidedByExternalUserId: actorContext.externalUserId,
422
+ decidedByPrincipalId: actorContext.guardianPrincipalId,
419
423
  });
420
424
 
421
425
  if (!resolved) {
@@ -39,12 +39,12 @@ const log = getLogger('guardian-request-resolvers');
39
39
 
40
40
  /** Actor context for the entity making the decision. */
41
41
  export interface ActorContext {
42
- /** External user ID of the deciding actor (undefined for desktop/trusted). */
42
+ /** External user ID of the deciding actor (undefined for desktop actors). */
43
43
  externalUserId: string | undefined;
44
44
  /** Channel the decision arrived on. */
45
45
  channel: string;
46
- /** Whether the actor is a trusted/desktop context. */
47
- isTrusted: boolean;
46
+ /** Principal ID for authorization must match the request's guardianPrincipalId. */
47
+ guardianPrincipalId: string | undefined;
48
48
  }
49
49
 
50
50
  /** The decision being applied. */
@@ -385,7 +385,9 @@ const accessRequestResolver: GuardianRequestResolver = {
385
385
  return {
386
386
  ok: true,
387
387
  applied: true,
388
- ...(ctx.actor.isTrusted ? { guardianReplyText: `Access denied for ${requesterLabel}.` } : {}),
388
+ // Desktop actors (vellum channel) receive inline reply text; channel
389
+ // actors get replies delivered via the channel delivery context.
390
+ ...(ctx.actor.channel === 'vellum' ? { guardianReplyText: `Access denied for ${requesterLabel}.` } : {}),
389
391
  };
390
392
  }
391
393
 
@@ -539,7 +541,9 @@ const accessRequestResolver: GuardianRequestResolver = {
539
541
  return {
540
542
  ok: true,
541
543
  applied: true,
542
- ...(ctx.actor.isTrusted ? { guardianReplyText: verificationReplyText } : {}),
544
+ // Desktop actors (vellum channel) receive inline reply text; channel
545
+ // actors get replies delivered via the channel delivery context.
546
+ ...(ctx.actor.channel === 'vellum' ? { guardianReplyText: verificationReplyText } : {}),
543
547
  };
544
548
  },
545
549
  };