@vellumai/assistant 0.3.27 → 0.4.0

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 (247) hide show
  1. package/ARCHITECTURE.md +81 -4
  2. package/Dockerfile +2 -2
  3. package/bun.lock +4 -1
  4. package/docs/trusted-contact-access.md +9 -2
  5. package/package.json +6 -3
  6. package/scripts/ipc/generate-swift.ts +9 -5
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  8. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  9. package/src/__tests__/agent-loop.test.ts +119 -0
  10. package/src/__tests__/approval-routes-http.test.ts +13 -5
  11. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  12. package/src/__tests__/asset-search-tool.test.ts +2 -0
  13. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  14. package/src/__tests__/attachments-store.test.ts +2 -0
  15. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  16. package/src/__tests__/bundled-asset.test.ts +107 -0
  17. package/src/__tests__/call-controller.test.ts +30 -29
  18. package/src/__tests__/call-routes-http.test.ts +34 -32
  19. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  20. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  21. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  22. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  23. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  24. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  25. package/src/__tests__/clarification-resolver.test.ts +2 -0
  26. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  27. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  28. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  29. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  30. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  31. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  32. package/src/__tests__/config-schema.test.ts +5 -5
  33. package/src/__tests__/config-watcher.test.ts +3 -1
  34. package/src/__tests__/connection-policy.test.ts +14 -5
  35. package/src/__tests__/contacts-tools.test.ts +3 -1
  36. package/src/__tests__/contradiction-checker.test.ts +2 -0
  37. package/src/__tests__/conversation-pairing.test.ts +10 -0
  38. package/src/__tests__/conversation-routes.test.ts +1 -1
  39. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  40. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  41. package/src/__tests__/credential-vault.test.ts +5 -4
  42. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  43. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  44. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  45. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  46. package/src/__tests__/encrypted-store.test.ts +10 -5
  47. package/src/__tests__/followup-tools.test.ts +3 -1
  48. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  49. package/src/__tests__/gmail-integration.test.ts +0 -1
  50. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  51. package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
  52. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  53. package/src/__tests__/guardian-dispatch.test.ts +21 -19
  54. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  55. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  56. package/src/__tests__/guardian-routing-invariants.test.ts +1092 -0
  57. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  58. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  59. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  60. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  61. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  62. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  63. package/src/__tests__/heartbeat-service.test.ts +20 -0
  64. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  65. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  66. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  67. package/src/__tests__/intent-routing.test.ts +2 -0
  68. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  69. package/src/__tests__/mcp-cli.test.ts +77 -0
  70. package/src/__tests__/media-generate-image.test.ts +21 -0
  71. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  72. package/src/__tests__/memory-regressions.test.ts +20 -20
  73. package/src/__tests__/non-member-access-request.test.ts +212 -36
  74. package/src/__tests__/notification-decision-fallback.test.ts +63 -3
  75. package/src/__tests__/notification-decision-strategy.test.ts +78 -0
  76. package/src/__tests__/notification-guardian-path.test.ts +15 -15
  77. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  78. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  79. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  80. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  81. package/src/__tests__/pairing-routes.test.ts +171 -0
  82. package/src/__tests__/playbook-execution.test.ts +3 -1
  83. package/src/__tests__/playbook-tools.test.ts +3 -1
  84. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  85. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  86. package/src/__tests__/recording-handler.test.ts +11 -0
  87. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  88. package/src/__tests__/recording-state-machine.test.ts +13 -2
  89. package/src/__tests__/registry.test.ts +7 -3
  90. package/src/__tests__/relay-server.test.ts +148 -28
  91. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  92. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  93. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  94. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  95. package/src/__tests__/schedule-tools.test.ts +3 -1
  96. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  97. package/src/__tests__/secret-scanner.test.ts +8 -0
  98. package/src/__tests__/send-endpoint-busy.test.ts +4 -0
  99. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  100. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  101. package/src/__tests__/session-agent-loop.test.ts +16 -0
  102. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  103. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  104. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  105. package/src/__tests__/session-profile-injection.test.ts +21 -0
  106. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  107. package/src/__tests__/session-queue.test.ts +23 -0
  108. package/src/__tests__/session-runtime-assembly.test.ts +126 -59
  109. package/src/__tests__/session-skill-tools.test.ts +27 -5
  110. package/src/__tests__/session-slash-known.test.ts +23 -0
  111. package/src/__tests__/session-slash-queue.test.ts +23 -0
  112. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  113. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  114. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  115. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  116. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  117. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  118. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  119. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  120. package/src/__tests__/skills.test.ts +8 -4
  121. package/src/__tests__/slack-channel-config.test.ts +3 -1
  122. package/src/__tests__/subagent-tools.test.ts +19 -0
  123. package/src/__tests__/swarm-recursion.test.ts +2 -0
  124. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  125. package/src/__tests__/swarm-tool.test.ts +2 -0
  126. package/src/__tests__/system-prompt.test.ts +3 -1
  127. package/src/__tests__/task-compiler.test.ts +3 -1
  128. package/src/__tests__/task-management-tools.test.ts +3 -1
  129. package/src/__tests__/task-tools.test.ts +3 -1
  130. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  131. package/src/__tests__/terminal-tools.test.ts +2 -0
  132. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  133. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  134. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  135. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  136. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  137. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  138. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  139. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  140. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  141. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  142. package/src/__tests__/view-image-tool.test.ts +3 -1
  143. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  144. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  145. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  146. package/src/__tests__/work-item-output.test.ts +3 -1
  147. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  148. package/src/agent/loop.ts +46 -3
  149. package/src/approvals/guardian-decision-primitive.ts +285 -0
  150. package/src/approvals/guardian-request-resolvers.ts +539 -0
  151. package/src/calls/call-controller.ts +26 -23
  152. package/src/calls/guardian-action-sweep.ts +10 -2
  153. package/src/calls/guardian-dispatch.ts +46 -40
  154. package/src/calls/relay-server.ts +358 -24
  155. package/src/calls/types.ts +1 -1
  156. package/src/calls/voice-session-bridge.ts +3 -3
  157. package/src/cli.ts +12 -0
  158. package/src/config/agent-schema.ts +14 -3
  159. package/src/config/calls-schema.ts +6 -6
  160. package/src/config/core-schema.ts +3 -3
  161. package/src/config/feature-flag-registry.json +8 -0
  162. package/src/config/mcp-schema.ts +1 -1
  163. package/src/config/memory-schema.ts +27 -19
  164. package/src/config/schema.ts +21 -21
  165. package/src/config/skills-schema.ts +7 -7
  166. package/src/config/system-prompt.ts +2 -1
  167. package/src/config/templates/BOOTSTRAP.md +47 -31
  168. package/src/config/templates/USER.md +5 -0
  169. package/src/config/update-bulletin-template-path.ts +4 -1
  170. package/src/config/vellum-skills/trusted-contacts/SKILL.md +149 -21
  171. package/src/daemon/handlers/config-inbox.ts +4 -4
  172. package/src/daemon/handlers/guardian-actions.ts +45 -66
  173. package/src/daemon/handlers/sessions.ts +148 -4
  174. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  175. package/src/daemon/ipc-contract/messages.ts +16 -0
  176. package/src/daemon/ipc-contract-inventory.json +1 -0
  177. package/src/daemon/lifecycle.ts +22 -16
  178. package/src/daemon/pairing-store.ts +86 -3
  179. package/src/daemon/server.ts +18 -0
  180. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  181. package/src/daemon/session-agent-loop.ts +33 -6
  182. package/src/daemon/session-lifecycle.ts +25 -17
  183. package/src/daemon/session-memory.ts +2 -2
  184. package/src/daemon/session-process.ts +68 -326
  185. package/src/daemon/session-runtime-assembly.ts +119 -25
  186. package/src/daemon/session-tool-setup.ts +3 -2
  187. package/src/daemon/session.ts +4 -3
  188. package/src/home-base/prebuilt/seed.ts +2 -1
  189. package/src/hooks/templates.ts +2 -1
  190. package/src/memory/canonical-guardian-store.ts +586 -0
  191. package/src/memory/channel-guardian-store.ts +2 -0
  192. package/src/memory/conversation-crud.ts +7 -7
  193. package/src/memory/db-init.ts +20 -0
  194. package/src/memory/embedding-local.ts +257 -39
  195. package/src/memory/embedding-runtime-manager.ts +471 -0
  196. package/src/memory/guardian-action-store.ts +7 -60
  197. package/src/memory/guardian-approvals.ts +9 -4
  198. package/src/memory/guardian-bindings.ts +25 -1
  199. package/src/memory/indexer.ts +3 -3
  200. package/src/memory/ingress-invite-store.ts +45 -0
  201. package/src/memory/job-handlers/backfill.ts +16 -9
  202. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  203. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  204. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  205. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  206. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  207. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  208. package/src/memory/migrations/index.ts +5 -0
  209. package/src/memory/migrations/registry.ts +5 -0
  210. package/src/memory/qdrant-client.ts +31 -22
  211. package/src/memory/schema-migration.ts +1 -0
  212. package/src/memory/schema.ts +56 -0
  213. package/src/notifications/copy-composer.ts +31 -4
  214. package/src/notifications/decision-engine.ts +57 -0
  215. package/src/permissions/defaults.ts +2 -0
  216. package/src/runtime/access-request-helper.ts +173 -0
  217. package/src/runtime/actor-trust-resolver.ts +221 -0
  218. package/src/runtime/channel-guardian-service.ts +12 -4
  219. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  220. package/src/runtime/channel-retry-sweep.ts +18 -6
  221. package/src/runtime/guardian-context-resolver.ts +38 -71
  222. package/src/runtime/guardian-decision-types.ts +6 -0
  223. package/src/runtime/guardian-reply-router.ts +717 -0
  224. package/src/runtime/http-server.ts +8 -0
  225. package/src/runtime/ingress-service.ts +80 -3
  226. package/src/runtime/invite-redemption-service.ts +141 -2
  227. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  228. package/src/runtime/routes/channel-route-shared.ts +1 -1
  229. package/src/runtime/routes/channel-routes.ts +1 -1
  230. package/src/runtime/routes/conversation-routes.ts +20 -2
  231. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  232. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  233. package/src/runtime/routes/inbound-message-handler.ts +205 -529
  234. package/src/runtime/routes/ingress-routes.ts +52 -4
  235. package/src/runtime/routes/pairing-routes.ts +3 -0
  236. package/src/runtime/tool-grant-request-helper.ts +195 -0
  237. package/src/tools/executor.ts +13 -1
  238. package/src/tools/guardian-control-plane-policy.ts +2 -2
  239. package/src/tools/sensitive-output-placeholders.ts +203 -0
  240. package/src/tools/tool-approval-handler.ts +53 -10
  241. package/src/tools/types.ts +13 -2
  242. package/src/util/bundled-asset.ts +31 -0
  243. package/src/util/canonicalize-identity.ts +52 -0
  244. package/src/util/logger.ts +20 -8
  245. package/src/util/platform.ts +10 -0
  246. package/src/util/voice-code.ts +29 -0
  247. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -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,
