@vellumai/assistant 0.3.26 → 0.3.28

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 (82) hide show
  1. package/ARCHITECTURE.md +48 -1
  2. package/Dockerfile +2 -2
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +6 -2
  5. package/src/__tests__/agent-loop.test.ts +119 -0
  6. package/src/__tests__/bundled-asset.test.ts +107 -0
  7. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  8. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  9. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  10. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  11. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  12. package/src/__tests__/guardian-dispatch.test.ts +19 -19
  13. package/src/__tests__/guardian-routing-invariants.test.ts +954 -0
  14. package/src/__tests__/mcp-cli.test.ts +77 -0
  15. package/src/__tests__/non-member-access-request.test.ts +31 -29
  16. package/src/__tests__/notification-decision-fallback.test.ts +61 -3
  17. package/src/__tests__/notification-decision-strategy.test.ts +17 -0
  18. package/src/__tests__/notification-guardian-path.test.ts +13 -15
  19. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  20. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  21. package/src/__tests__/secret-scanner.test.ts +8 -0
  22. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  23. package/src/__tests__/session-runtime-assembly.test.ts +76 -47
  24. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  25. package/src/agent/loop.ts +46 -3
  26. package/src/approvals/guardian-decision-primitive.ts +285 -0
  27. package/src/approvals/guardian-request-resolvers.ts +539 -0
  28. package/src/calls/guardian-dispatch.ts +46 -40
  29. package/src/calls/relay-server.ts +147 -2
  30. package/src/calls/types.ts +1 -1
  31. package/src/config/system-prompt.ts +2 -1
  32. package/src/config/templates/BOOTSTRAP.md +47 -31
  33. package/src/config/templates/USER.md +5 -0
  34. package/src/config/update-bulletin-template-path.ts +4 -1
  35. package/src/config/vellum-skills/trusted-contacts/SKILL.md +22 -17
  36. package/src/daemon/handlers/guardian-actions.ts +45 -66
  37. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  38. package/src/daemon/lifecycle.ts +3 -16
  39. package/src/daemon/server.ts +18 -0
  40. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  41. package/src/daemon/session-agent-loop.ts +32 -5
  42. package/src/daemon/session-process.ts +68 -307
  43. package/src/daemon/session-runtime-assembly.ts +112 -24
  44. package/src/daemon/session-tool-setup.ts +1 -0
  45. package/src/daemon/session.ts +1 -0
  46. package/src/home-base/prebuilt/seed.ts +2 -1
  47. package/src/hooks/templates.ts +2 -1
  48. package/src/memory/canonical-guardian-store.ts +524 -0
  49. package/src/memory/channel-guardian-store.ts +1 -0
  50. package/src/memory/db-init.ts +16 -0
  51. package/src/memory/guardian-action-store.ts +7 -60
  52. package/src/memory/guardian-approvals.ts +9 -4
  53. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  54. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  55. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  56. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  57. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  58. package/src/memory/migrations/index.ts +4 -0
  59. package/src/memory/migrations/registry.ts +5 -0
  60. package/src/memory/schema-migration.ts +1 -0
  61. package/src/memory/schema.ts +52 -0
  62. package/src/notifications/copy-composer.ts +16 -4
  63. package/src/notifications/decision-engine.ts +57 -0
  64. package/src/permissions/defaults.ts +2 -0
  65. package/src/runtime/access-request-helper.ts +137 -0
  66. package/src/runtime/actor-trust-resolver.ts +225 -0
  67. package/src/runtime/channel-guardian-service.ts +12 -4
  68. package/src/runtime/guardian-context-resolver.ts +32 -7
  69. package/src/runtime/guardian-decision-types.ts +6 -0
  70. package/src/runtime/guardian-reply-router.ts +687 -0
  71. package/src/runtime/http-server.ts +8 -0
  72. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  73. package/src/runtime/routes/conversation-routes.ts +18 -0
  74. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  75. package/src/runtime/routes/inbound-message-handler.ts +170 -525
  76. package/src/runtime/tool-grant-request-helper.ts +195 -0
  77. package/src/tools/executor.ts +13 -1
  78. package/src/tools/sensitive-output-placeholders.ts +203 -0
  79. package/src/tools/tool-approval-handler.ts +44 -1
  80. package/src/tools/types.ts +11 -0
  81. package/src/util/bundled-asset.ts +31 -0
  82. package/src/util/canonicalize-identity.ts +52 -0
@@ -6,40 +6,28 @@
6
6
  */
7
7
  // Side-effect import: registers the Telegram invite transport adapter so
8
8
  // getTransport('telegram') resolves at runtime.
9
- import { answerCall } from '../../calls/call-domain.js';
10
- import { isTerminalState } from '../../calls/call-state-machine.js';
11
- import { getCallSession } from '../../calls/call-store.js';
12
9
  import type { ChannelId, InterfaceId } from '../../channels/types.js';
13
10
  import { CHANNEL_IDS, INTERFACE_IDS, isChannelId, parseInterfaceId } from '../../channels/types.js';
14
11
  import { getGatewayInternalBaseUrl } from '../../config/env.js';
15
12
  import { RESEND_COOLDOWN_MS } from '../../daemon/handlers/config-channels.js';
16
13
  import * as attachmentsStore from '../../memory/attachments-store.js';
17
- import * as channelDeliveryStore from '../../memory/channel-delivery-store.js';
18
14
  import {
19
- createApprovalRequest,
20
- findPendingAccessRequestForRequester,
21
- } from '../../memory/channel-guardian-store.js';
15
+ createCanonicalGuardianRequest,
16
+ listCanonicalGuardianRequests,
17
+ listPendingCanonicalGuardianRequestsByDestinationChat,
18
+ } from '../../memory/canonical-guardian-store.js';
19
+ import * as channelDeliveryStore from '../../memory/channel-delivery-store.js';
22
20
  import { recordConversationSeenSignal } from '../../memory/conversation-attention-store.js';
23
21
  import * as conversationStore from '../../memory/conversation-store.js';
24
22
  import * as externalConversationStore from '../../memory/external-conversation-store.js';
