@vellumai/assistant 0.3.14 → 0.3.16

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 (295) hide show
  1. package/ARCHITECTURE.md +142 -0
  2. package/Dockerfile +2 -2
  3. package/README.md +5 -5
  4. package/docs/architecture/http-token-refresh.md +252 -0
  5. package/docs/architecture/memory.md +5 -4
  6. package/docs/architecture/scheduling.md +4 -88
  7. package/docs/runbook-trusted-contacts.md +283 -0
  8. package/docs/trusted-contact-access.md +247 -0
  9. package/package.json +1 -1
  10. package/scripts/ipc/check-swift-decoder-drift.ts +2 -0
  11. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +2 -6
  12. package/src/__tests__/access-request-decision.test.ts +331 -0
  13. package/src/__tests__/asset-materialize-tool.test.ts +7 -7
  14. package/src/__tests__/asset-search-tool.test.ts +15 -15
  15. package/src/__tests__/attachments-store.test.ts +13 -13
  16. package/src/__tests__/call-controller.test.ts +150 -4
  17. package/src/__tests__/call-conversation-messages.test.ts +2 -2
  18. package/src/__tests__/call-pointer-messages.test.ts +28 -0
  19. package/src/__tests__/call-start-guardian-guard.test.ts +93 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +108 -12
  21. package/src/__tests__/channel-guardian.test.ts +16 -14
  22. package/src/__tests__/checker.test.ts +24 -0
  23. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +2 -2
  24. package/src/__tests__/config-watcher.test.ts +358 -0
  25. package/src/__tests__/conversation-pairing.test.ts +24 -24
  26. package/src/__tests__/conversation-store.test.ts +36 -36
  27. package/src/__tests__/date-context.test.ts +179 -1
  28. package/src/__tests__/db-migration-rollback.test.ts +4 -7
  29. package/src/__tests__/deterministic-verification-control-plane.test.ts +5 -5
  30. package/src/__tests__/emit-signal-routing-intent.test.ts +179 -0
  31. package/src/__tests__/gateway-only-guard.test.ts +188 -0
  32. package/src/__tests__/guardian-action-conversation-turn.test.ts +451 -0
  33. package/src/__tests__/guardian-action-copy-generator.test.ts +197 -0
  34. package/src/__tests__/guardian-action-followup-executor.test.ts +379 -0
  35. package/src/__tests__/guardian-action-followup-store.test.ts +376 -0
  36. package/src/__tests__/guardian-action-late-reply.test.ts +294 -0
  37. package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +71 -0
  38. package/src/__tests__/guardian-action-sweep.test.ts +9 -9
  39. package/src/__tests__/guardian-control-plane-policy.test.ts +1 -3
  40. package/src/__tests__/guardian-outbound-http.test.ts +202 -10
  41. package/src/__tests__/guardian-verification-intent-routing.test.ts +179 -0
  42. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +141 -0
  43. package/src/__tests__/handlers-telegram-config.test.ts +6 -6
  44. package/src/__tests__/hooks-runner.test.ts +13 -4
  45. package/src/__tests__/ingress-routes-http.test.ts +443 -0
  46. package/src/__tests__/intent-routing.test.ts +14 -0
  47. package/src/__tests__/ipc-snapshot.test.ts +2 -5
  48. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  49. package/src/__tests__/memory-regressions.test.ts +16 -12
  50. package/src/__tests__/non-member-access-request.test.ts +282 -0
  51. package/src/__tests__/notification-decision-strategy.test.ts +136 -0
  52. package/src/__tests__/notification-routing-intent.test.ts +11 -2
  53. package/src/__tests__/notification-thread-candidates.test.ts +166 -0
  54. package/src/__tests__/recording-intent-fallback.test.ts +0 -1
  55. package/src/__tests__/recording-intent-handler.test.ts +6 -3
  56. package/src/__tests__/recording-intent.test.ts +3 -2
  57. package/src/__tests__/recording-state-machine.test.ts +337 -26
  58. package/src/__tests__/registry.test.ts +17 -8
  59. package/src/__tests__/relay-server.test.ts +105 -0
  60. package/src/__tests__/reminder.test.ts +13 -0
  61. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -4
  62. package/src/__tests__/scheduler-recurrence.test.ts +50 -0
  63. package/src/__tests__/server-history-render.test.ts +8 -8
  64. package/src/__tests__/session-agent-loop.test.ts +1 -0
  65. package/src/__tests__/session-runtime-assembly.test.ts +49 -0
  66. package/src/__tests__/session-skill-tools.test.ts +1 -0
  67. package/src/__tests__/skill-projection.benchmark.test.ts +11 -3
  68. package/src/__tests__/slack-channel-config.test.ts +230 -0
  69. package/src/__tests__/subagent-manager-notify.test.ts +4 -4
  70. package/src/__tests__/swarm-session-integration.test.ts +2 -2
  71. package/src/__tests__/system-prompt.test.ts +43 -0
  72. package/src/__tests__/task-management-tools.test.ts +3 -3
  73. package/src/__tests__/task-tools.test.ts +3 -3
  74. package/src/__tests__/trust-store.test.ts +17 -1
  75. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +491 -0
  76. package/src/__tests__/trusted-contact-multichannel.test.ts +409 -0
  77. package/src/__tests__/trusted-contact-verification.test.ts +360 -0
  78. package/src/__tests__/update-bulletin-format.test.ts +119 -0
  79. package/src/__tests__/update-bulletin-state.test.ts +129 -0
  80. package/src/__tests__/update-bulletin.test.ts +260 -0
  81. package/src/__tests__/update-template-contract.test.ts +29 -0
  82. package/src/agent/loop.ts +2 -2
  83. package/src/amazon/client.ts +2 -3
  84. package/src/calls/call-controller.ts +115 -34
  85. package/src/calls/call-conversation-messages.ts +2 -2
  86. package/src/calls/call-domain.ts +10 -3
  87. package/src/calls/call-pointer-messages.ts +17 -5
  88. package/src/calls/guardian-action-sweep.ts +77 -36
  89. package/src/calls/relay-server.ts +51 -12
  90. package/src/calls/twilio-routes.ts +3 -1
  91. package/src/calls/types.ts +1 -1
  92. package/src/calls/voice-session-bridge.ts +4 -4
  93. package/src/cli/core-commands.ts +3 -3
  94. package/src/cli/map.ts +8 -5
  95. package/src/config/bundled-skills/phone-calls/SKILL.md +16 -1
  96. package/src/config/bundled-skills/tasks/SKILL.md +1 -1
  97. package/src/config/bundled-skills/tasks/TOOLS.json +4 -4
  98. package/src/config/bundled-skills/time-based-actions/SKILL.md +11 -1
  99. package/src/config/computer-use-prompt.ts +1 -0
  100. package/src/config/core-schema.ts +16 -0
  101. package/src/config/env-registry.ts +1 -0
  102. package/src/config/env.ts +16 -1
  103. package/src/config/memory-schema.ts +5 -0
  104. package/src/config/schema.ts +4 -0
  105. package/src/config/system-prompt.ts +69 -2
  106. package/src/config/templates/BOOTSTRAP.md +1 -1
  107. package/src/config/templates/IDENTITY.md +8 -4
  108. package/src/config/templates/SOUL.md +14 -0
  109. package/src/config/templates/UPDATES.md +16 -0
  110. package/src/config/templates/USER.md +5 -1
  111. package/src/config/types.ts +1 -0
  112. package/src/config/update-bulletin-format.ts +52 -0
  113. package/src/config/update-bulletin-state.ts +49 -0
  114. package/src/config/update-bulletin.ts +82 -0
  115. package/src/config/vellum-skills/catalog.json +6 -0
  116. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +1 -1
  117. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +44 -10
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +4 -4
  119. package/src/config/vellum-skills/trusted-contacts/SKILL.md +147 -0
  120. package/src/config/vellum-skills/twilio-setup/SKILL.md +2 -2
  121. package/src/context/window-manager.ts +43 -3
  122. package/src/daemon/config-watcher.ts +1 -0
  123. package/src/daemon/connection-policy.ts +21 -1
  124. package/src/daemon/daemon-control.ts +164 -7
  125. package/src/daemon/date-context.ts +174 -1
  126. package/src/daemon/guardian-action-generators.ts +175 -0
  127. package/src/daemon/guardian-verification-intent.ts +120 -0
  128. package/src/daemon/handlers/apps.ts +1 -3
  129. package/src/daemon/handlers/config-channels.ts +8 -8
  130. package/src/daemon/handlers/config-heartbeat.ts +1 -1
  131. package/src/daemon/handlers/config-inbox.ts +55 -159
  132. package/src/daemon/handlers/config-ingress.ts +1 -1
  133. package/src/daemon/handlers/config-integrations.ts +1 -1
  134. package/src/daemon/handlers/config-platform.ts +1 -1
  135. package/src/daemon/handlers/config-scheduling.ts +2 -2
  136. package/src/daemon/handlers/config-slack-channel.ts +190 -0
  137. package/src/daemon/handlers/config-telegram.ts +1 -1
  138. package/src/daemon/handlers/config-twilio.ts +1 -1
  139. package/src/daemon/handlers/config-voice.ts +100 -0
  140. package/src/daemon/handlers/config.ts +3 -0
  141. package/src/daemon/handlers/index.ts +1 -1
  142. package/src/daemon/handlers/misc.ts +84 -6
  143. package/src/daemon/handlers/navigate-settings.ts +27 -0
  144. package/src/daemon/handlers/recording.ts +270 -144
  145. package/src/daemon/handlers/sessions.ts +107 -24
  146. package/src/daemon/handlers/subagents.ts +3 -3
  147. package/src/daemon/handlers/work-items.ts +10 -7
  148. package/src/daemon/ipc-contract/integrations.ts +9 -1
  149. package/src/daemon/ipc-contract/messages.ts +4 -0
  150. package/src/daemon/ipc-contract/sessions.ts +1 -1
  151. package/src/daemon/ipc-contract/settings.ts +26 -0
  152. package/src/daemon/ipc-contract/shared.ts +2 -0
  153. package/src/daemon/ipc-contract/work-items.ts +1 -7
  154. package/src/daemon/ipc-contract-inventory.json +5 -1
  155. package/src/daemon/ipc-contract.ts +5 -1
  156. package/src/daemon/lifecycle.ts +306 -266
  157. package/src/daemon/recording-executor.ts +1 -1
  158. package/src/daemon/recording-intent.ts +0 -41
  159. package/src/daemon/response-tier.ts +2 -2
  160. package/src/daemon/server.ts +6 -6
  161. package/src/daemon/session-agent-loop-handlers.ts +34 -9
  162. package/src/daemon/session-agent-loop.ts +15 -8
  163. package/src/daemon/session-history.ts +3 -2
  164. package/src/daemon/session-media-retry.ts +3 -0
  165. package/src/daemon/session-messaging.ts +38 -4
  166. package/src/daemon/session-notifiers.ts +2 -2
  167. package/src/daemon/session-process.ts +256 -23
  168. package/src/daemon/session-queue-manager.ts +2 -0
  169. package/src/daemon/session-runtime-assembly.ts +39 -0
  170. package/src/daemon/session-skill-tools.ts +13 -4
  171. package/src/daemon/session-tool-setup.ts +6 -7
  172. package/src/daemon/session.ts +19 -8
  173. package/src/daemon/tls-certs.ts +55 -13
  174. package/src/daemon/tool-side-effects.ts +13 -5
  175. package/src/gallery/default-gallery.ts +32 -9
  176. package/src/influencer/client.ts +2 -1
  177. package/src/memory/channel-delivery-store.ts +37 -567
  178. package/src/memory/channel-guardian-store.ts +66 -1317
  179. package/src/memory/conflict-store.ts +4 -4
  180. package/src/memory/conversation-attention-store.ts +4 -7
  181. package/src/memory/conversation-crud.ts +668 -0
  182. package/src/memory/conversation-queries.ts +361 -0
  183. package/src/memory/conversation-store.ts +45 -983
  184. package/src/memory/db-connection.ts +3 -0
  185. package/src/memory/db-init.ts +25 -0
  186. package/src/memory/delivery-channels.ts +175 -0
  187. package/src/memory/delivery-crud.ts +211 -0
  188. package/src/memory/delivery-status.ts +199 -0
  189. package/src/memory/embedding-backend.ts +70 -4
  190. package/src/memory/embedding-local.ts +12 -2
  191. package/src/memory/entity-extractor.ts +3 -8
  192. package/src/memory/fts-reconciler.ts +121 -0
  193. package/src/memory/guardian-action-store.ts +366 -3
  194. package/src/memory/guardian-approvals.ts +569 -0
  195. package/src/memory/guardian-bindings.ts +130 -0
  196. package/src/memory/guardian-rate-limits.ts +196 -0
  197. package/src/memory/guardian-verification.ts +520 -0
  198. package/src/memory/job-handlers/index-maintenance.ts +2 -1
  199. package/src/memory/job-utils.ts +8 -5
  200. package/src/memory/jobs-store.ts +66 -6
  201. package/src/memory/jobs-worker.ts +23 -1
  202. package/src/memory/migrations/030-guardian-action-followup.ts +21 -0
  203. package/src/memory/migrations/030-guardian-verification-purpose.ts +17 -0
  204. package/src/memory/migrations/031-conversations-thread-type-index.ts +5 -0
  205. package/src/memory/migrations/100-core-tables.ts +1 -1
  206. package/src/memory/migrations/101-watchers-and-logs.ts +4 -0
  207. package/src/memory/migrations/108-tasks-and-work-items.ts +1 -1
  208. package/src/memory/migrations/112-assistant-inbox.ts +1 -1
  209. package/src/memory/migrations/113-late-migrations.ts +1 -1
  210. package/src/memory/migrations/116-messages-fts.ts +13 -0
  211. package/src/memory/migrations/119-schema-indexes-and-columns.ts +37 -0
  212. package/src/memory/migrations/120-fk-cascade-rebuilds.ts +161 -0
  213. package/src/memory/migrations/index.ts +8 -3
  214. package/src/memory/migrations/validate-migration-state.ts +114 -15
  215. package/src/memory/qdrant-circuit-breaker.ts +105 -0
  216. package/src/memory/retriever.ts +46 -13
  217. package/src/memory/schema-migration.ts +3 -0
  218. package/src/memory/schema.ts +25 -7
  219. package/src/memory/search/semantic.ts +8 -90
  220. package/src/notifications/README.md +1 -1
  221. package/src/notifications/broadcaster.ts +20 -2
  222. package/src/notifications/conversation-pairing.ts +3 -3
  223. package/src/notifications/decision-engine.ts +173 -8
  224. package/src/notifications/deliveries-store.ts +27 -8
  225. package/src/notifications/preferences-store.ts +7 -7
  226. package/src/notifications/thread-candidates.ts +234 -0
  227. package/src/notifications/types.ts +18 -0
  228. package/src/permissions/defaults.ts +11 -1
  229. package/src/permissions/prompter.ts +17 -0
  230. package/src/permissions/trust-store.ts +2 -0
  231. package/src/providers/failover.ts +19 -0
  232. package/src/providers/registry.ts +46 -1
  233. package/src/runtime/approval-message-composer.ts +1 -1
  234. package/src/runtime/channel-guardian-service.ts +15 -3
  235. package/src/runtime/channel-retry-sweep.ts +7 -2
  236. package/src/runtime/guardian-action-conversation-turn.ts +85 -0
  237. package/src/runtime/guardian-action-followup-executor.ts +301 -0
  238. package/src/runtime/guardian-action-message-composer.ts +245 -0
  239. package/src/runtime/guardian-outbound-actions.ts +35 -15
  240. package/src/runtime/guardian-verification-templates.ts +15 -9
  241. package/src/runtime/http-errors.ts +93 -0
  242. package/src/runtime/http-server.ts +140 -51
  243. package/src/runtime/http-types.ts +53 -0
  244. package/src/runtime/ingress-service.ts +237 -0
  245. package/src/runtime/middleware/error-handler.ts +4 -3
  246. package/src/runtime/middleware/rate-limiter.ts +160 -0
  247. package/src/runtime/middleware/request-logger.ts +71 -0
  248. package/src/runtime/middleware/twilio-validation.ts +7 -6
  249. package/src/runtime/pending-interactions.ts +12 -0
  250. package/src/runtime/routes/access-request-decision.ts +215 -0
  251. package/src/runtime/routes/app-routes.ts +25 -18
  252. package/src/runtime/routes/approval-routes.ts +18 -47
  253. package/src/runtime/routes/attachment-routes.ts +15 -41
  254. package/src/runtime/routes/call-routes.ts +20 -20
  255. package/src/runtime/routes/channel-delivery-routes.ts +6 -5
  256. package/src/runtime/routes/contact-routes.ts +4 -9
  257. package/src/runtime/routes/conversation-attention-routes.ts +5 -4
  258. package/src/runtime/routes/conversation-routes.ts +26 -57
  259. package/src/runtime/routes/debug-routes.ts +71 -0
  260. package/src/runtime/routes/events-routes.ts +3 -2
  261. package/src/runtime/routes/guardian-approval-interception.ts +221 -0
  262. package/src/runtime/routes/identity-routes.ts +14 -10
  263. package/src/runtime/routes/inbound-conversation.ts +3 -2
  264. package/src/runtime/routes/inbound-message-handler.ts +527 -62
  265. package/src/runtime/routes/ingress-routes.ts +174 -0
  266. package/src/runtime/routes/integration-routes.ts +82 -20
  267. package/src/runtime/routes/pairing-routes.ts +11 -10
  268. package/src/runtime/routes/secret-routes.ts +10 -18
  269. package/src/runtime/verification-rate-limiter.ts +83 -0
  270. package/src/schedule/schedule-store.ts +13 -1
  271. package/src/schedule/scheduler.ts +2 -2
  272. package/src/security/secret-ingress.ts +5 -2
  273. package/src/security/secret-scanner.ts +72 -6
  274. package/src/subagent/manager.ts +6 -4
  275. package/src/swarm/plan-validator.ts +4 -1
  276. package/src/tasks/task-runner.ts +3 -1
  277. package/src/tools/browser/api-map.ts +9 -6
  278. package/src/tools/calls/call-start.ts +20 -0
  279. package/src/tools/executor.ts +50 -568
  280. package/src/tools/permission-checker.ts +272 -0
  281. package/src/tools/registry.ts +14 -6
  282. package/src/tools/reminder/reminder-store.ts +7 -7
  283. package/src/tools/reminder/reminder.ts +6 -3
  284. package/src/tools/secret-detection-handler.ts +301 -0
  285. package/src/tools/subagent/message.ts +1 -1
  286. package/src/tools/system/voice-config.ts +62 -0
  287. package/src/tools/tasks/index.ts +3 -3
  288. package/src/tools/tasks/work-item-list.ts +3 -3
  289. package/src/tools/tasks/work-item-update.ts +4 -5
  290. package/src/tools/tool-approval-handler.ts +192 -0
  291. package/src/tools/tool-manifest.ts +2 -0
  292. package/src/watcher/watcher-store.ts +9 -9
  293. package/src/work-items/work-item-runner.ts +9 -6
  294. /package/src/memory/migrations/{026-embeddings-nullable-vector-json.ts → 026a-embeddings-nullable-vector-json.ts} +0 -0
  295. /package/src/memory/migrations/{027-guardian-bootstrap-token.ts → 027a-guardian-bootstrap-token.ts} +0 -0
