@vellumai/assistant 0.3.18 → 0.3.20

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 (202) hide show
  1. package/ARCHITECTURE.md +155 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/docs/architecture/integrations.md +7 -11
  5. package/docs/architecture/security.md +80 -0
  6. package/package.json +1 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -0
  8. package/src/__tests__/approval-primitive.test.ts +540 -0
  9. package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
  10. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
  11. package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
  12. package/src/__tests__/call-controller.test.ts +605 -104
  13. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  14. package/src/__tests__/checker.test.ts +60 -0
  15. package/src/__tests__/cli.test.ts +42 -1
  16. package/src/__tests__/config-schema.test.ts +11 -127
  17. package/src/__tests__/config-watcher.test.ts +0 -8
  18. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  19. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  20. package/src/__tests__/diff.test.ts +22 -0
  21. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  22. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +779 -0
  23. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  24. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  25. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  26. package/src/__tests__/guardian-dispatch.test.ts +185 -1
  27. package/src/__tests__/guardian-grant-minting.test.ts +532 -0
  28. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  29. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  30. package/src/__tests__/ipc-snapshot.test.ts +58 -0
  31. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  32. package/src/__tests__/remote-skill-policy.test.ts +215 -0
  33. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  34. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  35. package/src/__tests__/scoped-approval-grants.test.ts +521 -0
  36. package/src/__tests__/scoped-grant-security-matrix.test.ts +444 -0
  37. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  38. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  39. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  40. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  41. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  42. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  43. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  44. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  45. package/src/__tests__/system-prompt.test.ts +1 -1
  46. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  47. package/src/__tests__/terminal-tools.test.ts +2 -93
  48. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  49. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  50. package/src/__tests__/trust-store.test.ts +2 -0
  51. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  52. package/src/__tests__/voice-scoped-grant-consumer.test.ts +533 -0
  53. package/src/agent/loop.ts +36 -1
  54. package/src/approvals/approval-primitive.ts +381 -0
  55. package/src/approvals/guardian-decision-primitive.ts +191 -0
  56. package/src/calls/call-controller.ts +276 -212
  57. package/src/calls/call-domain.ts +56 -6
  58. package/src/calls/guardian-dispatch.ts +56 -0
  59. package/src/calls/relay-server.ts +13 -0
  60. package/src/calls/types.ts +1 -1
  61. package/src/calls/voice-session-bridge.ts +59 -4
  62. package/src/cli/core-commands.ts +0 -4
  63. package/src/cli.ts +76 -34
  64. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  65. package/src/config/assistant-feature-flags.ts +162 -0
  66. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  67. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  68. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  69. package/src/config/bundled-skills/notifications/SKILL.md +18 -0
  70. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  71. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  72. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  73. package/src/config/core-schema.ts +1 -1
  74. package/src/config/env-registry.ts +10 -0
  75. package/src/config/feature-flag-registry.json +61 -0
  76. package/src/config/loader.ts +22 -1
  77. package/src/config/sandbox-schema.ts +0 -39
  78. package/src/config/schema.ts +12 -2
  79. package/src/config/skill-state.ts +34 -0
  80. package/src/config/skills-schema.ts +26 -0
  81. package/src/config/skills.ts +9 -0
  82. package/src/config/system-prompt.ts +110 -46
  83. package/src/config/templates/SOUL.md +1 -1
  84. package/src/config/types.ts +19 -1
  85. package/src/config/vellum-skills/catalog.json +1 -1
  86. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  87. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  88. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -1
  89. package/src/config/vellum-skills/trusted-contacts/SKILL.md +104 -3
  90. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  91. package/src/daemon/config-watcher.ts +0 -1
  92. package/src/daemon/daemon-control.ts +1 -1
  93. package/src/daemon/guardian-invite-intent.ts +124 -0
  94. package/src/daemon/handlers/avatar.ts +68 -0
  95. package/src/daemon/handlers/browser.ts +2 -2
  96. package/src/daemon/handlers/config-channels.ts +18 -0
  97. package/src/daemon/handlers/guardian-actions.ts +120 -0
  98. package/src/daemon/handlers/index.ts +4 -0
  99. package/src/daemon/handlers/sessions.ts +19 -0
  100. package/src/daemon/handlers/shared.ts +3 -1
  101. package/src/daemon/handlers/skills.ts +45 -2
  102. package/src/daemon/install-cli-launchers.ts +58 -13
  103. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  104. package/src/daemon/ipc-contract/sessions.ts +8 -2
  105. package/src/daemon/ipc-contract/settings.ts +25 -2
  106. package/src/daemon/ipc-contract/skills.ts +1 -0
  107. package/src/daemon/ipc-contract-inventory.json +10 -0
  108. package/src/daemon/ipc-contract.ts +4 -0
  109. package/src/daemon/lifecycle.ts +6 -2
  110. package/src/daemon/main.ts +1 -0
  111. package/src/daemon/server.ts +1 -0
  112. package/src/daemon/session-lifecycle.ts +52 -7
  113. package/src/daemon/session-memory.ts +45 -0
  114. package/src/daemon/session-process.ts +260 -422
  115. package/src/daemon/session-runtime-assembly.ts +12 -0
  116. package/src/daemon/session-skill-tools.ts +14 -1
  117. package/src/daemon/session-tool-setup.ts +5 -0
  118. package/src/daemon/session.ts +11 -0
  119. package/src/daemon/tool-side-effects.ts +35 -9
  120. package/src/index.ts +0 -2
  121. package/src/memory/conversation-display-order-migration.ts +44 -0
  122. package/src/memory/conversation-queries.ts +2 -0
  123. package/src/memory/conversation-store.ts +91 -0
  124. package/src/memory/db-init.ts +13 -1
  125. package/src/memory/embedding-local.ts +22 -8
  126. package/src/memory/guardian-action-store.ts +133 -2
  127. package/src/memory/guardian-verification.ts +1 -1
  128. package/src/memory/ingress-invite-store.ts +95 -1
  129. package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
  130. package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
  131. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  132. package/src/memory/migrations/index.ts +3 -0
  133. package/src/memory/schema.ts +35 -1
  134. package/src/memory/scoped-approval-grants.ts +518 -0
  135. package/src/messaging/providers/slack/client.ts +12 -0
  136. package/src/messaging/providers/slack/types.ts +5 -0
  137. package/src/notifications/decision-engine.ts +49 -12
  138. package/src/notifications/emit-signal.ts +7 -0
  139. package/src/notifications/signal.ts +7 -0
  140. package/src/notifications/thread-seed-composer.ts +2 -1
  141. package/src/permissions/checker.ts +27 -0
  142. package/src/runtime/channel-approval-types.ts +16 -6
  143. package/src/runtime/channel-approvals.ts +19 -15
  144. package/src/runtime/channel-invite-transport.ts +85 -0
  145. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  146. package/src/runtime/guardian-action-grant-minter.ts +154 -0
  147. package/src/runtime/guardian-action-message-composer.ts +30 -0
  148. package/src/runtime/guardian-decision-types.ts +91 -0
  149. package/src/runtime/http-server.ts +23 -1
  150. package/src/runtime/ingress-service.ts +22 -0
  151. package/src/runtime/invite-redemption-service.ts +181 -0
  152. package/src/runtime/invite-redemption-templates.ts +39 -0
  153. package/src/runtime/routes/call-routes.ts +2 -1
  154. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  155. package/src/runtime/routes/guardian-approval-interception.ts +66 -74
  156. package/src/runtime/routes/inbound-message-handler.ts +568 -409
  157. package/src/runtime/routes/pairing-routes.ts +4 -0
  158. package/src/security/encrypted-store.ts +31 -17
  159. package/src/security/keychain.ts +176 -2
  160. package/src/security/secure-keys.ts +97 -0
  161. package/src/security/tool-approval-digest.ts +67 -0
  162. package/src/skills/remote-skill-policy.ts +131 -0
  163. package/src/tools/browser/browser-execution.ts +2 -2
  164. package/src/tools/browser/browser-manager.ts +46 -32
  165. package/src/tools/browser/browser-screencast.ts +2 -2
  166. package/src/tools/calls/call-start.ts +1 -1
  167. package/src/tools/executor.ts +22 -17
  168. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  169. package/src/tools/skills/load.ts +22 -8
  170. package/src/tools/system/avatar-generator.ts +119 -0
  171. package/src/tools/system/navigate-settings.ts +65 -0
  172. package/src/tools/system/open-system-settings.ts +75 -0
  173. package/src/tools/system/voice-config.ts +121 -32
  174. package/src/tools/terminal/backends/native.ts +40 -19
  175. package/src/tools/terminal/backends/types.ts +3 -3
  176. package/src/tools/terminal/parser.ts +1 -1
  177. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  178. package/src/tools/terminal/sandbox.ts +1 -12
  179. package/src/tools/terminal/shell.ts +3 -31
  180. package/src/tools/tool-approval-handler.ts +141 -3
  181. package/src/tools/tool-manifest.ts +6 -0
  182. package/src/tools/types.ts +6 -0
  183. package/src/util/diff.ts +36 -13
  184. package/Dockerfile.sandbox +0 -5
  185. package/src/__tests__/doordash-client.test.ts +0 -187
  186. package/src/__tests__/doordash-session.test.ts +0 -154
  187. package/src/__tests__/signup-e2e.test.ts +0 -354
  188. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  189. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  190. package/src/cli/doordash.ts +0 -1057
  191. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  192. package/src/config/templates/LOOKS.md +0 -25
  193. package/src/doordash/cart-queries.ts +0 -787
  194. package/src/doordash/client.ts +0 -1016
  195. package/src/doordash/order-queries.ts +0 -85
  196. package/src/doordash/queries.ts +0 -13
  197. package/src/doordash/query-extractor.ts +0 -94
  198. package/src/doordash/search-queries.ts +0 -203
  199. package/src/doordash/session.ts +0 -84
  200. package/src/doordash/store-queries.ts +0 -246
  201. package/src/doordash/types.ts +0 -367
  202. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -1,9 +1,14 @@