25
- import {
26
- finalizeFollowup,
27
- getDeliveriesByRequestId,
28
- getExpiredDeliveriesByDestination,
29
- getFollowupDeliveriesByDestination,
30
- getGuardianActionRequest,
31
- getPendingDeliveriesByDestination,
32
- getPendingRequestByCallSessionId,
33
- progressFollowupState,
34
- resolveGuardianActionRequest,
35
- startFollowupFromExpiredRequest,
36
- } from '../../memory/guardian-action-store.js';
37
23
  import { findMember, updateLastSeen, upsertMember } from '../../memory/ingress-member-store.js';
38
24
  import { emitNotificationSignal } from '../../notifications/emit-signal.js';
39
25
  import { checkIngressForSecrets } from '../../security/secret-ingress.js';
26
+ import { canonicalizeInboundIdentity } from '../../util/canonicalize-identity.js';
40
27
  import { IngressBlockedError } from '../../util/errors.js';
41
28
  import { getLogger } from '../../util/logger.js';
42
29
  import { readHttpToken } from '../../util/platform.js';
30
+ import { notifyGuardianOfAccessRequest } from '../access-request-helper.js';
43
31
  import {
44
32
  buildApprovalUIMetadata,
45
33
  getApprovalInfoByConversation,
@@ -58,11 +46,8 @@ import {
58
46
  } from '../channel-guardian-service.js';
59
47
  import { getTransport } from '../channel-invite-transport.js';
60
48
  import { deliverChannelReply } from '../gateway-client.js';
61
- import { processGuardianFollowUpTurn } from '../guardian-action-conversation-turn.js';
62
- import { executeFollowupAction } from '../guardian-action-followup-executor.js';
63
- import { tryMintGuardianActionGrant } from '../guardian-action-grant-minter.js';
64
- import { composeGuardianActionMessageGenerative } from '../guardian-action-message-composer.js';
65
49
  import { resolveGuardianContext } from '../guardian-context-resolver.js';
50
+ import { routeGuardianReply } from '../guardian-reply-router.js';
66
51
  import {
67
52
  composeChannelVerifyReply,
68
53
  composeVerificationTelegram,
@@ -115,8 +100,8 @@ export async function handleChannelInbound(
115
100
  gatewayOriginSecret?: string,
116
101
  approvalCopyGenerator?: ApprovalCopyGenerator,
117
102
  approvalConversationGenerator?: ApprovalConversationGenerator,
118
- guardianActionCopyGenerator?: GuardianActionCopyGenerator,
119
- guardianFollowUpConversationGenerator?: GuardianFollowUpConversationGenerator,
103
+ _guardianActionCopyGenerator?: GuardianActionCopyGenerator,
104
+ _guardianFollowUpConversationGenerator?: GuardianFollowUpConversationGenerator,
120
105
  ): Promise<Response> {
121
106
  // Reject requests that lack valid gateway-origin proof. This ensures
122
107
  // channel inbound messages can only arrive via the gateway (which
@@ -202,6 +187,28 @@ export async function handleChannelInbound(
202
187
  log.debug({ raw: assistantId, canonical: canonicalAssistantId }, 'Canonicalized channel assistant ID');
203
188
  }
204
189
 
190
+ // Coerce senderExternalUserId to a string at the boundary — the field
191
+ // comes from unvalidated JSON and may be a number, object, or other
192
+ // non-string type. Non-string truthy values would throw inside
193
+ // canonicalizeInboundIdentity when it calls .trim().
194
+ const rawSenderId = body.senderExternalUserId != null
195
+ ? String(body.senderExternalUserId)
196
+ : undefined;
197
+
198
+ // Canonicalize the sender identity so all trust lookups, member matching,
199
+ // and guardian binding comparisons use a normalized form. Phone-like
200
+ // channels (sms, voice, whatsapp) are normalized to E.164; non-phone
201
+ // channels pass through the platform-stable ID unchanged.
202
+ const canonicalSenderId = rawSenderId
203
+ ? canonicalizeInboundIdentity(sourceChannel, rawSenderId)
204
+ : null;
205
+
206
+ // Track whether the original payload included a sender identity. A
207
+ // whitespace-only senderExternalUserId canonicalizes to null but still
208
+ // represents an explicit (malformed) identity claim that must enter the
209
+ // ACL deny path rather than bypassing it.
210
+ const hasSenderIdentityClaim = rawSenderId !== undefined;
211
+
205
212
  // ── Ingress ACL enforcement ──
206
213
  // Track the resolved member so the escalate branch can reference it after
207
214
  // recordInbound (where we have a conversationId).
@@ -235,13 +242,18 @@ export async function handleChannelInbound(
235
242
  sourceMetadata: body.sourceMetadata,
236
243
  });
237
244
 
238
- if (body.senderExternalUserId) {
239
- resolvedMember = findMember({
240
- assistantId: canonicalAssistantId,
241
- sourceChannel,
242
- externalUserId: body.senderExternalUserId,
243
- externalChatId,
244
- });
245
+ if (canonicalSenderId || hasSenderIdentityClaim) {
246
+ // Only perform member lookup when we have a usable canonical ID.
247
+ // Whitespace-only senders (hasSenderIdentityClaim=true but
248
+ // canonicalSenderId=null) skip the lookup and fall into the deny path.
249
+ if (canonicalSenderId) {
250
+ resolvedMember = findMember({
251
+ assistantId: canonicalAssistantId,
252
+ sourceChannel,
253
+ externalUserId: canonicalSenderId,
254
+ externalChatId,
255
+ });
256
+ }
245
257
 
246
258
  if (!resolvedMember) {
247
259
  // Determine whether a verification-code bypass is warranted: only allow
@@ -288,7 +300,7 @@ export async function handleChannelInbound(
288
300
  sourceChannel,
289
301
  externalChatId,
290
302
  externalMessageId,
291
- senderExternalUserId: body.senderExternalUserId,
303
+ senderExternalUserId: canonicalSenderId ?? rawSenderId,
292
304
  senderName: body.senderName,
293
305
  senderUsername: body.senderUsername,
294
306
  replyCallbackUrl: body.replyCallbackUrl,
@@ -300,21 +312,22 @@ export async function handleChannelInbound(
300
312
  }
301
313
 
302
314
  if (denyNonMember) {
303
- log.info({ sourceChannel, externalUserId: body.senderExternalUserId }, 'Ingress ACL: no member record, denying');
315
+ log.info({ sourceChannel, externalUserId: canonicalSenderId }, 'Ingress ACL: no member record, denying');
304
316
 
305
317
  // Notify the guardian about the access request so they can approve/deny.
306
- // Only fires when a guardian binding exists and no duplicate pending
307
- // request already exists for this requester.
318
+ // Uses the shared helper which handles guardian binding lookup,
319
+ // deduplication, canonical request creation, and notification emission.
308
320
  let guardianNotified = false;
309
321
  try {
310
- guardianNotified = notifyGuardianOfAccessRequest({
322
+ const accessResult = notifyGuardianOfAccessRequest({
311
323
  canonicalAssistantId,
312
324
  sourceChannel,
313
325
  externalChatId,
314
- senderExternalUserId: body.senderExternalUserId,
326
+ senderExternalUserId: canonicalSenderId ?? rawSenderId,
315
327
  senderName: body.senderName,
316
328
  senderUsername: body.senderUsername,
317
329
  });
330
+ guardianNotified = accessResult.notified;
318
331
  } catch (err) {
319
332
  log.error({ err, sourceChannel, externalChatId }, 'Failed to notify guardian of access request');
320
333
  }
@@ -373,7 +386,7 @@ export async function handleChannelInbound(
373
386
  sourceChannel,
374
387
  externalChatId,
375
388
  externalMessageId,
376
- senderExternalUserId: body.senderExternalUserId,
389
+ senderExternalUserId: canonicalSenderId ?? rawSenderId,
377
390
  senderName: body.senderName,
378
391
  senderUsername: body.senderUsername,
379
392
  replyCallbackUrl: body.replyCallbackUrl,
@@ -393,14 +406,15 @@ export async function handleChannelInbound(
393
406
  let guardianNotified = false;
394
407
  if (resolvedMember.status !== 'blocked') {
395
408
  try {
396
- guardianNotified = notifyGuardianOfAccessRequest({
409
+ const accessResult = notifyGuardianOfAccessRequest({
397
410
  canonicalAssistantId,
398
411
  sourceChannel,
399
412
  externalChatId,
400
- senderExternalUserId: body.senderExternalUserId,
413
+ senderExternalUserId: canonicalSenderId ?? rawSenderId,
401
414
  senderName: body.senderName,
402
415
  senderUsername: body.senderUsername,
403
416
  });
417
+ guardianNotified = accessResult.notified;
404
418
  } catch (err) {
405
419
  log.error({ err, sourceChannel, externalChatId }, 'Failed to notify guardian of access request');
406
420
  }
@@ -570,7 +584,7 @@ export async function handleChannelInbound(
570
584
  conversationId: result.conversationId,
571
585
  sourceChannel,
572
586
  externalChatId,
573
- externalUserId: body.senderExternalUserId ?? null,
587
+ externalUserId: canonicalSenderId ?? rawSenderId ?? null,
574
588
  displayName: body.senderName ?? null,
575
589
  username: body.senderUsername ?? null,
576
590
  });
@@ -600,19 +614,16 @@ export async function handleChannelInbound(
600
614
  assistantId: canonicalAssistantId,
601
615
  });
602
616
 
603
- createApprovalRequest({
604
- runId: `ingress-escalation-${Date.now()}`,
617
+ createCanonicalGuardianRequest({
618
+ kind: 'tool_approval',
619
+ sourceType: 'channel',
620
+ sourceChannel,
605
621
  conversationId: result.conversationId,
606
- assistantId: canonicalAssistantId,
607
- channel: sourceChannel,
608
- requesterExternalUserId: body.senderExternalUserId ?? '',
609
- requesterChatId: externalChatId,
622
+ requesterExternalUserId: canonicalSenderId ?? rawSenderId ?? undefined,
610
623
  guardianExternalUserId: binding.guardianExternalUserId,
611
- guardianChatId: binding.guardianDeliveryChatId,
612
624
  toolName: 'ingress_message',
613
- riskLevel: 'escalated_ingress',
614
- reason: 'Ingress policy requires guardian approval',
615
- expiresAt: Date.now() + GUARDIAN_APPROVAL_TTL_MS,
625
+ questionText: 'Ingress policy requires guardian approval',
626
+ expiresAt: new Date(Date.now() + GUARDIAN_APPROVAL_TTL_MS).toISOString(),
616
627
  });
617
628
 
618
629
  // Emit notification signal through the unified pipeline (fire-and-forget).
@@ -633,7 +644,7 @@ export async function handleChannelInbound(
633
644
  conversationId: result.conversationId,
634
645
  sourceChannel,
635
646
  externalChatId,
636
- senderIdentifier: body.senderName || body.senderUsername || body.senderExternalUserId || 'Unknown sender',
647
+ senderIdentifier: body.senderName || body.senderUsername || rawSenderId || 'Unknown sender',
637
648
  eventId: result.eventId,
638
649
  },
639
650
  dedupeKey: `escalation:${result.eventId}`,
@@ -679,14 +690,14 @@ export async function handleChannelInbound(
679
690
  commandIntent?.type === 'start' &&
680
691
  typeof commandIntent.payload === 'string' &&
681
692
  (commandIntent.payload as string).startsWith('gv_') &&
682
- body.senderExternalUserId
693
+ rawSenderId
683
694
  ) {
684
695
  const bootstrapToken = (commandIntent.payload as string).slice(3);
685
696
  const bootstrapSession = resolveBootstrapToken(canonicalAssistantId, sourceChannel, bootstrapToken);
686
697
 
687
698
  if (bootstrapSession && bootstrapSession.status === 'pending_bootstrap') {
688
699
  // Bind the pending_bootstrap session to the sender's identity
689
- bindSessionIdentity(bootstrapSession.id, body.senderExternalUserId, externalChatId);
700
+ bindSessionIdentity(bootstrapSession.id, rawSenderId!, externalChatId);
690
701
 
691
702
  // Transition bootstrap session to awaiting_response
692
703
  updateSessionStatus(bootstrapSession.id, 'awaiting_response');
@@ -696,7 +707,7 @@ export async function handleChannelInbound(
696
707
  const newSession = createOutboundSession({
697
708
  assistantId: canonicalAssistantId,
698
709
  channel: sourceChannel,
699
- expectedExternalUserId: body.senderExternalUserId,
710
+ expectedExternalUserId: rawSenderId!,
700
711
  expectedChatId: externalChatId,
701
712
  identityBindingStatus: 'bound',
702
713
  destinationAddress: externalChatId,
@@ -748,13 +759,13 @@ export async function handleChannelInbound(
748
759
  !result.duplicate &&
749
760
  shouldInterceptVerification &&
750
761
  guardianVerifyCode !== undefined &&
751
- body.senderExternalUserId
762
+ rawSenderId
752
763
  ) {
753
764
  const verifyResult = validateAndConsumeChallenge(
754
765
  canonicalAssistantId,
755
766
  sourceChannel,
756
767
  guardianVerifyCode,
757
- body.senderExternalUserId,
768
+ canonicalSenderId ?? rawSenderId!,
758
769
  externalChatId,
759
770
  body.senderUsername,
760
771
  body.senderName,
@@ -766,7 +777,7 @@ export async function handleChannelInbound(
766
777
  upsertMember({
767
778
  assistantId: canonicalAssistantId,
768
779
  sourceChannel,
769
- externalUserId: body.senderExternalUserId,
780
+ externalUserId: canonicalSenderId ?? rawSenderId!,
770
781
  externalChatId,
771
782
  status: 'active',
772
783
  policy: 'allow',
@@ -777,7 +788,7 @@ export async function handleChannelInbound(
777
788
  const verifyLogLabel = verifyResult.verificationType === 'trusted_contact'
778
789
  ? 'Trusted contact verified'
779
790
  : 'Guardian verified';
780
- log.info({ sourceChannel, externalUserId: body.senderExternalUserId, verificationType: verifyResult.verificationType }, `${verifyLogLabel}: auto-upserted ingress member`);
791
+ log.info({ sourceChannel, externalUserId: canonicalSenderId, verificationType: verifyResult.verificationType }, `${verifyLogLabel}: auto-upserted ingress member`);
781
792
 
782
793
  // Emit activated signal when a trusted contact completes verification.
783
794
  // Member record is persisted above before this event fires, satisfying
@@ -796,12 +807,12 @@ export async function handleChannelInbound(
796
807
  },
797
808
  contextPayload: {
798
809
  sourceChannel,
799
- externalUserId: body.senderExternalUserId,
810
+ externalUserId: canonicalSenderId ?? rawSenderId!,
800
811
  externalChatId,
801
812
  senderName: body.senderName ?? null,
802
813
  senderUsername: body.senderUsername ?? null,
803
814
  },
804
- dedupeKey: `trusted-contact:activated:${canonicalAssistantId}:${sourceChannel}:${body.senderExternalUserId}`,
815
+ dedupeKey: `trusted-contact:activated:${canonicalAssistantId}:${sourceChannel}:${canonicalSenderId ?? rawSenderId!}`,
805
816
  });
806
817
  }
807
818
  }
@@ -873,377 +884,107 @@ export async function handleChannelInbound(
873
884
  });
874
885
  }
875
886
 
876
- // ── Unified guardian action answer interception ──
877
- // Deterministic priority matching: pending follow-up expired.
878
- // When the guardian includes an explicit request code, match it across all
879
- // states in priority order. When only one actionable request exists,
880
- // auto-match without requiring a code prefix. Callback payloads (inline
881
- // button presses) are excluded — they should not be misclassified as
882
- // guardian answers.
887
+ // Legacy voice guardian action interception removed — all guardian reply
888
+ // routing now flows through the canonical router below (routeGuardianReply),
889
+ // which handles request code matching, callback parsing, and NL classification
890
+ // against canonical_guardian_requests.
891
+
892
+ // ── Actor role resolution ──
893
+ // Uses shared channel-agnostic resolution so all ingress paths classify
894
+ // guardian vs non-guardian actors the same way.
895
+ const guardianCtx: GuardianContext = resolveGuardianContext({
896
+ assistantId: canonicalAssistantId,
897
+ sourceChannel,
898
+ externalChatId,
899
+ senderExternalUserId: rawSenderId,
900
+ senderUsername: body.senderUsername,
901
+ });
902
+
903
+ // ── Canonical guardian reply router ──
904
+ // Attempts to route inbound messages through the canonical decision pipeline
905
+ // before falling through to the legacy approval interception. Handles
906
+ // deterministic callbacks (button presses), request code prefixes, and
907
+ // NL classification via the conversational approval engine.
883
908
  if (
884
909
  !result.duplicate &&
885
- !hasCallbackData &&
886
- trimmedContent.length > 0 &&
887
- body.senderExternalUserId &&
888
- replyCallbackUrl
910
+ replyCallbackUrl &&
911
+ (trimmedContent.length > 0 || hasCallbackData) &&
912
+ rawSenderId &&
913
+ guardianCtx.actorRole === 'guardian'
889
914
  ) {
890
- // Gather deliveries across all states for this destination, filtered by sender identity
891
- const allPending = getPendingDeliveriesByDestination(canonicalAssistantId, sourceChannel, externalChatId)
892
- .filter((d) => d.destinationExternalUserId === body.senderExternalUserId);
893
- const allFollowup = getFollowupDeliveriesByDestination(canonicalAssistantId, sourceChannel, externalChatId)
894
- .filter((d) => d.destinationExternalUserId === body.senderExternalUserId);
895
- const allExpired = getExpiredDeliveriesByDestination(canonicalAssistantId, sourceChannel, externalChatId)
896
- .filter((d) => d.destinationExternalUserId === body.senderExternalUserId);
897
- const totalActionable = allPending.length + allFollowup.length + allExpired.length;
898
-
899
- if (totalActionable > 0) {
900
- // ── Try to parse an explicit request code from the message ──
901
- // Check all deliveries across states for a code prefix match, in priority order
902
- type CodeMatch = { delivery: typeof allPending[0]; request: NonNullable<ReturnType<typeof getGuardianActionRequest>>; state: 'pending' | 'followup' | 'expired'; answerText: string };
903
- let codeMatch: CodeMatch | null = null;
904
- const upperContent = trimmedContent.toUpperCase();
905
- const orderedSets: Array<{ deliveries: typeof allPending; state: 'pending' | 'followup' | 'expired' }> = [
906
- { deliveries: allPending, state: 'pending' },
907
- { deliveries: allFollowup, state: 'followup' },
908
- { deliveries: allExpired, state: 'expired' },
909
- ];
910
- for (const { deliveries, state } of orderedSets) {
911
- for (const d of deliveries) {
912
- const req = getGuardianActionRequest(d.requestId);
913
- if (req && upperContent.startsWith(req.requestCode)) {
914
- codeMatch = { delivery: d, request: req, state, answerText: trimmedContent.slice(req.requestCode.length).trim() };
915
- break;
916
- }
917
- }
918
- if (codeMatch) break;
919
- }
920
-
921
- // ── Explicit code targets a non-pending state: handle terminal/remap ──
922
- if (codeMatch && codeMatch.state !== 'pending') {
923
- const targetReq = codeMatch.request;
924
-
925
- // Superseded request with no active call → terminal notice
926
- if (targetReq.status === 'expired' && targetReq.expiredReason === 'superseded') {
927
- const callSession = getCallSession(targetReq.callSessionId);
928
- const callStillActive = callSession && !isTerminalState(callSession.status);
929
- if (!callStillActive) {
930
- const staleText = await composeGuardianActionMessageGenerative(
931
- { scenario: 'guardian_stale_superseded' },
932
- {},
933
- guardianActionCopyGenerator,
934
- );
935
- try {
936
- await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: staleText, assistantId }, bearerToken);
937
- } catch (err) {
938
- log.error({ err, externalChatId }, 'Failed to deliver superseded terminal notice');
939
- }
940
- return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'stale_superseded' });
941
- }
942
- }
943
-
944
- // If the code pointed to expired/follow-up but there's a pending request,
945
- // route intentionally to the expired/follow-up handler with explanation
946
- // (the per-state blocks below will pick it up via codeMatch).
947
- }
948
-
949
- // ── Auto-match: single actionable request across all states ──
950
- // When there's only one request and no explicit code, auto-match directly
951
- if (!codeMatch && totalActionable === 1) {
952
- const singleDelivery = allPending[0] ?? allFollowup[0] ?? allExpired[0];
953
- const singleReq = getGuardianActionRequest(singleDelivery.requestId);
954
- if (singleReq) {
955
- const state: 'pending' | 'followup' | 'expired' = allPending.length === 1 ? 'pending' : allFollowup.length === 1 ? 'followup' : 'expired';
956
- // Strip the code prefix if the guardian uses it out of habit
957
- let text = trimmedContent;
958
- if (upperContent.startsWith(singleReq.requestCode)) {
959
- text = trimmedContent.slice(singleReq.requestCode.length).trim();
960
- }
961
- codeMatch = { delivery: singleDelivery, request: singleReq, state, answerText: text };
962
- }
915
+ // Compute destination-scoped pending request hints so the router can
916
+ // discover canonical requests delivered to this chat even when the
917
+ // request lacks a guardianExternalUserId (e.g. voice-originated
918
+ // pending_question requests).
919
+ //
920
+ // When delivery-scoped matches exist, union them with any identity-
921
+ // based pending requests so that requests without delivery rows (e.g.
922
+ // tool_approval requests created inline) are not silently excluded.
923
+ // Pass undefined (not []) when there are zero combined results so the
924
+ // router's own identity-based fallback stays active.
925
+ const deliveryScopedPendingRequests = listPendingCanonicalGuardianRequestsByDestinationChat(
926
+ sourceChannel,
927
+ externalChatId,
928
+ );
929
+ let pendingRequestIds: string[] | undefined;
930
+ if (deliveryScopedPendingRequests.length > 0) {
931
+ const deliveryIds = new Set(deliveryScopedPendingRequests.map(r => r.id));
932
+ // Also include identity-based pending requests so we don't hide them
933
+ const identityId = canonicalSenderId ?? rawSenderId!;
934
+ const identityPending = listCanonicalGuardianRequests({
935
+ status: 'pending',
936
+ guardianExternalUserId: identityId,
937
+ });
938
+ for (const r of identityPending) {
939
+ deliveryIds.add(r.id);
963
940
  }
941
+ pendingRequestIds = [...deliveryIds];
942
+ }
964
943
 
965
- // ── Unknown code: message looks like a code prefix but doesn't match anything ──
966
- // Detect when the message starts with a 6-char alphanumeric token that
967
- // resembles a request code but doesn't match any known delivery.
968
- if (!codeMatch && totalActionable > 0) {
969
- const possibleCodeMatch = trimmedContent.match(/^([A-F0-9]{6})\s/i);
970
- if (possibleCodeMatch) {
971
- const candidateCode = possibleCodeMatch[1].toUpperCase();
972
- // Check if this code exists in ANY delivery across states
973
- const allDeliveries = [...allPending, ...allFollowup, ...allExpired];
974
- const knownCodes = allDeliveries
975
- .map((d) => { const req = getGuardianActionRequest(d.requestId); return req?.requestCode; })
976
- .filter((code): code is string => typeof code === 'string');
977
- const isKnown = knownCodes.includes(candidateCode);
978
- if (!isKnown) {
979
- const unknownText = await composeGuardianActionMessageGenerative(
980
- { scenario: 'guardian_unknown_code', unknownCode: candidateCode },
981
- {},
982
- guardianActionCopyGenerator,
983
- );
984
- try {
985
- await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: unknownText, assistantId }, bearerToken);
986
- } catch (err) {
987
- log.error({ err, externalChatId }, 'Failed to deliver unknown code notice');
988
- }
989
- return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'unknown_code' });
990
- }
991
- }
992
- }
944
+ const routerResult = await routeGuardianReply({
945
+ messageText: trimmedContent,
946
+ channel: sourceChannel,
947
+ actor: {
948
+ externalUserId: canonicalSenderId ?? rawSenderId!,
949
+ channel: sourceChannel,
950
+ isTrusted: false,
951
+ },
952
+ conversationId: result.conversationId,
953
+ callbackData: body.callbackData,
954
+ pendingRequestIds,
955
+ approvalConversationGenerator,
956
+ channelDeliveryContext: {
957
+ replyCallbackUrl,
958
+ guardianChatId: externalChatId,
959
+ assistantId: canonicalAssistantId,
960
+ bearerToken,
961
+ },
962
+ });
993
963
 
994
- // ── No match and multiple actionable requests → disambiguation ──
995
- if (!codeMatch && totalActionable > 1) {
996
- const allDeliveries = [...allPending, ...allFollowup, ...allExpired];
997
- const codes = allDeliveries
998
- .map((d) => { const req = getGuardianActionRequest(d.requestId); return req ? req.requestCode : null; })
999
- .filter((code): code is string => typeof code === 'string' && code.length > 0);
1000
-
1001
- // Choose the appropriate disambiguation scenario based on which states are present
1002
- const disambiguationScenario = allPending.length > 0
1003
- ? 'guardian_pending_disambiguation' as const
1004
- : allFollowup.length > 0
1005
- ? 'guardian_followup_disambiguation' as const
1006
- : 'guardian_expired_disambiguation' as const;
1007
-
1008
- const disambiguationText = await composeGuardianActionMessageGenerative(
1009
- { scenario: disambiguationScenario, requestCodes: codes, channel: sourceChannel },
1010
- { requiredKeywords: codes },
1011
- guardianActionCopyGenerator,
1012
- );
964
+ if (routerResult.consumed) {
965
+ // Deliver reply text if the router produced one
966
+ if (routerResult.replyText) {
1013
967
  try {
1014
- await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: disambiguationText, assistantId }, bearerToken);
968
+ await deliverChannelReply(replyCallbackUrl, {
969
+ chatId: externalChatId,
970
+ text: routerResult.replyText,
971
+ assistantId: canonicalAssistantId,
972
+ }, bearerToken);
1015
973
  } catch (err) {
1016
- log.error({ err, externalChatId }, 'Failed to deliver guardian action disambiguation message');
974
+ log.error({ err, externalChatId }, 'Failed to deliver canonical router reply');
1017
975
  }
1018
- return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'disambiguation_sent' });
1019
976
  }
1020
977
 
1021
- // ── Dispatch matched delivery by state ──
1022
- if (codeMatch) {
1023
- const { request, state, answerText } = codeMatch;
1024
-
1025
- // ── PENDING state handler ──
1026
- if (state === 'pending' && request.status === 'pending') {
1027
- const answerResult = await answerCall({ callSessionId: request.callSessionId, answer: answerText, pendingQuestionId: request.pendingQuestionId });
1028
-
1029
- if (!('ok' in answerResult) || !answerResult.ok) {
1030
- const errorMsg = 'error' in answerResult ? answerResult.error : 'Unknown error';
1031
- log.warn({ callSessionId: request.callSessionId, error: errorMsg }, 'answerCall failed for guardian answer');
1032
- try {
1033
- const failureText = await composeGuardianActionMessageGenerative(
1034
- { scenario: 'guardian_answer_delivery_failed' },
1035
- {},
1036
- guardianActionCopyGenerator,
1037
- );
1038
- await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: failureText, assistantId }, bearerToken);
1039
- } catch (deliverErr) {
1040
- log.error({ err: deliverErr, externalChatId }, 'Failed to deliver guardian answer failure notice');
1041
- }
1042
- return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'answer_failed' });
1043
- }
1044
-
1045
- const resolved = resolveGuardianActionRequest(request.id, answerText, sourceChannel, body.senderExternalUserId);
1046
-
1047
- if (resolved) {
1048
- await tryMintGuardianActionGrant({
1049
- request,
1050
- answerText,
1051
- decisionChannel: sourceChannel,
1052
- guardianExternalUserId: body.senderExternalUserId,
1053
- approvalConversationGenerator,
1054
- });
1055
-
1056
- return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'resolved' });
1057
- } else {
1058
- const freshRequest = getGuardianActionRequest(request.id);
1059
- const relayedText = await composeGuardianActionMessageGenerative(
1060
- { scenario: 'guardian_stale_answered' as const },
1061
- {},
1062
- guardianActionCopyGenerator,
1063
- );
1064
- try {
1065
- await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: relayedText, assistantId }, bearerToken);
1066
- } catch (err) {
1067
- log.error({ err, externalChatId }, 'Failed to deliver guardian action stale notice');
1068
- }
1069
- log.info(
1070
- { requestId: request.id, freshStatus: freshRequest?.status },
1071
- 'answerCall succeeded but resolveGuardianActionRequest returned null — informed guardian answer was relayed',
1072
- );
1073
- return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'stale' });
1074
- }
1075
- }
1076
-
1077
- // ── FOLLOW-UP state handler ──
1078
- if (state === 'followup' && request.followupState === 'awaiting_guardian_choice') {
1079
- const turnResult = await processGuardianFollowUpTurn(
1080
- {
1081
- questionText: request.questionText,
1082
- lateAnswerText: request.lateAnswerText ?? '',
1083
- guardianReply: answerText,
1084
- },
1085
- guardianFollowUpConversationGenerator,
1086
- );
1087
-
1088
- let stateApplied = true;
1089
- if (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back') {
1090
- stateApplied = progressFollowupState(request.id, 'dispatching', turnResult.disposition) !== null;
1091
- } else if (turnResult.disposition === 'decline') {
1092
- stateApplied = finalizeFollowup(request.id, 'declined') !== null;
1093
- }
1094
-
1095
- if (!stateApplied) {
1096
- log.warn({ requestId: request.id, disposition: turnResult.disposition }, 'Follow-up state transition failed (already resolved)');
1097
- const staleText = await composeGuardianActionMessageGenerative(
1098
- { scenario: 'guardian_stale_followup' as const },
1099
- {},
1100
- guardianActionCopyGenerator,
1101
- );
1102
- try {
1103
- await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: staleText, assistantId }, bearerToken);
1104
- } catch (err) {
1105
- log.error({ err, externalChatId }, 'Failed to deliver stale follow-up notice');
1106
- }
1107
- return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianFollowUp: 'stale_ignored' });
1108
- }
1109
-
1110
- try {
1111
- await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: turnResult.replyText, assistantId }, bearerToken);
1112
- } catch (err) {
1113
- log.error({ err, externalChatId }, 'Failed to deliver guardian follow-up conversation reply');
1114
- }
1115
-
1116
- if (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back') {
1117
- void (async () => {
1118
- try {
1119
- const execResult = await executeFollowupAction(
1120
- request.id,
1121
- turnResult.disposition as 'call_back' | 'message_back',
1122
- guardianActionCopyGenerator,
1123
- );
1124
- await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: execResult.guardianReplyText, assistantId }, bearerToken);
1125
- } catch (execErr) {
1126
- log.error({ err: execErr, requestId: request.id }, 'Follow-up action execution or completion reply failed');
1127
- }
1128
- })();
1129
- }
1130
-
1131
- return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianFollowUp: turnResult.disposition });
1132
- }
1133
-
1134
- // ── EXPIRED state handler ──
1135
- if (state === 'expired' && request.status === 'expired' && request.followupState === 'none') {
1136
- // Superseded remap: if the request was superseded (not timed out
1137
- // or disconnected), check whether the call is still active with a
1138
- // current pending request. If so, remap the late approval to the
1139
- // current request instead of entering the callback/message follow-up.
1140
- if (request.expiredReason === 'superseded') {
1141
- const callSession = getCallSession(request.callSessionId);
1142
- const callStillActive = callSession && !isTerminalState(callSession.status);
1143
- const currentPending = callStillActive
1144
- ? getPendingRequestByCallSessionId(request.callSessionId)
1145
- : null;
1146
-
1147
- if (callStillActive && currentPending) {
1148
- const currentDeliveries = getDeliveriesByRequestId(currentPending.id);
1149
- // When senderExternalUserId is present, verify the sender has a
1150
- // matching delivery on the current pending request. When it's absent
1151
- // (trusted session), allow the remap without delivery check.
1152
- const senderHasDelivery = body.senderExternalUserId
1153
- ? currentDeliveries.some((d) => d.destinationExternalUserId === body.senderExternalUserId)
1154
- : true;
1155
- if (!senderHasDelivery) {
1156
- log.info(
1157
- { supersededRequestId: request.id, currentRequestId: currentPending.id, senderExternalUserId: body.senderExternalUserId },
1158
- 'Superseded remap skipped: sender has no delivery on current pending request',
1159
- );
1160
- } else {
1161
- const remapResult = await answerCall({
1162
- callSessionId: currentPending.callSessionId,
1163
- answer: answerText,
1164
- pendingQuestionId: currentPending.pendingQuestionId,
1165
- });
1166
-
1167
- if ('ok' in remapResult && remapResult.ok) {
1168
- const resolved = resolveGuardianActionRequest(currentPending.id, answerText, sourceChannel, body.senderExternalUserId);
1169
-
1170
- if (resolved) {
1171
- await tryMintGuardianActionGrant({
1172
- request: currentPending,
1173
- answerText,
1174
- decisionChannel: sourceChannel,
1175
- guardianExternalUserId: body.senderExternalUserId,
1176
- approvalConversationGenerator,
1177
- });
1178
- }
1179
-
1180
- const remapText = await composeGuardianActionMessageGenerative(
1181
- { scenario: 'guardian_superseded_remap', questionText: currentPending.questionText },
1182
- {},
1183
- guardianActionCopyGenerator,
1184
- );
1185
- try {
1186
- await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: remapText, assistantId }, bearerToken);
1187
- } catch (err) {
1188
- log.error({ err, externalChatId }, 'Failed to deliver superseded remap confirmation');
1189
- }
1190
- log.info(
1191
- { supersededRequestId: request.id, remappedToRequestId: currentPending.id },
1192
- 'Late approval for superseded request remapped to current pending request',
1193
- );
1194
- return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'superseded_remapped' });
1195
- }
1196
- log.warn(
1197
- { callSessionId: currentPending.callSessionId, error: 'error' in remapResult ? remapResult.error : 'unknown' },
1198
- 'Superseded remap answerCall failed, falling through to follow-up',
1199
- );
1200
- }
1201
- }
1202
- // Call not active or no pending request — fall through to follow-up
1203
- }
1204
-
1205
- const followupResult = startFollowupFromExpiredRequest(request.id, answerText);
1206
- if (followupResult) {
1207
- const followupText = await composeGuardianActionMessageGenerative(
1208
- { scenario: 'guardian_late_answer_followup', questionText: request.questionText, lateAnswerText: answerText },
1209
- {},
1210
- guardianActionCopyGenerator,
1211
- );
1212
- try {
1213
- await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: followupText, assistantId }, bearerToken);
1214
- } catch (err) {
1215
- log.error({ err, externalChatId }, 'Failed to deliver guardian action late answer follow-up');
1216
- }
1217
- return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'followup_initiated' });
1218
- } else {
1219
- const staleText = await composeGuardianActionMessageGenerative(
1220
- { scenario: 'guardian_stale_expired' as const },
1221
- {},
1222
- guardianActionCopyGenerator,
1223
- );
1224
- try {
1225
- await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: staleText, assistantId }, bearerToken);
1226
- } catch (err) {
1227
- log.error({ err, externalChatId }, 'Failed to deliver guardian action stale notice for expired follow-up race');
1228
- }
1229
- return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'stale' });
1230
- }
1231
- }
1232
- }
978
+ return Response.json({
979
+ accepted: true,
980
+ duplicate: false,
981
+ eventId: result.eventId,
982
+ canonicalRouter: routerResult.type,
983
+ requestId: routerResult.requestId,
984
+ });
1233
985
  }