@@ -12,14 +12,20 @@ import * as attachmentsStore from '../../memory/attachments-store.js';
12
12
  import * as channelDeliveryStore from '../../memory/channel-delivery-store.js';
13
13
  import {
14
14
  createApprovalRequest,
15
+ findPendingAccessRequestForRequester,
15
16
  } from '../../memory/channel-guardian-store.js';
16
17
  import { recordConversationSeenSignal } from '../../memory/conversation-attention-store.js';
17
18
  import * as conversationStore from '../../memory/conversation-store.js';
18
19
  import * as externalConversationStore from '../../memory/external-conversation-store.js';
19
20
  import {
21
+ finalizeFollowup,
22
+ getExpiredDeliveriesByDestination,
23
+ getFollowupDeliveriesByDestination,
20
24
  getGuardianActionRequest,
21
25
  getPendingDeliveriesByDestination,
26
+ progressFollowupState,
22
27
  resolveGuardianActionRequest,
28
+ startFollowupFromExpiredRequest,
23
29
  } from '../../memory/guardian-action-store.js';
24
30
  import { findMember, updateLastSeen, upsertMember } from '../../memory/ingress-member-store.js';
25
31
  import { emitNotificationSignal } from '../../notifications/emit-signal.js';
@@ -53,8 +59,14 @@ import {
53
59
  import type {
54
60
  ApprovalConversationGenerator,
55
61
  ApprovalCopyGenerator,
62
+ GuardianActionCopyGenerator,
63
+ GuardianFollowUpConversationGenerator,
56
64
  MessageProcessor,
57
65
  } from '../http-types.js';
66
+ import { processGuardianFollowUpTurn } from '../guardian-action-conversation-turn.js';
67
+ import { composeGuardianActionMessageGenerative } from '../guardian-action-message-composer.js';
68
+ import { executeFollowupAction } from '../guardian-action-followup-executor.js';
69
+ import { httpError } from '../http-errors.js';
58
70
  import { deliverReplyViaCallback } from './channel-delivery-routes.js';
59
71
  import {
60
72
  canonicalChannelAssistantId,
@@ -71,26 +83,13 @@ const log = getLogger('runtime-http');
71
83
 
72
84
  /**
73
85
  * Parse a guardian verification code from message content.
74
- * Accepts three formats:
75
- * 1. `/guardian_verify <code>` (legacy command format)
76
- * 2. `/guardian_verify@BotName <code>` (Telegram group format)
77
- * 3. A bare code as the entire message: 6-digit numeric OR 64-char hex
78
- * (hex is retained for backward compatibility with in-flight inbound
79
- * challenges that still use high-entropy secrets)
80
- * Returns `{ code, isExplicitCommand }` if recognized, or undefined otherwise.
81
- * `isExplicitCommand` is true for legacy /guardian_verify commands (explicit
82
- * intent) and false for bare codes (which need additional gating to avoid
83
- * intercepting normal 6-digit messages like zip codes or PINs).
86
+ * Accepts a bare code as the entire message: 6-digit numeric OR 64-char hex
87
+ * (hex is retained for compatibility with unbound inbound/bootstrap sessions
88
+ * that intentionally use high-entropy secrets).
84
89
  */
85
- function parseGuardianVerifyCommand(content: string): { code: string; isExplicitCommand: boolean } | undefined {
86
- // Legacy /guardian_verify command format
87
- const commandMatch = content.match(/^\/guardian_verify(?:@\S+)?\s+(\S+)/);
88
- if (commandMatch) return { code: commandMatch[1], isExplicitCommand: true };
89
-
90
- // Bare code: 6-digit numeric (identity-bound outbound sessions) or
91
- // 64-char hex (unbound inbound challenges)
90
+ function parseGuardianVerifyCode(content: string): string | undefined {
92
91
  const bareMatch = content.match(/^([0-9a-fA-F]{64}|\d{6})$/);
93
- if (bareMatch) return { code: bareMatch[1], isExplicitCommand: false };
92
+ if (bareMatch) return bareMatch[1];
94
93
 
95
94
  return undefined;
96
95
  }
@@ -103,6 +102,8 @@ export async function handleChannelInbound(
103
102
  gatewayOriginSecret?: string,
104
103
  approvalCopyGenerator?: ApprovalCopyGenerator,
105
104
  approvalConversationGenerator?: ApprovalConversationGenerator,
105
+ guardianActionCopyGenerator?: GuardianActionCopyGenerator,
106
+ guardianFollowUpConversationGenerator?: GuardianFollowUpConversationGenerator,
106
107
  ): Promise<Response> {
107
108
  // Reject requests that lack valid gateway-origin proof. This ensures
108
109
  // channel inbound messages can only arrive via the gateway (which
@@ -142,40 +143,34 @@ export async function handleChannelInbound(
142
143
  } = body;
143
144
 
144
145
  if (!body.sourceChannel || typeof body.sourceChannel !== 'string') {
145
- return Response.json({ error: 'sourceChannel is required' }, { status: 400 });
146
+ return httpError('BAD_REQUEST', 'sourceChannel is required', 400);
146
147
  }
147
148
  // Validate and narrow to canonical ChannelId at the boundary — the gateway
148
149
  // only sends well-known channel strings, so an unknown value is rejected.
149
150
  if (!isChannelId(body.sourceChannel)) {
150
- return Response.json(
151
- { error: `Invalid sourceChannel: ${body.sourceChannel}. Valid values: ${CHANNEL_IDS.join(', ')}` },
152
- { status: 400 },
153
- );
151
+ return httpError('BAD_REQUEST', `Invalid sourceChannel: ${body.sourceChannel}. Valid values: ${CHANNEL_IDS.join(', ')}`, 400);
154
152
  }
155
153
 
156
154
  const sourceChannel = body.sourceChannel;
157
155
 
158
156
  if (!body.interface || typeof body.interface !== 'string') {
159
- return Response.json({ error: 'interface is required' }, { status: 400 });
157
+ return httpError('BAD_REQUEST', 'interface is required', 400);
160
158
  }
161
159
  const sourceInterface = parseInterfaceId(body.interface);
162
160
  if (!sourceInterface) {
163
- return Response.json(
164
- { error: `Invalid interface: ${body.interface}. Valid values: ${INTERFACE_IDS.join(', ')}` },
165
- { status: 400 },
166
- );
161
+ return httpError('BAD_REQUEST', `Invalid interface: ${body.interface}. Valid values: ${INTERFACE_IDS.join(', ')}`, 400);
167
162
  }
168
163
 
169
164
  if (!externalChatId || typeof externalChatId !== 'string') {
170
- return Response.json({ error: 'externalChatId is required' }, { status: 400 });
165
+ return httpError('BAD_REQUEST', 'externalChatId is required', 400);
171
166
  }
172
167
  if (!externalMessageId || typeof externalMessageId !== 'string') {
173
- return Response.json({ error: 'externalMessageId is required' }, { status: 400 });
168
+ return httpError('BAD_REQUEST', 'externalMessageId is required', 400);
174
169
  }
175
170
 
176
171
  // Reject non-string content regardless of whether attachments are present.
177
172
  if (content != null && typeof content !== 'string') {
178
- return Response.json({ error: 'content must be a string' }, { status: 400 });
173
+ return httpError('BAD_REQUEST', 'content must be a string', 400);
179
174
  }
180
175
 
181
176
  const trimmedContent = typeof content === 'string' ? content.trim() : '';
@@ -184,7 +179,7 @@ export async function handleChannelInbound(
184
179
  const hasCallbackData = typeof body.callbackData === 'string' && body.callbackData.length > 0;
185
180
 
186
181
  if (trimmedContent.length === 0 && !hasAttachments && !isEdit && !hasCallbackData) {
187
- return Response.json({ error: 'content or attachmentIds is required' }, { status: 400 });
182
+ return httpError('BAD_REQUEST', 'content or attachmentIds is required', 400);
188
183
  }
189
184
 
190
185
  // Canonicalize the assistant ID so all DB-facing operations use the
@@ -199,10 +194,10 @@ export async function handleChannelInbound(
199
194
  // recordInbound (where we have a conversationId).
200
195
  let resolvedMember: ReturnType<typeof findMember> = null;
201
196
 
202
- // /guardian_verify must bypass the ACL membership check — users without a
197
+ // Verification codes must bypass the ACL membership check — users without a
203
198
  // member record need to verify before they can be recognized as members.
204
- const guardianVerifyParsed = parseGuardianVerifyCommand(trimmedContent);
205
- const isGuardianVerifyCommand = guardianVerifyParsed !== undefined;
199
+ const guardianVerifyCode = parseGuardianVerifyCode(trimmedContent);
200
+ const isGuardianVerifyCode = guardianVerifyCode !== undefined;
206
201
 
207
202
  // /start gv_<token> bootstrap commands must also bypass ACL — the user
208
203
  // hasn't been verified yet and needs to complete the bootstrap handshake.
@@ -223,11 +218,11 @@ export async function handleChannelInbound(
223
218
  });
224
219
 
225
220
  if (!resolvedMember) {
226
- // Determine whether a /guardian_verify bypass is warranted: only allow
221
+ // Determine whether a verification-code bypass is warranted: only allow
227
222
  // when there is a pending (unconsumed, unexpired) challenge AND no
228
223
  // active guardian binding for this (assistantId, channel).
229
224
  let denyNonMember = true;
230
- if (isGuardianVerifyCommand) {
225
+ if (isGuardianVerifyCode) {
231
226
  // Allow bypass when there is any consumable challenge or active
232
227
  // outbound session. The !hasActiveBinding guard is intentionally
233
228
  // omitted: rebind sessions create a consumable challenge while a
@@ -238,7 +233,7 @@ export async function handleChannelInbound(
238
233
  if (hasPendingChallenge || hasActiveOutboundSession) {
239
234
  denyNonMember = false;
240
235
  } else {
241
- log.info({ sourceChannel, hasPendingChallenge, hasActiveOutboundSession }, 'Ingress ACL: guardian_verify bypass denied');
236
+ log.info({ sourceChannel, hasPendingChallenge, hasActiveOutboundSession }, 'Ingress ACL: guardian verification bypass denied');
242
237
  }
243
238
  }
244
239
 
@@ -270,6 +265,23 @@ export async function handleChannelInbound(
270
265
  log.error({ err, externalChatId }, 'Failed to deliver ACL rejection reply');
271
266
  }
272
267
  }
268
+
269
+ // Notify the guardian about the access request so they can approve/deny.
270
+ // Only fires when a guardian binding exists and no duplicate pending
271
+ // request already exists for this requester.
272
+ try {
273
+ notifyGuardianOfAccessRequest({
274
+ canonicalAssistantId,
275
+ sourceChannel,
276
+ externalChatId,
277
+ senderExternalUserId: body.senderExternalUserId,
278
+ senderName: body.senderName,
279
+ senderUsername: body.senderUsername,
280
+ });
281
+ } catch (err) {
282
+ log.error({ err, sourceChannel, externalChatId }, 'Failed to notify guardian of access request');
283
+ }
284
+
273
285
  return Response.json({ accepted: true, denied: true, reason: 'not_a_member' });
274
286
  }
275
287
  }
@@ -329,7 +341,7 @@ export async function handleChannelInbound(
329
341
  : undefined;
330
342
 
331
343
  if (isEdit && !sourceMessageId) {
332
- return Response.json({ error: 'sourceMetadata.messageId is required for edits' }, { status: 400 });
344
+ return httpError('BAD_REQUEST', 'sourceMetadata.messageId is required for edits', 400);
333
345
  }
334
346
 
335
347
  // ── Edit path: update existing message content, no new agent loop ──
@@ -536,7 +548,7 @@ export async function handleChannelInbound(
536
548
  : undefined;
537
549
 
538
550
  // ── Telegram bootstrap deep-link handling ──
539
- // Intercept /start gv_<token> commands BEFORE the guardian_verify intercept.
551
+ // Intercept /start gv_<token> commands BEFORE the verification-code intercept.
540
552
  // When a user clicks the deep link, Telegram sends /start gv_<token> which
541
553
  // the gateway forwards with commandIntent: { type: 'start', payload: 'gv_<token>' }.
542
554
  // We resolve the bootstrap token, bind the session identity, create a new
@@ -569,7 +581,7 @@ export async function handleChannelInbound(
569
581
  destinationAddress: externalChatId,
570
582
  });
571
583
 
572
- // Compose and send the verification code via Telegram
584
+ // Compose and send the verification prompt via Telegram
573
585
  const telegramBody = composeVerificationTelegram(
574
586
  GUARDIAN_VERIFY_TEMPLATE_KEYS.TELEGRAM_CHALLENGE_REQUEST,
575
587
  {
@@ -595,34 +607,32 @@ export async function handleChannelInbound(
595
607
  // If not found or expired, fall through to normal /start handling
596
608
  }
597
609
 
598
- // ── Guardian verification command intercept (deterministic) ──
610
+ // ── Guardian verification code intercept (deterministic) ──
599
611
  // Validate/consume the challenge synchronously so side effects (member
600
612
  // upsert, binding creation) complete before any reply. The reply is
601
- // delivered via template-driven deterministic messages and the command
613
+ // delivered via template-driven deterministic messages and the code
602
614
  // is short-circuited — it NEVER enters the agent pipeline. This
603
- // prevents verification commands from producing agent-generated copy.
615
+ // prevents verification code messages from producing agent-generated copy.
604
616
  //
605
617
  // Bare 6-digit codes are only intercepted when there is actually a
606
618
  // pending challenge or active outbound session for this channel.
607
619
  // Without this guard, normal 6-digit messages (zip codes, PINs, etc.)
608
620
  // would be swallowed by the verification handler and never reach the
609
- // agent pipeline. Legacy /guardian_verify commands are always
610
- // intercepted because the explicit command prefix signals clear intent.
611
- const shouldInterceptVerification = guardianVerifyParsed !== undefined &&
612
- (guardianVerifyParsed.isExplicitCommand ||
613
- !!getPendingChallenge(canonicalAssistantId, sourceChannel) ||
621
+ // agent pipeline.
622
+ const shouldInterceptVerification = guardianVerifyCode !== undefined &&
623
+ (!!getPendingChallenge(canonicalAssistantId, sourceChannel) ||
614
624
  !!findActiveSession(canonicalAssistantId, sourceChannel));
615
625
 
616
626
  if (
617
627
  !result.duplicate &&
618
628
  shouldInterceptVerification &&
619
- guardianVerifyParsed !== undefined &&
629
+ guardianVerifyCode !== undefined &&
620
630
  body.senderExternalUserId
621
631
  ) {
622
632
  const verifyResult = validateAndConsumeChallenge(
623
633
  canonicalAssistantId,
624
634
  sourceChannel,
625
- guardianVerifyParsed.code,
635
+ guardianVerifyCode,
626
636
  body.senderExternalUserId,
627
637
  externalChatId,
628
638
  body.senderUsername,
@@ -642,17 +652,52 @@ export async function handleChannelInbound(
642
652
  displayName: body.senderName,
643
653
  username: body.senderUsername,
644
654
  });
645
- log.info({ sourceChannel, externalUserId: body.senderExternalUserId }, 'Guardian verified: auto-upserted ingress member');
655
+
656
+ const verifyLogLabel = verifyResult.verificationType === 'trusted_contact'
657
+ ? 'Trusted contact verified'
658
+ : 'Guardian verified';
659
+ log.info({ sourceChannel, externalUserId: body.senderExternalUserId, verificationType: verifyResult.verificationType }, `${verifyLogLabel}: auto-upserted ingress member`);
660
+
661
+ // Emit activated signal when a trusted contact completes verification.
662
+ // Member record is persisted above before this event fires, satisfying
663
+ // the persistence-before-event ordering invariant.
664
+ if (verifyResult.verificationType === 'trusted_contact') {
665
+ void emitNotificationSignal({
666
+ sourceEventName: 'ingress.trusted_contact.activated',
667
+ sourceChannel,
668
+ sourceSessionId: result.conversationId,
669
+ assistantId: canonicalAssistantId,
670
+ attentionHints: {
671
+ requiresAction: false,
672
+ urgency: 'low',
673
+ isAsyncBackground: false,
674
+ visibleInSourceNow: false,
675
+ },
676
+ contextPayload: {
677
+ sourceChannel,
678
+ externalUserId: body.senderExternalUserId,
679
+ externalChatId,
680
+ senderName: body.senderName ?? null,
681
+ senderUsername: body.senderUsername ?? null,
682
+ },
683
+ dedupeKey: `trusted-contact:activated:${canonicalAssistantId}:${sourceChannel}:${body.senderExternalUserId}`,
684
+ });
685
+ }
646
686
  }
647
687
 
648
688
  // Deliver a deterministic template-driven reply and short-circuit.
649
- // Verification commands must never produce agent-generated copy.
689
+ // Verification code messages must never produce agent-generated copy.
650
690
  if (replyCallbackUrl) {
651
- const replyText = verifyResult.success
652
- ? composeChannelVerifyReply(GUARDIAN_VERIFY_TEMPLATE_KEYS.CHANNEL_VERIFY_SUCCESS)
653
- : composeChannelVerifyReply(GUARDIAN_VERIFY_TEMPLATE_KEYS.CHANNEL_VERIFY_FAILED, {
654
- failureReason: stripVerificationFailurePrefix(verifyResult.reason),
655
- });
691
+ let replyText: string;
692
+ if (!verifyResult.success) {
693
+ replyText = composeChannelVerifyReply(GUARDIAN_VERIFY_TEMPLATE_KEYS.CHANNEL_VERIFY_FAILED, {
694
+ failureReason: stripVerificationFailurePrefix(verifyResult.reason),
695
+ });
696
+ } else if (verifyResult.verificationType === 'trusted_contact') {
697
+ replyText = composeChannelVerifyReply(GUARDIAN_VERIFY_TEMPLATE_KEYS.CHANNEL_TRUSTED_CONTACT_VERIFY_SUCCESS);
698
+ } else {
699
+ replyText = composeChannelVerifyReply(GUARDIAN_VERIFY_TEMPLATE_KEYS.CHANNEL_VERIFY_SUCCESS);
700
+ }
656
701
  try {
657
702
  await deliverChannelReply(replyCallbackUrl, {
658
703
  chatId: externalChatId,
@@ -711,8 +756,11 @@ export async function handleChannelInbound(
711
756
  // Check if this inbound message is a reply to a cross-channel guardian
712
757
  // action request (from a voice call). Must run before approval interception
713
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.
714
761
  if (
715
762
  !result.duplicate &&
763
+ !hasCallbackData &&
716
764
  trimmedContent.length > 0 &&
717
765
  body.senderExternalUserId &&
718
766
  replyCallbackUrl
@@ -778,9 +826,14 @@ export async function handleChannelInbound(
778
826
  const errorMsg = 'error' in answerResult ? answerResult.error : 'Unknown error';
779
827
  log.warn({ callSessionId: request.callSessionId, error: errorMsg }, 'answerCall failed for guardian answer');
780
828
  try {
829
+ const failureText = await composeGuardianActionMessageGenerative(
830
+ { scenario: 'guardian_answer_delivery_failed' },
831
+ {},
832
+ guardianActionCopyGenerator,
833
+ );
781
834
  await deliverChannelReply(replyCallbackUrl, {
782
835
  chatId: externalChatId,
783
- text: 'Failed to deliver your answer to the call. Please try again.',
836
+ text: failureText,
784
837
  assistantId,
785
838
  }, bearerToken);
786
839
  } catch (deliverErr) {
@@ -809,16 +862,34 @@ export async function handleChannelInbound(
809
862
  guardianAnswer: 'resolved',
810
863
  });
811
864
  } else {
812
- // Already answered from another channel
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
+ );
813
880
  try {
814
881
  await deliverChannelReply(replyCallbackUrl, {
815
882
  chatId: externalChatId,
816
- text: 'This question has already been answered from another channel.',
883
+ text: relayedText,
817
884
  assistantId,
818
885
  }, bearerToken);
819
886
  } catch (err) {
820
887
  log.error({ err, externalChatId }, 'Failed to deliver guardian action stale notice');
821
888
  }
889
+ log.info(
890
+ { requestId: request.id, freshStatus: freshRequest?.status },
891
+ 'answerCall succeeded but resolveGuardianActionRequest returned null — informed guardian answer was relayed',
892
+ );
822
893
  return Response.json({
823
894
  accepted: true,
824
895
  duplicate: false,
@@ -832,6 +903,298 @@ export async function handleChannelInbound(
832
903
  }
833
904
  }
834
905
 
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 },
954
+ guardianActionCopyGenerator,
955
+ );
956
+ try {
957
+ await deliverChannelReply(replyCallbackUrl, {
958
+ chatId: externalChatId,
959
+ text: disambiguationText,
960
+ assistantId,
961
+ }, bearerToken);
962
+ } catch (err) {
963
+ log.error({ err, externalChatId }, 'Failed to deliver guardian action expired disambiguation message');
964
+ }
965
+ return Response.json({
966
+ accepted: true,
967
+ duplicate: false,
968
+ eventId: result.eventId,
969
+ guardianAnswer: 'disambiguation_sent',
970
+ });
971
+ }
972
+ }
973
+
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 },
1010
+ {},
1011
+ guardianActionCopyGenerator,
1012
+ );
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
+ });
1028
+ }
1029
+ }
1030
+ }
1031
+ }
1032
+ }
1033
+ }
1034
+
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
+ );
1052
+
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
+ }
1067
+
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 },
1083
+ guardianActionCopyGenerator,
1084
+ );
1085
+ try {
1086
+ await deliverChannelReply(replyCallbackUrl, {
1087
+ chatId: externalChatId,
1088
+ text: disambiguationText,
1089
+ assistantId,
1090
+ }, bearerToken);
1091
+ } catch (err) {
1092
+ log.error({ err, externalChatId }, 'Failed to deliver guardian follow-up disambiguation message');
1093
+ }
1094
+ return Response.json({
1095
+ accepted: true,
1096
+ duplicate: false,
1097
+ eventId: result.eventId,
1098
+ guardianFollowUp: 'disambiguation_sent',
1099
+ });
1100
+ }
1101
+ }
1102
+
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
+ );
1115
+
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) !== null;
1124
+ } else if (turnResult.disposition === 'decline') {
1125
+ stateApplied = finalizeFollowup(followupRequest.id, 'declined') !== null;
1126
+ }
1127
+ // keep_pending: no state change — guardian can reply again
1128
+
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
+ );
1136
+ 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');
1144
+ }
1145
+ return Response.json({
1146
+ accepted: true,
1147
+ duplicate: false,
1148
+ eventId: result.eventId,
1149
+ guardianFollowUp: 'stale_ignored',
1150
+ });
1151
+ }
1152
+
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
+ }
1163
+
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',
1173
+ guardianActionCopyGenerator,
1174
+ );
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');
1182
+ }
1183
+ })();
1184
+ }
1185
+
1186
+ return Response.json({
1187
+ accepted: true,
1188
+ duplicate: false,
1189
+ eventId: result.eventId,
1190
+ guardianFollowUp: turnResult.disposition,
1191
+ });
1192
+ }
1193
+ }
1194
+ }
1195
+ }
1196
+ }
1197
+
835
1198
  // ── Actor role resolution ──