@@ -91,6 +76,7 @@ import { handleApprovalInterception } from './guardian-approval-interception.js'
91
76
  import { deliverGeneratedApprovalPrompt } from './guardian-approval-prompt.js';
92
77
 
93
78
  import '../channel-invite-transports/telegram.js';
79
+ import '../channel-invite-transports/voice.js';
94
80
 
95
81
  const log = getLogger('runtime-http');
96
82
 
@@ -115,8 +101,8 @@ export async function handleChannelInbound(
115
101
  gatewayOriginSecret?: string,
116
102
  approvalCopyGenerator?: ApprovalCopyGenerator,
117
103
  approvalConversationGenerator?: ApprovalConversationGenerator,
118
- guardianActionCopyGenerator?: GuardianActionCopyGenerator,
119
- guardianFollowUpConversationGenerator?: GuardianFollowUpConversationGenerator,
104
+ _guardianActionCopyGenerator?: GuardianActionCopyGenerator,
105
+ _guardianFollowUpConversationGenerator?: GuardianFollowUpConversationGenerator,
120
106
  ): Promise<Response> {
121
107
  // Reject requests that lack valid gateway-origin proof. This ensures
122
108
  // channel inbound messages can only arrive via the gateway (which
@@ -202,6 +188,28 @@ export async function handleChannelInbound(
202
188
  log.debug({ raw: assistantId, canonical: canonicalAssistantId }, 'Canonicalized channel assistant ID');
203
189
  }
204
190
 
191
+ // Coerce senderExternalUserId to a string at the boundary — the field
192
+ // comes from unvalidated JSON and may be a number, object, or other
193
+ // non-string type. Non-string truthy values would throw inside
194
+ // canonicalizeInboundIdentity when it calls .trim().
195
+ const rawSenderId = body.senderExternalUserId != null
196
+ ? String(body.senderExternalUserId)
197
+ : undefined;
198
+
199
+ // Canonicalize the sender identity so all trust lookups, member matching,
200
+ // and guardian binding comparisons use a normalized form. Phone-like
201
+ // channels (sms, voice, whatsapp) are normalized to E.164; non-phone
202
+ // channels pass through the platform-stable ID unchanged.
203
+ const canonicalSenderId = rawSenderId
204
+ ? canonicalizeInboundIdentity(sourceChannel, rawSenderId)
205
+ : null;
206
+
207
+ // Track whether the original payload included a sender identity. A
208
+ // whitespace-only senderExternalUserId canonicalizes to null but still
209
+ // represents an explicit (malformed) identity claim that must enter the
210
+ // ACL deny path rather than bypassing it.
211
+ const hasSenderIdentityClaim = rawSenderId !== undefined;
212
+
205
213
  // ── Ingress ACL enforcement ──
206
214
  // Track the resolved member so the escalate branch can reference it after
207
215
  // recordInbound (where we have a conversationId).
@@ -222,7 +230,7 @@ export async function handleChannelInbound(
222
230
  typeof (rawCommandIntentForAcl as Record<string, unknown>).payload === 'string' &&
223
231
  ((rawCommandIntentForAcl as Record<string, unknown>).payload as string).startsWith('gv_');
224
232
 
225
- // Parse invite token from /start iv_<token> commands using the transport
233
+ // Parse invite token from /start payloads using the channel transport
226
234
  // adapter. The token is extracted once here so both the ACL bypass and
227
235
  // the intercept handler can reference it without re-parsing.
228
236
  const commandIntentForAcl = rawCommandIntentForAcl && typeof rawCommandIntentForAcl === 'object' && !Array.isArray(rawCommandIntentForAcl)
@@ -235,13 +243,18 @@ export async function handleChannelInbound(
235
243
  sourceMetadata: body.sourceMetadata,
236
244
  });
237
245
 
238
- if (body.senderExternalUserId) {
239
- resolvedMember = findMember({
240
- assistantId: canonicalAssistantId,
241
- sourceChannel,
242
- externalUserId: body.senderExternalUserId,
243
- externalChatId,
244
- });
246
+ if (canonicalSenderId || hasSenderIdentityClaim) {
247
+ // Only perform member lookup when we have a usable canonical ID.
248
+ // Whitespace-only senders (hasSenderIdentityClaim=true but
249
+ // canonicalSenderId=null) skip the lookup and fall into the deny path.
250
+ if (canonicalSenderId) {
251
+ resolvedMember = findMember({
252
+ assistantId: canonicalAssistantId,
253
+ sourceChannel,
254
+ externalUserId: canonicalSenderId,
255
+ externalChatId,
256
+ });
257
+ }
245
258
 
246
259
  if (!resolvedMember) {
247
260
  // Determine whether a verification-code bypass is warranted: only allow
@@ -279,7 +292,7 @@ export async function handleChannelInbound(
279
292
  }
280
293
 
281
294
  // ── Invite token intercept (non-member) ──
282
- // /start iv_<token> deep links grant access without guardian approval.
295
+ // /start invite deep links grant access without guardian approval.
283
296
  // Intercept here — before the deny gate — so valid invites short-circuit
284
297
  // the ACL rejection and never reach the agent pipeline.
285
298
  if (inviteToken && denyNonMember) {
@@ -288,7 +301,7 @@ export async function handleChannelInbound(
288
301
  sourceChannel,
289
302
  externalChatId,
290
303
  externalMessageId,
291
- senderExternalUserId: body.senderExternalUserId,
304
+ senderExternalUserId: canonicalSenderId ?? rawSenderId,
292
305
  senderName: body.senderName,
293
306
  senderUsername: body.senderUsername,
294
307
  replyCallbackUrl: body.replyCallbackUrl,
@@ -300,21 +313,22 @@ export async function handleChannelInbound(
300
313
  }
301
314
 
302
315
  if (denyNonMember) {
303
- log.info({ sourceChannel, externalUserId: body.senderExternalUserId }, 'Ingress ACL: no member record, denying');
316
+ log.info({ sourceChannel, externalUserId: canonicalSenderId }, 'Ingress ACL: no member record, denying');
304
317
 
305
318
  // 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.
319
+ // Uses the shared helper which handles guardian binding lookup,
320
+ // deduplication, canonical request creation, and notification emission.
308
321
  let guardianNotified = false;
309
322
  try {
310
- guardianNotified = notifyGuardianOfAccessRequest({
323
+ const accessResult = notifyGuardianOfAccessRequest({
311
324
  canonicalAssistantId,
312
325
  sourceChannel,
313
326
  externalChatId,
314
- senderExternalUserId: body.senderExternalUserId,
327
+ senderExternalUserId: canonicalSenderId ?? rawSenderId,
315
328
  senderName: body.senderName,
316
329
  senderUsername: body.senderUsername,
317
330
  });
331
+ guardianNotified = accessResult.notified;
318
332
  } catch (err) {
319
333
  log.error({ err, sourceChannel, externalChatId }, 'Failed to notify guardian of access request');
320
334
  }
@@ -373,7 +387,7 @@ export async function handleChannelInbound(
373
387
  sourceChannel,
374
388
  externalChatId,
375
389
  externalMessageId,
376
- senderExternalUserId: body.senderExternalUserId,
390
+ senderExternalUserId: canonicalSenderId ?? rawSenderId,
377
391
  senderName: body.senderName,
378
392
  senderUsername: body.senderUsername,
379
393
  replyCallbackUrl: body.replyCallbackUrl,
@@ -393,14 +407,15 @@ export async function handleChannelInbound(
393
407
  let guardianNotified = false;
394
408
  if (resolvedMember.status !== 'blocked') {
395
409
  try {
396
- guardianNotified = notifyGuardianOfAccessRequest({
410
+ const accessResult = notifyGuardianOfAccessRequest({
397
411
  canonicalAssistantId,
398
412
  sourceChannel,
399
413
  externalChatId,
400
- senderExternalUserId: body.senderExternalUserId,
414
+ senderExternalUserId: canonicalSenderId ?? rawSenderId,
401
415
  senderName: body.senderName,
402
416
  senderUsername: body.senderUsername,
403
417
  });
418
+ guardianNotified = accessResult.notified;
404
419
  } catch (err) {
405
420
  log.error({ err, sourceChannel, externalChatId }, 'Failed to notify guardian of access request');
406
421
  }
@@ -570,7 +585,7 @@ export async function handleChannelInbound(
570
585
  conversationId: result.conversationId,
571
586
  sourceChannel,
572
587
  externalChatId,
573
- externalUserId: body.senderExternalUserId ?? null,
588
+ externalUserId: canonicalSenderId ?? rawSenderId ?? null,
574
589
  displayName: body.senderName ?? null,
575
590
  username: body.senderUsername ?? null,
576
591
  });
@@ -600,19 +615,16 @@ export async function handleChannelInbound(
600
615
  assistantId: canonicalAssistantId,
601
616
  });
602
617
 
603
- createApprovalRequest({
604
- runId: `ingress-escalation-${Date.now()}`,
618
+ createCanonicalGuardianRequest({
619
+ kind: 'tool_approval',
620
+ sourceType: 'channel',
621
+ sourceChannel,
605
622
  conversationId: result.conversationId,
606
- assistantId: canonicalAssistantId,
607
- channel: sourceChannel,
608
- requesterExternalUserId: body.senderExternalUserId ?? '',
609
- requesterChatId: externalChatId,
623
+ requesterExternalUserId: canonicalSenderId ?? rawSenderId ?? undefined,
610
624
  guardianExternalUserId: binding.guardianExternalUserId,
611
- guardianChatId: binding.guardianDeliveryChatId,
612
625
  toolName: 'ingress_message',
613
- riskLevel: 'escalated_ingress',
614
- reason: 'Ingress policy requires guardian approval',
615
- expiresAt: Date.now() + GUARDIAN_APPROVAL_TTL_MS,
626
+ questionText: 'Ingress policy requires guardian approval',
627
+ expiresAt: new Date(Date.now() + GUARDIAN_APPROVAL_TTL_MS).toISOString(),
616
628
  });
617
629
 
618
630
  // Emit notification signal through the unified pipeline (fire-and-forget).
@@ -633,7 +645,7 @@ export async function handleChannelInbound(
633
645
  conversationId: result.conversationId,
634
646
  sourceChannel,
635
647
  externalChatId,
636
- senderIdentifier: body.senderName || body.senderUsername || body.senderExternalUserId || 'Unknown sender',
648
+ senderIdentifier: body.senderName || body.senderUsername || rawSenderId || 'Unknown sender',
637
649
  eventId: result.eventId,
638
650
  },
639
651
  dedupeKey: `escalation:${result.eventId}`,
@@ -679,14 +691,14 @@ export async function handleChannelInbound(
679
691
  commandIntent?.type === 'start' &&
680
692
  typeof commandIntent.payload === 'string' &&
681
693
  (commandIntent.payload as string).startsWith('gv_') &&
682
- body.senderExternalUserId
694
+ rawSenderId
683
695
  ) {
684
696
  const bootstrapToken = (commandIntent.payload as string).slice(3);
685
697
  const bootstrapSession = resolveBootstrapToken(canonicalAssistantId, sourceChannel, bootstrapToken);
686
698
 
687
699
  if (bootstrapSession && bootstrapSession.status === 'pending_bootstrap') {
688
700
  // Bind the pending_bootstrap session to the sender's identity
689
- bindSessionIdentity(bootstrapSession.id, body.senderExternalUserId, externalChatId);
701
+ bindSessionIdentity(bootstrapSession.id, rawSenderId!, externalChatId);
690
702
 
691
703
  // Transition bootstrap session to awaiting_response
692
704
  updateSessionStatus(bootstrapSession.id, 'awaiting_response');
@@ -696,7 +708,7 @@ export async function handleChannelInbound(
696
708
  const newSession = createOutboundSession({
697
709
  assistantId: canonicalAssistantId,
698
710
  channel: sourceChannel,
699
- expectedExternalUserId: body.senderExternalUserId,
711
+ expectedExternalUserId: rawSenderId!,
700
712
  expectedChatId: externalChatId,
701
713
  identityBindingStatus: 'bound',
702
714
  destinationAddress: externalChatId,
@@ -748,13 +760,13 @@ export async function handleChannelInbound(
748
760
  !result.duplicate &&
749
761
  shouldInterceptVerification &&
750
762
  guardianVerifyCode !== undefined &&
751
- body.senderExternalUserId
763
+ rawSenderId
752
764
  ) {
753
765
  const verifyResult = validateAndConsumeChallenge(
754
766
  canonicalAssistantId,
755
767
  sourceChannel,
756
768
  guardianVerifyCode,
757
- body.senderExternalUserId,
769
+ canonicalSenderId ?? rawSenderId!,
758
770
  externalChatId,
759
771
  body.senderUsername,
760
772
  body.senderName,
@@ -763,21 +775,37 @@ export async function handleChannelInbound(
763
775
  const guardianVerifyOutcome: 'verified' | 'failed' = verifyResult.success ? 'verified' : 'failed';
764
776
 
765
777
  if (verifyResult.success) {
778
+ const existingMember = (canonicalSenderId ?? rawSenderId)
779
+ ? findMember({
780
+ assistantId: canonicalAssistantId,
781
+ sourceChannel,
782
+ externalUserId: canonicalSenderId ?? rawSenderId!,
783
+ externalChatId,
784
+ })
785
+ : null;
786
+ const memberMatchesSender = existingMember?.externalUserId
787
+ ? canonicalizeInboundIdentity(sourceChannel, existingMember.externalUserId) === (canonicalSenderId ?? rawSenderId)
788
+ : false;
789
+ const preservedDisplayName = memberMatchesSender && existingMember?.displayName?.trim().length
790
+ ? existingMember.displayName
791
+ : body.senderName;
792
+
766
793
  upsertMember({
767
794
  assistantId: canonicalAssistantId,
768
795
  sourceChannel,
769
- externalUserId: body.senderExternalUserId,
796
+ externalUserId: canonicalSenderId ?? rawSenderId!,
770
797
  externalChatId,
771
798
  status: 'active',
772
799
  policy: 'allow',
773
- displayName: body.senderName,
800
+ // Keep guardian-curated member name stable across re-verification.
801
+ displayName: preservedDisplayName,
774
802
  username: body.senderUsername,
775
803
  });
776
804
 
777
805
  const verifyLogLabel = verifyResult.verificationType === 'trusted_contact'
778
806
  ? 'Trusted contact verified'
779
807
  : 'Guardian verified';
780
- log.info({ sourceChannel, externalUserId: body.senderExternalUserId, verificationType: verifyResult.verificationType }, `${verifyLogLabel}: auto-upserted ingress member`);
808
+ log.info({ sourceChannel, externalUserId: canonicalSenderId, verificationType: verifyResult.verificationType }, `${verifyLogLabel}: auto-upserted ingress member`);
781
809
 
782
810
  // Emit activated signal when a trusted contact completes verification.
783
811
  // Member record is persisted above before this event fires, satisfying
@@ -796,12 +824,12 @@ export async function handleChannelInbound(
796
824
  },
797
825
  contextPayload: {
798
826
  sourceChannel,
799
- externalUserId: body.senderExternalUserId,
827
+ externalUserId: canonicalSenderId ?? rawSenderId!,
800
828
  externalChatId,
801
829
  senderName: body.senderName ?? null,
802
830
  senderUsername: body.senderUsername ?? null,
803
831
  },
804
- dedupeKey: `trusted-contact:activated:${canonicalAssistantId}:${sourceChannel}:${body.senderExternalUserId}`,
832
+ dedupeKey: `trusted-contact:activated:${canonicalAssistantId}:${sourceChannel}:${canonicalSenderId ?? rawSenderId!}`,
805
833
  });
806
834
  }
807
835
  }
@@ -873,382 +901,126 @@ export async function handleChannelInbound(
873
901
  });
874
902
  }
875
903
 
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.
883
- if (
884
- !result.duplicate &&
885
- !hasCallbackData &&
886
- trimmedContent.length > 0 &&
887
- body.senderExternalUserId &&
888
- replyCallbackUrl
889
- ) {
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
- }
904
+ // Legacy voice guardian action interception removed — all guardian reply
905
+ // routing now flows through the canonical router below (routeGuardianReply),
906
+ // which handles request code matching, callback parsing, and NL classification
907
+ // against canonical_guardian_requests.
920
908
 
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
- }
909
+ // ── Actor role resolution ──
910
+ // Uses shared channel-agnostic resolution so all ingress paths classify
911
+ // guardian vs non-guardian actors the same way.
912
+ const guardianCtx: GuardianContext = resolveGuardianContext({
913
+ assistantId: canonicalAssistantId,
914
+ sourceChannel,
915
+ externalChatId,
916
+ senderExternalUserId: rawSenderId,
917
+ senderUsername: body.senderUsername,
918
+ senderDisplayName: body.senderName,
919
+ });
943
920
 
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
- }
921
+ // Hoisted flag: set by the canonical guardian reply router when the invite
922
+ // handoff bypass fires. Prevents legacy approval interception from swallowing
923
+ // the message when other approvals are pending in the same chat.
924
+ let skipApprovalInterception = false;
948
925
 
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
- }
926
+ // ── Canonical guardian reply router ──
927
+ // Attempts to route inbound messages through the canonical decision pipeline
928
+ // before falling through to the legacy approval interception. Handles
929
+ // deterministic callbacks (button presses), request code prefixes, and
930
+ // NL classification via the conversational approval engine.
931
+ if (
932
+ !result.duplicate &&
933
+ replyCallbackUrl &&
934
+ (trimmedContent.length > 0 || hasCallbackData) &&
935
+ rawSenderId &&
936
+ guardianCtx.trustClass === 'guardian'
937
+ ) {
938
+ // Compute destination-scoped pending request hints so the router can
939
+ // discover canonical requests delivered to this chat even when the
940
+ // request lacks a guardianExternalUserId (e.g. voice-originated
941
+ // pending_question requests).
942
+ //
943
+ // When delivery-scoped matches exist, union them with any identity-
944
+ // based pending requests so that requests without delivery rows (e.g.
945
+ // tool_approval requests created inline) are not silently excluded.
946
+ // Pass undefined (not []) when there are zero combined results so the
947
+ // router's own identity-based fallback stays active.
948
+ const deliveryScopedPendingRequests = listPendingCanonicalGuardianRequestsByDestinationChat(
949
+ sourceChannel,
950
+ externalChatId,
951
+ );
952
+ let pendingRequestIds: string[] | undefined;
953
+ if (deliveryScopedPendingRequests.length > 0) {
954
+ const deliveryIds = new Set(deliveryScopedPendingRequests.map(r => r.id));
955
+ // Also include identity-based pending requests so we don't hide them
956
+ const identityId = canonicalSenderId ?? rawSenderId!;
957
+ const identityPending = listCanonicalGuardianRequests({
958
+ status: 'pending',
959
+ guardianExternalUserId: identityId,
960
+ });
961
+ for (const r of identityPending) {
962
+ deliveryIds.add(r.id);
963
963
  }
964
+ pendingRequestIds = [...deliveryIds];
965
+ }
964
966
 
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
- }
967
+ const routerResult = await routeGuardianReply({
968
+ messageText: trimmedContent,
969
+ channel: sourceChannel,
970
+ actor: {
971
+ externalUserId: canonicalSenderId ?? rawSenderId!,
972
+ channel: sourceChannel,
973
+ isTrusted: false,
974
+ },
975
+ conversationId: result.conversationId,
976
+ callbackData: body.callbackData,
977
+ pendingRequestIds,
978
+ approvalConversationGenerator,
979
+ channelDeliveryContext: {
980
+ replyCallbackUrl,
981
+ guardianChatId: externalChatId,
982
+ assistantId: canonicalAssistantId,
983
+ bearerToken,
984
+ },
985
+ });
993
986
 
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
- );
987
+ if (routerResult.consumed) {
988
+ // Deliver reply text if the router produced one
989
+ if (routerResult.replyText) {
1013
990
  try {
1014
- await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: disambiguationText, assistantId }, bearerToken);
991
+ await deliverChannelReply(replyCallbackUrl, {
992
+ chatId: externalChatId,
993
+ text: routerResult.replyText,
994
+ assistantId: canonicalAssistantId,
995
+ }, bearerToken);
1015
996
  } catch (err) {
1016
- log.error({ err, externalChatId }, 'Failed to deliver guardian action disambiguation message');
997
+ log.error({ err, externalChatId }, 'Failed to deliver canonical router reply');
1017
998
  }
1018
- return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'disambiguation_sent' });
1019
999
  }
1020
1000
 
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
- }
1001
+ return Response.json({
1002
+ accepted: true,
1003
+ duplicate: false,
1004
+ eventId: result.eventId,
1005
+ canonicalRouter: routerResult.type,
1006
+ requestId: routerResult.requestId,
1007
+ });
1008
+ }
1204
1009
 
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
- }
1010
+ if (routerResult.skipApprovalInterception) {
1011
+ skipApprovalInterception = true;
1233
1012
  }
1234
1013
  }
1235
1014
 
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
1015
  // ── Approval interception ──
1248
1016
  // Keep this active whenever callback context is available.
1017
+ // Skipped when the canonical router flagged skipApprovalInterception (e.g.
1018
+ // invite handoff bypass) to prevent the legacy interceptor from swallowing
1019
+ // messages that should reach the assistant.
1249
1020
  if (
1250
1021
  replyCallbackUrl &&
1251
- !result.duplicate
1022
+ !result.duplicate &&
1023
+ !skipApprovalInterception
1252
1024
  ) {
1253
1025
  const approvalResult = await handleApprovalInterception({
1254
1026
  conversationId: result.conversationId,
@@ -1256,7 +1028,7 @@ export async function handleChannelInbound(
1256
1028
  content: trimmedContent,
1257
1029
  externalChatId,
1258
1030
  sourceChannel,
1259
- senderExternalUserId: body.senderExternalUserId,
1031
+ senderExternalUserId: canonicalSenderId ?? rawSenderId,
1260
1032
  replyCallbackUrl,
1261
1033
  bearerToken,
1262
1034
  guardianCtx,
@@ -1547,111 +1319,6 @@ async function handleInviteTokenIntercept(params: {
1547
1319
  return Response.json({ accepted: true, eventId: dedupResult.eventId, denied: true, inviteRedemption: outcome.reason });
1548
1320
  }
1549
1321
 
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
1322
  // ---------------------------------------------------------------------------
1656
1323
  // Background message processing
1657
1324
  // ---------------------------------------------------------------------------
@@ -1733,6 +1400,7 @@ function startPendingApprovalPromptWatcher(params: {
1733
1400
  conversationId: string;
1734
1401
  sourceChannel: ChannelId;
1735
1402
  externalChatId: string;
1403
+ guardianTrustClass: GuardianContext['trustClass'];
1736
1404
  replyCallbackUrl: string;
1737
1405
  bearerToken?: string;
1738
1406
  assistantId?: string;
@@ -1742,12 +1410,19 @@ function startPendingApprovalPromptWatcher(params: {
1742
1410
  conversationId,
1743
1411
  sourceChannel,
1744
1412
  externalChatId,
1413
+ guardianTrustClass,
1745
1414
  replyCallbackUrl,
1746
1415
  bearerToken,
1747
1416
  assistantId,
1748
1417
  approvalCopyGenerator,
1749
1418
  } = params;
1750
1419
 
1420
+ // Approval prompt delivery is guardian-only. Non-guardian and unverified
1421
+ // actors must never receive approval prompt broadcasts for the conversation.
1422
+ if (guardianTrustClass !== 'guardian') {
1423
+ return () => {};
1424
+ }
1425
+
1751
1426
  let active = true;
1752
1427
  const deliveredRequestIds = new Set<string>();
1753
1428
 
@@ -1826,6 +1501,7 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
1826
1501
  conversationId,
1827
1502
  sourceChannel,
1828
1503
  externalChatId,
1504
+ guardianTrustClass: guardianCtx.trustClass,
1829
1505
  replyCallbackUrl,
1830
1506
  bearerToken,
1831
1507
  assistantId,
@@ -1849,7 +1525,7 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
1849
1525
  },
1850
1526
  assistantId,
1851
1527
  guardianContext: toGuardianRuntimeContext(sourceChannel, guardianCtx),
1852
- isInteractive: guardianCtx.actorRole === 'guardian',
1528
+ isInteractive: guardianCtx.trustClass === 'guardian',
1853
1529
  ...(cmdIntent ? { commandIntent: cmdIntent } : {}),
1854
1530
  },
1855
1531
  sourceChannel,