1234
986
  }
1235
987
 
1236
- // ── Actor role resolution ──
1237
- // Uses shared channel-agnostic resolution so all ingress paths classify
1238
- // guardian vs non-guardian actors the same way.
1239
- const guardianCtx: GuardianContext = resolveGuardianContext({
1240
- assistantId: canonicalAssistantId,
1241
- sourceChannel,
1242
- externalChatId,
1243
- senderExternalUserId: body.senderExternalUserId,
1244
- senderUsername: body.senderUsername,
1245
- });
1246
-
1247
988
  // ── Approval interception ──
1248
989
  // Keep this active whenever callback context is available.
1249
990
  if (
@@ -1256,7 +997,7 @@ export async function handleChannelInbound(
1256
997
  content: trimmedContent,
1257
998
  externalChatId,
1258
999
  sourceChannel,
1259
- senderExternalUserId: body.senderExternalUserId,
1000
+ senderExternalUserId: canonicalSenderId ?? rawSenderId,
1260
1001
  replyCallbackUrl,
1261
1002
  bearerToken,
1262
1003
  guardianCtx,
@@ -1547,111 +1288,6 @@ async function handleInviteTokenIntercept(params: {
1547
1288
  return Response.json({ accepted: true, eventId: dedupResult.eventId, denied: true, inviteRedemption: outcome.reason });
1548
1289
  }
1549
1290
 
1550
- // ---------------------------------------------------------------------------
1551
- // Non-member access request notification
1552
- // ---------------------------------------------------------------------------
1553
-
1554
- /**
1555
- * Fire-and-forget: look up the guardian binding and, if present, create an
1556
- * approval request + emit a notification signal so the guardian can
1557
- * approve/deny the unknown user. Deduplicates by checking for an existing
1558
- * pending approval for the same (requester, assistant, channel).
1559
- */
1560
- function notifyGuardianOfAccessRequest(params: {
1561
- canonicalAssistantId: string;
1562
- sourceChannel: ChannelId;
1563
- externalChatId: string;
1564
- senderExternalUserId?: string;
1565
- senderName?: string;
1566
- senderUsername?: string;
1567
- }): boolean {
1568
- const {
1569
- canonicalAssistantId,
1570
- sourceChannel,
1571
- externalChatId,
1572
- senderExternalUserId,
1573
- senderName,
1574
- senderUsername,
1575
- } = params;
1576
-
1577
- if (!senderExternalUserId) return false;
1578
-
1579
- const binding = getGuardianBinding(canonicalAssistantId, sourceChannel);
1580
- if (!binding) {
1581
- log.debug({ sourceChannel, canonicalAssistantId }, 'No guardian binding for access request notification');
1582
- return false;
1583
- }
1584
-
1585
- // Deduplicate: skip if there is already a pending approval request for
1586
- // the same requester on this channel. Still return true — the guardian
1587
- // was already notified for this request.
1588
- const existing = findPendingAccessRequestForRequester(
1589
- canonicalAssistantId,
1590
- sourceChannel,
1591
- senderExternalUserId,
1592
- 'ingress_access_request',
1593
- );
1594
- if (existing) {
1595
- log.debug(
1596
- { sourceChannel, senderExternalUserId, existingId: existing.id },
1597
- 'Skipping duplicate access request notification',
1598
- );
1599
- return true;
1600
- }
1601
-
1602
- const senderIdentifier = senderName || senderUsername || senderExternalUserId;
1603
- const requestId = `access-req-${canonicalAssistantId}-${sourceChannel}-${senderExternalUserId}-${Date.now()}`;
1604
-
1605
- const approvalRequest = createApprovalRequest({
1606
- runId: `ingress-access-request-${Date.now()}`,
1607
- requestId,
1608
- conversationId: `access-req-${sourceChannel}-${senderExternalUserId}`,
1609
- assistantId: canonicalAssistantId,
1610
- channel: sourceChannel,
1611
- requesterExternalUserId: senderExternalUserId,
1612
- requesterChatId: externalChatId,
1613
- guardianExternalUserId: binding.guardianExternalUserId,
1614
- guardianChatId: binding.guardianDeliveryChatId,
1615
- toolName: 'ingress_access_request',
1616
- riskLevel: 'access_request',
1617
- reason: `${senderIdentifier} is requesting access to the assistant`,
1618
- expiresAt: Date.now() + GUARDIAN_APPROVAL_TTL_MS,
1619
- });
1620
-
1621
- void emitNotificationSignal({
1622
- sourceEventName: 'ingress.access_request',
1623
- sourceChannel,
1624
- sourceSessionId: `access-req-${sourceChannel}-${senderExternalUserId}`,
1625
- assistantId: canonicalAssistantId,
1626
- attentionHints: {
1627
- requiresAction: true,
1628
- urgency: 'high',
1629
- isAsyncBackground: false,
1630
- visibleInSourceNow: false,
1631
- },
1632
- contextPayload: {
1633
- requestId,
1634
- sourceChannel,
1635
- externalChatId,
1636
- senderExternalUserId,
1637
- senderName: senderName ?? null,
1638
- senderUsername: senderUsername ?? null,
1639
- senderIdentifier,
1640
- },
1641
- // Scoped to the approval request ID so duplicate notifications for the
1642
- // same request are suppressed, but a new request (after deny/expire)
1643
- // gets its own dedupe key and the guardian is notified again.
1644
- dedupeKey: `access-request:${approvalRequest.id}`,
1645
- });
1646
-
1647
- log.info(
1648
- { sourceChannel, senderExternalUserId, senderIdentifier },
1649
- 'Guardian notified of non-member access request',
1650
- );
1651
-
1652
- return true;
1653
- }
1654
-
1655
1291
  // ---------------------------------------------------------------------------
1656
1292
  // Background message processing
1657
1293
  // ---------------------------------------------------------------------------
@@ -1733,6 +1369,7 @@ function startPendingApprovalPromptWatcher(params: {
1733
1369
  conversationId: string;
1734
1370
  sourceChannel: ChannelId;
1735
1371
  externalChatId: string;
1372
+ guardianActorRole: GuardianContext['actorRole'];
1736
1373
  replyCallbackUrl: string;
1737
1374
  bearerToken?: string;
1738
1375
  assistantId?: string;
@@ -1742,12 +1379,19 @@ function startPendingApprovalPromptWatcher(params: {
1742
1379
  conversationId,
1743
1380
  sourceChannel,
1744
1381
  externalChatId,
1382
+ guardianActorRole,
1745
1383
  replyCallbackUrl,
1746
1384
  bearerToken,
1747
1385
  assistantId,
1748
1386
  approvalCopyGenerator,
1749
1387
  } = params;
1750
1388
 
1389
+ // Approval prompt delivery is guardian-only. Non-guardian and unverified
1390
+ // actors must never receive approval prompt broadcasts for the conversation.
1391
+ if (guardianActorRole !== 'guardian') {
1392
+ return () => {};
1393
+ }
1394
+
1751
1395
  let active = true;
1752
1396
  const deliveredRequestIds = new Set<string>();
1753
1397
 
@@ -1826,6 +1470,7 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
1826
1470
  conversationId,
1827
1471
  sourceChannel,
1828
1472
  externalChatId,
1473
+ guardianActorRole: guardianCtx.actorRole,
1829
1474
  replyCallbackUrl,
1830
1475
  bearerToken,
1831
1476
  assistantId,