836
1199
  // Uses shared channel-agnostic resolution so all ingress paths classify
837
1200
  // guardian vs non-guardian actors the same way.
@@ -1028,6 +1391,108 @@ export async function handleChannelInbound(
1028
1391
  });
1029
1392
  }
1030
1393
 
1394
+ // ---------------------------------------------------------------------------
1395
+ // Non-member access request notification
1396
+ // ---------------------------------------------------------------------------
1397
+
1398
+ /**
1399
+ * Fire-and-forget: look up the guardian binding and, if present, create an
1400
+ * approval request + emit a notification signal so the guardian can
1401
+ * approve/deny the unknown user. Deduplicates by checking for an existing
1402
+ * pending approval for the same (requester, assistant, channel).
1403
+ */
1404
+ function notifyGuardianOfAccessRequest(params: {
1405
+ canonicalAssistantId: string;
1406
+ sourceChannel: ChannelId;
1407
+ externalChatId: string;
1408
+ senderExternalUserId?: string;
1409
+ senderName?: string;
1410
+ senderUsername?: string;
1411
+ }): void {
1412
+ const {
1413
+ canonicalAssistantId,
1414
+ sourceChannel,
1415
+ externalChatId,
1416
+ senderExternalUserId,
1417
+ senderName,
1418
+ senderUsername,
1419
+ } = params;
1420
+
1421
+ if (!senderExternalUserId) return;
1422
+
1423
+ const binding = getGuardianBinding(canonicalAssistantId, sourceChannel);
1424
+ if (!binding) {
1425
+ log.debug({ sourceChannel, canonicalAssistantId }, 'No guardian binding for access request notification');
1426
+ return;
1427
+ }
1428
+
1429
+ // Deduplicate: skip if there is already a pending approval request for
1430
+ // the same requester on this channel.
1431
+ const existing = findPendingAccessRequestForRequester(
1432
+ canonicalAssistantId,
1433
+ sourceChannel,
1434
+ senderExternalUserId,
1435
+ 'ingress_access_request',
1436
+ );
1437
+ if (existing) {
1438
+ log.debug(
1439
+ { sourceChannel, senderExternalUserId, existingId: existing.id },
1440
+ 'Skipping duplicate access request notification',
1441
+ );
1442
+ return;
1443
+ }
1444
+
1445
+ const senderIdentifier = senderName || senderUsername || senderExternalUserId;
1446
+ const requestId = `access-req-${canonicalAssistantId}-${sourceChannel}-${senderExternalUserId}-${Date.now()}`;
1447
+
1448
+ const approvalRequest = createApprovalRequest({
1449
+ runId: `ingress-access-request-${Date.now()}`,
1450
+ requestId,
1451
+ conversationId: `access-req-${sourceChannel}-${senderExternalUserId}`,
1452
+ assistantId: canonicalAssistantId,
1453
+ channel: sourceChannel,
1454
+ requesterExternalUserId: senderExternalUserId,
1455
+ requesterChatId: externalChatId,
1456
+ guardianExternalUserId: binding.guardianExternalUserId,
1457
+ guardianChatId: binding.guardianDeliveryChatId,
1458
+ toolName: 'ingress_access_request',
1459
+ riskLevel: 'access_request',
1460
+ reason: `${senderIdentifier} is requesting access to the assistant`,
1461
+ expiresAt: Date.now() + GUARDIAN_APPROVAL_TTL_MS,
1462
+ });
1463
+
1464
+ void emitNotificationSignal({
1465
+ sourceEventName: 'ingress.access_request',
1466
+ sourceChannel,
1467
+ sourceSessionId: `access-req-${sourceChannel}-${senderExternalUserId}`,
1468
+ assistantId: canonicalAssistantId,
1469
+ attentionHints: {
1470
+ requiresAction: true,
1471
+ urgency: 'high',
1472
+ isAsyncBackground: false,
1473
+ visibleInSourceNow: false,
1474
+ },
1475
+ contextPayload: {
1476
+ requestId,
1477
+ sourceChannel,
1478
+ externalChatId,
1479
+ senderExternalUserId,
1480
+ senderName: senderName ?? null,
1481
+ senderUsername: senderUsername ?? null,
1482
+ senderIdentifier,
1483
+ },
1484
+ // Scoped to the approval request ID so duplicate notifications for the
1485
+ // same request are suppressed, but a new request (after deny/expire)
1486
+ // gets its own dedupe key and the guardian is notified again.
1487
+ dedupeKey: `access-request:${approvalRequest.id}`,
1488
+ });
1489
+
1490
+ log.info(
1491
+ { sourceChannel, senderExternalUserId, senderIdentifier },
1492
+ 'Guardian notified of non-member access request',
1493
+ );
1494
+ }
1495
+
1031
1496
  // ---------------------------------------------------------------------------
1032
1497
  // Background message processing
1033
1498
  // ---------------------------------------------------------------------------
@@ -1225,7 +1690,7 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
1225
1690
  },
1226
1691
  assistantId,
1227
1692
  guardianContext: toGuardianRuntimeContext(sourceChannel, guardianCtx),
1228
- isInteractive: true,
1693
+ isInteractive: guardianCtx.actorRole === 'guardian',
1229
1694
  ...(cmdIntent ? { commandIntent: cmdIntent } : {}),
1230
1695
  },
1231
1696
  sourceChannel,