@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
@@ -197,19 +197,26 @@ function findPendingCanonicalRequests(
197
197
  });
198
198
  }
199
199
 
200
- // For desktop/trusted actors without an externalUserId, query by
201
- // conversationId so the NL path can discover pending requests.
200
+ // Actors without an externalUserId: scope by conversationId so the NL
201
+ // path can discover pending requests bound to this conversation.
202
+ // Include guardianPrincipalId filter when available so the guardian only
203
+ // sees requests they are authorized to act on.
202
204
  if (conversationId) {
203
205
  return listCanonicalGuardianRequests({
204
206
  status: 'pending',
205
207
  conversationId,
208
+ ...(actor.guardianPrincipalId ? { guardianPrincipalId: actor.guardianPrincipalId } : {}),
206
209
  });
207
210
  }
208
211
 
209
- // Trusted actors without a conversationId: return all pending requests
210
- // so desktop sessions can always discover pending guardian work.
211
- if (actor.isTrusted) {
212
- return listCanonicalGuardianRequests({ status: 'pending' });
212
+ // Actors with a guardianPrincipalId but no externalUserId or
213
+ // conversationId: query by principal so desktop sessions can still
214
+ // discover pending guardian work via their bound principal.
215
+ if (actor.guardianPrincipalId) {
216
+ return listCanonicalGuardianRequests({
217
+ status: 'pending',
218
+ guardianPrincipalId: actor.guardianPrincipalId,
219
+ });
213
220
  }
214
221
 
215
222
  return [];
@@ -301,22 +308,30 @@ export async function routeGuardianReply(
301
308
  // silently defaulting to approve_once.
302
309
  if (!codeResult.remainingText || codeResult.remainingText.trim().length === 0) {
303
310
  // Identity check: only expose request details to the assigned guardian
304
- // or trusted (desktop) actors. Mirrors the identity check in
305
- // applyCanonicalGuardianDecision to prevent leaking request details
311
+ // principal. Strict principal equality prevents leaking request details
306
312
  // (toolName, questionText) to unauthorized senders.
313
+ if (!actor.guardianPrincipalId) {
314
+ return {
315
+ decisionApplied: false,
316
+ consumed: true,
317
+ type: 'code_only_clarification',
318
+ requestId: request.id,
319
+ replyText: 'Request not found.',
320
+ };
321
+ }
322
+
307
323
  if (
308
- request.guardianExternalUserId &&
309
- !actor.isTrusted &&
310
- actor.externalUserId !== request.guardianExternalUserId
324
+ request.guardianPrincipalId &&
325
+ actor.guardianPrincipalId !== request.guardianPrincipalId
311
326
  ) {
312
327
  log.warn(
313
328
  {
314
- event: 'router_code_only_identity_mismatch',
329
+ event: 'router_code_only_principal_mismatch',
315
330
  requestId: request.id,
316
- expectedGuardian: request.guardianExternalUserId,
317
- actualActor: actor.externalUserId,
331
+ expectedPrincipal: request.guardianPrincipalId,
332
+ actualPrincipal: actor.guardianPrincipalId,
318
333
  },
319
- 'Code-only clarification blocked: actor identity does not match expected guardian',
334
+ 'Code-only clarification blocked: actor principal does not match request principal',
320
335
  );
321
336
  return {
322
337
  decisionApplied: false,
@@ -492,7 +507,9 @@ export async function routeGuardianReply(
492
507
  // Attach the engine's reply text for stale/expired/identity-mismatch cases,
493
508
  // but preserve resolver-authored replies (for example verification codes)
494
509
  // and explicit resolver-failure text.
495
- const hasResolverReplyText = Boolean(result.canonicalResult?.applied && result.canonicalResult.resolverReplyText);
510
+ const hasResolverReplyText = Boolean(
511
+ result.canonicalResult?.applied && result.canonicalResult.resolverReplyText,
512
+ );
496
513
  if (engineResult.replyText && result.type !== 'canonical_resolver_failed' && !hasResolverReplyText) {
497
514
  result.replyText = engineResult.replyText;
498
515
  }
@@ -5,24 +5,30 @@
5
5
  * 'vellum' channel with a guardianPrincipalId. This is required for
6
6
  * the identity-bound hatch bootstrap flow.
7
7
  *
8
- * - If a vellum binding already exists, no-op.
8
+ * - If a vellum binding already exists with a guardianPrincipalId, no-op.
9
+ * - If a vellum binding exists but lacks guardianPrincipalId, backfill it
10
+ * from the binding's guardianExternalUserId.
9
11
  * - If no vellum binding exists, creates one with a fresh principal.
10
12
  * - Preserves existing guardian bindings for other channels unchanged.
11
13
  */
12
14
 
15
+ import { eq } from 'drizzle-orm';
13
16
  import { v4 as uuid } from 'uuid';
14
17
 
18
+ import { getDb } from '../memory/db.js';
15
19
  import {
16
20
  createBinding,
17
21
  getActiveBinding,
18
22
  } from '../memory/guardian-bindings.js';
23
+ import { channelGuardianBindings } from '../memory/schema.js';
19
24
  import { getLogger } from '../util/logger.js';
20
25
  import { DAEMON_INTERNAL_ASSISTANT_ID } from './assistant-scope.js';
21
26
 
22
27
  const log = getLogger('guardian-vellum-migration');
23
28
 
24
29
  /**
25
- * Ensure a vellum guardian binding exists for the given assistant.
30
+ * Ensure a vellum guardian binding exists for the given assistant,
31
+ * with a populated guardianPrincipalId.
26
32
  * Called during daemon startup to backfill existing installations.
27
33
  *
28
34
  * Returns the guardianPrincipalId (existing or newly created).
@@ -30,11 +36,28 @@ const log = getLogger('guardian-vellum-migration');
30
36
  export function ensureVellumGuardianBinding(assistantId: string = DAEMON_INTERNAL_ASSISTANT_ID): string {
31
37
  const existing = getActiveBinding(assistantId, 'vellum');
32
38
  if (existing) {
39
+ // If the binding exists but is missing guardianPrincipalId, backfill it
40
+ // from the binding's guardianExternalUserId (the canonical identity).
41
+ if (!existing.guardianPrincipalId) {
42
+ const principalId = existing.guardianExternalUserId;
43
+ const db = getDb();
44
+ db.update(channelGuardianBindings)
45
+ .set({ guardianPrincipalId: principalId, updatedAt: Date.now() })
46
+ .where(eq(channelGuardianBindings.id, existing.id))
47
+ .run();
48
+
49
+ log.info(
50
+ { assistantId, guardianPrincipalId: principalId },
51
+ 'Backfilled guardianPrincipalId on existing vellum binding',
52
+ );
53
+ return principalId;
54
+ }
55
+
33
56
  log.debug(
34
- { assistantId, guardianPrincipalId: existing.guardianExternalUserId },
35
- 'Vellum guardian binding already exists',
57
+ { assistantId, guardianPrincipalId: existing.guardianPrincipalId },
58
+ 'Vellum guardian binding already exists with principal',
36
59
  );
37
- return existing.guardianExternalUserId;
60
+ return existing.guardianPrincipalId;
38
61
  }
39
62
 
40
63
  const guardianPrincipalId = `vellum-principal-${uuid()}`;
@@ -44,6 +67,7 @@ export function ensureVellumGuardianBinding(assistantId: string = DAEMON_INTERNA
44
67
  channel: 'vellum',
45
68
  guardianExternalUserId: guardianPrincipalId,
46
69
  guardianDeliveryChatId: 'local',
70
+ guardianPrincipalId,
47
71
  verifiedVia: 'startup-migration',
48
72
  metadataJson: JSON.stringify({ migratedAt: Date.now() }),
49
73
  });
@@ -1,10 +1,6 @@
1
1
  /**
2
2
  * Shared types for the runtime HTTP server and its route handlers.
3
3
  */
4
- import type {
5
- CallPointerMessageContext,
6
- ComposeCallPointerMessageOptions,
7
- } from '../calls/call-pointer-message-composer.js';
8
4
  import type { ChannelId, InterfaceId } from '../channels/types.js';
9
5
  import type { Session } from '../daemon/session.js';
10
6
  import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
@@ -59,15 +55,6 @@ export type ApprovalConversationGenerator = (
59
55
  context: ApprovalConversationContext,
60
56
  ) => Promise<ApprovalConversationResult>;
61
57
 
62
- /**
63
- * Daemon-injected function that generates call pointer copy using a provider.
64
- * Returns generated text or `null` on failure (caller falls back to deterministic text).
65
- */
66
- export type PointerCopyGenerator = (
67
- context: CallPointerMessageContext,
68
- options?: ComposeCallPointerMessageOptions,
69
- ) => Promise<string | null>;
70
-
71
58
  /**
72
59
  * Daemon-injected function that generates guardian action copy using a provider.
73
60
  * Returns generated text or `null` on failure (caller falls back to deterministic text).
@@ -20,6 +20,7 @@ import {
20
20
  resolveGuardianContext,
21
21
  toGuardianRuntimeContext,
22
22
  } from './guardian-context-resolver.js';
23
+ import { ensureVellumGuardianBinding } from './guardian-vellum-migration.js';
23
24
 
24
25
  const log = getLogger('local-actor-identity');
25
26
 
@@ -42,17 +43,22 @@ export function resolveLocalIpcGuardianContext(
42
43
  const binding = getActiveBinding(assistantId, 'vellum');
43
44
 
44
45
  if (!binding) {
45
- // No vellum binding yet (pre-bootstrap). The local user is
46
- // inherently the guardian of their own machine, so produce a
47
- // guardian context without a binding match. The trust resolver
48
- // would classify this as 'unknown' due to no_binding, but for
49
- // the local IPC case that is incorrect -- the local macOS user
50
- // is always the guardian.
51
- log.debug('No vellum guardian binding found; using fallback guardian context for IPC');
52
- return {
53
- sourceChannel,
54
- trustClass: 'guardian',
55
- };
46
+ // No vellum binding yet (pre-bootstrap). Eagerly create one so
47
+ // downstream code that creates decisionable canonical requests
48
+ // (tool_approval, pending_question) always has a guardianPrincipalId
49
+ // available. Without this, createCanonicalGuardianRequest throws
50
+ // IntegrityError and the request is silently dropped.
51
+ log.debug('No vellum guardian binding found; bootstrapping binding for IPC');
52
+ const principalId = ensureVellumGuardianBinding(assistantId);
53
+
54
+ // Re-resolve through the shared pipeline now that the binding exists.
55
+ const guardianCtx = resolveGuardianContext({
56
+ assistantId,
57
+ sourceChannel: 'vellum',
58
+ conversationExternalId: 'local',
59
+ actorExternalId: principalId,
60
+ });
61
+ return toGuardianRuntimeContext(sourceChannel, guardianCtx);
56
62
  }
57
63
 
58
64
  const guardianPrincipalId = binding.guardianExternalUserId;
@@ -66,8 +72,8 @@ export function resolveLocalIpcGuardianContext(
66
72
  const guardianCtx = resolveGuardianContext({
67
73
  assistantId,
68
74
  sourceChannel: 'vellum',
69
- externalChatId: 'local',
70
- senderExternalUserId: guardianPrincipalId,
75
+ conversationExternalId: 'local',
76
+ actorExternalId: guardianPrincipalId,
71
77
  });
72
78
 
73
79
  // Overlay the caller's actual sourceChannel onto the resolved context
@@ -116,8 +116,8 @@ export function verifyHttpActorToken(req: Request): ActorTokenVerification {
116
116
  const guardianCtx = resolveGuardianContext({
117
117
  assistantId,
118
118
  sourceChannel: 'vellum',
119
- externalChatId: 'local',
120
- senderExternalUserId: claims.guardianPrincipalId,
119
+ conversationExternalId: 'local',
120
+ actorExternalId: claims.guardianPrincipalId,
121
121
  });
122
122
 
123
123
  const guardianContext = toGuardianRuntimeContext('vellum' as ChannelId, guardianCtx);
@@ -34,17 +34,17 @@ export async function handleReplayDeadLetters(req: Request): Promise<Response> {
34
34
  export async function handleChannelDeliveryAck(req: Request): Promise<Response> {
35
35
  const body = await req.json() as {
36
36
  sourceChannel?: string;
37
- externalChatId?: string;
37
+ conversationExternalId?: string;
38
38
  externalMessageId?: string;
39
39
  };
40
40
 
41
- const { sourceChannel, externalChatId, externalMessageId } = body;
41
+ const { sourceChannel, conversationExternalId, externalMessageId } = body;
42
42
 
43
43
  if (!sourceChannel || typeof sourceChannel !== 'string') {
44
44
  return httpError('BAD_REQUEST', 'sourceChannel is required', 400);
45
45
  }
46
- if (!externalChatId || typeof externalChatId !== 'string') {
47
- return httpError('BAD_REQUEST', 'externalChatId is required', 400);
46
+ if (!conversationExternalId || typeof conversationExternalId !== 'string') {
47
+ return httpError('BAD_REQUEST', 'conversationExternalId is required', 400);
48
48
  }
49
49
  if (!externalMessageId || typeof externalMessageId !== 'string') {
50
50
  return httpError('BAD_REQUEST', 'externalMessageId is required', 400);
@@ -52,7 +52,7 @@ export async function handleChannelDeliveryAck(req: Request): Promise<Response>
52
52
 
53
53
  const acked = channelDeliveryStore.acknowledgeDelivery(
54
54
  sourceChannel,
55
- externalChatId,
55
+ conversationExternalId,
56
56
  externalMessageId,
57
57
  );
58
58
 
@@ -86,6 +86,8 @@ async function tryConsumeCanonicalGuardianReply(params: {
86
86
  approvalConversationGenerator?: ApprovalConversationGenerator;
87
87
  /** Verified actor identity from actor-token middleware. */
88
88
  verifiedActorExternalUserId?: string;
89
+ /** Verified actor principal ID for principal-based authorization. */
90
+ verifiedActorPrincipalId?: string;
89
91
  }): Promise<{ consumed: boolean; messageId?: string }> {
90
92
  const {
91
93
  conversationId,
@@ -97,6 +99,7 @@ async function tryConsumeCanonicalGuardianReply(params: {
97
99
  onEvent,
98
100
  approvalConversationGenerator,
99
101
  verifiedActorExternalUserId,
102
+ verifiedActorPrincipalId,
100
103
  } = params;
101
104
  const trimmedContent = content.trim();
102
105
 
@@ -113,12 +116,7 @@ async function tryConsumeCanonicalGuardianReply(params: {
113
116
  actor: {
114
117
  externalUserId: verifiedActorExternalUserId,
115
118
  channel: sourceChannel,
116
- // When a verified identity is available, disable the trusted bypass so
117
- // that the identity-match checks in applyCanonicalGuardianDecision
118
- // actually run. Only fall back to isTrusted when no verified identity
119
- // was resolved (defensive — shouldn't happen for vellum since
120
- // verification runs upstream).
121
- isTrusted: !verifiedActorExternalUserId,
119
+ guardianPrincipalId: verifiedActorPrincipalId,
122
120
  },
123
121
  conversationId,
124
122
  pendingRequestIds,
@@ -157,7 +155,7 @@ async function tryConsumeCanonicalGuardianReply(params: {
157
155
  assistantMessageChannel: sourceChannel,
158
156
  userMessageInterface: sourceInterface,
159
157
  assistantMessageInterface: sourceInterface,
160
- provenanceActorRole: 'guardian' as const,
158
+ provenanceTrustClass: 'guardian' as const,
161
159
  };
162
160
 
163
161
  const userMessage = createUserMessage(content, attachments);
@@ -345,33 +343,41 @@ function makeHubPublisher(
345
343
 
346
344
  // Create a canonical guardian request so IPC/HTTP handlers can find it
347
345
  // via applyCanonicalGuardianDecision.
348
- const guardianContext = session.guardianContext;
349
- const sourceChannel = guardianContext?.sourceChannel ?? 'vellum';
350
- const canonicalRequest = createCanonicalGuardianRequest({
351
- id: msg.requestId,
352
- kind: 'tool_approval',
353
- sourceType: resolveCanonicalRequestSourceType(sourceChannel),
354
- sourceChannel,
355
- conversationId,
356
- requesterExternalUserId: guardianContext?.requesterExternalUserId,
357
- requesterChatId: guardianContext?.requesterChatId,
358
- guardianExternalUserId: guardianContext?.guardianExternalUserId,
359
- toolName: msg.toolName,
360
- status: 'pending',
361
- requestCode: generateCanonicalRequestCode(),
362
- expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
363
- });
364
-
365
- // For trusted-contact sessions, bridge to guardian.question so the
366
- // guardian gets notified and can approve via callback/request-code.
367
- if (guardianContext) {
368
- bridgeConfirmationRequestToGuardian({
369
- canonicalRequest,
370
- guardianContext,
346
+ try {
347
+ const guardianContext = session.guardianContext;
348
+ const sourceChannel = guardianContext?.sourceChannel ?? 'vellum';
349
+ const canonicalRequest = createCanonicalGuardianRequest({
350
+ id: msg.requestId,
351
+ kind: 'tool_approval',
352
+ sourceType: resolveCanonicalRequestSourceType(sourceChannel),
353
+ sourceChannel,
371
354
  conversationId,
355
+ requesterExternalUserId: guardianContext?.requesterExternalUserId,
356
+ requesterChatId: guardianContext?.requesterChatId,
357
+ guardianExternalUserId: guardianContext?.guardianExternalUserId,
358
+ guardianPrincipalId: guardianContext?.guardianPrincipalId ?? undefined,
372
359
  toolName: msg.toolName,
373
- assistantId: session.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID,
360
+ status: 'pending',
361
+ requestCode: generateCanonicalRequestCode(),
362
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
374
363
  });
364
+
365
+ // For trusted-contact sessions, bridge to guardian.question so the
366
+ // guardian gets notified and can approve via callback/request-code.
367
+ if (guardianContext) {
368
+ bridgeConfirmationRequestToGuardian({
369
+ canonicalRequest,
370
+ guardianContext,
371
+ conversationId,
372
+ toolName: msg.toolName,
373
+ assistantId: session.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID,
374
+ });
375
+ }
376
+ } catch (err) {
377
+ log.debug(
378
+ { err, requestId: msg.requestId, conversationId },
379
+ 'Failed to create canonical request from hub publisher',
380
+ );
375
381
  }
376
382
  } else if (msg.type === 'secret_request') {
377
383
  pendingInteractions.register(msg.requestId, {
@@ -505,12 +511,15 @@ export async function handleSendMessage(
505
511
  ? smDeps.resolveAttachments(attachmentIds)
506
512
  : [];
507
513
 
508
- // Resolve the verified actor's external user ID for inline approval
509
- // routing. Uses the guardianExternalUserId from the verified context
510
- // (actor-token or local-fallback) rather than hardcoding undefined.
514
+ // Resolve the verified actor's external user ID and principal for inline
515
+ // approval routing. Uses the guardianExternalUserId and guardianPrincipalId
516
+ // from the verified context (actor-token or local-fallback).
511
517
  const verifiedActorExternalUserId = actorVerification?.ok
512
518
  ? actorVerification.guardianContext.guardianExternalUserId
513
- : undefined;
519
+ : session.guardianContext?.guardianExternalUserId;
520
+ const verifiedActorPrincipalId = actorVerification?.ok
521
+ ? actorVerification.guardianContext.guardianPrincipalId ?? undefined
522
+ : session.guardianContext?.guardianPrincipalId ?? undefined;
514
523
 
515
524
  // Try to consume the message as a canonical guardian approval/rejection reply.
516
525
  // On failure, degrade to the existing queue/auto-deny path rather than
@@ -526,6 +535,7 @@ export async function handleSendMessage(
526
535
  onEvent,
527
536
  approvalConversationGenerator: deps.approvalConversationGenerator,
528
537
  verifiedActorExternalUserId,
538
+ verifiedActorPrincipalId,
529
539
  });
530
540
  if (inlineReplyResult.consumed) {
531
541
  return Response.json(
@@ -124,13 +124,19 @@ export async function handleGuardianActionDecision(req: Request, server: ServerW
124
124
  ? tokenResult.claims.guardianPrincipalId
125
125
  : tokenResult.guardianContext.guardianExternalUserId;
126
126
 
127
+ // Resolve the actor's principal ID: from the token claims if present,
128
+ // otherwise from the vellum guardian binding (local fallback).
129
+ const actorPrincipalId = tokenResult.claims
130
+ ? tokenResult.claims.guardianPrincipalId
131
+ : tokenResult.guardianContext.guardianPrincipalId ?? undefined;
132
+
127
133
  const canonicalResult = await applyCanonicalGuardianDecision({
128
134
  requestId,
129
135
  action: action as ApprovalAction,
130
136
  actorContext: {
131
137
  externalUserId: actorExternalUserId,
132
138
  channel: 'vellum',
133
- isTrusted: true,
139
+ guardianPrincipalId: actorPrincipalId,
134
140
  },
135
141
  userText: undefined,
136
142
  });