1
1
  /**
2
2
  * Channel inbound message handler: validates, records, and routes inbound
3
3
  * messages from all channels. Handles ingress ACL, edits, guardian
4
- * verification, guardian action answers, and approval interception.
4
+ * verification, guardian action answers, approval interception, and
5
+ * invite token redemption.
5
6
  */
7
+ // Side-effect import: registers the Telegram invite transport adapter so
8
+ // getTransport('telegram') resolves at runtime.
6
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';
7
12
  import type { ChannelId, InterfaceId } from '../../channels/types.js';
8
13
  import { CHANNEL_IDS, INTERFACE_IDS, isChannelId, parseInterfaceId } from '../../channels/types.js';
9
14
  import { getGatewayInternalBaseUrl } from '../../config/env.js';
@@ -19,10 +24,12 @@ import * as conversationStore from '../../memory/conversation-store.js';
19
24
  import * as externalConversationStore from '../../memory/external-conversation-store.js';
20
25
  import {
21
26
  finalizeFollowup,
27
+ getDeliveriesByRequestId,
22
28
  getExpiredDeliveriesByDestination,
23
29
  getFollowupDeliveriesByDestination,
24
30
  getGuardianActionRequest,
25
31
  getPendingDeliveriesByDestination,
32
+ getPendingRequestByCallSessionId,
26
33
  progressFollowupState,
27
34
  resolveGuardianActionRequest,
28
35
  startFollowupFromExpiredRequest,
@@ -49,9 +56,11 @@ import {
49
56
  updateSessionStatus,
50
57
  validateAndConsumeChallenge,
51
58
  } from '../channel-guardian-service.js';
59
+ import { getTransport } from '../channel-invite-transport.js';
52
60
  import { deliverChannelReply } from '../gateway-client.js';
53
61
  import { processGuardianFollowUpTurn } from '../guardian-action-conversation-turn.js';
54
62
  import { executeFollowupAction } from '../guardian-action-followup-executor.js';
63
+ import { tryMintGuardianActionGrant } from '../guardian-action-grant-minter.js';
55
64
  import { composeGuardianActionMessageGenerative } from '../guardian-action-message-composer.js';
56
65
  import { resolveGuardianContext } from '../guardian-context-resolver.js';
57
66
  import {
@@ -67,6 +76,8 @@ import type {
67
76
  GuardianFollowUpConversationGenerator,
68
77
  MessageProcessor,
69
78
  } from '../http-types.js';
79
+ import { redeemInvite } from '../invite-redemption-service.js';
80
+ import { getInviteRedemptionReply } from '../invite-redemption-templates.js';
70
81
  import { deliverReplyViaCallback } from './channel-delivery-routes.js';
71
82
  import {
72
83
  canonicalChannelAssistantId,
@@ -79,6 +90,8 @@ import {
79
90
  import { handleApprovalInterception } from './guardian-approval-interception.js';
80
91
  import { deliverGeneratedApprovalPrompt } from './guardian-approval-prompt.js';
81
92
 
93
+ import '../channel-invite-transports/telegram.js';
94
+
82
95
  const log = getLogger('runtime-http');
83
96
 
84
97
  /**
@@ -209,6 +222,19 @@ export async function handleChannelInbound(
209
222
  typeof (rawCommandIntentForAcl as Record<string, unknown>).payload === 'string' &&
210
223
  ((rawCommandIntentForAcl as Record<string, unknown>).payload as string).startsWith('gv_');
211
224
 
225
+ // Parse invite token from /start iv_<token> commands using the transport
226
+ // adapter. The token is extracted once here so both the ACL bypass and
227
+ // the intercept handler can reference it without re-parsing.
228
+ const commandIntentForAcl = rawCommandIntentForAcl && typeof rawCommandIntentForAcl === 'object' && !Array.isArray(rawCommandIntentForAcl)
229
+ ? rawCommandIntentForAcl as Record<string, unknown>
230
+ : undefined;
231
+ const inviteTransport = getTransport(sourceChannel);
232
+ const inviteToken = inviteTransport?.extractInboundToken({
233
+ commandIntent: commandIntentForAcl,
234
+ content: trimmedContent,
235
+ sourceMetadata: body.sourceMetadata,
236
+ });
237
+
212
238
  if (body.senderExternalUserId) {
213
239
  resolvedMember = findMember({
214
240
  assistantId: canonicalAssistantId,
@@ -252,25 +278,36 @@ export async function handleChannelInbound(
252
278
  }
253
279
  }
254
280
 
281
+ // ── Invite token intercept (non-member) ──
282
+ // /start iv_<token> deep links grant access without guardian approval.
283
+ // Intercept here — before the deny gate — so valid invites short-circuit
284
+ // the ACL rejection and never reach the agent pipeline.
285
+ if (inviteToken && denyNonMember) {
286
+ const inviteResult = await handleInviteTokenIntercept({
287
+ rawToken: inviteToken,
288
+ sourceChannel,
289
+ externalChatId,
290
+ externalMessageId,
291
+ senderExternalUserId: body.senderExternalUserId,
292
+ senderName: body.senderName,
293
+ senderUsername: body.senderUsername,
294
+ replyCallbackUrl: body.replyCallbackUrl,
295
+ bearerToken,
296
+ assistantId,
297
+ canonicalAssistantId,
298
+ });
299
+ if (inviteResult) return inviteResult;
300
+ }
301
+
255
302
  if (denyNonMember) {
256
303
  log.info({ sourceChannel, externalUserId: body.senderExternalUserId }, 'Ingress ACL: no member record, denying');
257
- if (body.replyCallbackUrl) {
258
- try {
259
- await deliverChannelReply(body.replyCallbackUrl, {
260
- chatId: externalChatId,
261
- text: "Sorry, you haven't been approved to message this assistant. You can ask its Guardian for an invite.",
262
- assistantId,
263
- }, bearerToken);
264
- } catch (err) {
265
- log.error({ err, externalChatId }, 'Failed to deliver ACL rejection reply');
266
- }
267
- }
268
304
 
269
305
  // Notify the guardian about the access request so they can approve/deny.
270
306
  // Only fires when a guardian binding exists and no duplicate pending
271
307
  // request already exists for this requester.
308
+ let guardianNotified = false;
272
309
  try {
273
- notifyGuardianOfAccessRequest({
310
+ guardianNotified = notifyGuardianOfAccessRequest({
274
311
  canonicalAssistantId,
275
312
  sourceChannel,
276
313
  externalChatId,
@@ -282,25 +319,109 @@ export async function handleChannelInbound(
282
319
  log.error({ err, sourceChannel, externalChatId }, 'Failed to notify guardian of access request');
283
320
  }
284
321
 
285
- return Response.json({ accepted: true, denied: true, reason: 'not_a_member' });
286
- }
287
- }
288
-
289
- if (resolvedMember) {
290
- if (resolvedMember.status !== 'active') {
291
- log.info({ sourceChannel, memberId: resolvedMember.id, status: resolvedMember.status }, 'Ingress ACL: member not active, denying');
292
322
  if (body.replyCallbackUrl) {
323
+ const replyText = guardianNotified
324
+ ? "Hmm looks like you don't have access to talk to me. I'll let them know you tried talking to me and get back to you."
325
+ : "Sorry, you haven't been approved to message this assistant.";
293
326
  try {
294
327
  await deliverChannelReply(body.replyCallbackUrl, {
295
328
  chatId: externalChatId,
296
- text: "Sorry, you haven't been approved to message this assistant. You can ask its Guardian for an invite.",
329
+ text: replyText,
297
330
  assistantId,
298
331
  }, bearerToken);
299
332
  } catch (err) {
300
333
  log.error({ err, externalChatId }, 'Failed to deliver ACL rejection reply');
301
334
  }
302
335
  }
303
- return Response.json({ accepted: true, denied: true, reason: `member_${resolvedMember.status}` });
336
+
337
+ return Response.json({ accepted: true, denied: true, reason: 'not_a_member' });
338
+ }
339
+ }
340
+
341
+ if (resolvedMember) {
342
+ if (resolvedMember.status !== 'active') {
343
+ // Same bypass logic as the no-member branch: verification codes and
344
+ // bootstrap commands must pass through even when the member record is
345
+ // revoked/blocked — otherwise the user can never re-verify.
346
+ let denyInactiveMember = true;
347
+ if (isGuardianVerifyCode) {
348
+ const hasPendingChallenge = !!getPendingChallenge(canonicalAssistantId, sourceChannel);
349
+ const hasActiveOutboundSession = !!findActiveSession(canonicalAssistantId, sourceChannel);
350
+ if (hasPendingChallenge || hasActiveOutboundSession) {
351
+ denyInactiveMember = false;
352
+ } else {
353
+ log.info({ sourceChannel, memberId: resolvedMember.id, hasPendingChallenge, hasActiveOutboundSession }, 'Ingress ACL: inactive member verification bypass denied');
354
+ }
355
+ }
356
+ if (isBootstrapCommand) {
357
+ const bootstrapPayload = (rawCommandIntentForAcl as Record<string, unknown>).payload as string;
358
+ const bootstrapTokenForAcl = bootstrapPayload.slice(3);
359
+ const bootstrapSessionForAcl = resolveBootstrapToken(canonicalAssistantId, sourceChannel, bootstrapTokenForAcl);
360
+ if (bootstrapSessionForAcl && bootstrapSessionForAcl.status === 'pending_bootstrap') {
361
+ denyInactiveMember = false;
362
+ } else {
363
+ log.info({ sourceChannel, memberId: resolvedMember.id, hasValidBootstrapSession: false }, 'Ingress ACL: inactive member bootstrap bypass denied');
364
+ }
365
+ }
366
+
367
+ // ── Invite token intercept (inactive member) ──
368
+ // Same as the non-member branch: invite tokens can reactivate
369
+ // revoked/pending members without requiring guardian approval.
370
+ if (inviteToken && denyInactiveMember) {
371
+ const inviteResult = await handleInviteTokenIntercept({
372
+ rawToken: inviteToken,
373
+ sourceChannel,
374
+ externalChatId,
375
+ externalMessageId,
376
+ senderExternalUserId: body.senderExternalUserId,
377
+ senderName: body.senderName,
378
+ senderUsername: body.senderUsername,
379
+ replyCallbackUrl: body.replyCallbackUrl,
380
+ bearerToken,
381
+ assistantId,
382
+ canonicalAssistantId,
383
+ });
384
+ if (inviteResult) return inviteResult;
385
+ }
386
+
387
+ if (denyInactiveMember) {
388
+ log.info({ sourceChannel, memberId: resolvedMember.id, status: resolvedMember.status }, 'Ingress ACL: member not active, denying');
389
+
390
+ // For revoked/pending members, notify the guardian so they can
391
+ // re-approve. Blocked members are intentionally excluded — the
392
+ // guardian already made an explicit decision to block them.
393
+ let guardianNotified = false;
394
+ if (resolvedMember.status !== 'blocked') {
395
+ try {
396
+ guardianNotified = notifyGuardianOfAccessRequest({
397
+ canonicalAssistantId,
398
+ sourceChannel,
399
+ externalChatId,
400
+ senderExternalUserId: body.senderExternalUserId,
401
+ senderName: body.senderName,
402
+ senderUsername: body.senderUsername,
403
+ });
404
+ } catch (err) {
405
+ log.error({ err, sourceChannel, externalChatId }, 'Failed to notify guardian of access request');
406
+ }
407
+ }
408
+
409
+ if (body.replyCallbackUrl) {
410
+ const replyText = guardianNotified
411
+ ? "Hmm looks like you don't have access to talk to me. I'll let them know you tried talking to me and get back to you."
412
+ : "Sorry, you haven't been approved to message this assistant.";
413
+ try {
414
+ await deliverChannelReply(body.replyCallbackUrl, {
415
+ chatId: externalChatId,
416
+ text: replyText,
417
+ assistantId,
418
+ }, bearerToken);
419
+ } catch (err) {
420
+ log.error({ err, externalChatId }, 'Failed to deliver ACL rejection reply');
421
+ }
422
+ }
423
+ return Response.json({ accepted: true, denied: true, reason: `member_${resolvedMember.status}` });
424
+ }
304
425
  }
305
426
 
306
427
  if (resolvedMember.policy === 'deny') {
@@ -309,7 +430,7 @@ export async function handleChannelInbound(
309
430
  try {
310
431
  await deliverChannelReply(body.replyCallbackUrl, {
311
432
  chatId: externalChatId,
312
- text: "Sorry, you haven't been approved to message this assistant. You can ask its Guardian for an invite.",
433
+ text: "Sorry, you haven't been approved to message this assistant.",
313
434
  assistantId,
314
435
  }, bearerToken);
315
436
  } catch (err) {
@@ -752,12 +873,13 @@ export async function handleChannelInbound(
752
873
  });
753
874
  }
754
875
 
755
- // ── Guardian action answer interception ──
756
- // Check if this inbound message is a reply to a cross-channel guardian
757
- // action request (from a voice call). Must run before approval interception
758
- // so guardian answers are not mistakenly routed into the approval flow.
759
- // Callback payloads (inline button presses) are excluded they should
760
- // not be misclassified as guardian answers.
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.
761
883
  if (
762
884
  !result.duplicate &&
763
885
  !hasCallbackData &&
@@ -765,430 +887,346 @@ export async function handleChannelInbound(
765
887
  body.senderExternalUserId &&
766
888
  replyCallbackUrl
767
889
  ) {
768
- const pendingDeliveries = getPendingDeliveriesByDestination(canonicalAssistantId, sourceChannel, externalChatId);
769
- if (pendingDeliveries.length > 0) {
770
- // Identity check: only the designated guardian can answer
771
- const validDeliveries = pendingDeliveries.filter(
772
- (d) => d.destinationExternalUserId === body.senderExternalUserId,
773
- );
774
-
775
- if (validDeliveries.length > 0) {
776
- let matchedDelivery = validDeliveries.length === 1 ? validDeliveries[0] : null;
777
- let answerText = trimmedContent;
778
-
779
- // Multiple pending deliveries: require request code prefix for disambiguation
780
- if (validDeliveries.length > 1) {
781
- for (const d of validDeliveries) {
782
- const req = getGuardianActionRequest(d.requestId);
783
- if (req && trimmedContent.toUpperCase().startsWith(req.requestCode)) {
784
- matchedDelivery = d;
785
- answerText = trimmedContent.slice(req.requestCode.length).trim();
786
- break;
787
- }
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;
788
916
  }
917
+ }
918
+ if (codeMatch) break;
919
+ }
789
920
 
790
- if (!matchedDelivery) {
791
- // Send disambiguation message listing the request codes
792
- const codes = validDeliveries
793
- .map((d) => {
794
- const req = getGuardianActionRequest(d.requestId);
795
- return req ? req.requestCode : null;
796
- })
797
- .filter(Boolean);
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
+ );
798
935
  try {
799
- await deliverChannelReply(replyCallbackUrl, {
800
- chatId: externalChatId,
801
- text: `You have multiple pending guardian questions. Please prefix your reply with the reference code (${codes.join(', ')}) to indicate which question you are answering.`,
802
- assistantId,
803
- }, bearerToken);
936
+ await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: staleText, assistantId }, bearerToken);
804
937
  } catch (err) {
805
- log.error({ err, externalChatId }, 'Failed to deliver guardian action disambiguation message');
938
+ log.error({ err, externalChatId }, 'Failed to deliver superseded terminal notice');
806
939
  }
807
- return Response.json({
808
- accepted: true,
809
- duplicate: false,
810
- eventId: result.eventId,
811
- guardianAnswer: 'disambiguation_sent',
812
- });
940
+ return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'stale_superseded' });
813
941
  }
814
942
  }
815
943
 
816
- if (matchedDelivery) {
817
- const request = getGuardianActionRequest(matchedDelivery.requestId);
818
- if (request) {
819
- // Attempt to deliver the answer to the call first. Only resolve
820
- // the guardian action request if answerCall succeeds, so that a
821
- // failed delivery (e.g. pending question timed out) leaves the
822
- // request pending for retry from another channel.
823
- const answerResult = await answerCall({ callSessionId: request.callSessionId, answer: answerText });
824
-
825
- if (!('ok' in answerResult) || !answerResult.ok) {
826
- const errorMsg = 'error' in answerResult ? answerResult.error : 'Unknown error';
827
- log.warn({ callSessionId: request.callSessionId, error: errorMsg }, 'answerCall failed for guardian answer');
828
- try {
829
- const failureText = await composeGuardianActionMessageGenerative(
830
- { scenario: 'guardian_answer_delivery_failed' },
831
- {},
832
- guardianActionCopyGenerator,
833
- );
834
- await deliverChannelReply(replyCallbackUrl, {
835
- chatId: externalChatId,
836
- text: failureText,
837
- assistantId,
838
- }, bearerToken);
839
- } catch (deliverErr) {
840
- log.error({ err: deliverErr, externalChatId }, 'Failed to deliver guardian answer failure notice');
841
- }
842
- return Response.json({
843
- accepted: true,
844
- duplicate: false,
845
- eventId: result.eventId,
846
- guardianAnswer: 'answer_failed',
847
- });
848
- }
849
-
850
- const resolved = resolveGuardianActionRequest(
851
- request.id,
852
- answerText,
853
- sourceChannel,
854
- body.senderExternalUserId,
855
- );
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
+ }
856
948
 
857
- if (resolved) {
858
- return Response.json({
859
- accepted: true,
860
- duplicate: false,
861
- eventId: result.eventId,
862
- guardianAnswer: 'resolved',
863
- });
864
- } else {
865
- // resolveGuardianActionRequest returned null — request was no
866
- // longer pending. answerCall already succeeded above, so the
867
- // answer WAS delivered to the call. Don't initiate a follow-up
868
- // negotiation; instead tell the guardian the answer was relayed.
869
- const freshRequest = getGuardianActionRequest(request.id);
870
-
871
- // answerCall succeeded, so the answer was delivered regardless
872
- // of the resolve race. Inform the guardian accordingly.
873
- const relayedText = await composeGuardianActionMessageGenerative(
874
- {
875
- scenario: 'guardian_stale_answered' as const,
876
- },
877
- {},
878
- guardianActionCopyGenerator,
879
- );
880
- try {
881
- await deliverChannelReply(replyCallbackUrl, {
882
- chatId: externalChatId,
883
- text: relayedText,
884
- assistantId,
885
- }, bearerToken);
886
- } catch (err) {
887
- log.error({ err, externalChatId }, 'Failed to deliver guardian action stale notice');
888
- }
889
- log.info(
890
- { requestId: request.id, freshStatus: freshRequest?.status },
891
- 'answerCall succeeded but resolveGuardianActionRequest returned null — informed guardian answer was relayed',
892
- );
893
- return Response.json({
894
- accepted: true,
895
- duplicate: false,
896
- eventId: result.eventId,
897
- guardianAnswer: 'stale',
898
- });
899
- }
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();
900
960
  }
961
+ codeMatch = { delivery: singleDelivery, request: singleReq, state, answerText: text };
901
962
  }
902
963
  }
903
- }
904
- }
905
964
 
906
- // ── Expired guardian action late answer interception ──
907
- // When no pending delivery was found above, check for expired requests
908
- // eligible for follow-up (status='expired', followup_state='none').
909
- // Exclude callback payloads inline button presses should not be
910
- // misclassified as late guardian answers.
911
- if (
912
- !result.duplicate &&
913
- !hasCallbackData &&
914
- trimmedContent.length > 0 &&
915
- body.senderExternalUserId &&
916
- replyCallbackUrl
917
- ) {
918
- const expiredDeliveries = getExpiredDeliveriesByDestination(canonicalAssistantId, sourceChannel, externalChatId);
919
- if (expiredDeliveries.length > 0) {
920
- const validExpired = expiredDeliveries.filter(
921
- (d) => d.destinationExternalUserId === body.senderExternalUserId,
922
- );
923
-
924
- if (validExpired.length > 0) {
925
- let matchedExpired = validExpired.length === 1 ? validExpired[0] : null;
926
- let expiredAnswerText = trimmedContent;
927
-
928
- // Multiple expired deliveries: require request code prefix for disambiguation
929
- if (validExpired.length > 1) {
930
- for (const d of validExpired) {
931
- const req = getGuardianActionRequest(d.requestId);
932
- if (req && trimmedContent.toUpperCase().startsWith(req.requestCode)) {
933
- matchedExpired = d;
934
- expiredAnswerText = trimmedContent.slice(req.requestCode.length).trim();
935
- break;
936
- }
937
- }
938
-
939
- if (!matchedExpired) {
940
- // Send disambiguation message listing the request codes
941
- const codes = validExpired
942
- .map((d) => {
943
- const req = getGuardianActionRequest(d.requestId);
944
- return req ? req.requestCode : null;
945
- })
946
- .filter((code): code is string => typeof code === 'string' && code.length > 0);
947
- const disambiguationText = await composeGuardianActionMessageGenerative(
948
- {
949
- scenario: 'guardian_expired_disambiguation',
950
- requestCodes: codes,
951
- channel: sourceChannel,
952
- },
953
- { requiredKeywords: codes },
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
+ {},
954
982
  guardianActionCopyGenerator,
955
983
  );
956
984
  try {
957
- await deliverChannelReply(replyCallbackUrl, {
958
- chatId: externalChatId,
959
- text: disambiguationText,
960
- assistantId,
961
- }, bearerToken);
985
+ await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: unknownText, assistantId }, bearerToken);
962
986
  } catch (err) {
963
- log.error({ err, externalChatId }, 'Failed to deliver guardian action expired disambiguation message');
987
+ log.error({ err, externalChatId }, 'Failed to deliver unknown code notice');
964
988
  }
965
- return Response.json({
966
- accepted: true,
967
- duplicate: false,
968
- eventId: result.eventId,
969
- guardianAnswer: 'disambiguation_sent',
970
- });
989
+ return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'unknown_code' });
971
990
  }
972
991
  }
992
+ }
973
993
 
974
- if (matchedExpired) {
975
- const expiredRequest = getGuardianActionRequest(matchedExpired.requestId);
976
-
977
- if (expiredRequest && expiredRequest.status === 'expired' && expiredRequest.followupState === 'none') {
978
- const followupResult = startFollowupFromExpiredRequest(expiredRequest.id, expiredAnswerText);
979
- if (followupResult) {
980
- const followupText = await composeGuardianActionMessageGenerative(
981
- {
982
- scenario: 'guardian_late_answer_followup',
983
- questionText: expiredRequest.questionText,
984
- lateAnswerText: expiredAnswerText,
985
- },
986
- {},
987
- guardianActionCopyGenerator,
988
- );
989
- try {
990
- await deliverChannelReply(replyCallbackUrl, {
991
- chatId: externalChatId,
992
- text: followupText,
993
- assistantId,
994
- }, bearerToken);
995
- } catch (err) {
996
- log.error({ err, externalChatId }, 'Failed to deliver guardian action late answer follow-up');
997
- }
998
- return Response.json({
999
- accepted: true,
1000
- duplicate: false,
1001
- eventId: result.eventId,
1002
- guardianAnswer: 'followup_initiated',
1003
- });
1004
- } else {
1005
- // startFollowupFromExpiredRequest returned null (race condition:
1006
- // another reply already transitioned the request). Send a stale
1007
- // notice instead of falling through to the normal agent pipeline.
1008
- const staleText = await composeGuardianActionMessageGenerative(
1009
- { scenario: 'guardian_stale_expired' as const },
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
+ );
1013
+ try {
1014
+ await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: disambiguationText, assistantId }, bearerToken);
1015
+ } catch (err) {
1016
+ log.error({ err, externalChatId }, 'Failed to deliver guardian action disambiguation message');
1017
+ }
1018
+ return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'disambiguation_sent' });
1019
+ }
1020
+
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' },
1010
1035
  {},
1011
1036
  guardianActionCopyGenerator,
1012
1037
  );
1013
- try {
1014
- await deliverChannelReply(replyCallbackUrl, {
1015
- chatId: externalChatId,
1016
- text: staleText,
1017
- assistantId,
1018
- }, bearerToken);
1019
- } catch (err) {
1020
- log.error({ err, externalChatId }, 'Failed to deliver guardian action stale notice for expired follow-up race');
1021
- }
1022
- return Response.json({
1023
- accepted: true,
1024
- duplicate: false,
1025
- eventId: result.eventId,
1026
- guardianAnswer: 'stale',
1027
- });
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');
1028
1041
  }
1042
+ return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianAnswer: 'answer_failed' });
1029
1043
  }
1030
- }
1031
- }
1032
- }
1033
- }
1034
1044
 
1035
- // ── Guardian follow-up conversation interception ──
1036
- // When a request is in `awaiting_guardian_choice` state, the guardian has
1037
- // already been asked "call back or send a message?". Their next message
1038
- // is the reply to that prompt — route it through the conversation engine
1039
- // to classify their intent.
1040
- if (
1041
- !result.duplicate &&
1042
- !hasCallbackData &&
1043
- trimmedContent.length > 0 &&
1044
- body.senderExternalUserId &&
1045
- replyCallbackUrl
1046
- ) {
1047
- const followupDeliveries = getFollowupDeliveriesByDestination(canonicalAssistantId, sourceChannel, externalChatId);
1048
- if (followupDeliveries.length > 0) {
1049
- const validFollowup = followupDeliveries.filter(
1050
- (d) => d.destinationExternalUserId === body.senderExternalUserId,
1051
- );
1045
+ const resolved = resolveGuardianActionRequest(request.id, answerText, sourceChannel, body.senderExternalUserId);
1052
1046
 
1053
- if (validFollowup.length > 0) {
1054
- let matchedFollowup = validFollowup.length === 1 ? validFollowup[0] : null;
1055
- let followupReplyText = trimmedContent;
1056
-
1057
- // Multiple follow-up deliveries: require request code prefix for disambiguation
1058
- if (validFollowup.length > 1) {
1059
- for (const d of validFollowup) {
1060
- const req = getGuardianActionRequest(d.requestId);
1061
- if (req && trimmedContent.toUpperCase().startsWith(req.requestCode)) {
1062
- matchedFollowup = d;
1063
- followupReplyText = trimmedContent.slice(req.requestCode.length).trim();
1064
- break;
1065
- }
1066
- }
1047
+ if (resolved) {
1048
+ await tryMintGuardianActionGrant({
1049
+ request,
1050
+ answerText,
1051
+ decisionChannel: sourceChannel,
1052
+ guardianExternalUserId: body.senderExternalUserId,
1053
+ approvalConversationGenerator,
1054
+ });
1067
1055
 
1068
- if (!matchedFollowup) {
1069
- // Send disambiguation message listing the request codes
1070
- const codes = validFollowup
1071
- .map((d) => {
1072
- const req = getGuardianActionRequest(d.requestId);
1073
- return req ? req.requestCode : null;
1074
- })
1075
- .filter((code): code is string => typeof code === 'string' && code.length > 0);
1076
- const disambiguationText = await composeGuardianActionMessageGenerative(
1077
- {
1078
- scenario: 'guardian_followup_disambiguation',
1079
- requestCodes: codes,
1080
- channel: sourceChannel,
1081
- },
1082
- { requiredKeywords: codes },
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
+ {},
1083
1062
  guardianActionCopyGenerator,
1084
1063
  );
1085
1064
  try {
1086
- await deliverChannelReply(replyCallbackUrl, {
1087
- chatId: externalChatId,
1088
- text: disambiguationText,
1089
- assistantId,
1090
- }, bearerToken);
1065
+ await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: relayedText, assistantId }, bearerToken);
1091
1066
  } catch (err) {
1092
- log.error({ err, externalChatId }, 'Failed to deliver guardian follow-up disambiguation message');
1067
+ log.error({ err, externalChatId }, 'Failed to deliver guardian action stale notice');
1093
1068
  }
1094
- return Response.json({
1095
- accepted: true,
1096
- duplicate: false,
1097
- eventId: result.eventId,
1098
- guardianFollowUp: 'disambiguation_sent',
1099
- });
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' });
1100
1074
  }
1101
1075
  }
1102
1076
 
1103
- if (matchedFollowup) {
1104
- const followupRequest = getGuardianActionRequest(matchedFollowup.requestId);
1105
-
1106
- if (followupRequest && followupRequest.followupState === 'awaiting_guardian_choice') {
1107
- const turnResult = await processGuardianFollowUpTurn(
1108
- {
1109
- questionText: followupRequest.questionText,
1110
- lateAnswerText: followupRequest.lateAnswerText ?? '',
1111
- guardianReply: followupReplyText,
1112
- },
1113
- guardianFollowUpConversationGenerator,
1114
- );
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) !== undefined;
1091
+ } else if (turnResult.disposition === 'decline') {
1092
+ stateApplied = finalizeFollowup(request.id, 'declined') !== undefined;
1093
+ }
1115
1094
 
1116
- // Apply the disposition to the follow-up state machine.
1117
- // Both progressFollowupState and finalizeFollowup are compare-and-set:
1118
- // they return null when the transition was not applied (e.g. a concurrent
1119
- // reply already advanced the state). In that case we notify the guardian
1120
- // that the request was already resolved and skip action execution.
1121
- let stateApplied = true;
1122
- if (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back') {
1123
- stateApplied = progressFollowupState(followupRequest.id, 'dispatching', turnResult.disposition) !== undefined;
1124
- } else if (turnResult.disposition === 'decline') {
1125
- stateApplied = finalizeFollowup(followupRequest.id, 'declined') !== undefined;
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');
1126
1106
  }
1127
- // keep_pending: no state change guardian can reply again
1107
+ return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianFollowUp: 'stale_ignored' });
1108
+ }
1128
1109
 
1129
- if (!stateApplied) {
1130
- log.warn({ requestId: followupRequest.id, disposition: turnResult.disposition }, 'Follow-up state transition failed (already resolved)');
1131
- const staleText = await composeGuardianActionMessageGenerative(
1132
- { scenario: 'guardian_stale_followup' as const },
1133
- {},
1134
- guardianActionCopyGenerator,
1135
- );
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 () => {
1136
1118
  try {
1137
- await deliverChannelReply(replyCallbackUrl, {
1138
- chatId: externalChatId,
1139
- text: staleText,
1140
- assistantId,
1141
- }, bearerToken);
1142
- } catch (err) {
1143
- log.error({ err, externalChatId }, 'Failed to deliver stale follow-up notice');
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');
1144
1127
  }
1145
- return Response.json({
1146
- accepted: true,
1147
- duplicate: false,
1148
- eventId: result.eventId,
1149
- guardianFollowUp: 'stale_ignored',
1150
- });
1151
- }
1128
+ })();
1129
+ }
1152
1130
 
1153
- // Deliver the generated reply to the guardian
1154
- try {
1155
- await deliverChannelReply(replyCallbackUrl, {
1156
- chatId: externalChatId,
1157
- text: turnResult.replyText,
1158
- assistantId,
1159
- }, bearerToken);
1160
- } catch (err) {
1161
- log.error({ err, externalChatId }, 'Failed to deliver guardian follow-up conversation reply');
1162
- }
1131
+ return Response.json({ accepted: true, duplicate: false, eventId: result.eventId, guardianFollowUp: turnResult.disposition });
1132
+ }
1163
1133
 
1164
- // Execute the action and send a completion/failure reply (fire-and-forget).
1165
- // The initial reply above acknowledges the guardian's choice; the executor
1166
- // carries out the actual call_back or message_back and posts a second message.
1167
- if (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back') {
1168
- void (async () => {
1169
- try {
1170
- const execResult = await executeFollowupAction(
1171
- followupRequest.id,
1172
- turnResult.disposition as 'call_back' | 'message_back',
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
+ {},
1173
1183
  guardianActionCopyGenerator,
1174
1184
  );
1175
- await deliverChannelReply(replyCallbackUrl, {
1176
- chatId: externalChatId,
1177
- text: execResult.guardianReplyText,
1178
- assistantId,
1179
- }, bearerToken);
1180
- } catch (execErr) {
1181
- log.error({ err: execErr, requestId: followupRequest.id }, 'Follow-up action execution or completion reply failed');
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' });
1182
1195
  }
1183
- })();
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
+ }
1184
1201
  }
1202
+ // Call not active or no pending request — fall through to follow-up
1203
+ }
1185
1204
 
1186
- return Response.json({
1187
- accepted: true,
1188
- duplicate: false,
1189
- eventId: result.eventId,
1190
- guardianFollowUp: turnResult.disposition,
1191
- });
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' });
1192
1230
  }
1193
1231
  }
1194
1232
  }
@@ -1391,6 +1429,124 @@ export async function handleChannelInbound(
1391
1429
  });
1392
1430
  }
1393
1431
 
1432
+ // ---------------------------------------------------------------------------
1433
+ // Invite token intercept
1434
+ // ---------------------------------------------------------------------------
1435
+
1436
+ /**
1437
+ * Handle an inbound invite token for a non-member or inactive member.
1438
+ *
1439
+ * Redeems the invite, delivers a deterministic reply, and returns a Response
1440
+ * to short-circuit the handler. Returns `null` when the intercept should not
1441
+ * fire (e.g. already_member outcome — let normal flow handle it).
1442
+ */
1443
+ async function handleInviteTokenIntercept(params: {
1444
+ rawToken: string;
1445
+ sourceChannel: ChannelId;
1446
+ externalChatId: string;
1447
+ externalMessageId: string;
1448
+ senderExternalUserId?: string;
1449
+ senderName?: string;
1450
+ senderUsername?: string;
1451
+ replyCallbackUrl?: string;
1452
+ bearerToken?: string;
1453
+ assistantId?: string;
1454
+ canonicalAssistantId: string;
1455
+ }): Promise<Response | null> {
1456
+ const {
1457
+ rawToken,
1458
+ sourceChannel,
1459
+ externalChatId,
1460
+ externalMessageId,
1461
+ senderExternalUserId,
1462
+ senderName,
1463
+ senderUsername,
1464
+ replyCallbackUrl,
1465
+ bearerToken,
1466
+ assistantId,
1467
+ canonicalAssistantId,
1468
+ } = params;
1469
+
1470
+ // Record the inbound event for dedup tracking BEFORE performing redemption.
1471
+ // Without this, duplicate webhook deliveries (common with Telegram) would
1472
+ // not be tracked: the first delivery redeems the invite and returns early,
1473
+ // then the retry finds an active member, passes ACL, and the raw
1474
+ // /start iv_<token> message leaks into the agent pipeline.
1475
+ const dedupResult = channelDeliveryStore.recordInbound(
1476
+ sourceChannel,
1477
+ externalChatId,
1478
+ externalMessageId,
1479
+ { assistantId: canonicalAssistantId },
1480
+ );
1481
+
1482
+ if (dedupResult.duplicate) {
1483
+ return Response.json({
1484
+ accepted: true,
1485
+ duplicate: true,
1486
+ eventId: dedupResult.eventId,
1487
+ });
1488
+ }
1489
+
1490
+ const outcome = redeemInvite({
1491
+ rawToken,
1492
+ sourceChannel,
1493
+ externalUserId: senderExternalUserId,
1494
+ externalChatId,
1495
+ displayName: senderName,
1496
+ username: senderUsername,
1497
+ assistantId: canonicalAssistantId,
1498
+ });
1499
+
1500
+ log.info(
1501
+ { sourceChannel, externalChatId, ok: outcome.ok, type: outcome.ok ? outcome.type : undefined, reason: !outcome.ok ? outcome.reason : undefined },
1502
+ 'Invite token intercept: redemption result',
1503
+ );
1504
+
1505
+ // already_member means the user has an active record — let the normal
1506
+ // flow handle them (they passed ACL or the member is active).
1507
+ if (outcome.ok && outcome.type === 'already_member') {
1508
+ // Deliver a quick acknowledgement and short-circuit so the user
1509
+ // does not trigger the deny gate or a duplicate agent loop.
1510
+ const replyText = getInviteRedemptionReply(outcome);
1511
+ if (replyCallbackUrl) {
1512
+ try {
1513
+ await deliverChannelReply(replyCallbackUrl, {
1514
+ chatId: externalChatId,
1515
+ text: replyText,
1516
+ assistantId,
1517
+ }, bearerToken);
1518
+ } catch (err) {
1519
+ log.error({ err, externalChatId }, 'Failed to deliver invite already-member reply');
1520
+ }
1521
+ }
1522
+ channelDeliveryStore.markProcessed(dedupResult.eventId);
1523
+ return Response.json({ accepted: true, eventId: dedupResult.eventId, inviteRedemption: 'already_member' });
1524
+ }
1525
+
1526
+ const replyText = getInviteRedemptionReply(outcome);
1527
+
1528
+ if (replyCallbackUrl) {
1529
+ try {
1530
+ await deliverChannelReply(replyCallbackUrl, {
1531
+ chatId: externalChatId,
1532
+ text: replyText,
1533
+ assistantId,
1534
+ }, bearerToken);
1535
+ } catch (err) {
1536
+ log.error({ err, externalChatId }, 'Failed to deliver invite redemption reply');
1537
+ }
1538
+ }
1539
+
1540
+ if (outcome.ok && outcome.type === 'redeemed') {
1541
+ channelDeliveryStore.markProcessed(dedupResult.eventId);
1542
+ return Response.json({ accepted: true, eventId: dedupResult.eventId, inviteRedemption: 'redeemed', memberId: outcome.memberId });
1543
+ }
1544
+
1545
+ // Failed redemption — inform the user and deny
1546
+ channelDeliveryStore.markProcessed(dedupResult.eventId);
1547
+ return Response.json({ accepted: true, eventId: dedupResult.eventId, denied: true, inviteRedemption: outcome.reason });
1548
+ }
1549
+
1394
1550
  // ---------------------------------------------------------------------------
1395
1551
  // Non-member access request notification
1396
1552
  // ---------------------------------------------------------------------------
@@ -1408,7 +1564,7 @@ function notifyGuardianOfAccessRequest(params: {
1408
1564
  senderExternalUserId?: string;
1409
1565
  senderName?: string;
1410
1566
  senderUsername?: string;
1411
- }): void {
1567
+ }): boolean {
1412
1568
  const {
1413
1569
  canonicalAssistantId,
1414
1570
  sourceChannel,
@@ -1418,16 +1574,17 @@ function notifyGuardianOfAccessRequest(params: {
1418
1574
  senderUsername,
1419
1575
  } = params;
1420
1576
 
1421
- if (!senderExternalUserId) return;
1577
+ if (!senderExternalUserId) return false;
1422
1578
 
1423
1579
  const binding = getGuardianBinding(canonicalAssistantId, sourceChannel);
1424
1580
  if (!binding) {
1425
1581
  log.debug({ sourceChannel, canonicalAssistantId }, 'No guardian binding for access request notification');
1426
- return;
1582
+ return false;
1427
1583
  }
1428
1584
 
1429
1585
  // Deduplicate: skip if there is already a pending approval request for
1430
- // the same requester on this channel.
1586
+ // the same requester on this channel. Still return true — the guardian
1587
+ // was already notified for this request.
1431
1588
  const existing = findPendingAccessRequestForRequester(
1432
1589
  canonicalAssistantId,
1433
1590
  sourceChannel,
@@ -1439,7 +1596,7 @@ function notifyGuardianOfAccessRequest(params: {
1439
1596
  { sourceChannel, senderExternalUserId, existingId: existing.id },
1440
1597
  'Skipping duplicate access request notification',
1441
1598
  );
1442
- return;
1599
+ return true;
1443
1600
  }
1444
1601
 
1445
1602
  const senderIdentifier = senderName || senderUsername || senderExternalUserId;
@@ -1491,6 +1648,8 @@ function notifyGuardianOfAccessRequest(params: {
1491
1648
  { sourceChannel, senderExternalUserId, senderIdentifier },
1492
1649
  'Guardian notified of non-member access request',
1493
1650
  );
1651
+
1652
+ return true;
1494
1653
  }
1495
1654
 
1496
1655
  // ---------------------------------------------------------------------------